initial stage of hlctl rust rewrite

This commit is contained in:
emilis 2024-02-29 20:36:38 +00:00
parent e0526addc6
commit d0c19f7947
40 changed files with 6715 additions and 1783 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
# Openbsd core dumps
*.core
target/

644
Cargo.lock generated Normal file
View File

@ -0,0 +1,644 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "aho-corasick"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "628a8f9bd1e24b4e0db2b4bc2d000b001e7dd032d54afa60a68836aeec5aa54a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87"
[[package]]
name = "anstyle-parse"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7"
dependencies = [
"anstyle",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca"
[[package]]
name = "bitflags"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf"
[[package]]
name = "clap"
version = "4.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1"
[[package]]
name = "colorchoice"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "either"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a"
[[package]]
name = "env_logger"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580"
dependencies = [
"humantime",
"is-terminal",
"log",
"regex",
"termcolor",
]
[[package]]
name = "equivalent"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "errno"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "hashbrown"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "hermit-abi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hlctl"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"log",
"paste",
"pretty_assertions",
"pretty_env_logger",
"serde",
"serde_json",
"strum",
"thiserror",
"toml",
"which",
]
[[package]]
name = "home"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5"
dependencies = [
"windows-sys",
]
[[package]]
name = "humantime"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]]
name = "indexmap"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "is-terminal"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b"
dependencies = [
"hermit-abi",
"libc",
"windows-sys",
]
[[package]]
name = "itoa"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c"
[[package]]
name = "libc"
version = "0.2.153"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
[[package]]
name = "linux-raw-sys"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"
[[package]]
name = "log"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
[[package]]
name = "memchr"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149"
[[package]]
name = "once_cell"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "paste"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
[[package]]
name = "pretty_assertions"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66"
dependencies = [
"diff",
"yansi",
]
[[package]]
name = "pretty_env_logger"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c"
dependencies = [
"env_logger",
"log",
]
[[package]]
name = "proc-macro2"
version = "1.0.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
[[package]]
name = "rustix"
version = "0.38.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
[[package]]
name = "ryu"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1"
[[package]]
name = "serde"
version = "1.0.195"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.195"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_spanned"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1"
dependencies = [
"serde",
]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strum"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "syn"
version = "2.0.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "thiserror"
version = "1.0.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "toml"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a195ec8c9da26928f773888e0742ca3ca1040c6cd859c919c9f59c1954ab35"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d34d383cd00a163b4a5b85053df514d45bc330f6de7737edfe0a93311d1eaa03"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"winnow",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "utf8parse"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
[[package]]
name = "which"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fa5e0c10bf77f44aac573e498d1a82d5fbd5e91f6fc0a99e7be4b38e85e101c"
dependencies = [
"either",
"home",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596"
dependencies = [
"winapi",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef"
[[package]]
name = "windows_i686_gnu"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313"
[[package]]
name = "windows_i686_msvc"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04"
[[package]]
name = "winnow"
version = "0.5.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7cf47b659b318dccbd69cc4797a39ae128f533dce7902a1096044d1967b9c16"
dependencies = [
"memchr",
]
[[package]]
name = "yansi"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"

22
Cargo.toml Normal file
View File

@ -0,0 +1,22 @@
[package]
name = "hlctl"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.79"
clap = { version = "4.4.18", features = ["derive", "cargo"] }
paste = "1.0.14"
serde = { version = "1.0.195", features = ["derive"] }
serde_json = { version = "1.0.113" }
strum = { version = "0.25.0", features = ["derive"] }
thiserror = "1.0.57"
toml = "0.8.8"
which = "6.0.0"
log = "0.4"
pretty_env_logger = "0.5"
[dev-dependencies]
pretty_assertions = "1.4.0"

View File

@ -1,47 +0,0 @@
package cmdlets
import (
"fmt"
"strings"
)
type Usage struct {
Name string
Short string
Long []string
Flags map[string]string
Examples []string
}
func (u Usage) String() string {
str := strings.Builder{}
str.WriteString(
fmt.Sprintf("%s SUBCOMMAND\n\n", strings.ToUpper(u.Name)),
)
str.WriteString(fmt.Sprintf("\t%s\t%s\n\n", u.Name, u.Short))
for _, line := range u.Long {
str.WriteString(fmt.Sprintf("\t%s\n", line))
}
str.WriteRune('\n')
if len(u.Flags) != 0 {
for flag, desc := range u.Flags {
str.WriteString(fmt.Sprintf("\t%s\n\t\t%s\n", flag, desc))
}
str.WriteRune('\n')
}
if len(u.Examples) != 0 {
str.WriteString("EXAMPLES\n\n")
for _, example := range u.Examples {
str.WriteString(fmt.Sprintf("\t%s\n", example))
}
str.WriteRune('\n')
}
return str.String()
}
type Commandlet interface {
Name() string
Usage() Usage
Invoke(args ...string) error
}

View File

@ -1,175 +0,0 @@
package group
import (
"errors"
"fmt"
"strconv"
"sectorinf.com/emilis/hlctl/cmdlets"
"sectorinf.com/emilis/hlctl/config"
"sectorinf.com/emilis/hlctl/ctllog"
"sectorinf.com/emilis/hlctl/groupctl"
"sectorinf.com/emilis/hlctl/hlcl"
"sectorinf.com/emilis/hlctl/notifyctl"
)
var log = ctllog.Logger{}.New("group", ctllog.ColorRGB{}.FromHex("27ee7e"))
type command struct{}
var Command command
func (command) Name() string {
return "group"
}
func (command) Usage() cmdlets.Usage {
return cmdlets.Usage{
Name: Command.Name(),
Short: "Controls tag grouping and behavior",
Long: []string{
"`group` is responsible for controlling how groups behave",
"in hlwm, and switching between them",
},
Flags: map[string]string{
"+": "Jump to next group",
"-": "Jump to previous group",
"[number]": "Jump to [number] group",
"move [number]": "Move the currently focused window to group [number]",
},
Examples: []string{
"hcctl group +",
"hcctl group -",
"hcctl group 2",
"hcctl group move 2",
},
}
}
func (command) Invoke(args ...string) error {
if len(args) == 0 {
return errors.New("group requires arguments")
}
arg := args[0]
if arg == "move" && len(args) < 2 {
return errors.New("group move requires an argument")
}
var active uint8
currentActiveCfg, err := config.Get(config.ActiveGroup)
if err != nil {
log.Warnf(
"error getting active group, defaulting to 1: %s",
err.Error(),
)
active = 1
} else {
val, err := currentActiveCfg.Uint()
mustUint(config.ActiveGroup, err)
active = uint8(val)
}
log.Print("Loading num of tags per group")
numCfg, err := config.Get(config.TagsPerGroup)
if err != nil {
return fmt.Errorf("%s: %w", config.TagsPerGroup, err)
}
num, err := numCfg.Uint()
mustUint(config.TagsPerGroup, err)
log.Print(num, "tags per group")
focus, err := config.GetFocusedTag()
if err != nil {
return fmt.Errorf("get focused tag: %w", err)
}
log.Print("Currently in", focus, "tag")
maxCfg, err := config.Get(config.GroupCount)
if err != nil {
return fmt.Errorf("%s: %w", config.GroupCount, err)
}
max, err := maxCfg.Uint()
mustUint(config.GroupCount, err)
var (
newActive uint8
)
switch arg {
case "+":
newActive = shiftGroup(false, active, uint8(max))
case "-":
newActive = shiftGroup(true, active, uint8(max))
case "move":
a, err := strconv.ParseUint(arg, 10, 8)
target := uint8(a)
if err != nil || target < 1 || target > max {
return fmt.Errorf("out of range, use [1:%d]", max)
}
return groupctl.MoveTo(uint8(a))
default:
a, err := strconv.ParseUint(arg, 10, 8)
target := uint8(a)
if err != nil || target < 1 || target > max {
return fmt.Errorf("out of range, use [1:%d]", max)
}
newActive = uint8(a)
}
if active != newActive {
log.Printf("Group %d -> %d", active, newActive)
config.Set(config.ActiveGroup, strconv.Itoa(int(newActive)))
// shift the tag index
if err := groupctl.Update(); err != nil {
return fmt.Errorf(
"updating groups [%d -> %d]: %w",
active,
newActive,
err,
)
}
}
if err := groupctl.SetKeybinds(newActive, uint8(num)); err != nil {
return fmt.Errorf("SetKeybinds: %w", err)
}
fg, _ := config.Get(config.ColorText)
bg, _ := config.Get(config.ColorNormal)
font, _ := config.Get(config.Font)
return notifyctl.Display(
fmt.Sprintf("Group %d", newActive),
2,
fg.String(),
bg.String(),
font.String(),
)
}
func moveTo(currentTag, currentGroup, newGroup, num uint8) error {
relTag := (currentTag - ((currentGroup - 1) * num))
newTag := ((newGroup - 1) * num) + relTag
return hlcl.MoveIndex(uint8(newTag))
}
func mustUint(cfg string, err error) {
if err != nil {
panic(fmt.Sprintf(
"%s must be set as uint: %s",
cfg,
err.Error(),
))
}
}
func shiftGroup(backwards bool, active, num uint8) uint8 {
// Maintain the logic of the fish scripts,
// 0 == 1, act as if groups are 1 indexed
if backwards && active <= 1 {
active = num
} else if backwards {
active--
} else if active >= num {
active = 1
} else {
active++
}
return uint8(active)
}

View File

@ -1,319 +0,0 @@
package init
import (
"fmt"
"os/exec"
"strconv"
"strings"
"sectorinf.com/emilis/hlctl/cmdlets"
"sectorinf.com/emilis/hlctl/config"
"sectorinf.com/emilis/hlctl/ctllog"
"sectorinf.com/emilis/hlctl/groupctl"
"sectorinf.com/emilis/hlctl/hlcl"
"sectorinf.com/emilis/hlctl/hlcl/cmd"
"sectorinf.com/emilis/hlctl/panelctl"
"sectorinf.com/emilis/hlctl/svcctl"
)
const (
Return = "Return"
Shift = "Shift"
Tab = "Tab"
Left = "Left"
Right = "Right"
Up = "Up"
Down = "Down"
Space = "space"
Control = "Control"
Backtick = "grave"
)
var log = ctllog.Logger{}.New("init", ctllog.Green)
type command struct{}
var Command command
func (command) Name() string {
return "init"
}
func (command) Usage() cmdlets.Usage {
return cmdlets.Usage{
Name: Command.Name(),
Short: "Initialize herbstluftwm, should be run from " +
"the autostart script",
Long: []string{
"`init` starts the configuration of herbstluftwm with the",
"default configuration, setting up keybinds, hlctl groups,",
"and other commands.",
},
Flags: map[string]string{},
Examples: []string{
"hcctl init",
},
}
}
func logError(err error) {
if err != nil {
log.Error(err.Error())
}
}
type Keybind struct {
Keys []string
Command cmd.Command
Args []string
}
func (k Keybind) Set() {
err := hlcl.Keybind(k.Keys, k.Command, k.Args...)
if err != nil {
log.Errorf(
"Keybind [%s]: %s", strings.Join(k.Args, "-"), err,
)
} else {
binds := strings.Join(k.Keys, "-")
command := strings.Join(
append([]string{k.Command.String()}, k.Args...),
" ",
)
log.Printf("%s -> [%s]", binds, command)
}
}
func Keybinds(cfg config.HlwmConfig) []Keybind {
// convenience for setting []string
c := func(v ...string) []string {
return v
}
key := func(keys []string, cm cmd.Command, args ...string) Keybind {
return Keybind{
Keys: keys,
Command: cm,
Args: args,
}
}
Mod := cfg.Mod
step := "+0.05"
base := []Keybind{
key(c(Mod, Shift, "r"), cmd.Reload),
key(c(Mod, Shift, "c"), cmd.Close),
key(c(Mod, Return), cmd.Spawn, "dmenu_run"),
key(c(Mod, "s"), cmd.Spawn, "flameshot", "gui"),
key(c(Mod, cfg.TermSpawnKey), cmd.Spawn, cfg.Term),
// Focus
key(c(Mod, Left), cmd.Focus, "left"),
key(c(Mod, Right), cmd.Focus, "right"),
key(c(Mod, Up), cmd.Focus, "up"),
key(c(Mod, Down), cmd.Focus, "down"),
key(c(Mod, Tab), cmd.Cycle),
key(c(Mod, "i"), cmd.JumpTo, "urgent"),
// Move frames
key(c(Mod, Shift, Left), cmd.Shift, "left"),
key(c(Mod, Shift, Right), cmd.Shift, "right"),
key(c(Mod, Shift, Up), cmd.Shift, "up"),
key(c(Mod, Shift, Down), cmd.Shift, "down"),
// Frames
key(c(Mod, "u"), cmd.Split, "bottom", "0.7"),
key(c(Mod, "o"), cmd.Split, "right", "0.5"),
key(c(Mod, "r"), cmd.Remove),
key(c(Mod, "f"), cmd.Fullscreen, "toggle"),
key(c(Mod, Space), cmd.CycleLayout),
// Resizing
key(c(Mod, Control, Left), cmd.Resize, "left", step),
key(c(Mod, Control, Right), cmd.Resize, "right", step),
key(c(Mod, Control, Up), cmd.Resize, "up", step),
key(c(Mod, Control, Down), cmd.Resize, "down", step),
// Commands
key(c(Mod, Backtick), cmd.Spawn, "hlctl", "group", "+"),
}
for i := 0; i < int(cfg.Groups.Groups); i++ {
base = append(
base,
key(c(Mod, fmt.Sprintf("F%d", i+1)), cmd.Spawn,
"hlctl", "group", strconv.Itoa(i+1)),
)
}
return base
}
type set struct {
name string
value any
}
func (s set) String() string {
return fmt.Sprint(s.value)
}
func Theme(cfg config.HlwmConfig) {
s := func(name string, value any) set {
return set{
name: name,
value: value,
}
}
col := cfg.Theme.Colors
// Theme sets
for _, row := range [...]set{
s("frame_border_active_color", col.Active),
s("frame_border_normal_color", "black"),
s("frame_bg_active_color", col.Active),
s("frame_border_width", 2),
s("show_frame_decorations", "focused_if_multiple"),
s("tree_style", "╾│ ├└╼─┐"),
s("frame_bg_transparent", "on"),
s("smart_window_surroundings", "off"), // TODO
s("smart_frame_surroundings", "on"),
s("frame_transparent_width", 5),
s("frame_gap", 3),
s("window_gap", 0),
s("frame_padding", 0),
s("mouse_recenter_gap", 0),
} {
value := row.String()
if err := hlcl.Set(row.name, value); err != nil {
log.Errorf("Set [%s] -> [%s]: %s", row.name, value, err)
}
}
// Theme attrs
for _, row := range [...]set{
s("reset", 1),
s("tiling.reset", 1),
s("floating.reset", 1),
s("title_height", 15),
s("title_when", "multiple_tabs"),
s("title_font", cfg.FontBold),
s("title_depth", 3),
s("inner_width", 0),
s("inner_color", "black"),
s("border_width", 1),
s("floating.border_width", 4),
s("floating.outer_width", 1),
s("tiling.outer_width", 1),
s("background_color", "black"),
s("active.color", col.Active),
s("active.inner_color", col.Active),
s("active.outer_color", col.Active),
s("normal.color", col.Normal),
s("normal.inner_color", col.Normal),
s("normal.outer_color", col.Normal),
s("urgent.color", col.Urgent),
s("urgent.inner_color", col.Urgent),
s("urgent.outer_color", col.Urgent),
s("title_color", "white"),
s("normal.title_color", col.Text),
} {
attr := fmt.Sprintf("theme.%s", row.name)
v := row.String()
log.Printf("Setting [%s] -> [%s]", attr, v)
if err := hlcl.SetAttr(attr, v); err != nil {
log.Errorf("Attr [%s] -> [%s]: %s", attr, v, err)
}
}
}
func SetRules() {
sFmt := "windowtype~_NET_WM_WINDOW_TYPE_%s"
logError(hlcl.Rule("focus=on"))
logError(hlcl.Rule("floatplacement=smart"))
logError(hlcl.Rule("fixedsize", "floating=on"))
logError(hlcl.Rule(
fmt.Sprintf(sFmt, "(DIALOG|UTILITY|SPLASH)"),
"floating=on",
))
logError(hlcl.Rule(
fmt.Sprintf(sFmt, "(NOTIFICATION|DOCK|DESKTOP)"),
"manage=off",
))
}
func AddTags(groupCount, tagsPerGroup uint8) error {
for group := 0; group < int(groupCount); group++ {
for tag := 0; tag < int(tagsPerGroup); tag++ {
err := hlcl.AddTag(fmt.Sprintf("%d|%d", group+1, tag+1))
if err != nil {
return fmt.Errorf(
"Adding tag [%d] for group [%d]: %s",
tag+1, group+1, err,
)
}
}
}
defaultAttr, err := hlcl.GetAttr("tags.by-name.default.index")
if err != nil {
// no default
log.Print("No 'default' tag, continuing...")
return nil
}
focus, err := config.GetFocusedTag()
if err != nil {
return fmt.Errorf("(removing default) getting current tag: %s", err)
}
def, err := strconv.ParseUint(defaultAttr, 10, 8)
if err != nil {
return fmt.Errorf("Bug? default index not uint: %s", err)
}
if uint8(def) == focus {
if err := hlcl.UseIndex(focus + 1); err != nil {
log.Warnf("Tried switching away from default: %s", err)
}
}
if err := hlcl.MergeTag("default"); err != nil {
return fmt.Errorf("merging default: %s", err)
}
return nil
}
func (command) Invoke(args ...string) error {
log.Print("begining herbstluftwm setup via hlctl")
log.Print("loading config")
cfg := config.Load()
if err := cfg.Validate(); err != nil {
return fmt.Errorf("validating config: %s", err)
}
if err := cfg.SetAll(); err != nil {
return fmt.Errorf("setting cfg values to hlwm: %s", err)
}
exec.Command("xsetroot", "-solid", "black").Run()
logError(hlcl.KeyUnbind())
log.Print("Adding tags")
logError(AddTags(cfg.Groups.Groups, cfg.Groups.Tags))
logError(groupctl.ResetActive())
for _, key := range Keybinds(cfg) {
key.Set()
}
log.Print("Resetting groups")
logError(groupctl.ResetActive())
log.Print("Theming...")
Theme(cfg)
log.Print("Rules")
SetRules()
log.Print("Unlocking")
if err := hlcl.Unlock(); err != nil {
return fmt.Errorf("failed unlock: %w", err)
}
svcctl.RestartServices(cfg.Services)
log.Print("Running panels")
return panelctl.Run()
}

View File

@ -1,84 +0,0 @@
package notify
import (
"errors"
"fmt"
"strconv"
"strings"
"sectorinf.com/emilis/hlctl/cmdlets"
"sectorinf.com/emilis/hlctl/config"
"sectorinf.com/emilis/hlctl/ctllog"
"sectorinf.com/emilis/hlctl/notifyctl"
)
var log = ctllog.Logger{}.New("notify", ctllog.ColorRGB{}.FromHex("ff003e"))
type command struct{}
var Command command
func (command) Name() string {
return "notify"
}
func (command) Usage() cmdlets.Usage {
return cmdlets.Usage{
Name: Command.Name(),
Short: "Draws notifications using dzen2",
Long: []string{
"`notify` Will create notifications using the theme colors",
"that hlwm was set over",
},
Flags: map[string]string{
"-s": "Amount of seconds to persist for, default 1",
},
Examples: []string{
"notify -s 3 \"Hello world\"",
"notify Hey what's up chicken head",
},
}
}
func (command) Invoke(args ...string) error {
var (
secs string = "1"
)
cleanArgs := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := args[i]
if len(arg) > 1 && arg[0] == '-' {
var next string
if i+1 < len(args) {
next = args[i+1]
}
switch arg[1] {
case 's':
secs = next
i++
default:
cleanArgs = append(cleanArgs, arg)
}
} else {
cleanArgs = append(cleanArgs, arg)
}
}
if len(cleanArgs) == 0 {
return errors.New("no message")
}
message := strings.Join(cleanArgs, " ")
cfg := config.Load()
secsNum, err := strconv.ParseUint(secs, 10, 64)
if err != nil {
return fmt.Errorf("seconds[%s]: %w", secs, err)
}
c := cfg.Theme.Colors
return notifyctl.Display(
message,
uint(secsNum),
c.Text,
c.Normal,
cfg.Font,
)
}

View File

@ -1,33 +0,0 @@
package save
import (
"sectorinf.com/emilis/hlctl/cmdlets"
"sectorinf.com/emilis/hlctl/config"
"sectorinf.com/emilis/hlctl/ctllog"
)
var log = ctllog.Logger{}.New("save", ctllog.Green)
type command struct{}
var Command command
func (command) Name() string {
return "save"
}
func (command) Usage() cmdlets.Usage {
return cmdlets.Usage{
Name: Command.Name(),
Short: "Save the currently loaded configuration to file",
Long: []string{
"`save` simply saves the configurable state of hlctl",
"to a configuration file located at",
"$HOME/.config/herbstluftwm/hlctl.toml",
},
}
}
func (command) Invoke(args ...string) error {
return config.Collect().Save()
}

View File

@ -1,101 +0,0 @@
package config
import (
"errors"
"fmt"
"strconv"
"sectorinf.com/emilis/hlctl/hlcl"
"sectorinf.com/emilis/hlctl/hlcl/cmd"
)
const (
Term = "Term"
Font = "Font"
FontBold = "FontBold"
ModKey = "ModKey"
TermSpawnKey = "TermSpawnKey"
GroupCount = "GroupCount"
TagsPerGroup = "TagsPerGroup"
ActiveGroup = "ActiveGroup"
Services = "Services"
// Colors
ColorActive = "ColorActive"
ColorNormal = "ColorNormal"
ColorUrgent = "ColorUrgent"
ColorText = "ColorText"
)
const (
settingFmt = "settings.my_%s"
)
var (
ErrInvalidType = errors.New("invalid type")
)
type Setting struct {
raw string
valueType string
}
func (s Setting) String() string {
return s.raw
}
func (s Setting) Uint() (uint8, error) {
if s.valueType != "uint" {
return 0, fmt.Errorf("%s: %w", s.valueType, ErrInvalidType)
}
val, err := strconv.ParseUint(s.raw, 10, 8)
if err != nil {
panic(fmt.Sprintf(
"hc uint setting[%s] failed to parse: %s",
s.raw,
err.Error(),
))
}
return uint8(val), nil
}
func GetFocusedTag() (uint8, error) {
attr, err := hlcl.GetAttr("tags.focus.index")
if err != nil {
return 0, err
}
val, err := strconv.ParseUint(attr, 10, 8)
if err != nil {
return 0, fmt.Errorf("parsing [%s] to uint8: %w", attr, err)
}
return uint8(val), nil
}
func Get(name string) (Setting, error) {
attrPath := fmt.Sprintf(settingFmt, name)
attr, err := hlcl.GetAttr(attrPath)
if err != nil {
return Setting{}, err
}
attrType, err := hlcl.AttrType(attrPath)
if err != nil {
// Default to string, silently
attrType = "string"
}
return Setting{
raw: attr,
valueType: attrType,
}, nil
}
func Set(name string, value string) error {
return hlcl.SetAttr(fmt.Sprintf(settingFmt, name), value)
}
func New(name string, value string, attrType cmd.AttributeType) error {
if _, err := Get(name); err == nil {
// Already exists, just set value
return Set(name, value)
}
return hlcl.NewAttr(fmt.Sprintf(settingFmt, name), value, attrType)
}

View File

@ -1,198 +0,0 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/BurntSushi/toml"
"sectorinf.com/emilis/hlctl/ctllog"
"sectorinf.com/emilis/hlctl/hlcl/cmd"
)
var (
cfgPath = filepath.Join(
os.Getenv("HOME"),
".config",
"herbstluftwm",
"hlctl.toml",
)
log = ctllog.Logger{}.New("config", ctllog.ColorRGB{}.FromHex("ff5aff"))
)
type (
HlwmConfig struct {
Font string
FontBold string
Mod string
TermSpawnKey string
Term string
Groups GroupConfig
Theme Theme
Services ServicesConfig
}
ServicesConfig struct {
Services []string
}
GroupConfig struct {
Groups uint8
Tags uint8
}
Theme struct {
Colors Colors
}
Colors struct {
Active string
Normal string
Urgent string
Text string
}
)
func (h HlwmConfig) Save() error {
log.Printf("Saving to [%s]", cfgPath)
cfg, err := os.Create(cfgPath)
if err != nil {
return fmt.Errorf("opening cfg path: [%s]: %w", cfgPath, err)
}
defer cfg.Close()
err = toml.NewEncoder(cfg).Encode(h)
if err != nil {
return fmt.Errorf("encoding config to toml: %w", err)
}
return cfg.Sync()
}
func (h HlwmConfig) Validate() error {
if h.Font == "" ||
h.FontBold == "" ||
h.Mod == "" ||
h.TermSpawnKey == "" ||
h.Term == "" ||
h.Groups.Groups == 0 ||
h.Groups.Tags == 0 ||
len(h.Theme.Colors.Active) < 6 ||
len(h.Theme.Colors.Normal) < 6 ||
len(h.Theme.Colors.Urgent) < 6 ||
len(h.Theme.Colors.Text) < 6 {
return errors.New("some configuration options are missing")
}
return nil
}
// Collect the config from loaded attributes. Or get default
// if that fails.
func Collect() HlwmConfig {
getOrNothing := func(settingName string, value *string) {
v, err := Get(settingName)
if err != nil {
return
}
*value = v.String()
}
getUints := func(settingName string, value *uint8) {
v, err := Get(settingName)
if err != nil {
return
}
val, err := v.Uint()
if err != nil {
return
}
*value = uint8(val)
}
cfg := Default
getOrNothing(Font, &cfg.Font)
getOrNothing(FontBold, &cfg.FontBold)
getOrNothing(ModKey, &cfg.Mod)
getOrNothing(TermSpawnKey, &cfg.TermSpawnKey)
getOrNothing(Term, &cfg.Term)
getUints(GroupCount, &cfg.Groups.Groups)
getUints(TagsPerGroup, &cfg.Groups.Tags)
getOrNothing(ColorActive, &cfg.Theme.Colors.Active)
getOrNothing(ColorNormal, &cfg.Theme.Colors.Normal)
getOrNothing(ColorUrgent, &cfg.Theme.Colors.Urgent)
getOrNothing(ColorText, &cfg.Theme.Colors.Text)
svcsString, err := Get(Services)
if err == nil {
cfg.Services.Services = strings.Split(svcsString.String(), ";")
}
return cfg
}
func (h HlwmConfig) SetAll() error {
err := New(GroupCount, strconv.Itoa(int(h.Groups.Groups)), cmd.Uint)
if err != nil {
return fmt.Errorf("%s: %w", GroupCount, err)
}
err = New(TagsPerGroup, strconv.Itoa(int(h.Groups.Tags)), cmd.Uint)
if err != nil {
return fmt.Errorf("%s: %w", TagsPerGroup, err)
}
sets := map[string]string{
Font: h.Font,
FontBold: h.FontBold,
ModKey: h.Mod,
TermSpawnKey: h.TermSpawnKey,
Term: h.Term,
ColorActive: h.Theme.Colors.Active,
ColorNormal: h.Theme.Colors.Normal,
ColorUrgent: h.Theme.Colors.Urgent,
ColorText: h.Theme.Colors.Text,
Services: strings.Join(h.Services.Services, ";"),
}
for cfg, val := range sets {
if err := New(cfg, val, cmd.String); err != nil {
return fmt.Errorf("%s[%s]: %w", cfg, val, err)
}
}
return nil
}
func Load() HlwmConfig {
cfg := HlwmConfig{}
if _, err := toml.DecodeFile(cfgPath, &cfg); err != nil {
log.Warnf(
"could not read file [%s] for config, using default",
cfgPath,
)
return Default
}
return cfg
}
var (
Default = HlwmConfig{
Font: "-*-fixed-medium-*-*-*-12-*-*-*-*-*-*-*",
FontBold: "-*-fixed-bold-*-*-*-12-*-*-*-*-*-*-*",
Mod: "Mod4",
TermSpawnKey: "Home",
Term: "alacritty",
Groups: GroupConfig{
Groups: 3,
Tags: 5,
},
Theme: Theme{
Colors: Colors{
Active: "#800080",
Normal: "#330033",
Urgent: "#7811A1",
Text: "#898989",
},
},
Services: ServicesConfig{
Services: []string{"fcitx5"},
},
}
)

View File

@ -1,102 +0,0 @@
package ctllog
import (
"encoding/hex"
"fmt"
)
// \x1b[38;2;255;100;0mTRUECOLOR\x1b[0m
// ESC[ 38;2;⟨r⟩;⟨g⟩;⟨b⟩ m Select RGB foreground color
const (
escape byte = 0x1B
csi byte = '['
separator byte = ';'
csiEnd byte = 'm'
fgColor byte = '3'
bgColor byte = '4'
trueColor byte = '8'
rgb byte = '2'
reset byte = '0'
)
var (
trueColorFMT = string([]byte{
escape,
csi,
'%', 'c',
trueColor,
separator,
rgb,
separator,
'%', 'd',
separator,
'%', 'd',
separator,
'%', 'd',
csiEnd,
'%', 's',
escape,
csi,
reset,
csiEnd,
})
)
type Variant byte
const (
Foreground = Variant(fgColor)
Background = Variant(bgColor)
)
type ColorRGB struct {
r uint8
g uint8
b uint8
}
func (ColorRGB) FromHex(hexString string) ColorRGB {
if len(hexString) < 6 {
return White
}
if hexString[0] == '#' {
hexString = hexString[1:]
}
if len(hexString) != 6 {
return White
}
res, err := hex.DecodeString(hexString)
if err != nil {
fmt.Println(err.Error())
return White
}
return ColorRGB{
r: res[0],
g: res[1],
b: res[2],
}
}
func getStr(str string, typeByte Variant, color ColorRGB) string {
return fmt.Sprintf(
trueColorFMT,
typeByte,
color.r,
color.g,
color.b,
str,
)
}
var (
Black ColorRGB
White = ColorRGB{255, 255, 255}
Green = ColorRGB{0, 255, 0}
Red = ColorRGB{255, 0, 0}
Blue = ColorRGB{0, 0, 255}
Purple = ColorRGB{255, 0, 255}
Yellow = ColorRGB{255, 255, 0}
)

View File

@ -1,22 +0,0 @@
package ctllog
import (
"testing"
// "sectorinf.com/emilis/hlctl/ctllog"
)
func TestColorFromHex(t *testing.T) {
testCases := map[string]ColorRGB{
"000000": {},
"0c2238": {12, 34, 56},
}
for hexColor, expected := range testCases {
t.Run(hexColor, func(t *testing.T) {
actual := ColorRGB{}.FromHex(hexColor)
if expected != actual {
t.Fatalf("expected %+v got %+v", expected, actual)
}
})
}
}

View File

@ -1,94 +0,0 @@
package ctllog
import (
"fmt"
"io"
"os"
"runtime/debug"
"strings"
)
var (
logfile io.Writer
)
func init() {
if os.Getenv("debug") == "true" {
path := fmt.Sprintf("%s/.hlctl.log", os.Getenv("HOME"))
file, err := os.Create(path)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not open [%s] for logging: %s\n", path, err)
fmt.Fprintln(os.Stderr, "Defaulting to stderr")
logfile = os.Stderr
}
logfile = file
} else {
logfile = os.Stderr
}
}
type Logger struct {
coloredName string
}
func (Logger) New(name string, color ColorRGB) Logger {
coloredName := getStr("[ "+name+" ]", Foreground, color)
return Logger{
coloredName: coloredName,
}
}
func (l Logger) print(str string) {
lines := strings.Split(str, "\n")
for i, line := range lines {
lines[i] = fmt.Sprintf("%s: %s", l.coloredName, line)
}
str = strings.Join(lines, "\n")
fmt.Fprintln(logfile, str)
}
func (l Logger) colored(color ColorRGB, args ...any) {
l.print(getStr(fmt.Sprint(args...), Foreground, color))
}
func (l Logger) coloredf(color ColorRGB, format string, args ...any) {
l.print(getStr(fmt.Sprintf(format, args...), Foreground, color))
}
func (l Logger) fatal(str string) {
l.print(getStr(str, Background, Red))
debug.PrintStack()
os.Exit(16)
}
func (l Logger) Print(args ...any) {
l.print(fmt.Sprint(args...))
}
func (l Logger) Printf(format string, args ...any) {
l.print(fmt.Sprintf(format, args...))
}
func (l Logger) Warn(args ...any) {
l.colored(Yellow, args...)
}
func (l Logger) Warnf(format string, args ...any) {
l.coloredf(Yellow, format, args...)
}
func (l Logger) Error(args ...any) {
l.colored(Red, args...)
}
func (l Logger) Errorf(format string, args ...any) {
l.coloredf(Red, format, args...)
}
func (l Logger) Fatal(args ...any) {
l.fatal(fmt.Sprint(args...))
}
func (l Logger) Fatalf(format string, args ...any) {
l.fatal(fmt.Sprintf(format, args...))
}

5
go.mod
View File

@ -1,5 +0,0 @@
module sectorinf.com/emilis/hlctl
go 1.19
require github.com/BurntSushi/toml v1.2.1

2
go.sum
View File

@ -1,2 +0,0 @@
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=

View File

@ -1,106 +0,0 @@
package groupctl
import (
"fmt"
"strconv"
"sectorinf.com/emilis/hlctl/config"
"sectorinf.com/emilis/hlctl/ctllog"
"sectorinf.com/emilis/hlctl/hlcl"
"sectorinf.com/emilis/hlctl/hlcl/cmd"
)
var log = ctllog.Logger{}.New("group", ctllog.ColorRGB{}.FromHex("27ee7e"))
func ResetActive() error {
err := config.New(config.ActiveGroup, "1", cmd.Uint)
if err != nil {
return fmt.Errorf("new active group: %w", err)
}
return Update()
}
// Update switches to the correct tag based on the active group
func Update() error {
activeCfg, err := config.Get(config.ActiveGroup)
if err != nil {
return fmt.Errorf("%s: %w", config.ActiveGroup, err)
}
active, err := activeCfg.Uint()
if err != nil {
return fmt.Errorf("%s: %w", config.ActiveGroup, err)
}
focus, err := config.GetFocusedTag()
if err != nil {
return fmt.Errorf("focus: %w", err)
}
numCfg, err := config.Get(config.TagsPerGroup)
if err != nil {
return fmt.Errorf("get %s: %w", config.TagsPerGroup, err)
}
num, err := numCfg.Uint()
if err != nil {
return fmt.Errorf("%s: %w", config.TagsPerGroup, err)
}
relativeTag := focus % uint8(num)
absTag := ((active - 1) * num) + relativeTag
if focus != absTag {
log.Printf("Tag %d -> %d", focus, absTag)
}
if err := hlcl.UseIndex(absTag); err != nil {
return fmt.Errorf("UseIndex[%d]: %w", absTag, err)
}
return SetKeybinds(active, num)
}
func MoveTo(group uint8) error {
numCfg, err := config.Get(config.TagsPerGroup)
if err != nil {
return fmt.Errorf("get %s: %w", config.TagsPerGroup, err)
}
num, err := numCfg.Uint()
if err != nil {
return fmt.Errorf("%s: %w", config.TagsPerGroup, err)
}
focus, err := config.GetFocusedTag()
if err != nil {
return fmt.Errorf("focus: %w", err)
}
relative := focus % num
newTag := ((group - 1) * num) + relative
log.Printf("Moving window to tag -> %d", newTag)
return hlcl.MoveIndex(newTag)
}
func SetKeybinds(group uint8, num uint8) error {
offset := int((group - 1) * num)
mod, err := config.Get(config.ModKey)
if err != nil {
return fmt.Errorf("%s: %w", config.ModKey, err)
}
log.Printf("Setting %d keybinds for group %d", num, group)
for i := 0; i < int(num); i++ {
actualTag := strconv.Itoa(offset + i)
tag := strconv.Itoa(i + 1)
err = hlcl.Keybind(
[]string{mod.String(), tag},
cmd.UseIndex,
actualTag,
)
if err != nil {
return fmt.Errorf("Setting use_index for [%d]: %w", i+1, err)
}
err = hlcl.Keybind(
[]string{mod.String(), "Shift", tag},
cmd.MoveIndex,
actualTag,
)
if err != nil {
return fmt.Errorf("Setting move_index for [%d]: %w", i+1, err)
}
}
return nil
}

View File

@ -1,103 +0,0 @@
package cmd
import (
"fmt"
"strings"
"unicode"
)
type Command string
func (c Command) String() string {
return string(c)
}
func (c Command) Pretty() string {
parts := strings.Split(string(c), "_")
for i, part := range parts {
// Set the first character to be upper case
if len(part) == 0 {
continue
} else if len(part) == 1 {
parts[i] = strings.ToUpper(part)
}
var first rune
for _, r := range part {
first = r
break
}
parts[i] = fmt.Sprintf("%c%s", unicode.ToUpper(first), part[1:])
}
return strings.Join(parts, "")
}
const (
Reload Command = "reload"
Close Command = "close"
Spawn Command = "spawn"
ListMonitors Command = "list_monitors"
Lock Command = "lock"
Unlock Command = "unlock"
// Attr family
GetAttr Command = "get_attr"
SetAttr Command = "set_attr"
Attr Command = "attr"
NewAttr Command = "new_attr"
AttrType Command = "attr_type"
RemoveAttr Command = "remove_attr"
Set Command = "set"
EmitHook Command = "emit_hook"
Rule Command = "rule"
Unrule Command = "unrule"
Substitute Command = "substitute"
// Controls
Keybind Command = "keybind"
Keyunbind Command = "keyunbind"
Mousebind Command = "mousebind"
Mouseunbind Command = "mouseunbind"
UseIndex Command = "use_index"
MoveIndex Command = "move_index"
JumpTo Command = "jumpto"
// Views/Frames
AddTag Command = "add"
MergeTag Command = "merge_tag"
Cycle Command = "cycle"
Focus Command = "focus"
Shift Command = "shift"
Split Command = "split"
Remove Command = "remove"
Fullscreen Command = "fullscreen"
CycleLayout Command = "cycle_layout"
Resize Command = "resize"
)
type MouseButton string
func (m MouseButton) String() string {
return string(m)
}
const (
Mouse1 MouseButton = "Mouse1"
Mouse2 MouseButton = "Mouse2"
Mouse3 MouseButton = "Mouse3"
)
type AttributeType string
func (a AttributeType) String() string {
return string(a)
}
const (
String AttributeType = "string"
Bool AttributeType = "bool"
Color AttributeType = "color"
Int AttributeType = "int"
Uint AttributeType = "uint"
)

View File

@ -1,190 +0,0 @@
// package hlcl contains logic for interacting with the
// herbstclient
package hlcl
import (
"bytes"
"fmt"
"io"
"os/exec"
"strconv"
"strings"
"sectorinf.com/emilis/hlctl/ctllog"
"sectorinf.com/emilis/hlctl/hlcl/cmd"
)
var log = ctllog.Logger{}.New("hlcl", ctllog.Purple)
func runGeneric(command string, stdin string, args ...string) (string, error) {
cmd := exec.Command(command, args...)
log.Printf("Running command [%s] with args: %v", command, args)
var stdout, stderr = bytes.Buffer{}, bytes.Buffer{}
cmd.Stderr = &stderr
cmd.Stdout = &stdout
if stdin != "" {
pipe, err := cmd.StdinPipe()
if err != nil {
return "", fmt.Errorf("stdin pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("start: %w", err)
}
io.WriteString(pipe, stdin+"\n")
_, err = pipe.Write([]byte(stdin))
if err != nil {
return "", fmt.Errorf("write stdin: %w", err)
}
pipe.Close()
} else {
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("start: %w", err)
}
}
if err := cmd.Wait(); err != nil {
status, ok := err.(*exec.ExitError)
if !ok {
return "", fmt.Errorf("%s exec error: %w", command, err)
}
exitCode := status.ExitCode()
return "", fmt.Errorf(
"%s error (%d): %s",
command,
exitCode,
strings.TrimSpace(stderr.String()),
)
}
return strings.TrimSpace(stdout.String()), nil
}
func run(command cmd.Command, args ...string) (string, error) {
args = append([]string{command.String()}, args...)
return runGeneric("herbstclient", "", args...)
}
// query executes a command and pretty formats and error message
// based on the command's Pretty(), and returns the output
func query(c cmd.Command, args ...string) (string, error) {
value, err := run(c, args...)
if err != nil {
return value, fmt.Errorf(
"%s(%s): %w",
c.Pretty(),
strings.Join(args, ", "),
err,
)
}
return value, nil
}
// execute: wrapper for [query], discarding the stdout result
func execute(c cmd.Command, args ...string) error {
_, err := query(c, args...)
return err
}
func NewAttr(path string, value string, aType cmd.AttributeType) error {
return execute(cmd.NewAttr, aType.String(), path, value)
}
func SetAttr(path string, value string) error {
return execute(cmd.SetAttr, path, value)
}
func GetAttr(path string) (string, error) {
return query(cmd.GetAttr, path)
}
func AttrType(path string) (string, error) {
return query(cmd.AttrType, path)
}
func KeyUnbind() error {
return execute(cmd.Keyunbind, "--all")
}
func MouseUnbind() error {
return execute(cmd.Mouseunbind, "--all")
}
func ListMonitors() ([]Screen, error) {
out, err := query(cmd.ListMonitors)
if err != nil {
return nil, err
}
outs := strings.Split(out, "\n")
scrs := make([]Screen, len(outs))
for i, line := range outs {
scrs[i] = Screen{}.FromString(line)
}
return scrs, nil
}
func bindCMD(
bindCommand cmd.Command,
keys []string,
command cmd.Command,
args ...string,
) error {
bindKeys := strings.Join(keys, "-")
args = append([]string{bindKeys, command.String()}, args...)
return execute(bindCommand, args...)
}
func Keybind(keys []string, command cmd.Command, args ...string) error {
return bindCMD(cmd.Keybind, keys, command, args...)
}
func Mousebind(keys []string, command cmd.Command, args ...string) error {
return bindCMD(cmd.Mousebind, keys, command, args...)
}
func Set(name, value string) error {
return execute(cmd.Set, name, value)
}
func UseIndex(index uint8) error {
return execute(cmd.UseIndex, strconv.Itoa(int(index)))
}
func MergeTag(name string) error {
return execute(cmd.MergeTag, name)
}
func MoveIndex(index uint8) error {
return execute(cmd.MoveIndex, strconv.Itoa(int(index)))
}
func TextWidth(font, message string) (string, error) {
return runGeneric("textwidth", "", font, message)
}
func Rule(name string, args ...string) error {
return execute(cmd.Rule, append([]string{name}, args...)...)
}
func Unrule() error {
return execute(cmd.Unrule, "-F")
}
func SilentSpawn(command string, args ...string) error {
return execute(cmd.Spawn, append([]string{command}, args...)...)
}
func Lock() error {
return execute(cmd.Lock)
}
func Unlock() error {
return execute(cmd.Unlock)
}
func AddTag(name string) error {
return execute(cmd.AddTag, name)
}
func Dzen2(message string, args ...string) error {
_, err := runGeneric("dzen2", message, args...)
return err
}

View File

@ -1,29 +0,0 @@
package hlcl
import "strings"
type Screen struct {
ID string
Resolution string
WithTag string
Focused bool
}
func (Screen) FromString(v string) Screen {
parts := strings.Split(v, " ")
if len(parts) < 5 {
return Screen{
ID: v[:1],
}
}
for i, part := range parts {
parts[i] = strings.TrimSpace(
strings.TrimRight(
strings.Trim(part, "\""), ":"))
}
return Screen{
ID: parts[0],
Resolution: parts[1],
// Maybe later
}
}

80
main.go
View File

@ -1,80 +0,0 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
"sectorinf.com/emilis/hlctl/cmdlets"
"sectorinf.com/emilis/hlctl/cmdlets/group"
initcmd "sectorinf.com/emilis/hlctl/cmdlets/init"
"sectorinf.com/emilis/hlctl/cmdlets/notify"
"sectorinf.com/emilis/hlctl/cmdlets/save"
"sectorinf.com/emilis/hlctl/ctllog"
)
var log = ctllog.Logger{}.New("hlctl", ctllog.Green)
var cmds = []cmdlets.Commandlet{
initcmd.Command,
group.Command,
notify.Command,
save.Command,
}
var (
ErrNotFound = errors.New("not found")
)
func getCmdlet(name string) (cmdlets.Commandlet, error) {
for _, c := range cmds {
if c.Name() == name {
return c, nil
}
}
return nil, fmt.Errorf("%s: %w", name, ErrNotFound)
}
func help() {
commands := make([]string, len(cmds))
for i, c := range cmds {
commands[i] = c.Name()
}
fmt.Fprint(os.Stderr, "USAGE: hlctl [command] [arguments]\n")
fmt.Fprintf(os.Stderr, "\nAvailable commands: %v\n", commands)
fmt.Fprint(os.Stderr, "\nSee hlctl help [command] for details\n")
}
func main() {
// omit the program name
args := os.Args[1:]
if len(args) == 0 {
help()
os.Exit(1)
}
command := strings.ToLower(args[0])
if command == "help" {
if len(args) == 1 {
help()
return
}
name := args[1]
c, err := getCmdlet(name)
if err != nil {
help()
os.Exit(1)
}
fmt.Println(c.Usage())
return
}
c, err := getCmdlet(command)
if err != nil {
help()
os.Exit(1)
}
if err := c.Invoke(args[1:]...); err != nil {
log.Fatalf(" %s > %s", c.Name(), err.Error())
}
}

View File

@ -1,33 +0,0 @@
package notifyctl
import (
"strconv"
"sectorinf.com/emilis/hlctl/ctllog"
"sectorinf.com/emilis/hlctl/hlcl"
)
var log = ctllog.Logger{}.New("notify", ctllog.ColorRGB{}.FromHex("ff003e"))
func Display(message string, duration uint, fg, bg, font string) error {
width, err := hlcl.TextWidth(font, message)
if err != nil {
log.Errorf("%s; defaulting to width 100", err.Error())
width = "100"
}
widthNum, err := strconv.ParseUint(width, 10, 8)
if err != nil {
panic(err)
}
return hlcl.Dzen2(
message,
"-p", strconv.Itoa(int(duration)),
"-fg", fg,
"-bg", bg,
"-fn", font,
"-ta", "c",
"-w", strconv.Itoa(int(widthNum)+30),
"-h", "16",
)
}

View File

@ -1,37 +0,0 @@
package panelctl
import (
"fmt"
"log"
"os"
"os/exec"
"sectorinf.com/emilis/hlctl/hlcl"
)
func Run() error {
screens, err := hlcl.ListMonitors()
if err != nil {
return err
}
panel := func(mon string) error {
return exec.Command(
"bash",
fmt.Sprintf(
"%s/.config/herbstluftwm/panel.sh",
os.Getenv("HOME"),
),
mon,
).Run()
}
for i := 1; i < len(screens); i++ {
id := screens[i].ID
go func() {
if err := panel(id); err != nil {
log.Fatalf("Monitor [%s] panel: %s", id, err)
}
}()
}
return panel(screens[0].ID)
}

1020
src/config.rs Normal file

File diff suppressed because it is too large Load Diff

289
src/hlwm/attribute.rs Normal file
View File

@ -0,0 +1,289 @@
use std::{num::ParseIntError, str::FromStr};
use serde::{Deserialize, Serialize};
use strum::IntoEnumIterator;
use thiserror::Error;
use crate::hlwm::hex::ParseHex;
use super::{
color::{self, Color, X11Color},
hex::HexError,
octal::{OctalError, ParseOctal},
StringParseError,
};
#[derive(Debug, Error)]
pub enum AttributeError {
#[error("error parsing integer value: {0:?}")]
ParseIntError(#[from] ParseIntError),
#[error("error parsing bool value: [{0}]")]
ParseBoolError(String),
#[error("error parsing color value: {0}")]
ParseColorError(#[from] color::ParseError),
#[error("unknown attribute type [{0}]")]
UnknownType(String),
#[error("not a valid rectangle: [{0}]")]
NotRectangle(String),
#[error("hex parsing error: [{0}]")]
HexError(#[from] HexError),
#[error("octal parsing error: [{0}]")]
OctalError(#[from] OctalError),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum AttributeOption {
Bool(Option<bool>),
Color(Option<Color>),
Int(Option<i32>),
String(Option<String>),
Uint(Option<u32>),
Rectangle(Option<(u32, u32)>),
WindowID(Option<u32>),
}
impl Default for AttributeOption {
fn default() -> Self {
Self::Int(None)
}
}
impl AttributeOption {
pub fn new(type_string: &str, value_string: &Option<String>) -> Result<Self, AttributeError> {
if let Some(val) = value_string {
return Ok(Attribute::new(type_string, val)?.into());
}
match type_string {
"int" => Ok(Self::Int(None)),
"bool" => Ok(Self::Bool(None)),
"uint" => Ok(Self::Uint(None)),
"color" => Ok(Self::Color(None)),
"windowid" => Ok(Self::WindowID(None)),
"rectangle" => Ok(Self::Rectangle(None)),
"string" | "names" | "regex" => Ok(Self::String(None)),
_ => Err(AttributeError::UnknownType(type_string.to_string())),
}
}
fn to_default_attr(&self) -> Attribute {
match self {
AttributeOption::Int(_) => Attribute::Int(Default::default()),
AttributeOption::Uint(_) => Attribute::Uint(Default::default()),
AttributeOption::Bool(_) => Attribute::Bool(Default::default()),
AttributeOption::Color(_) => Attribute::Color(Default::default()),
AttributeOption::String(_) => Attribute::String(Default::default()),
AttributeOption::WindowID(_) => Attribute::WindowID(Default::default()),
AttributeOption::Rectangle(_) => Attribute::Rectangle {
x: Default::default(),
y: Default::default(),
},
}
}
pub fn type_string(&self) -> &'static str {
self.to_default_attr().type_string()
}
pub fn value_string(&self) -> Option<String> {
match self {
AttributeOption::Int(val) => val.map(|val| val.to_string()),
AttributeOption::Bool(val) => val.map(|val| val.to_string()),
AttributeOption::Uint(val) => val.map(|val| val.to_string()),
AttributeOption::Color(val) => val.clone().map(|val| val.to_string()),
AttributeOption::String(val) => val.clone().map(|val| val.to_string()),
AttributeOption::WindowID(val) => val.map(|w| format!("{w:#x}")),
AttributeOption::Rectangle(val) => val.map(|(x, y)| format!("{x}x{y}")),
}
}
}
impl From<Attribute> for AttributeOption {
fn from(value: Attribute) -> Self {
match value {
Attribute::Bool(val) => Self::Bool(Some(val)),
Attribute::Color(val) => Self::Color(Some(val)),
Attribute::Int(val) => Self::Int(Some(val)),
Attribute::String(val) => Self::String(Some(val)),
Attribute::Uint(val) => Self::Uint(Some(val)),
Attribute::Rectangle { x, y } => Self::Rectangle(Some((x, y))),
Attribute::WindowID(win) => Self::WindowID(Some(win)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, strum::EnumDiscriminants)]
#[strum_discriminants(
name(AttributeType),
derive(strum::Display, strum::EnumIter),
strum(serialize_all = "lowercase")
)]
pub enum Attribute {
Bool(bool),
Color(Color),
Int(i32),
String(String),
Uint(u32),
Rectangle { x: u32, y: u32 },
WindowID(u32),
}
impl From<i32> for Attribute {
fn from(value: i32) -> Self {
Self::Int(value)
}
}
impl From<u32> for Attribute {
fn from(value: u32) -> Self {
Self::Uint(value)
}
}
impl From<String> for Attribute {
fn from(value: String) -> Self {
Self::String(value)
}
}
impl From<&str> for Attribute {
fn from(value: &str) -> Self {
Self::String(value.to_string())
}
}
impl From<Color> for Attribute {
fn from(value: Color) -> Self {
Self::Color(value)
}
}
impl From<bool> for Attribute {
fn from(value: bool) -> Self {
Self::Bool(value)
}
}
impl From<(u32, u32)> for Attribute {
fn from((x, y): (u32, u32)) -> Self {
Self::Rectangle { x, y }
}
}
impl Attribute {
pub fn new(type_string: &str, value_string: &str) -> Result<Self, AttributeError> {
match type_string {
"bool" => match value_string {
"on" | "true" => Ok(Attribute::Bool(true)),
"off" | "false" => Ok(Attribute::Bool(false)),
_ => Err(AttributeError::ParseBoolError(type_string.to_string())),
},
"color" => Ok(Attribute::Color(Color::from_str(value_string)?)),
"int" => Ok(Attribute::Int(value_string.parse()?)),
"string" | "names" | "regex" => Ok(Attribute::String(value_string.to_string())),
"uint" => Ok(Attribute::Uint(value_string.parse()?)),
"rectangle" => {
let parts = value_string.split('x').collect::<Vec<_>>();
if parts.len() != 2 {
return Err(AttributeError::NotRectangle(value_string.to_string()));
}
Ok(Attribute::Rectangle {
x: parts.get(0).unwrap().parse()?,
y: parts.get(1).unwrap().parse()?,
})
}
"windowid" => {
if let Some(hex) = value_string.strip_prefix("0x") {
Ok(Attribute::WindowID(hex.parse_hex()?))
} else if let Some(octal) = value_string.strip_prefix("0") {
Ok(Attribute::WindowID(octal.parse_octal()?))
} else {
Ok(Attribute::WindowID(value_string.parse()?))
}
}
_ => Err(AttributeError::UnknownType(type_string.to_string())),
}
}
fn guess_from_value(value: &str) -> Option<Attribute> {
match value {
"on" | "true" => Some(Self::Bool(true)),
"off" | "false" => Some(Self::Bool(false)),
_ => {
// Match for all colors first
if let Some(color) = X11Color::iter()
.into_iter()
.find(|c| c.to_string() == value)
{
return Some(Attribute::Color(Color::X11(color)));
}
// Match for primitive types or color string
let mut chars = value.chars();
match chars.next().unwrap() {
'+' => Self::guess_from_value(&chars.collect::<String>()),
'0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '-' => {
i32::from_str(value).ok().map(|i| Attribute::Int(i))
}
'#' => Color::from_hex(value).ok().map(|c| Attribute::Color(c)),
_ => None,
}
}
}
}
pub fn guess_type(value: &str) -> Self {
if value.is_empty() {
Self::String(String::new())
} else {
Self::guess_from_value(value).unwrap_or(Attribute::String(value.to_string()))
}
}
pub fn type_string(&self) -> &'static str {
match self {
Attribute::Bool(_) => "bool",
Attribute::Color(_) => "color",
Attribute::Int(_) => "int",
Attribute::String(_) => "string",
Attribute::Uint(_) => "uint",
Attribute::Rectangle { x: _, y: _ } => "rectangle",
Attribute::WindowID(_) => "windowid",
}
}
}
impl std::fmt::Display for Attribute {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Attribute::Bool(b) => write!(f, "{b}"),
Attribute::Color(c) => write!(f, "{c}"),
Attribute::Int(i) => write!(f, "{i}"),
Attribute::String(s) => f.write_str(s),
Attribute::Uint(u) => write!(f, "{u}"),
Attribute::Rectangle { x, y } => write!(f, "{x}x{y}"),
Attribute::WindowID(win) => write!(f, "{win:#x}"),
}
}
}
impl Default for Attribute {
fn default() -> Self {
Self::Int(0)
}
}
impl Default for AttributeType {
fn default() -> Self {
Self::String
}
}
impl FromStr for AttributeType {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::iter()
.find(|t| t.to_string() == s)
.ok_or(StringParseError::UnknownValue)
}
}

426
src/hlwm/color.rs Normal file
View File

@ -0,0 +1,426 @@
use std::str::FromStr;
use serde::{de::Unexpected, Deserialize, Serialize};
use strum::IntoEnumIterator;
use thiserror::Error;
use crate::hlwm::StringParseError;
use super::hex::{HexError, ParseHex};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Color {
RGB { r: u8, g: u8, b: u8 },
X11(X11Color),
}
impl Serialize for Color {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Color {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct ExpectedColor;
impl serde::de::Expected for ExpectedColor {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an X11 color string or a hex color string")
}
}
let str_val: String = Deserialize::deserialize(deserializer)?;
Ok(Color::from_str(&str_val).map_err(|_| {
serde::de::Error::invalid_value(Unexpected::Str(&str_val), &ExpectedColor)
})?)
}
}
impl Default for Color {
fn default() -> Self {
Self::RGB {
r: Default::default(),
g: Default::default(),
b: Default::default(),
}
}
}
#[derive(Debug, Clone, Error)]
pub enum ParseError {
#[error("length must be either 6 characters or 7 with '#' prefix")]
InvalidLength,
#[error("invalid hex value: [{0}]")]
HexError(#[from] HexError),
}
impl FromStr for Color {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Ok(x11) = X11Color::from_str(s) {
return Ok(Self::X11(x11));
}
Self::from_hex(s)
}
}
impl Color {
pub const BLACK: Color = Color::rgb(0, 0, 0);
pub fn from_hex(hex: &str) -> Result<Self, ParseError> {
let expected_len = if hex.starts_with('#') { 7 } else { 6 };
if hex.len() != expected_len {
return Err(ParseError::InvalidLength);
}
let hex = hex.strip_prefix('#').unwrap_or(hex);
Ok(Self::RGB {
r: (&hex[0..2]).parse_hex()?,
g: (&hex[2..4]).parse_hex()?,
b: (&hex[4..6]).parse_hex()?,
})
}
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self::RGB { r, g, b }
}
}
impl std::fmt::Display for Color {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Color::RGB { r, g, b } => write!(f, "#{r:02X}{g:02X}{b:02X}"),
Color::X11(color) => f.write_str(&color.to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, strum::Display, strum::EnumIter, PartialEq, Eq)]
pub enum X11Color {
GhostWhite,
WhiteSmoke,
FloralWhite,
OldLace,
AntiqueWhite,
PapayaWhip,
BlanchedAlmond,
PeachPuff,
NavajoWhite,
LemonChiffon,
MintCream,
AliceBlue,
LavenderBlush,
MistyRose,
DarkSlateGray,
DarkSlateGrey,
DimGray,
DimGrey,
SlateGray,
SlateGrey,
LightSlateGray,
LightSlateGrey,
X11Gray,
X11Grey,
WebGray,
WebGrey,
LightGrey,
LightGray,
MidnightBlue,
NavyBlue,
CornflowerBlue,
DarkSlateBlue,
SlateBlue,
MediumSlateBlue,
LightSlateBlue,
MediumBlue,
RoyalBlue,
DodgerBlue,
DeepSkyBlue,
SkyBlue,
LightSkyBlue,
SteelBlue,
LightSteelBlue,
LightBlue,
PowderBlue,
PaleTurquoise,
DarkTurquoise,
MediumTurquoise,
LightCyan,
CadetBlue,
MediumAquamarine,
DarkGreen,
DarkOliveGreen,
DarkSeaGreen,
SeaGreen,
MediumSeaGreen,
LightSeaGreen,
PaleGreen,
SpringGreen,
LawnGreen,
X11Green,
WebGreen,
MediumSpringGreen,
GreenYellow,
LimeGreen,
YellowGreen,
ForestGreen,
OliveDrab,
DarkKhaki,
PaleGoldenrod,
LightGoldenrodYellow,
LightYellow,
LightGoldenrod,
DarkGoldenrod,
RosyBrown,
IndianRed,
SaddleBrown,
SandyBrown,
DarkSalmon,
LightSalmon,
DarkOrange,
LightCoral,
OrangeRed,
HotPink,
DeepPink,
LightPink,
PaleVioletRed,
X11Maroon,
WebMaroon,
MediumVioletRed,
VioletRed,
MediumOrchid,
DarkOrchid,
DarkViolet,
BlueViolet,
X11Purple,
WebPurple,
MediumPurple,
AntiqueWhite1,
AntiqueWhite2,
AntiqueWhite3,
AntiqueWhite4,
PeachPuff1,
PeachPuff2,
PeachPuff3,
PeachPuff4,
NavajoWhite1,
NavajoWhite2,
NavajoWhite3,
NavajoWhite4,
LemonChiffon1,
LemonChiffon2,
LemonChiffon3,
LemonChiffon4,
LavenderBlush1,
LavenderBlush2,
LavenderBlush3,
LavenderBlush4,
MistyRose1,
MistyRose2,
MistyRose3,
MistyRose4,
SlateBlue1,
SlateBlue2,
SlateBlue3,
SlateBlue4,
RoyalBlue1,
RoyalBlue2,
RoyalBlue3,
RoyalBlue4,
DodgerBlue1,
DodgerBlue2,
DodgerBlue3,
DodgerBlue4,
SteelBlue1,
SteelBlue2,
SteelBlue3,
SteelBlue4,
DeepSkyBlue1,
DeepSkyBlue2,
DeepSkyBlue3,
DeepSkyBlue4,
SkyBlue1,
SkyBlue2,
SkyBlue3,
SkyBlue4,
LightSkyBlue1,
LightSkyBlue2,
LightSkyBlue3,
LightSkyBlue4,
SlateGray1,
SlateGray2,
SlateGray3,
SlateGray4,
LightSteelBlue1,
LightSteelBlue2,
LightSteelBlue3,
LightSteelBlue4,
LightBlue1,
LightBlue2,
LightBlue3,
LightBlue4,
LightCyan1,
LightCyan2,
LightCyan3,
LightCyan4,
PaleTurquoise1,
PaleTurquoise2,
PaleTurquoise3,
PaleTurquoise4,
CadetBlue1,
CadetBlue2,
CadetBlue3,
CadetBlue4,
DarkSlateGray1,
DarkSlateGray2,
DarkSlateGray3,
DarkSlateGray4,
DarkSeaGreen1,
DarkSeaGreen2,
DarkSeaGreen3,
DarkSeaGreen4,
SeaGreen1,
SeaGreen2,
SeaGreen3,
SeaGreen4,
PaleGreen1,
PaleGreen2,
PaleGreen3,
PaleGreen4,
SpringGreen1,
SpringGreen2,
SpringGreen3,
SpringGreen4,
OliveDrab1,
OliveDrab2,
OliveDrab3,
OliveDrab4,
DarkOliveGreen1,
DarkOliveGreen2,
DarkOliveGreen3,
DarkOliveGreen4,
LightGoldenrod1,
LightGoldenrod2,
LightGoldenrod3,
LightGoldenrod4,
LightYellow1,
LightYellow2,
LightYellow3,
LightYellow4,
DarkGoldenrod1,
DarkGoldenrod2,
DarkGoldenrod3,
DarkGoldenrod4,
RosyBrown1,
RosyBrown2,
RosyBrown3,
RosyBrown4,
IndianRed1,
IndianRed2,
IndianRed3,
IndianRed4,
LightSalmon1,
LightSalmon2,
LightSalmon3,
LightSalmon4,
DarkOrange1,
DarkOrange2,
DarkOrange3,
DarkOrange4,
OrangeRed1,
OrangeRed2,
OrangeRed3,
OrangeRed4,
DeepPink1,
DeepPink2,
DeepPink3,
DeepPink4,
HotPink1,
HotPink2,
HotPink3,
HotPink4,
LightPink1,
LightPink2,
LightPink3,
LightPink4,
PaleVioletRed1,
PaleVioletRed2,
PaleVioletRed3,
PaleVioletRed4,
VioletRed1,
VioletRed2,
VioletRed3,
VioletRed4,
MediumOrchid1,
MediumOrchid2,
MediumOrchid3,
MediumOrchid4,
DarkOrchid1,
DarkOrchid2,
DarkOrchid3,
DarkOrchid4,
MediumPurple1,
MediumPurple2,
MediumPurple3,
MediumPurple4,
DarkGrey,
DarkGray,
DarkBlue,
DarkCyan,
DarkMagenta,
DarkRed,
LightGreen,
RebeccaPurple,
}
impl FromStr for X11Color {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::iter()
.into_iter()
.find(|i| i.to_string() == s)
.ok_or(StringParseError::UnknownValue)
}
}
#[cfg(test)]
mod test {
use serde::{Deserialize, Serialize};
use super::{Color, X11Color};
#[derive(Debug, Serialize, Deserialize)]
pub struct ColorContainer {
color: Color,
}
fn color_serialize_deserialize(color: Color) {
let original_color = color.clone();
let color_str =
toml::to_string_pretty(&ColorContainer { color }).expect("serialization failure");
let color_parsed: ColorContainer =
toml::from_str(color_str.as_str()).expect("deserialization failure");
assert_eq!(original_color, color_parsed.color);
}
#[test]
fn color_serialize_deserialize_rgb() {
color_serialize_deserialize(Color::RGB {
r: 128,
g: 128,
b: 128,
});
}
#[test]
fn color_serialize_deserialize_x11() {
color_serialize_deserialize(Color::X11(X11Color::HotPink2));
}
}

1014
src/hlwm/command.rs Normal file

File diff suppressed because it is too large Load Diff

108
src/hlwm/hex.rs Normal file
View File

@ -0,0 +1,108 @@
use std::num::TryFromIntError;
use thiserror::Error;
macro_rules! hex_digit {
($digit:expr) => {
match $digit {
'0' => Ok(0),
'1' => Ok(1),
'2' => Ok(2),
'3' => Ok(3),
'4' => Ok(4),
'5' => Ok(5),
'6' => Ok(6),
'7' => Ok(7),
'8' => Ok(8),
'9' => Ok(9),
'a' | 'A' => Ok(10),
'b' | 'B' => Ok(11),
'c' | 'C' => Ok(12),
'd' | 'D' => Ok(13),
'e' | 'E' => Ok(14),
'f' | 'F' => Ok(15),
_ => Err(HexError::InvalidCharacter($digit)),
}
};
}
#[derive(Debug, Error, Clone)]
pub enum HexError {
#[error("invalid/non-hex character: [{0}]")]
InvalidCharacter(char),
#[error("try_from_int failed: [{0}]")]
TryFromIntError(#[from] TryFromIntError),
}
pub trait FromHex: Sized {
fn from_hex(hex: &str) -> Result<Self, HexError>;
}
pub trait ParseHex<T> {
fn parse_hex(&self) -> Result<T, HexError>;
}
impl<T, V: AsRef<str>> ParseHex<T> for V
where
T: FromHex,
{
fn parse_hex(&self) -> Result<T, HexError> {
T::from_hex(self.as_ref())
}
}
macro_rules! impl_from_hex {
($ty:ty) => {
impl FromHex for $ty {
fn from_hex(hex: &str) -> Result<Self, HexError> {
if let Some(without_prefix) = hex.strip_prefix("0x") {
return Self::from_hex(without_prefix);
}
Ok(hex
.chars()
.map(|c| hex_digit!(c))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.rev()
.enumerate()
.map(|(idx, value)| -> Result<_, TryFromIntError> {
Ok((16 as $ty).pow(idx.try_into()?) * value)
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.sum())
}
}
};
($($ty:ty),+) => {
$(
impl_from_hex!($ty);
)+
};
}
impl_from_hex!(i8, i16, i32, i64, u8, u16, u32, u64);
#[cfg(test)]
mod test {
use super::FromHex;
macro_rules! hex {
($val:literal) => {
(stringify!($val), $val as u32)
};
}
#[test]
fn test_hex_parse() {
for (str, expected) in [
hex!(0x15),
hex!(0xFFFF),
hex!(0xD3ADBEEF),
hex!(0x0),
hex!(0x10),
] {
let actual = u32::from_hex(str).expect("from_hex");
assert_eq!(expected, actual);
}
}
}

55
src/hlwm/hlwmbool.rs Normal file
View File

@ -0,0 +1,55 @@
use std::{fmt::Display, str::FromStr};
use serde::{Deserialize, Serialize};
use super::StringParseError;
#[inline(always)]
pub fn from_hlwm_string(s: &str) -> Result<bool, StringParseError> {
match s {
"on" | "true" => Ok(true),
"off" | "false" => Ok(false),
_ => Err(StringParseError::BoolError(s.to_string())),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ToggleBool {
Bool(bool),
Toggle,
}
impl From<bool> for ToggleBool {
fn from(value: bool) -> Self {
Self::Bool(value)
}
}
impl FromStr for ToggleBool {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Ok(b) = from_hlwm_string(s) {
Ok(Self::Bool(b))
} else if s == "toggle" {
Ok(Self::Toggle)
} else {
Err(StringParseError::UnknownValue)
}
}
}
impl Default for ToggleBool {
fn default() -> Self {
Self::Toggle
}
}
impl Display for ToggleBool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ToggleBool::Bool(b) => write!(f, "{b}"),
ToggleBool::Toggle => f.write_str("toggle"),
}
}
}

149
src/hlwm/hook.rs Normal file
View File

@ -0,0 +1,149 @@
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use strum::IntoEnumIterator;
use crate::gen_parse;
use super::{command::CommandParseError, window::Window, Monitor, Tag, ToCommandString};
#[derive(Debug, Clone, Serialize, Deserialize, strum::Display, strum::EnumIter, PartialEq)]
#[strum(serialize_all = "snake_case")]
pub enum Hook {
/// The attribute `path` was changed from `old` to `new`.
/// Requires that the attribute `path` has been passed to the [HlwmCommand::Watch] command before.
AttributeChanged {
path: String,
old: String,
new: String,
},
/// The fullscreen state of `window` was changed to [on|off].
Fullscreen {
on: bool,
window: Window,
},
/// The `tag` was selected on `monitor`.
TagChanged {
tag: String,
monitor: Monitor,
},
/// The `window` with title `title` was focused
FocusChanged {
window: Window,
title: String,
},
WindowTitleChanged {
window: Window,
title: String,
},
/// The flags (i.e. urgent or filled state) have been changed.
TagFlags,
TagAdded(Tag),
TagRenamed {
old: String,
new: String,
},
Urgent {
on: bool,
window: Window,
},
/// A `window` appeared which triggered a rule/hook.
Rule {
hook: String,
window: Window,
},
/// Tells a panel to quit. The default panel.sh quits on this hook. Many scripts are using this hook.
QuitPanel,
/// Tells all daemons that the autostart file is reloadedand tells them to quit.
/// This hook **should** be emitted in the first line of every autostart file.
Reload,
}
impl Hook {
fn from_raw_parts(command: &str, args: Vec<String>) -> Result<Self, CommandParseError> {
let command = Self::iter()
.find(|cmd| cmd.to_string() == command)
.ok_or(CommandParseError::UnknownCommand(command.to_string()))?;
gen_parse!(command, args);
match command {
Hook::AttributeChanged {
path: _,
old: _,
new: _,
} => parse!(path: String, old: String, new: String => AttributeChanged),
Hook::Fullscreen { on: _, window: _ } => {
parse!(on: FromStr, window: FromStr => Fullscreen)
}
Hook::TagChanged { tag: _, monitor: _ } => {
parse!(tag: String, monitor: FromStr => TagChanged)
}
Hook::FocusChanged {
window: _,
title: _,
} => parse!(window: FromStr, title: String => FocusChanged),
Hook::WindowTitleChanged {
window: _,
title: _,
} => parse!(window: FromStr, title: String => WindowTitleChanged),
Hook::TagFlags => Ok(Hook::TagFlags),
Hook::TagAdded(_) => parse!(FromStr => TagAdded),
Hook::TagRenamed { old: _, new: _ } => parse!(old: String, new: String => TagRenamed),
Hook::Urgent { on: _, window: _ } => parse!(on: Bool, window: FromStr => Urgent),
Hook::Rule { hook: _, window: _ } => parse!(hook: String, window: FromStr => Rule),
Hook::QuitPanel => Ok(Hook::QuitPanel),
Hook::Reload => Ok(Hook::Reload),
}
}
}
impl FromStr for Hook {
type Err = CommandParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut split = s.split("\t");
Hook::from_raw_parts(
split
.next()
.ok_or(CommandParseError::UnknownCommand(format!("hook {s}")))?,
split.map(String::from).collect(),
)
}
}
impl Default for Hook {
fn default() -> Self {
Self::Reload
}
}
impl ToCommandString for Hook {
fn to_command_string(&self) -> String {
[self.to_string()]
.into_iter()
.chain(match self {
Hook::AttributeChanged { path, old, new } => {
vec![path.to_string(), old.to_string(), new.to_string()].into_iter()
}
Hook::Fullscreen { on, window } => {
vec![on.to_string(), window.to_string()].into_iter()
}
Hook::TagChanged { tag, monitor } => {
vec![tag.to_string(), monitor.to_string()].into_iter()
}
Hook::WindowTitleChanged { window, title }
| Hook::FocusChanged { window, title } => {
vec![window.to_string(), title.to_string()].into_iter()
}
Hook::QuitPanel | Hook::Reload | Hook::TagFlags => vec![].into_iter(),
Hook::TagAdded(tag) => vec![tag.to_string()].into_iter(),
Hook::TagRenamed { old, new } => vec![old.to_string(), new.to_string()].into_iter(),
Hook::Urgent { on, window } => vec![on.to_string(), window.to_string()].into_iter(),
Hook::Rule { hook, window } => {
vec![hook.to_string(), window.to_string()].into_iter()
}
})
.collect::<Vec<_>>()
.join("\t")
}
}

497
src/hlwm/key.rs Normal file
View File

@ -0,0 +1,497 @@
use std::{fmt::Display, str::FromStr};
use serde::{Deserialize, Serialize};
use strum::IntoEnumIterator;
use thiserror::Error;
use crate::hlwm::split;
use super::{
command::{CommandParseError, HlwmCommand},
StringParseError, ToCommandString,
};
#[derive(Debug, Clone, Copy, PartialEq, strum::EnumIter)]
pub enum Key {
Mod1Alt,
Mod4Super,
Return,
Shift,
Tab,
Left,
Right,
Up,
Down,
Space,
Control,
Backtick,
F1,
F2,
F3,
F4,
F5,
F6,
F7,
F8,
F9,
F10,
F11,
F12,
Home,
Delete,
Char(char),
Mouse(MouseButton),
}
impl Serialize for Key {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Key {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct ExpectedKey;
impl serde::de::Expected for ExpectedKey {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("Expected a supported key")
}
}
let str_val: String = Deserialize::deserialize(deserializer)?;
Ok(Self::from_str(&str_val).map_err(|_| {
serde::de::Error::invalid_value(serde::de::Unexpected::Str(&str_val), &ExpectedKey)
})?)
}
}
impl FromStr for Key {
type Err = KeyParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Button1" | "Button2" | "Button3" | "Button4" | "Button5" => {
Ok(Self::Mouse(MouseButton::from_str(s)?))
}
_ => {
if let Some(key) = Self::iter().into_iter().find(|key| key.to_string() == s) {
match key {
Key::Char(_) | Key::Mouse(_) => (),
_ => return Ok(key),
}
}
if s.len() == 1 {
Ok(Self::Char(s.chars().next().unwrap()))
} else {
Err(KeyParseError::ExpectedCharKey(s.to_string()))
}
}
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub enum MouseButton {
Button1,
Button2,
Button3,
Button4,
Button5,
}
impl Default for MouseButton {
fn default() -> Self {
Self::Button1
}
}
impl FromStr for MouseButton {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Button1" => Ok(Self::Button1),
"Button2" => Ok(Self::Button2),
"Button3" => Ok(Self::Button3),
"Button4" => Ok(Self::Button4),
"Button5" => Ok(Self::Button5),
_ => Err(StringParseError::UnknownValue),
}
}
}
impl Display for MouseButton {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MouseButton::Button1 => write!(f, "Button1"),
MouseButton::Button2 => write!(f, "Button2"),
MouseButton::Button3 => write!(f, "Button3"),
MouseButton::Button4 => write!(f, "Button4"),
MouseButton::Button5 => write!(f, "Button5"),
}
}
}
impl Display for Key {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let data = match self {
Key::Return => "Return".to_string(),
Key::Shift => "Shift".to_string(),
Key::Tab => "Tab".to_string(),
Key::Left => "Left".to_string(),
Key::Right => "Right".to_string(),
Key::Up => "Up".to_string(),
Key::Down => "Down".to_string(),
Key::Space => "space".to_string(),
Key::Control => "Control".to_string(),
Key::Backtick => "grave".to_string(),
Key::Char(c) => c.to_string(),
Key::Mouse(m) => m.to_string(),
Key::F1 => "F1".to_string(),
Key::F2 => "F2".to_string(),
Key::F3 => "F3".to_string(),
Key::F4 => "F4".to_string(),
Key::F5 => "F5".to_string(),
Key::F6 => "F6".to_string(),
Key::F7 => "F7".to_string(),
Key::F8 => "F8".to_string(),
Key::F9 => "F9".to_string(),
Key::F10 => "F10".to_string(),
Key::F11 => "F11".to_string(),
Key::F12 => "F12".to_string(),
Key::Home => "Home".to_string(),
Key::Delete => "Delete".to_string(),
Key::Mod1Alt => "Mod1".to_string(),
Key::Mod4Super => "Mod4".to_string(),
};
f.write_str(&data)
}
}
#[derive(Debug, Clone, strum::Display, strum::EnumIter, PartialEq)]
#[strum(serialize_all = "snake_case")]
pub enum MousebindAction {
Move,
Resize,
Zoom,
Call(Box<HlwmCommand>),
}
impl Serialize for MousebindAction {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_command_string())
}
}
impl<'de> Deserialize<'de> for MousebindAction {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct ExpectedKey;
impl serde::de::Expected for ExpectedKey {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("Expected a supported key")
}
}
let str_val: String = Deserialize::deserialize(deserializer)?;
Ok(Self::from_str(&str_val).map_err(|_| {
serde::de::Error::invalid_value(serde::de::Unexpected::Str(&str_val), &ExpectedKey)
})?)
}
}
impl FromStr for MousebindAction {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = split::tab_or_space(s).into_iter();
let first = parts.next().ok_or(StringParseError::UnknownValue)?;
let act = Self::iter()
.find(|i| i.to_string() == first)
.ok_or(StringParseError::UnknownValue)?;
match act {
MousebindAction::Move | MousebindAction::Resize | MousebindAction::Zoom => Ok(act),
MousebindAction::Call(_) => {
let command = parts.next().ok_or(StringParseError::UnknownValue)?;
let args = parts.collect();
Ok(MousebindAction::Call(Box::new(
HlwmCommand::from_raw_parts(&command, args)
.map_err(|err| StringParseError::CommandParseError(err.to_string()))?,
)))
}
}
}
}
impl Default for MousebindAction {
fn default() -> Self {
Self::Move
}
}
impl ToCommandString for MousebindAction {
fn to_command_string(&self) -> String {
match self {
MousebindAction::Move | MousebindAction::Resize | MousebindAction::Zoom => {
self.to_string()
}
MousebindAction::Call(cmd) => format!("{self}\t{}", cmd.to_string()),
}
}
}
#[derive(Debug, Error)]
pub enum KeyParseError {
#[error("value too short (expected >= 2 parts, got {0} parts)")]
TooShort(usize),
#[error("no keys in keybind")]
NoKeys,
#[error("command parse error: {0}")]
CommandParseError(String),
#[error("expected char key, got: [{0}]")]
ExpectedCharKey(String),
#[error("string parse error: [{0}]")]
StringParseError(String),
}
impl From<StringParseError> for KeyParseError {
fn from(value: StringParseError) -> Self {
KeyParseError::StringParseError(value.to_string())
}
}
impl From<CommandParseError> for KeyParseError {
fn from(value: CommandParseError) -> Self {
KeyParseError::CommandParseError(value.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Keybind {
pub keys: Vec<Key>,
pub command: Box<HlwmCommand>,
}
impl Default for Keybind {
fn default() -> Self {
Self {
keys: Default::default(),
command: Default::default(),
}
}
}
impl FromStr for Keybind {
type Err = KeyParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts = s.split('\t').collect::<Vec<&str>>();
if parts.len() < 2 {
return Err(KeyParseError::TooShort(s.len()));
}
let mut parts = parts.into_iter();
let keys = parts
.next()
.unwrap()
.split("+")
.map(|key| Key::from_str(key))
.collect::<Result<Vec<Key>, _>>()?;
let command = parts.next().unwrap();
let args: Vec<String> = parts.map(String::from).collect();
Ok(Self {
keys,
command: Box::new(HlwmCommand::from_raw_parts(command, args)?),
})
}
}
impl ToCommandString for Keybind {
fn to_command_string(&self) -> String {
format!("{self}\t{}", self.command.to_command_string())
}
}
impl Keybind {
pub fn new<I: IntoIterator<Item = Key>>(keys: I, command: HlwmCommand) -> Self {
Self {
keys: keys.into_iter().collect(),
command: Box::new(command),
}
}
}
impl Display for Keybind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(
&(&self.keys)
.into_iter()
.map(|key| key.to_string())
.collect::<Vec<String>>()
.join("+"),
)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Mousebind {
pub keys: Vec<Key>,
pub action: MousebindAction,
}
impl FromStr for Mousebind {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s.split("\t");
let keys = parts
.next()
.ok_or(StringParseError::UnknownValue)?
.split("-")
.map(|key| key.parse())
.collect::<Result<Vec<Key>, _>>()?;
let action = parts.next().ok_or(StringParseError::UnknownValue)?;
let mut action = MousebindAction::iter()
.into_iter()
.find(|act| act.to_string() == action)
.ok_or(StringParseError::UnknownValue)?;
if let MousebindAction::Call(_) = action {
let command = parts.next().ok_or(StringParseError::UnknownValue)?;
action = MousebindAction::Call(Box::new(
HlwmCommand::from_raw_parts(command, parts.map(String::from).collect())
.map_err(|_| StringParseError::UnknownValue)?,
));
}
Ok(Self { keys, action })
}
}
impl Mousebind {
pub fn new<I: Iterator<Item = Key>>(mod_key: Key, keys: I, action: MousebindAction) -> Self {
let keys = [mod_key].into_iter().chain(keys).collect();
Self { keys, action }
}
}
impl Default for Mousebind {
fn default() -> Self {
Self {
keys: Default::default(),
action: Default::default(),
}
}
}
impl Display for Mousebind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{keys}\t{action}",
keys = (&self.keys)
.into_iter()
.map(|key| key.to_string())
.collect::<Vec<String>>()
.join("-"),
action = self.action.to_command_string(),
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum KeyUnbind {
Keybind(Vec<Key>),
All,
}
impl FromStr for KeyUnbind {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"--all" => Ok(Self::All),
_ => Ok(KeyUnbind::Keybind(
s.split("-")
.map(|key| key.parse())
.collect::<Result<_, _>>()?,
)),
}
}
}
impl Default for KeyUnbind {
fn default() -> Self {
Self::All
}
}
impl Display for KeyUnbind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KeyUnbind::Keybind(keys) => f.write_str(
&keys
.into_iter()
.map(|k| k.to_string())
.collect::<Vec<String>>()
.join("-"),
),
KeyUnbind::All => f.write_str("--all"),
}
}
}
#[cfg(test)]
mod test {
use serde::{Deserialize, Serialize};
use strum::IntoEnumIterator;
use crate::hlwm::command::HlwmCommand;
use super::{Key, MousebindAction};
#[derive(Debug, Serialize, Deserialize)]
pub struct KeyWrapper {
key: Key,
}
#[test]
fn key_serialize_deserialize() {
for (original_key, wrapper) in Key::iter().map(|key| (key, KeyWrapper { key })) {
let wrapper_str = toml::to_string_pretty(&wrapper).unwrap();
let parsed: KeyWrapper = toml::from_str(&wrapper_str).unwrap();
assert_eq!(original_key, parsed.key);
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MousebindActionWrapper {
action: MousebindAction,
}
#[test]
fn mousebindaction_serialize_deserialize() {
for (original_key, wrapper) in [
MousebindAction::Call(Box::new(HlwmCommand::Cycle)),
MousebindAction::Move,
MousebindAction::Resize,
MousebindAction::Zoom,
]
.map(|action| (action.clone(), MousebindActionWrapper { action }))
{
let wrapper_str = toml::to_string_pretty(&wrapper).unwrap();
let parsed: MousebindActionWrapper = toml::from_str(&wrapper_str).unwrap();
assert_eq!(original_key, parsed.action);
}
}
}

142
src/hlwm/macros.rs Normal file
View File

@ -0,0 +1,142 @@
#[macro_export]
macro_rules! gen_parse {
($command:ident, $args:ident) => {
macro_rules! parse {
(And_Or => $$val:tt) => {
{
let mut args = $args.into_iter();
let separator: Separator = args
.next()
.ok_or(CommandParseError::MissingArgument)?
.parse()?;
let sep_str = separator.to_string();
let args = args.collect::<Vec<_>>();
let commands = args
.split(|itm| itm.eq(&sep_str))
.map(|itm| match itm.len() {
0 => Err(CommandParseError::MissingArgument),
1 => HlwmCommand::from_str(itm.first().unwrap()),
_ => {
let mut args = itm.into_iter();
HlwmCommand::from_raw_parts(
&args.next().unwrap(),
args.map(|i| i.to_owned()).collect(),
)
}
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Self::$$val{
separator,
commands,
})}
};
($$arg_type:tt => $$val:tt) => {
{
#[allow(unused_mut)]
let mut args_iter = $args.into_iter();
Ok(Self::$$val(parse!(Argument args_iter: $$arg_type)))
}
};
($$($$arg:tt: $$arg_type:tt),+ => $val:tt) => {
{
#[allow(unused_mut)]
let mut args_iter = $args.into_iter();
Ok(Self::$val{
$$(
$$arg: parse!(Argument args_iter: $$arg_type)
),+
})
}
};
(Argument $$args:ident: String) => {
$$args
.next()
.ok_or(CommandParseError::BadArgument {
command: $command.to_string(),
})?
};
(Argument $$args:ident: FromStrAll) => {
$$args
.collect::<Vec<_>>()
.join("\t")
.parse()
.map_err(|_| CommandParseError::BadArgument {
command: $command.to_string(),
})?
};
(Argument $$args:ident: FromStr) => {
parse!(Argument $$args: String)
.parse()
.map_err(|_| CommandParseError::BadArgument {
command: $command.to_string(),
})?
};
(Argument $$args:ident: [Option<FromStr>]) => {
{
let args: Vec<_> = $$args.collect();
if args.len() == 0 {
None
} else {
let mut args = args.into_iter();
Some(parse!(Argument args: String)
.parse()
.map_err(|_| CommandParseError::BadArgument {
command: $command.to_string(),
})?)
}
}
};
(Argument $$args:ident: [Vec<String>]) => {
$$args.collect::<Vec<String>>()
};
(Argument $$args:ident: Bool) => {
match parse!(Argument $$args: String).as_str() {
"on" | "true" => true,
"off" | "false" => false,
_ => return Err(CommandParseError::BadArgument {
command: $command.to_string(),
}),
}
};
(Argument $$args:ident: [Option<String>]) => {
{
let args = $$args.collect::<Vec<String>>();
if args.len() == 0 {
None
} else {
Some(args.join("\t"))
}
}
};
(Argument $$args:ident: [Option<Vec<String>>]) => {
{
let args = $$args.collect::<Vec<String>>();
if args.len() == 0 {
None
} else {
Some(args)
}
}
};
(Argument $$args:ident: [Option<FromStr>]) => {
{
let args = $$args.map(|item| item.parse().map_err(|_| CommandParseError::BadArgument {
command: $command.to_string(),
})).collect::<Result<Vec<_>, _>>()?;
if args.len() == 0 {
None
} else {
Some(args)
}
}
};
(Argument $$args:ident: [Vec<FromStr>]) => {
{
$$args.map(|item| item.parse().map_err(|_| CommandParseError::BadArgument {
command: $command.to_string(),
})).collect::<Result<Vec<_>, _>>()?
}
};
}
};
}

465
src/hlwm/mod.rs Normal file
View File

@ -0,0 +1,465 @@
use std::{
convert::Infallible,
fmt::Display,
num::{ParseFloatError, ParseIntError},
os::unix::process::ExitStatusExt,
process::{self, Child, Stdio},
str::FromStr,
};
use log::{debug, error};
use serde::{Deserialize, Serialize};
use strum::IntoEnumIterator;
use thiserror::Error;
use self::{
attribute::{Attribute, AttributeError},
command::{CommandError, HlwmCommand},
key::KeyParseError,
setting::{Setting, SettingName},
window::TagStatus,
};
pub mod attribute;
pub mod color;
pub mod command;
mod hex;
mod hlwmbool;
pub mod hook;
pub mod key;
mod octal;
pub mod rule;
pub mod setting;
mod split;
pub mod theme;
pub mod window;
#[macro_use]
mod macros;
pub use hlwmbool::ToggleBool;
pub type Monitor = u32;
#[derive(Clone, Copy, Debug)]
pub struct Client;
impl Client {
pub fn new() -> Self {
Self
}
#[inline(always)]
fn herbstclient() -> std::process::Command {
std::process::Command::new("herbstclient")
}
/// Run the command and wait for it to finish.
///
/// To run the command and return a handle instead of waiting,
/// see [Client::spawn]
pub fn execute(&self, command: HlwmCommand) -> Result<process::Output, CommandError> {
let mut args = command.args();
debug!("running command: [{}]", (&args).join(" "),);
let output = Self::herbstclient()
.args(args)
.stderr(Stdio::null())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.spawn()?
.wait_with_output()?;
let exit_status = output.status;
if let Some(code) = exit_status.code() {
if code == 0 {
return Ok(output);
} else {
let output = String::from_utf8(output.stdout)
.unwrap_or_default()
.trim()
.to_string();
error!("command failed with error code [{code}]");
if !output.is_empty() {
error!("command output: {output}");
}
return Err(CommandError::StatusCode(
code,
if output.is_empty() {
None
} else {
Some(output)
},
));
}
}
if let Some(signal) = exit_status.signal() {
return Err(CommandError::KilledBySignal {
signal,
core_dumped: exit_status.core_dumped(),
});
}
if let Some(signal) = exit_status.stopped_signal() {
return Err(CommandError::StoppedBySignal(signal));
}
Err(CommandError::OtherExitStatus(exit_status))
}
pub fn execute_iter<I>(&self, commands: I) -> Result<(), (HlwmCommand, CommandError)>
where
I: IntoIterator<Item = HlwmCommand>,
{
for cmd in commands.into_iter() {
if let Err(err) = self.execute(cmd.clone()) {
return Err((cmd, err));
}
}
Ok(())
}
pub fn spawn(self, command: HlwmCommand) -> Result<Child, CommandError> {
Ok(Self::herbstclient().args(command.args()).spawn()?)
}
pub fn set_setting(&self, setting: Setting) -> Result<(), CommandError> {
self.execute(HlwmCommand::Set(setting))?;
Ok(())
}
pub fn get_attr_out(&self, attr: String, out: &mut Attribute) -> Result<(), CommandError> {
Ok(*out = Attribute::new(
&out.type_string(),
&self
.query(HlwmCommand::GetAttr(attr))?
.first()
.ok_or(CommandError::Empty)?,
)?)
}
pub fn get_attr(&self, attr: String) -> Result<Attribute, CommandError> {
let attr_type = self
.query(HlwmCommand::AttrType(attr.clone()))?
.first()
.cloned()
.ok_or(CommandError::Empty)?;
let attr_val = self
.query(HlwmCommand::GetAttr(attr))?
.first()
.cloned()
.ok_or(CommandError::Empty)?;
Ok(Attribute::new(&attr_type, &attr_val)?)
}
pub fn set_attr(&self, path: String, new_value: Attribute) -> Result<(), CommandError> {
self.execute(HlwmCommand::SetAttr { path, new_value })?;
Ok(())
}
pub fn get_setting(&self, setting: SettingName) -> Result<Setting, CommandError> {
Ok(Setting::from_str(&String::from_utf8(
self.execute(HlwmCommand::Get(setting))?.stdout,
)?)
.map_err(|_| StringParseError::UnknownValue)?)
}
pub fn query(&self, command: HlwmCommand) -> Result<Vec<String>, CommandError> {
let lines = String::from_utf8(self.execute(command)?.stdout)?
.split("\n")
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.map(|l| l.to_owned())
.collect::<Vec<_>>();
if lines.is_empty() {
return Err(CommandError::Empty);
}
Ok(lines)
}
pub fn tag_status(&self) -> Result<Vec<TagStatus>, CommandError> {
Ok(self
.query(HlwmCommand::TagStatus { monitor: None })?
.first()
.ok_or(CommandError::Empty)?
.split("\t")
.filter(|f| !f.is_empty())
.map(|i| i.parse())
.collect::<Result<Vec<_>, _>>()?)
}
}
pub trait ToCommandString {
fn to_command_string(&self) -> String;
}
#[derive(Debug, Error)]
pub enum StringParseError {
#[error("unknown value")]
UnknownValue,
#[error("failed parsing float: [{0}]")]
FloatError(#[from] ParseFloatError),
#[error("invalid bool value: [{0}]")]
BoolError(String),
#[error("failed parsing int: [{0}]")]
IntError(#[from] ParseIntError),
#[error("command parse error")]
CommandParseError(String),
#[error("invalid length for part [{1}]: [{0}]")]
InvalidLength(usize, &'static str),
#[error("required arguments missing: [{0}]")]
RequiredArgMissing(String),
#[error("attribute error: [{0}]")]
AttributeError(#[from] AttributeError),
#[error("key parse error: [{0}]")]
KeyParseError(#[from] KeyParseError),
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub enum Tag {
Index(i32),
Name(String),
}
impl FromStr for Tag {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Ok(val) = i32::from_str(s) {
Ok(Self::Index(val))
} else {
Ok(Self::Name(s.to_string()))
}
}
}
impl Default for Tag {
fn default() -> Self {
Self::Index(Default::default())
}
}
impl std::fmt::Display for Tag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Tag::Index(i) => write!(f, "{i}"),
Tag::Name(name) => f.write_str(name),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Index<N>
where
N: PartialEq,
{
Relative(N),
Absolute(N),
}
impl<N> FromStr for Index<N>
where
N: PartialEq + FromStr,
{
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut chars = s.chars();
let prefix = chars.next().ok_or(StringParseError::UnknownValue)?;
match prefix {
'+' => Ok(Self::Relative(
N::from_str(&chars.collect::<String>())
.map_err(|_| StringParseError::UnknownValue)?,
)),
'-' => Ok(Self::Relative(
N::from_str(s).map_err(|_| StringParseError::UnknownValue)?,
)),
_ => Ok(Self::Absolute(
N::from_str(s).map_err(|_| StringParseError::UnknownValue)?,
)),
}
}
}
impl<N> Default for Index<N>
where
N: PartialEq + Default,
{
fn default() -> Self {
Self::Absolute(Default::default())
}
}
impl<N> Display for Index<N>
where
N: Display + PartialOrd + Default + PartialEq,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Index::Relative(rel) => {
if *rel > N::default() {
write!(f, "+{rel}")
} else {
write!(f, "{rel}")
}
}
Index::Absolute(abs) => write!(f, "{abs}"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, strum::EnumIter, PartialEq)]
pub enum Separator {
Comma,
Period,
}
impl FromStr for Separator {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::iter()
.into_iter()
.find(|i| i.to_string() == s)
.ok_or(StringParseError::UnknownValue)
}
}
impl Default for Separator {
fn default() -> Self {
Self::Comma
}
}
impl Display for Separator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Separator::Comma => f.write_str(","),
Separator::Period => f.write_str("."),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, strum::EnumIter, PartialEq)]
pub enum Operator {
/// =
Equal,
/// !=
NotEqual,
/// <=
LessThanEqual,
/// <
LessThan,
/// \>=
GreaterThanEqual,
/// \>
GreaterThan,
}
impl FromStr for Operator {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::iter()
.into_iter()
.find(|i| i.to_string() == s)
.ok_or(StringParseError::UnknownValue)
}
}
impl Default for Operator {
fn default() -> Self {
Self::Equal
}
}
impl Display for Operator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Operator::Equal => f.write_str("="),
Operator::NotEqual => f.write_str("!="),
Operator::LessThanEqual => f.write_str("<="),
Operator::LessThan => f.write_str("<"),
Operator::GreaterThanEqual => f.write_str(">="),
Operator::GreaterThan => f.write_str(">"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, strum::Display, strum::EnumIter, PartialEq)]
#[strum(serialize_all = "snake_case")]
pub enum Direction {
Up,
Down,
Left,
Right,
}
impl FromStr for Direction {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::iter()
.into_iter()
.find(|dir| dir.to_string() == s)
.ok_or(StringParseError::UnknownValue)
}
}
impl Default for Direction {
fn default() -> Self {
Self::Up
}
}
#[derive(Debug, Clone, Serialize, Deserialize, strum::Display, strum::EnumIter, PartialEq)]
#[strum(serialize_all = "snake_case")]
pub enum Align {
Top(Option<f64>),
Left(Option<f64>),
Right(Option<f64>),
Bottom(Option<f64>),
Explode,
Auto,
}
impl FromStr for Align {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s.split("\t");
let align = parts.next().ok_or(StringParseError::UnknownValue)?;
let fraction = match parts.next().map(|f| f64::from_str(f)) {
Some(val) => Some(val?),
None => None,
};
match align {
"bottom" | "vertical" | "horiz" | "v" => Ok(Self::Bottom(fraction)),
"left" => Ok(Self::Left(fraction)),
"right" | "horizontal" | "horiz" | "h" => Ok(Self::Right(fraction)),
"top" => Ok(Self::Top(fraction)),
"explode" => Ok(Self::Explode),
"auto" => Ok(Self::Auto),
_ => Err(StringParseError::UnknownValue),
}
}
}
impl Default for Align {
fn default() -> Self {
Self::Top(None)
}
}
impl ToCommandString for Align {
fn to_command_string(&self) -> String {
match self {
Align::Top(fraction)
| Align::Left(fraction)
| Align::Right(fraction)
| Align::Bottom(fraction) => match fraction {
Some(fraction) => vec![self.to_string(), fraction.to_string()],
None => vec![self.to_string()],
},
Align::Explode | Align::Auto => vec![self.to_string()],
}
.join("\t")
}
}

99
src/hlwm/octal.rs Normal file
View File

@ -0,0 +1,99 @@
use std::num::TryFromIntError;
use thiserror::Error;
macro_rules! octal_digit {
($digit:expr) => {
match $digit {
'0' => Ok(0),
'1' => Ok(1),
'2' => Ok(2),
'3' => Ok(3),
'4' => Ok(4),
'5' => Ok(5),
'6' => Ok(6),
'7' => Ok(7),
_ => Err(OctalError::InvalidCharacter($digit)),
}
};
}
#[derive(Debug, Error, Clone)]
pub enum OctalError {
#[error("invalid/non-octal character: [{0}]")]
InvalidCharacter(char),
#[error("try_from_int failed: [{0}]")]
TryFromIntError(#[from] TryFromIntError),
}
pub trait FromOctal: Sized {
fn from_octal(octal: &str) -> Result<Self, OctalError>;
}
pub trait ParseOctal<T> {
fn parse_octal(&self) -> Result<T, OctalError>;
}
impl<T, V: AsRef<str>> ParseOctal<T> for V
where
T: FromOctal,
{
fn parse_octal(&self) -> Result<T, OctalError> {
T::from_octal(self.as_ref())
}
}
macro_rules! impl_from_octal {
($ty:ty) => {
impl FromOctal for $ty {
fn from_octal(octal: &str) -> Result<Self, OctalError> {
if let Some(without_prefix) = octal.strip_prefix("0o") {
return Self::from_octal(without_prefix);
}
Ok(octal
.chars()
.map(|c| octal_digit!(c))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.rev()
.enumerate()
.map(|(idx, value)| -> Result<_, TryFromIntError> {
Ok((8 as $ty).pow(idx.try_into()?) * value)
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.sum())
}
}
};
($($ty:ty),+) => {
$(
impl_from_octal!($ty);
)+
};
}
impl_from_octal!(i8, i16, i32, i64, u8, u16, u32, u64);
#[cfg(test)]
mod test {
use super::FromOctal;
macro_rules! octal {
($val:literal) => {
(stringify!($val), $val as u32)
};
}
#[test]
fn test_hex_parse() {
for (str, expected) in [
octal!(0o15),
octal!(0o777),
octal!(0o1337),
octal!(0o0),
octal!(0o10),
] {
let actual = u32::from_octal(str).expect("from_hex");
assert_eq!(expected, actual);
}
}
}

666
src/hlwm/rule.rs Normal file
View File

@ -0,0 +1,666 @@
use std::{
borrow::BorrowMut,
fmt::{Display, Write},
str::FromStr,
};
use serde::{de::Expected, Deserialize, Serialize};
use strum::IntoEnumIterator;
use super::{hlwmbool, hook::Hook, split, StringParseError, ToCommandString};
#[derive(Debug, Clone, PartialEq)]
pub struct Rule {
/// Rule labels default to an incremental index.
/// These default labels are unique, unless you assign a different
/// rule a custom integer LABEL. Default labels can be captured
/// with the printlabel flag.
label: Option<String>,
flag: Option<Flag>,
/// If each condition of this rule matches against this client,
/// then every [Consequence] is executed.
/// (If there are no conditions given, then this rule is executed for each client)
condition: Option<Condition>,
consequences: Vec<Consequence>,
}
impl Default for Rule {
fn default() -> Self {
Self {
label: Default::default(),
flag: Default::default(),
condition: Some(Condition::FixedSize),
consequences: vec![Consequence::Focus(true)],
}
}
}
impl ToCommandString for Rule {
fn to_command_string(&self) -> String {
let mut args = Vec::with_capacity(4);
if let Some(label) = self.label.as_ref() {
args.push(format!("--label={}", label));
}
if let Some(flag) = self.flag.as_ref() {
args.push(format!("{flag}"));
}
if let Some(cond) = self.condition.as_ref() {
args.push(cond.to_command_string());
}
args.push(
(&self.consequences)
.into_iter()
.map(|c| format!("--{}", c.to_command_string()))
.collect::<Vec<_>>()
.join("\t"),
);
args.join("\t")
}
}
impl Rule {
pub fn new(
condition: Option<Condition>,
consequences: Vec<Consequence>,
label: Option<String>,
flag: Option<Flag>,
) -> Self {
Self {
label,
flag,
condition,
consequences,
}
}
}
impl FromStr for Rule {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts = split::tab_or_space(s);
if parts.is_empty() {
return Err(StringParseError::InvalidLength(0, "parse rule"));
}
let mut condition: Option<Condition> = None;
let mut consequences = vec![];
let mut label: Option<String> = None;
let mut flag: Option<Flag> = None;
for arg in (&parts)
.into_iter()
.map(|a| a.strip_prefix("--"))
.filter(|a| a.is_some())
{
let arg = arg.unwrap();
if condition.is_none() && arg == Condition::FixedSize.to_string() {
condition = Some(Condition::FixedSize);
continue;
}
let parts = arg.split(['=', '~']).collect::<Vec<_>>();
if parts.len() != 2 {
return Err(StringParseError::InvalidLength(parts.len(), "rule=parts"));
}
let mut parts = parts.into_iter();
let (name, value) = (parts.next().unwrap(), parts.next().unwrap());
if name == "label" && label.is_none() {
label = Some(value.to_string());
continue;
}
if condition.is_none() && Condition::iter().any(|prop| prop.to_string() == name) {
condition = Some(match Condition::from_str(arg) {
Ok(arg) => arg,
Err(err) => panic!("<<condition>>\n\n{err}\n\n\n"),
});
continue;
}
if Consequence::iter().any(|cons| cons.to_string() == name) {
consequences.push(match Consequence::from_str(arg) {
Ok(arg) => arg,
Err(err) => panic!("<<consequence>>\n\n{err}\n\n\n"),
});
continue;
}
}
let mut args = parts.into_iter().filter(|a| !a.starts_with("--"));
while let Some(arg) = args.next() {
if flag.is_none() {
if let Ok(flag_res) = Flag::from_str(&arg) {
flag = Some(flag_res);
continue;
}
}
if condition.is_none() {
if let Ok(cond) = Condition::from_str(&arg) {
condition = Some(cond);
continue;
}
}
if let Ok(cons) = Consequence::from_str(&arg) {
consequences.push(cons);
continue;
}
}
if consequences.is_empty() {
return Err(StringParseError::RequiredArgMissing(
"condition and/or consequences".into(),
));
}
Ok(Self {
label,
flag,
condition: condition,
consequences: consequences,
})
}
}
impl Serialize for Rule {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_command_string().replace("\t", " "))
}
}
impl<'de> Deserialize<'de> for Rule {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
pub struct Expect;
impl Expected for Expect {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "a valid herbstluftwm rule")
}
}
let str_val: String = Deserialize::deserialize(deserializer)?;
Ok(Self::from_str(&str_val).map_err(|_| {
serde::de::Error::invalid_value(serde::de::Unexpected::Str(&str_val), &Expect)
})?)
}
}
#[derive(Debug, Clone, PartialEq, strum::EnumIter)]
pub enum RuleOperator {
/// ~ matches if clients property is matched by the regex value.
Regex,
/// = matches if clients property string is equal to value.
Equal,
}
impl RuleOperator {
pub const fn char(&self) -> char {
match self {
RuleOperator::Regex => '~',
RuleOperator::Equal => '=',
}
}
pub const fn match_set() -> [char; 2] {
[Self::Regex.char(), Self::Equal.char()]
}
}
impl TryFrom<char> for RuleOperator {
type Error = StringParseError;
fn try_from(value: char) -> Result<Self, Self::Error> {
Self::iter()
.find(|i| i.char() == value)
.ok_or(StringParseError::UnknownValue)
}
}
impl Default for RuleOperator {
fn default() -> Self {
Self::Equal
}
}
impl Display for RuleOperator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_char(self.char())
}
}
impl FromStr for RuleOperator {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.chars()
.next()
.ok_or(StringParseError::InvalidLength(s.len(), "rule operator"))?
.try_into()
}
}
#[derive(Debug, Clone, PartialEq, strum::Display, strum::EnumIter)]
#[strum(serialize_all = "lowercase")]
pub enum Condition {
/// the first entry in clients WM_CLASS.
Instance {
operator: RuleOperator,
value: String,
},
/// the second entry in clients WM_CLASS.
Class {
operator: RuleOperator,
value: String,
},
/// clients window title.
Title {
operator: RuleOperator,
value: String,
},
/// the clients process id (Warning: the pid is not available for every client.
/// This only matches if the client sets _NET_WM_PID to the pid itself).
Pid {
operator: RuleOperator,
value: String,
},
/// this clients process group id. Since the pgid of a window is derived
/// from its pid the same restrictions apply as above.
Pgid {
operator: RuleOperator,
value: String,
},
/// matches if the age of the rule measured in seconds does not exceed value.
/// This condition only can be used with the = operator. If maxage already is
/// exceeded (and never will match again), then this rule is removed.
/// (With this you can build rules that only live for a certain time.)
MaxAge {
operator: RuleOperator,
value: String,
},
/// matches the _NET_WM_WINDOW_TYPE property of a window.
/// If _NET_WM_WINDOW_TYPE has multiple entries, then only the first entry is used here.
WindowType {
operator: RuleOperator,
value: String,
},
/// matches the WM_WINDOW_ROLE property of a window if it is set by the window.
WindowRole {
operator: RuleOperator,
value: String,
},
/// matches if the window does not allow being resized (i.e. if the minimum
/// size matches the maximum size). This condition does not take a parameter.
FixedSize,
}
impl ToCommandString for Condition {
fn to_command_string(&self) -> String {
match self {
Condition::Instance { operator, value }
| Condition::Class { operator, value }
| Condition::Title { operator, value }
| Condition::Pid { operator, value }
| Condition::Pgid { operator, value }
| Condition::MaxAge { operator, value }
| Condition::WindowType { operator, value }
| Condition::WindowRole { operator, value } => format!("--{self}{operator}{value}"),
// Note: There might be a bug where if you use --fixedsize
// herbstclient treats it as if fixedsize requires an argument.
//
// To deal with this on our end, we omit the -- prefix for fixedsize
Condition::FixedSize => self.to_string(),
}
}
}
impl FromStr for Condition {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// Handle the case for fixedsize first so that we can treat the rest
// of the variants as having arguments
if s == Self::FixedSize.to_string() {
return Ok(Self::FixedSize);
}
let ((name, match_val), match_char) =
match split::on_first_match(s, &RuleOperator::match_set()) {
Some(parts) => parts,
None => return Err(StringParseError::InvalidLength(1, "property")),
};
let mut prop = Self::iter()
.find(|i| i.to_string() == name)
.ok_or(StringParseError::UnknownValue)?;
match prop.borrow_mut() {
Condition::Instance { operator, value }
| Condition::Class { operator, value }
| Condition::Title { operator, value }
| Condition::Pid { operator, value }
| Condition::Pgid { operator, value }
| Condition::MaxAge { operator, value }
| Condition::WindowType { operator, value }
| Condition::WindowRole { operator, value } => {
*operator = match_char.try_into()?;
*value = match_val;
}
// Should be handled at the top of the function. If it's here that's not a valid
// use of fixedsize.
Condition::FixedSize => return Err(StringParseError::UnknownValue),
};
Ok(prop)
}
}
#[derive(Debug, Clone, PartialEq, strum::Display, strum::EnumIter)]
#[strum(serialize_all = "lowercase")]
pub enum Consequence {
/// moves the client to tag value.
Tag(String),
/// moves the client to the tag on monitor VALUE.
/// If the tag consequence was also specified,
/// and switchtag is set for the client, move the client to that tag,
/// then display that tag on monitor VALUE. If the tag consequence was specified,
/// but switchtag was not, ignore this consequence.
Monitor(String),
/// decides whether the client gets the input focus in its tag. The default is off.
Focus(bool),
/// if focus is activated and the client is put to a not focused tag,
/// then switchtag tells whether the clients tag will be shown or not.
/// If the tag is shown on any monitor but is not focused, the clients tag
/// only is brought to the current monitor if swap_monitors_to_get_tag is activated.
SwitchTag(bool),
/// decides whether the client will be managed or not. The default is on.
Manage(bool),
/// moves the window to a specified index in the tree.
Index(i32),
/// sets the floating state of the client.
Floating(bool),
/// sets the pseudotile state of the client.
PseudoTile(bool),
/// sets the sticky attribute of the client.
Sticky(bool),
/// sets whether the window state (the fullscreen state and the demands attention flag)
/// can be changed by the application via ewmh itself. This does not affect the
/// initial fullscreen state requested by the window.
EwmhRequests(bool),
/// sets whether hlwm should let the client know about EMWH changes
/// (currently only the fullscreen state). If this is set, applications do
/// not change to their fullscreen-mode while still being fullscreen.
EwmhNotify(bool),
/// sets the fullscreen flag of the client.
Fullscreen(bool),
/// emits the custom hook rule VALUE WINID when this rule is triggered
/// by a new window with the id WINID. This consequence can be used multiple times,
/// which will cause a hook to be emitted for each occurrence of a hook consequence.
Hook(Hook),
/// sets the keymask for a client.
/// A regular expression that is matched against the string representation of
/// all key bindings (as they are printed by list_keybinds).
/// While this client is focused, only bindings that match the expression will be active.
/// Any other bindings will be disabled. The default keymask is an empty string (),
/// which does not disable any keybinding
KeyMask(String),
/// sets a regex that determines which key bindings are inactive
/// for a client.
/// A regular expression that describes which keybindings are inactive while
/// the client is focused. If a key combination is pressed and its string
/// representation (as given by list_keybinds) matches the regex,
/// then the key press is propagated to the client
KeysInactive(String),
/// changes the floating position of a window
FloatPlacement(FloatPlacement),
/// Sets the clients floating_geometry attribute.
/// The VALUE is a rectangle, interpreted relatively to the monitor.
/// If floatplacement is also specified for the client (possibly by another rule),
/// then only the size of the floating_geometry is used.
/// In order to force the position from the geometry, it is necessary to add
/// floatplacement=none.
FloatingGeometry { x: u32, y: u32 },
}
impl ToCommandString for Consequence {
fn to_command_string(&self) -> String {
match self {
Consequence::Focus(value)
| Consequence::SwitchTag(value)
| Consequence::Manage(value)
| Consequence::Floating(value)
| Consequence::PseudoTile(value)
| Consequence::Sticky(value)
| Consequence::EwmhRequests(value)
| Consequence::EwmhNotify(value)
| Consequence::Fullscreen(value) => vec![
self.to_string(),
match value {
true => "on".into(),
false => "off".into(),
},
],
Consequence::Tag(value)
| Consequence::Monitor(value)
| Consequence::KeyMask(value)
| Consequence::KeysInactive(value) => vec![self.to_string(), value.to_string()],
Consequence::Index(value) => vec![self.to_string(), value.to_string()],
Consequence::Hook(value) => vec![self.to_string(), value.to_string()],
Consequence::FloatPlacement(value) => vec![self.to_string(), value.to_string()],
Consequence::FloatingGeometry { x, y } => vec![self.to_string(), format!("{x}x{y}")],
}
.join("=")
}
}
impl FromStr for Consequence {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts = split::tab_or_space(s);
let mut parts = parts.into_iter();
let name = parts.next().unwrap();
let (name, value_str) = if name.contains('=') {
let parts = name.split('=').collect::<Vec<_>>();
if parts.len() != 2 {
return Err(StringParseError::UnknownValue);
}
let mut parts = parts.into_iter();
(
parts.next().unwrap().to_string(),
parts.next().unwrap().to_string(),
)
} else {
match parts.next() {
Some(op) => {
if op != "=" {
return Err(StringParseError::UnknownValue);
}
}
None => return Err(StringParseError::UnknownValue),
};
let value = parts.collect::<Vec<_>>().join("\t");
(name, value)
};
let mut cons = Self::iter()
.find(|i| i.to_string() == name)
.ok_or(StringParseError::UnknownValue)?;
match cons.borrow_mut() {
Consequence::Focus(value)
| Consequence::SwitchTag(value)
| Consequence::Floating(value)
| Consequence::PseudoTile(value)
| Consequence::Sticky(value)
| Consequence::EwmhRequests(value)
| Consequence::EwmhNotify(value)
| Consequence::Fullscreen(value)
| Consequence::Manage(value) => {
*value = hlwmbool::from_hlwm_string(&value_str)?;
}
Consequence::Tag(value)
| Consequence::Monitor(value)
| Consequence::KeyMask(value)
| Consequence::KeysInactive(value) => *value = value_str,
Consequence::Index(value) => *value = i32::from_str(&value_str)?,
Consequence::Hook(value) => {
*value = Hook::from_str(&value_str).map_err(|_| StringParseError::UnknownValue)?
}
Consequence::FloatPlacement(value) => *value = FloatPlacement::from_str(&value_str)?,
Consequence::FloatingGeometry { x, y } => {
let mut values = value_str.split('=');
*x = u32::from_str(values.next().ok_or(StringParseError::UnknownValue)?)
.map_err(|_| StringParseError::UnknownValue)?;
*y = u32::from_str(values.next().ok_or(StringParseError::UnknownValue)?)
.map_err(|_| StringParseError::UnknownValue)?;
}
};
Ok(cons)
}
}
#[derive(Debug, Clone, PartialEq, strum::Display, strum::EnumIter)]
#[strum(serialize_all = "lowercase")]
pub enum FloatPlacement {
/// does not change the placement at all
None,
/// centers the window on the monitor
Center,
/// tries to place it with as little overlap to other floating windows as possible.
/// If there are multiple options with the least overlap, then the position with
/// the least overlap to tiling windows is chosen
Smart,
}
impl FromStr for FloatPlacement {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::iter()
.find(|i| i.to_string() == s)
.ok_or(StringParseError::UnknownValue)
}
}
impl Default for FloatPlacement {
fn default() -> Self {
Self::Smart
}
}
#[derive(Debug, Clone, PartialEq, strum::EnumIter)]
pub enum Flag {
/// negates the next CONDITION.
Not,
/// only apply this rule once (and delete it afterwards).
Once,
/// prints the label of the newly created rule to stdout.
PrintLabel,
/// prepend the rule to the list of rules instead of appending it.
/// So its consequences may be overwritten by already existing rules.
Prepend,
}
impl Display for Flag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Flag::Not => f.write_str("not"),
Flag::Once => f.write_str("once"),
Flag::PrintLabel => f.write_str("printlabel"),
Flag::Prepend => f.write_str("prepend"),
}
}
}
impl FromStr for Flag {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"!" | "not" => Ok(Self::Not),
"once" => Ok(Self::Once),
"printlabel" => Ok(Self::PrintLabel),
"prepend" => Ok(Self::Prepend),
_ => Err(StringParseError::UnknownValue),
}
}
}
#[macro_export]
macro_rules! rule {
($consequence:expr) => {
crate::hlwm::rule::Rule::new(None, vec![$consequence], None, None)
};
($condition:expr, $consequence:expr) => {
crate::hlwm::rule::Rule::new(Some($condition), vec![$consequence], None, None)
};
($flag:tt: $condition:expr, $consequence:expr) => {
crate::hlwm::rule::Rule::new(
Some($condition),
vec![$consequence],
None,
Some(rule!(Flag $flag)),
)
};
($label:literal => $consequence:expr) => {
crate::hlwm::rule::Rule::new(None, vec![$consequence], Some($label.into()), None)
};
($label:literal => $condition:expr, $consequence:expr) => {
crate::hlwm::rule::Rule::new(Some($condition), vec![$consequence], Some($label.into()), None)
};
($label:literal => $flag:tt: $condition:expr, $consequence:expr) => {
crate::hlwm::rule::Rule::new(Some($condition), vec![$consequence], Some($label.into()), Some(rule!(Flag $flag)))
};
(Flag Not) => {
crate::hlwm::rule::Flag::Not
};
(Flag Once) => {
crate::hlwm::rule::Flag::Once
};
(Flag PrintLabel) => {
crate::hlwm::rule::Flag::PrintLabel
};
(Flag Prepend) => {
crate::hlwm::rule::Flag::Prepend
};
(Flag $other:tt) => {
compile_error!("flag can be one of: Not, Once, PrintLabel, Prepend")
}
}
#[cfg(test)]
mod test {
use serde::{Deserialize, Serialize};
use super::{Condition, Consequence, Flag, Rule, RuleOperator};
#[derive(Debug, Deserialize, Serialize)]
pub struct RuleWrapper {
rule: Rule,
}
#[test]
fn rule_serialize_deserialize() {
for rule in [Rule::new(
Some(Condition::Class {
operator: RuleOperator::Equal,
value: "Netscape".into(),
}),
vec![Consequence::Tag(1.to_string())],
Some("label".into()),
Some(Flag::Not),
)] {
let serialized = toml::to_string_pretty(&RuleWrapper { rule: rule.clone() })
.expect("serializing rule");
let deserialized: RuleWrapper =
toml::from_str(&serialized).expect("deserializing rule");
assert_eq!(rule, deserialized.rule);
}
}
}

481
src/hlwm/setting.rs Normal file
View File

@ -0,0 +1,481 @@
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::{gen_parse, hlwm::command::CommandParseError};
use strum::IntoEnumIterator;
use super::{
color::Color,
hlwmbool::ToggleBool,
split::{self},
StringParseError, ToCommandString,
};
#[derive(Debug, Clone, strum::Display, strum::EnumIter, PartialEq, strum::EnumDiscriminants)]
#[strum_discriminants(
name(SettingName),
derive(strum::Display, strum::EnumIter),
strum(serialize_all = "snake_case")
)]
#[strum(serialize_all = "snake_case")]
pub enum Setting {
/// If set, detect_monitors is automatically executed every time a monitor is connected, disconnected or resized.
AutoDetectMonitors(ToggleBool),
/// If set, EWMH panels are automatically detected and reserve space at the side of the monitors
/// they are on (via pad attributes of each monitor).
/// This setting is activated per default.
AutoDetectPanels(ToggleBool),
/// This setting controls the behaviour of focus and shift if no -e or -i argument is given.
/// If set, then focus and shift changes the focused frame even if there are other clients
/// in this frame in the specified direction.
/// Else, a client within current frame is selected if it is in the specified direction.
DefaultDirectionExternalOnly(ToggleBool),
/// Name of the layout algorithm, which is used if a new frame is created (on a new tag or by a non-trivial split).
DefaultFrameLayout(FrameLayout),
/// String to append when window or tab titles are shortened to fit in the available space
Ellipsis(String),
/// If set, commands [HlwmCommand::Focus] and [HlwmCommand::Shift] cross monitor boundaries.
/// If there is no client in the direction given to focus, then the monitor in the specified
/// direction is focused.
/// Similarly, if shift cannot move a window within a tag, the window is moved to the
/// neighbour monitor in the desired direction.
FocusCrossesMonitorBoundaries(ToggleBool),
/// If set and a window is focused by mouse cursor, this window is focused
/// (this feature is also known as sloppy focus).
/// If unset, you need to click to change the window focus by mouse.
///
/// If another window is hidden by the focus change
/// (e.g. when having pseudotiled windows in the max layout)
/// then an extra click is required to change the focus.
FocusFollowsMouse(ToggleBool),
/// If set, only pagers and taskbars are allowed to change the focus. If unset, all applications can request a
/// focus change
FocusStealingPrevention(ToggleBool),
/// Focused frame opacity in percent. Requires a running compositing manager to take actual effect.
FrameActiveOpacity(u8),
/// The fill color of a focused frame
FrameBgActiveColor(Color),
/// The fill color of an unfocused frame
/// It is only visible if non-focused frames are configured to be visible, see [ShowFrameDecoration].
FrameBgNormalColor(Color),
/// If set, the background of frames are transparent.
/// That means a rectangle is cut out from the inner such that only the frame border
/// and a stripe of width [Setting::FrameTransparentWidth] can be seen.
/// Use [Setting::FrameActiveOpacity] and [Setting::FrameNormalOpacity] for real transparency.
FrameBgTransparent(ToggleBool),
/// The border color of a focused frame
FrameBorderActiveColor(Color),
/// The color of the inner border of a frame
FrameBorderInnerColor(Color),
/// The width of the inner border of a frame.
/// Must be less than [Setting::FrameBorderWidth], since it does not add to the frame border width but is a
/// part of it.
FrameBorderInnerWidth(u32),
/// The border color of an unfocused frame
FrameBorderNormalColor(Color),
/// Border width of a frame
FrameBorderWidth(u32),
/// The gap between frames in the tiling mode
FrameGap(u32),
/// Unfocused frame opacity in percent.
/// Requires a running compositing manager to take actual effect
FrameNormalOpacity(u8),
/// The padding within a frame in the tiling mode
/// i.e. the space between the border of a frame and the windows within it
FramePadding(u32),
/// Specifies the width of the remaining frame colored with [Setting::FrameBgActiveColor]
/// if [Setting::FrameBgTransparent] is set
FrameTransparentWidth(u32),
/// This setting affects the size of the last client in a frame that is arranged by grid layout.
/// If set, then the last client always fills the gap within this frame.
/// If unset, then the last client has the same size as all other clients in this frame
GaplessGrid(ToggleBool),
/// If activated, windows are explicitly hidden when they are covered by another window in a frame with max layout.
/// This only has a visible effect if a compositor is used.
/// If activated, shadows do not stack up and transparent windows show the wallpaper behind them instead of
/// the other clients in the max layout.
HideCoveredWindows(ToggleBool),
/// If greater than 0, then the clients on all monitors arent moved or resized anymore.
/// If it is set to 0, then the arranging of monitors is enabled again, and all monitors are rearranged
/// if their content has changed in the meantime.
/// You should not change this setting manually due to concurrency issues;
/// use the commands [HlwmCommand::Lock] and [HlwmCommand::Unlock] instead.
MonitorsLocked(u8),
/// Specifies the gap around a monitor.
/// If the monitor is selected and the mouse position would be restored into this gap,
/// it is set to the center of the monitor.
/// This is useful, when the monitor was left via mouse movement, but is reselected by keyboard.
/// If the gap is 0 (default), the mouse is never recentered
MouseRecenterGap(u32),
/// If greater than 0, it specifies the least distance between a centered pseudotile window
/// and the border of the frame or tile it is assigned to.
/// If this distance is lower than pseudotile_center_threshold, it is aligned to the top left
/// of the clients tile.
PseudotileCenterThreshold(u32),
/// If set, a window is raised if it is clicked. The value of this setting is only noticed in floating mode
RaiseOnClick(ToggleBool),
/// If set, a window is raised if it is focused. The value of this setting is only used in floating mode
RaiseOnFocus(ToggleBool),
/// If set, a window is raised temporarily if it is focused on its tag.
/// Temporarily in this case means that the window will return to its previous stacking position
/// if another window is focused
RaiseOnFocusTemporarily(ToggleBool),
/// This controls, which frame decorations are shown (or none at all)
ShowFrameDecorations(ShowFrameDecoration),
/// See the docs for members of [SmartFrameSurroundings]
SmartFrameSurroundings(SmartFrameSurroundings),
/// If set, window borders and gaps will be removed and minimal when theres no
/// ambiguity regarding the focused window.
/// This minimal window decoration can be configured by the `theme.minimal` object.
SmartWindowSurroundings(ToggleBool),
/// If a client is dragged in floating mode, then it snaps to neighbour clients
/// if the distance between them is smaller than SnapDistance
SnapDistance(u32),
/// Specifies the remaining gap if a dragged client snaps to an edge in floating mode.
/// If SnapGap is set to 0, no gap will remain
SnapGap(u32),
/// If set: If you want to view a tag, that already is viewed on another monitor,
/// then the monitor contents will be swapped and you see the wanted tag on the focused monitor.
/// If not set, the other monitor is focused if it shows the desired tag.
SwapMonitorsToGetTag(ToggleBool),
/// If activated, multiple windows in a frame with the max layout algorithm are drawn as tabs
TabbedMax(ToggleBool),
/// It contains the chars that are used to print a nice ascii tree.
/// It must contain at least 8 characters. e.g. `X|:#+*-.` produces a tree like:
/// ```
/// X-.
/// #-. child 0
/// | #-* child 00
/// | +-* child 01
/// +-. child 1
/// : #-* child 10
/// : +-* child 11
/// ```
/// Useful values for tree_style are: `╾│ ├└╼─┐` or `-| |'--.` or `╾│ ├╰╼─╮.`
TreeStyle(String),
/// If set, a client's window content is resized immediately during resizing it with the mouse.
/// If unset, the client's content is resized after the mouse button is released.
UpdateDraggedClients(ToggleBool),
/// If set, verbose output is logged to herbstluftwms stderr.
/// The default value is controlled by the `--verbose` command line flag
Verbose(ToggleBool),
/// Border color of a focused window
/// **Warning:**
/// This only exists for compatibility reasons; it is only an alias for the attribute `theme.active.color`
WindowBorderActiveColor(Color),
/// Color of the inner border of a window.
/// **Warning:**
/// This only exists for compatibility reasons; it is only an alias for the attribute `theme.inner_color`
WindowBorderInnerColor(Color),
/// The width of the inner border of a window.
/// Must be less than window_border_width, since it does not add to the window border width but is a part of it.
/// **Warning:**
/// This only exists for compatibility reasons; it is only an alias for the attribute `theme.inner_width`
WindowBorderInnerWidth(u32),
/// Border color of an unfocused window
/// **Warning:**
/// This only exists for compatibility reasons; it is only an alias for the attribute `theme.normal.color`
WindowBorderNormalColor(Color),
/// Border color of an unfocused but urgent window.
/// **Warning:**
/// This only exists for compatibility reasons; it is only an alias for the attribute `theme.urgent.color`
WindowBorderUrgentColor(Color),
/// Border width of a window
/// **Warning:**
/// This only exists for compatibility reasons; it is only an alias for the attribute `theme.border_width`
WindowBorderWidth(u32),
/// The gap between windows within one frame in the tiling mode.
WindowGap(u32),
/// It controls the value of the `_NET_WM_NAME` property on the root window,
/// which specifies the name of the running window manager.
/// The value of this setting is not updated if the actual `_NET_WM_NAME` property
/// on the root window is changed externally. Example usage:
///
/// ```
/// cycle_value wmname herbstluftwm LG3D
/// ```
Wmname(String),
}
impl Default for SettingName {
fn default() -> Self {
SettingName::AutoDetectMonitors
}
}
impl FromStr for SettingName {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::iter()
.find(|i| i.to_string() == s)
.ok_or(StringParseError::UnknownValue)
}
}
impl Serialize for Setting {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_command_string().replace("\t", " = "))
}
}
impl<'de> Deserialize<'de> for Setting {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let str_val: String = Deserialize::deserialize(deserializer)?;
let ((command, arg), _) = split::on_first_match(&str_val, &['='])
.ok_or(CommandParseError::InvalidArgumentCount(0, "setting".into()))
.map_err(|err| {
serde::de::Error::invalid_value(serde::de::Unexpected::Str(&str_val), &err)
})?;
Ok(
Self::from_raw_parts(&command.trim(), &arg.trim()).map_err(|err| {
serde::de::Error::invalid_value(serde::de::Unexpected::Str(&str_val), &err)
})?,
)
}
}
impl FromStr for Setting {
type Err = CommandParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let ((command, arg), _) = split::on_first_match(s, &['\t', ' '])
.ok_or(CommandParseError::InvalidArgumentCount(0, "setting".into()))?;
Self::from_raw_parts(&command.trim(), &arg.trim())
}
}
impl Setting {
pub fn from_raw_parts(command: &str, arg: &str) -> Result<Self, CommandParseError> {
let command = Self::iter()
.find(|cmd| cmd.to_string() == command)
.ok_or(CommandParseError::UnknownCommand(command.to_string()))?;
let args = [arg.to_string()];
gen_parse!(command, args);
match command {
Setting::Verbose(_) => parse!(FromStr => Verbose),
Setting::TabbedMax(_) => parse!(FromStr => TabbedMax),
Setting::GaplessGrid(_) => parse!(FromStr => GaplessGrid),
Setting::RaiseOnClick(_) => parse!(FromStr => RaiseOnClick),
Setting::RaiseOnFocus(_) => parse!(FromStr => RaiseOnFocus),
Setting::AutoDetectPanels(_) => parse!(FromStr => AutoDetectPanels),
Setting::FocusFollowsMouse(_) => parse!(FromStr => FocusFollowsMouse),
Setting::HideCoveredWindows(_) => parse!(FromStr => HideCoveredWindows),
Setting::AutoDetectMonitors(_) => parse!(FromStr => AutoDetectMonitors),
Setting::FrameBgTransparent(_) => parse!(FromStr => FrameBgTransparent),
Setting::SwapMonitorsToGetTag(_) => parse!(FromStr => SwapMonitorsToGetTag),
Setting::UpdateDraggedClients(_) => parse!(FromStr => UpdateDraggedClients),
Setting::FocusStealingPrevention(_) => parse!(FromStr => FocusStealingPrevention),
Setting::RaiseOnFocusTemporarily(_) => parse!(FromStr => RaiseOnFocusTemporarily),
Setting::SmartWindowSurroundings(_) => parse!(FromStr => SmartWindowSurroundings),
Setting::DefaultDirectionExternalOnly(_) => {
parse!(FromStr => DefaultDirectionExternalOnly)
}
Setting::FocusCrossesMonitorBoundaries(_) => {
parse!(FromStr => FocusCrossesMonitorBoundaries)
}
Setting::FrameBgActiveColor(_) => parse!(FromStr => FrameBgActiveColor),
Setting::FrameBgNormalColor(_) => parse!(FromStr => FrameBgNormalColor),
Setting::DefaultFrameLayout(_) => parse!(FromStr => DefaultFrameLayout),
Setting::ShowFrameDecorations(_) => parse!(FromStr => ShowFrameDecorations),
Setting::FrameBorderInnerColor(_) => parse!(FromStr => FrameBorderInnerColor),
Setting::FrameBorderActiveColor(_) => parse!(FromStr => FrameBorderActiveColor),
Setting::FrameBorderNormalColor(_) => parse!(FromStr => FrameBorderNormalColor),
Setting::SmartFrameSurroundings(_) => parse!(FromStr => SmartFrameSurroundings),
Setting::WindowBorderInnerColor(_) => parse!(FromStr => WindowBorderInnerColor),
Setting::WindowBorderActiveColor(_) => parse!(FromStr => WindowBorderActiveColor),
Setting::WindowBorderNormalColor(_) => parse!(FromStr => WindowBorderNormalColor),
Setting::WindowBorderUrgentColor(_) => parse!(FromStr => WindowBorderUrgentColor),
Setting::SnapGap(_) => parse!(FromStr => SnapGap),
Setting::FrameGap(_) => parse!(FromStr => FrameGap),
Setting::WindowGap(_) => parse!(FromStr => WindowGap),
Setting::SnapDistance(_) => parse!(FromStr => SnapDistance),
Setting::FramePadding(_) => parse!(FromStr => FramePadding),
Setting::MonitorsLocked(_) => parse!(FromStr => MonitorsLocked),
Setting::MouseRecenterGap(_) => parse!(FromStr => MouseRecenterGap),
Setting::FrameBorderWidth(_) => parse!(FromStr => FrameBorderWidth),
Setting::WindowBorderWidth(_) => parse!(FromStr => WindowBorderWidth),
Setting::FrameActiveOpacity(_) => parse!(FromStr => FrameActiveOpacity),
Setting::FrameNormalOpacity(_) => parse!(FromStr => FrameNormalOpacity),
Setting::FrameTransparentWidth(_) => parse!(FromStr => FrameTransparentWidth),
Setting::FrameBorderInnerWidth(_) => parse!(FromStr => FrameBorderInnerWidth),
Setting::WindowBorderInnerWidth(_) => parse!(FromStr => WindowBorderInnerWidth),
Setting::PseudotileCenterThreshold(_) => parse!(FromStr => PseudotileCenterThreshold),
Setting::Wmname(_) => parse!(String => Wmname),
Setting::Ellipsis(_) => parse!(String => Ellipsis),
Setting::TreeStyle(_) => parse!(String => TreeStyle),
}
}
}
impl Default for Setting {
fn default() -> Self {
Self::AutoDetectMonitors(ToggleBool::Bool(true))
}
}
impl ToCommandString for Setting {
fn to_command_string(&self) -> String {
match self {
Setting::AutoDetectMonitors(value)
| Setting::AutoDetectPanels(value)
| Setting::DefaultDirectionExternalOnly(value)
| Setting::FocusCrossesMonitorBoundaries(value)
| Setting::FocusFollowsMouse(value)
| Setting::FocusStealingPrevention(value)
| Setting::FrameBgTransparent(value)
| Setting::GaplessGrid(value)
| Setting::HideCoveredWindows(value)
| Setting::RaiseOnClick(value)
| Setting::RaiseOnFocus(value)
| Setting::RaiseOnFocusTemporarily(value)
| Setting::SmartWindowSurroundings(value)
| Setting::TabbedMax(value)
| Setting::UpdateDraggedClients(value)
| Setting::Verbose(value)
| Setting::SwapMonitorsToGetTag(value) => vec![self.to_string(), value.to_string()],
Setting::TreeStyle(value) | Setting::Wmname(value) | Setting::Ellipsis(value) => {
vec![self.to_string(), value.to_string()]
}
Setting::FrameActiveOpacity(value)
| Setting::FrameNormalOpacity(value)
| Setting::MonitorsLocked(value) => vec![self.to_string(), value.to_string()],
Setting::FrameBorderInnerWidth(value)
| Setting::FrameBorderWidth(value)
| Setting::FrameGap(value)
| Setting::FramePadding(value)
| Setting::FrameTransparentWidth(value)
| Setting::MouseRecenterGap(value)
| Setting::PseudotileCenterThreshold(value)
| Setting::SnapDistance(value)
| Setting::SnapGap(value)
| Setting::WindowBorderInnerWidth(value)
| Setting::WindowBorderWidth(value)
| Setting::WindowGap(value) => vec![self.to_string(), value.to_string()],
Setting::FrameBgActiveColor(value)
| Setting::FrameBgNormalColor(value)
| Setting::FrameBorderActiveColor(value)
| Setting::FrameBorderInnerColor(value)
| Setting::FrameBorderNormalColor(value)
| Setting::WindowBorderActiveColor(value)
| Setting::WindowBorderInnerColor(value)
| Setting::WindowBorderNormalColor(value)
| Setting::WindowBorderUrgentColor(value) => vec![self.to_string(), value.to_string()],
Setting::DefaultFrameLayout(value) => vec![self.to_string(), value.to_string()],
Setting::ShowFrameDecorations(value) => vec![self.to_string(), value.to_string()],
Setting::SmartFrameSurroundings(value) => vec![self.to_string(), value.to_string()],
}
.join("\t")
}
}
#[derive(Debug, Clone, Serialize, Deserialize, strum::Display, strum::EnumString, PartialEq)]
#[strum(serialize_all = "snake_case")]
pub enum ShowFrameDecoration {
/// Show no frame decorations at all
None,
/// Show decorations of frames that have client windows,
Nonempty,
/// Show decorations on the tags with at least two frames,
IfMultiple,
/// Show decorations of frames that have no client windows,
IfEmpty,
/// Show the decoration of focused and nonempty frames,
Focused,
/// Show decorations of focused and non-empty frames on tags with at least two frames.
FocusedIfMultiple,
/// Show all frame decorations.
All,
}
impl Default for ShowFrameDecoration {
fn default() -> Self {
Self::FocusedIfMultiple
}
}
#[derive(Debug, Clone, Serialize, Deserialize, strum::Display, strum::EnumString, PartialEq)]
#[strum(serialize_all = "snake_case")]
pub enum SmartFrameSurroundings {
/// Frame borders and gaps will be removed when there is no ambiguity regarding the focused frame
HideAll,
/// Only frame gaps will be removed when there is no ambiguity regarding the focused frame
HideGaps,
/// Always show frame borders and gaps
Off,
}
impl Default for SmartFrameSurroundings {
fn default() -> Self {
Self::Off
}
}
#[derive(Debug, Clone, Serialize, Deserialize, strum::Display, strum::EnumString, PartialEq)]
#[strum(serialize_all = "snake_case")]
pub enum FrameLayout {
/// clients are placed below each other
Vertical,
/// clients are placed next to each other
Horizontal,
/// all clients are maximized in this frame
Max,
/// clients are arranged in an almost quadratic grid
Grid,
}
impl Default for FrameLayout {
fn default() -> Self {
Self::Vertical
}
}
#[cfg(test)]
mod test {
use serde::{Deserialize, Serialize};
use crate::hlwm::{setting::SettingName, ToggleBool};
use super::Setting;
#[derive(Debug, Serialize, Deserialize)]
pub struct SettingWrapper {
setting: Setting,
}
#[test]
fn setting_serialize_deserialize() {
let setting = SettingWrapper {
setting: Setting::AutoDetectMonitors(ToggleBool::Toggle),
};
let serialized = toml::to_string_pretty(&setting).expect("serializing");
let deserialized: SettingWrapper = toml::from_str(&serialized).expect("deserializing");
assert_eq!(setting.setting, deserialized.setting);
}
#[test]
fn setting_name() {
assert_eq!(
"auto_detect_monitors",
SettingName::AutoDetectMonitors.to_string()
)
}
}

114
src/hlwm/split.rs Normal file
View File

@ -0,0 +1,114 @@
use std::ops::Deref;
#[allow(unused)]
pub enum SplitArg<'a> {
Normal(&'a str),
Once(&'a str),
}
impl<'a> SplitArg<'a> {
fn once(&self) -> bool {
if let Self::Once(_) = self {
true
} else {
false
}
}
}
impl<'a> Deref for SplitArg<'a> {
type Target = str;
fn deref(&self) -> &Self::Target {
match self {
SplitArg::Normal(s) | SplitArg::Once(s) => s,
}
}
}
impl<'a> From<&'a str> for SplitArg<'a> {
fn from(value: &'a str) -> Self {
SplitArg::Normal(value)
}
}
impl<'a> From<&'a String> for SplitArg<'a> {
fn from(value: &'a String) -> Self {
SplitArg::Normal(value)
}
}
pub fn tab_or_space<'a, S>(s: S) -> Vec<String>
where
S: Into<SplitArg<'a>>,
{
let value = s.into();
let mut chars = value.chars();
let mut working: Vec<char> = Vec::with_capacity(value.len());
let mut out: Vec<String> = Vec::new();
let mut match_quote = false;
while let Some(c) = chars.next() {
match c {
'\\' => {
working.push(c);
if let Some(c) = chars.next() {
working.push(c);
}
}
'"' => {
match_quote = !match_quote;
working.push(c);
}
'\t' | ' ' => {
if match_quote {
working.push(c);
} else if working.is_empty() {
continue;
} else {
out.push((&working).into_iter().collect());
working.clear();
if value.once() {
out.push(chars.collect());
break;
}
}
}
_ => working.push(c),
}
}
if !working.is_empty() {
out.push(working.into_iter().collect());
}
out
}
pub fn on_first_match(s: &str, match_set: &[char]) -> Option<((String, String), char)> {
let mut first: Vec<char> = Vec::with_capacity(s.len());
let mut chars = s.chars();
while let Some(char) = chars.next() {
if (&match_set).into_iter().any(|m| m.eq(&char)) {
return Some(((first.into_iter().collect(), chars.collect()), char));
}
first.push(char);
}
None
}
#[cfg(test)]
mod test {
#[test]
fn test_tab_or_space_split() {
for (base, expected) in [
("hello world", vec!["hello", "world"]),
("hello\tworld", vec!["hello", "world"]),
(
r#"hello "world! I never made it!" up til now"#,
vec!["hello", "\"world! I never made it!\"", "up", "til", "now"],
),
("hello world", vec!["hello", "world"]),
] {
assert_eq!(expected, super::tab_or_space(base))
}
}
}

238
src/hlwm/theme.rs Normal file
View File

@ -0,0 +1,238 @@
use std::{borrow::BorrowMut, num::ParseIntError};
use serde::{de::Expected, Deserialize, Serialize};
use strum::IntoEnumIterator;
use thiserror::Error;
use crate::hlwm::split;
use super::{
attribute::Attribute,
color::{self, Color},
StringParseError,
};
#[derive(Debug, Error)]
pub enum ThemeAttrParseError {
#[error("path not found")]
PathNotFound,
#[error("integer value parse error: [{0}]")]
ParseIntError(#[from] ParseIntError),
#[error("color value parse error: [{0}]")]
ColorParseError(#[from] color::ParseError),
#[error("string value parse error: [{0}]")]
StringParseError(#[from] StringParseError),
}
macro_rules! theme_attr {
($($name:tt($value:tt),)*) => {
#[derive(Debug, Clone, strum::Display, strum::EnumIter)]
#[strum(serialize_all = "snake_case")]
pub enum ThemeAttr {
$(
$name($value),
)*
}
impl PartialEq for ThemeAttr {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
$(
(Self::$name(_), Self::$name(_)) => true,
)+
_ => false,
}
}
}
impl From<ThemeAttr> for Attribute {
fn from(value: ThemeAttr) -> Self {
match value {
$(
ThemeAttr::$name(val) => theme_attr!(Attr $value)(val),
)*
}
}
}
impl ThemeAttr {
pub fn from_raw_parts(path: &str, value: &str) -> Result<Self, ThemeAttrParseError> {
match ThemeAttr::iter()
.find(|attr| attr.attr_path() == path)
.map(|attr| -> Result<Self, ThemeAttrParseError> {
let mut attr = attr.clone();
match attr.borrow_mut() {
$(
ThemeAttr::$name(val) => *val = theme_attr!(Parse value: $value),
)+
};
Ok(attr)
})
.map(|res| if let Err(err) = res {panic!("::: {err}")} else {res})
.ok_or(ThemeAttrParseError::PathNotFound)
{
Ok(res) => res,
Err(err) => return Err(err),
}
}
}
};
(Attr u32) => {
Attribute::Uint
};
(Attr String) => {
Attribute::String
};
(Attr bool) => {
Attribute::Bool
};
(Attr Color) => {
Attribute::Color
};
(Attr i32) => {
Attribute::Int
};
(Default u32) => {
0u32
};
(Default i32) => {
0i32
};
(Default Color) => {
Color::BLACK
};
(Default String) => {
String::new()
};
(Parse $v:tt: String) => {
$v.to_string()
};
(Parse $v:tt: $v_ty:tt) => {
$v.parse::<$v_ty>()?
}
}
theme_attr!(
TitleHeight(u32),
TitleWhen(String),
TitleFont(String),
TitleDepth(i32),
InnerWidth(u32),
InnerColor(Color),
BorderWidth(u32),
FloatingBorderWidth(u32),
FloatingOuterWidth(u32),
TilingOuterWidth(u32),
BackgroundColor(Color),
ActiveColor(Color),
ActiveInnerColor(Color),
ActiveOuterColor(Color),
NormalColor(Color),
NormalInnerColor(Color),
NormalOuterColor(Color),
NormalTitleColor(Color),
UrgentColor(Color),
UrgentInnerColor(Color),
UrgentOuterColor(Color),
TitleColor(Color),
);
impl ThemeAttr {
pub fn attr_path(&self) -> String {
macro_rules! match_section {
($self:ident => $($section:literal),+) => {
{
let attr_str = $self.to_string();
let mut attr_parts = attr_str.split('_');
let first = attr_parts.next().unwrap();
let remainder = attr_parts.collect::<Vec<_>>().join("_");
match first {
$(
$section => ["theme", $section, &remainder].join("."),
)+
_ => ["theme", &attr_str].join("."),
}
}
};
}
match_section!(self => "floating", "tiling", "active", "normal", "urgent")
}
}
impl Serialize for ThemeAttr {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&format!(
"{} = {}",
self.attr_path(),
Attribute::from(self.clone())
))
}
}
impl<'de> Deserialize<'de> for ThemeAttr {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
pub enum Expect {
NoEquals,
ThemeAttrParseError(ThemeAttrParseError),
}
impl Expected for Expect {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Expect::NoEquals => write!(f, "value having an equals sign"),
Expect::ThemeAttrParseError(err) => write!(f, "parsing error: {err}"),
}
}
}
let str_val: String = Deserialize::deserialize(deserializer)?;
let ((first, second), _) =
split::on_first_match(&str_val, &['=']).ok_or(serde::de::Error::invalid_value(
serde::de::Unexpected::Str(&str_val),
&Expect::NoEquals,
))?;
Ok(
Self::from_raw_parts(&first.trim(), &second.trim()).map_err(|err| {
serde::de::Error::invalid_value(
serde::de::Unexpected::Str(&str_val),
&Expect::ThemeAttrParseError(err),
)
})?,
)
}
}
#[cfg(test)]
mod test {
use serde::{Deserialize, Serialize};
use crate::hlwm::theme::ThemeAttr;
use pretty_assertions::assert_eq;
#[test]
fn test_partial_eq_theme_attr_string() {
assert!(ThemeAttr::TitleFont(String::from("hello"))
.eq(&ThemeAttr::TitleFont(String::from("world"))))
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ThemeAttrWrapper {
attr: ThemeAttr,
}
#[test]
fn theme_serialize_deserialize() {
let theme = ThemeAttrWrapper {
attr: ThemeAttr::TitleHeight(15),
};
let theme_str = toml::to_string_pretty(&theme).expect("serialization");
let theme_parsed: ThemeAttrWrapper = toml::from_str(&theme_str).expect("deserialization");
assert_eq!(theme.attr, theme_parsed.attr);
}
}

181
src/hlwm/window.rs Normal file
View File

@ -0,0 +1,181 @@
use std::{
fmt::{Display, Write},
str::FromStr,
};
use serde::{Deserialize, Serialize};
use strum::IntoEnumIterator;
use super::{
rule::{Condition, RuleOperator},
StringParseError,
};
#[derive(Debug, Clone, Serialize, Deserialize, strum::EnumIter, PartialEq)]
pub enum Window {
Urgent,
X11(i32),
/// references the minimized window on the focused tag
/// that has been minimized for the longest time
LongestMinimized,
/// references the minimized window on the focused tag
/// that has been minimized most recently
LastMinimized,
}
impl FromStr for Window {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Ok(val) = i32::from_str(s) {
Ok(Self::X11(val))
} else {
Window::iter()
.find(|w| w.to_string() == s)
.ok_or(StringParseError::UnknownValue)
}
}
}
impl Default for Window {
fn default() -> Self {
Self::Urgent
}
}
impl From<i32> for Window {
fn from(value: i32) -> Self {
Self::X11(value)
}
}
impl std::fmt::Display for Window {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Window::Urgent => f.write_str("urgent"),
Window::X11(id) => write!(f, "{id}"),
Window::LongestMinimized => f.write_str("longest-minimized"),
Window::LastMinimized => f.write_str("last-minimized"),
}
}
}
pub struct TagStatus {
name: String,
state: TagState,
}
impl TagStatus {
pub fn name(&self) -> &str {
&self.name
}
pub fn state(&self) -> TagState {
self.state
}
}
impl FromStr for TagStatus {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s.chars();
let state = parts
.next()
.ok_or(StringParseError::UnknownValue)?
.try_into()?;
let name = parts.collect();
Ok(Self { name, state })
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, strum::EnumIter)]
pub enum TagState {
Empty,
NotEmpty,
/// The tag contains an urgent window
Urgent,
/// The tag is viewed on the specified MONITOR and it is focused
SameMonitorFocused,
/// The tag is viewed on the specified MONITOR, but this monitor is not focused
SameMonitor,
/// The tag is viewed on a different MONITOR and it is focused
DifferentMonitorFocused,
/// The tag is viewed on a different MONITOR, but this monitor is not focused
DifferentMonitor,
}
impl TryFrom<char> for TagState {
type Error = StringParseError;
fn try_from(value: char) -> Result<Self, Self::Error> {
Self::iter()
.into_iter()
.find(|i| char::from(i) == value)
.ok_or(StringParseError::UnknownValue)
}
}
impl FromStr for TagState {
type Err = StringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::iter()
.into_iter()
.find(|i| i.to_string() == s)
.ok_or(StringParseError::UnknownValue)
}
}
impl From<&TagState> for char {
fn from(value: &TagState) -> Self {
match value {
TagState::SameMonitorFocused => '#',
TagState::SameMonitor => '+',
TagState::DifferentMonitorFocused => '%',
TagState::DifferentMonitor => '-',
TagState::Empty => '.',
TagState::NotEmpty => ':',
TagState::Urgent => '!',
}
}
}
impl Display for TagState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_char(self.into())
}
}
#[derive(Clone, Copy, strum::Display)]
#[strum(serialize_all = "UPPERCASE")]
pub enum WindowType {
Dialog,
Utility,
Splash,
Notification,
Dock,
Desktop,
}
impl WindowType {
pub fn or<I: Iterator<Item = Self>>(types: I) -> Condition {
Condition::WindowType {
operator: RuleOperator::Regex,
value: format!(
"_NET_WM_WINDOW_TYPE_({})",
types.map(|t| t.to_string()).collect::<Vec<_>>().join("|")
),
}
}
}
#[macro_export]
macro_rules! window_types {
($($ty:tt),+) => {
crate::hlwm::window::WindowType::or([$(
crate::hlwm::window::WindowType::$ty
),+].into_iter())
};
}

103
src/main.rs Normal file
View File

@ -0,0 +1,103 @@
#![feature(macro_metavar_expr)]
use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand};
use config::Config;
use log::{error, info};
mod config;
mod hlwm;
#[derive(Parser, Debug, Clone)]
#[command(name = "hlctl")]
struct Args {
#[command(subcommand)]
command: HlctlCommand,
}
#[derive(Subcommand, Debug, Clone)]
enum HlctlCommand {
/// Initialize herbstluftwm, should be run from the autostart script
Init,
#[command(arg_required_else_help = true)]
/// Draws notifications using dzen2
Notify {
/// Amount of seconds to persist for
#[arg(short = 's', long = None, default_value_t = 1)]
seconds: u8,
},
/// Save the currently loaded configuration to file
#[command(long_about = r#"
`save` Tries to find an existing config file. If not present, the default config is used.
Whichever one is loaded, its tags are used. All other values are collected from the environment
(or default values) and this new config is saved.
The configuration file located at $HOME/.config/herbstluftwm/hlctl.toml"#)]
Save,
PrintConfig,
Panel,
}
fn main() {
pretty_env_logger::init();
let args = Args::parse();
match args.command {
HlctlCommand::Init => init(),
HlctlCommand::Notify { seconds } => println!("notify for {seconds}"),
HlctlCommand::Save => save(),
HlctlCommand::PrintConfig => print_config(),
HlctlCommand::Panel => todo!(),
}
}
fn load_or_default_config() -> Config {
Config::from_file(&Path::new(&Config::default_path().unwrap())).unwrap_or(Config::default())
}
fn x_set_root(path: PathBuf) {
match std::process::Command::new(path)
.args(["-solid", "black"])
.spawn()
{
Ok(mut child) => {
if let Err(err) = child.wait() {
error!("running xsetroot: [{err}]");
}
}
Err(err) => error!("running xsetroot: [{err}]"),
}
}
fn init() {
info!("begining herbstluftwm setup via hlctl");
if let Ok(path) = which::which("xsetroot") {
x_set_root(path);
}
info!("loading config");
hlwm::Client::new()
.execute_iter(
load_or_default_config()
.to_command_set()
.expect("marshalling init command set"),
)
.expect("running init command set");
}
fn merged_config() -> Config {
let default = load_or_default_config();
let mut collected = Config::from_herbstluft();
collected.tags = default.tags;
collected
}
fn print_config() {
println!("{}", merged_config().serialize().unwrap())
}
fn save() {
merged_config()
.write_to_file(&Path::new(&Config::default_path().unwrap()))
.expect("failed writing to file");
}

View File

@ -1,23 +0,0 @@
package svcctl
import (
"os/exec"
"sectorinf.com/emilis/hlctl/config"
"sectorinf.com/emilis/hlctl/ctllog"
"sectorinf.com/emilis/hlctl/hlcl"
)
var log = ctllog.Logger{}.New("config", ctllog.ColorRGB{}.FromHex("2e8647"))
func RestartServices(cfg config.ServicesConfig) {
for _, svc := range cfg.Services {
log.Printf("Restarting [%s]", svc)
if err := exec.Command("pkill", "-9", svc).Run(); err != nil {
log.Errorf("Killing [%s]: %s", svc, err)
}
if err := hlcl.SilentSpawn(svc); err != nil {
log.Errorf("Starting [%s]: %s", svc, err)
}
}
}