From d0c19f7947e5524b807b70d9c14d40fa763e8e00 Mon Sep 17 00:00:00 2001 From: emilis Date: Thu, 29 Feb 2024 20:36:38 +0000 Subject: [PATCH] initial stage of hlctl rust rewrite --- .gitignore | 2 + Cargo.lock | 644 ++++++++++++++++++++++++ Cargo.toml | 22 + cmdlets/cmdlet.go | 47 -- cmdlets/group/group.go | 175 ------- cmdlets/init/init.go | 319 ------------ cmdlets/notify/notify.go | 84 ---- cmdlets/save/save.go | 33 -- config/config.go | 101 ---- config/type.go | 198 -------- ctllog/colors.go | 102 ---- ctllog/colors_test.go | 22 - ctllog/log.go | 94 ---- go.mod | 5 - go.sum | 2 - groupctl/group.go | 106 ---- hlcl/cmd/enum.go | 103 ---- hlcl/herbstclient.go | 190 ------- hlcl/type.go | 29 -- main.go | 80 --- notifyctl/notify.go | 33 -- panelctl/panel.go | 37 -- src/config.rs | 1020 ++++++++++++++++++++++++++++++++++++++ src/hlwm/attribute.rs | 289 +++++++++++ src/hlwm/color.rs | 426 ++++++++++++++++ src/hlwm/command.rs | 1014 +++++++++++++++++++++++++++++++++++++ src/hlwm/hex.rs | 108 ++++ src/hlwm/hlwmbool.rs | 55 ++ src/hlwm/hook.rs | 149 ++++++ src/hlwm/key.rs | 497 +++++++++++++++++++ src/hlwm/macros.rs | 142 ++++++ src/hlwm/mod.rs | 465 +++++++++++++++++ src/hlwm/octal.rs | 99 ++++ src/hlwm/rule.rs | 666 +++++++++++++++++++++++++ src/hlwm/setting.rs | 481 ++++++++++++++++++ src/hlwm/split.rs | 114 +++++ src/hlwm/theme.rs | 238 +++++++++ src/hlwm/window.rs | 181 +++++++ src/main.rs | 103 ++++ svcctl/svc.go | 23 - 40 files changed, 6715 insertions(+), 1783 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml delete mode 100644 cmdlets/cmdlet.go delete mode 100644 cmdlets/group/group.go delete mode 100644 cmdlets/init/init.go delete mode 100644 cmdlets/notify/notify.go delete mode 100644 cmdlets/save/save.go delete mode 100644 config/config.go delete mode 100644 config/type.go delete mode 100644 ctllog/colors.go delete mode 100644 ctllog/colors_test.go delete mode 100644 ctllog/log.go delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 groupctl/group.go delete mode 100644 hlcl/cmd/enum.go delete mode 100644 hlcl/herbstclient.go delete mode 100644 hlcl/type.go delete mode 100644 main.go delete mode 100644 notifyctl/notify.go delete mode 100644 panelctl/panel.go create mode 100644 src/config.rs create mode 100644 src/hlwm/attribute.rs create mode 100644 src/hlwm/color.rs create mode 100644 src/hlwm/command.rs create mode 100644 src/hlwm/hex.rs create mode 100644 src/hlwm/hlwmbool.rs create mode 100644 src/hlwm/hook.rs create mode 100644 src/hlwm/key.rs create mode 100644 src/hlwm/macros.rs create mode 100644 src/hlwm/mod.rs create mode 100644 src/hlwm/octal.rs create mode 100644 src/hlwm/rule.rs create mode 100644 src/hlwm/setting.rs create mode 100644 src/hlwm/split.rs create mode 100644 src/hlwm/theme.rs create mode 100644 src/hlwm/window.rs create mode 100644 src/main.rs delete mode 100644 svcctl/svc.go diff --git a/.gitignore b/.gitignore index 873cff8..cae29b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ # Openbsd core dumps *.core + +target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7bdab86 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..705926a --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/cmdlets/cmdlet.go b/cmdlets/cmdlet.go deleted file mode 100644 index 977e44f..0000000 --- a/cmdlets/cmdlet.go +++ /dev/null @@ -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 -} diff --git a/cmdlets/group/group.go b/cmdlets/group/group.go deleted file mode 100644 index 162d383..0000000 --- a/cmdlets/group/group.go +++ /dev/null @@ -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) -} diff --git a/cmdlets/init/init.go b/cmdlets/init/init.go deleted file mode 100644 index b05d355..0000000 --- a/cmdlets/init/init.go +++ /dev/null @@ -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() -} diff --git a/cmdlets/notify/notify.go b/cmdlets/notify/notify.go deleted file mode 100644 index 0e1e0a4..0000000 --- a/cmdlets/notify/notify.go +++ /dev/null @@ -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, - ) -} diff --git a/cmdlets/save/save.go b/cmdlets/save/save.go deleted file mode 100644 index b2c9e95..0000000 --- a/cmdlets/save/save.go +++ /dev/null @@ -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() -} \ No newline at end of file diff --git a/config/config.go b/config/config.go deleted file mode 100644 index 8386324..0000000 --- a/config/config.go +++ /dev/null @@ -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) -} diff --git a/config/type.go b/config/type.go deleted file mode 100644 index 6452c37..0000000 --- a/config/type.go +++ /dev/null @@ -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"}, - }, - } -) \ No newline at end of file diff --git a/ctllog/colors.go b/ctllog/colors.go deleted file mode 100644 index efd15f2..0000000 --- a/ctllog/colors.go +++ /dev/null @@ -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} -) \ No newline at end of file diff --git a/ctllog/colors_test.go b/ctllog/colors_test.go deleted file mode 100644 index 4b00d50..0000000 --- a/ctllog/colors_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/ctllog/log.go b/ctllog/log.go deleted file mode 100644 index ca87bce..0000000 --- a/ctllog/log.go +++ /dev/null @@ -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...)) -} \ No newline at end of file diff --git a/go.mod b/go.mod deleted file mode 100644 index b2e6113..0000000 --- a/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module sectorinf.com/emilis/hlctl - -go 1.19 - -require github.com/BurntSushi/toml v1.2.1 diff --git a/go.sum b/go.sum deleted file mode 100644 index 4614a74..0000000 --- a/go.sum +++ /dev/null @@ -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= diff --git a/groupctl/group.go b/groupctl/group.go deleted file mode 100644 index 5734061..0000000 --- a/groupctl/group.go +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/hlcl/cmd/enum.go b/hlcl/cmd/enum.go deleted file mode 100644 index 1b77aaf..0000000 --- a/hlcl/cmd/enum.go +++ /dev/null @@ -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" -) diff --git a/hlcl/herbstclient.go b/hlcl/herbstclient.go deleted file mode 100644 index 2dd5953..0000000 --- a/hlcl/herbstclient.go +++ /dev/null @@ -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 -} diff --git a/hlcl/type.go b/hlcl/type.go deleted file mode 100644 index cc742e0..0000000 --- a/hlcl/type.go +++ /dev/null @@ -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 - } -} diff --git a/main.go b/main.go deleted file mode 100644 index 5ec6d62..0000000 --- a/main.go +++ /dev/null @@ -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()) - } -} diff --git a/notifyctl/notify.go b/notifyctl/notify.go deleted file mode 100644 index f8eb228..0000000 --- a/notifyctl/notify.go +++ /dev/null @@ -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", - ) -} diff --git a/panelctl/panel.go b/panelctl/panel.go deleted file mode 100644 index a8dfa0d..0000000 --- a/panelctl/panel.go +++ /dev/null @@ -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) -} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e31a690 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,1020 @@ +use std::{ + borrow::BorrowMut, + cmp::Ordering, + convert::Infallible, + env, + fmt::Display, + fs::{self}, + io, + ops::Deref, + path::Path, + str::FromStr, + string::FromUtf8Error, +}; + +use log::info; +use serde::{ + de::{Expected, Unexpected}, + Deserialize, Serialize, +}; +use strum::IntoEnumIterator; +use thiserror::Error; + +use crate::{ + hlwm::{ + attribute::Attribute, + color::Color, + command::{CommandError, HlwmCommand}, + hook::Hook, + key::{Key, KeyParseError, KeyUnbind, Keybind, MouseButton, Mousebind, MousebindAction}, + rule::{Condition, Consequence, FloatPlacement, Rule}, + setting::{FrameLayout, Setting, ShowFrameDecoration, SmartFrameSurroundings}, + theme::ThemeAttr, + window::Window, + Align, Client, Direction, Index, Operator, Separator, StringParseError, ToggleBool, + }, + rule, window_types, +}; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("IO error")] + IoError(#[from] io::Error), + #[error("toml deserialization error: {0}")] + TomlDeserializeError(#[from] toml::de::Error), + #[error("toml serialization error: {0}")] + TomlSerializeError(#[from] toml::ser::Error), + #[error("json error: {0}")] + JsonError(#[from] serde_json::Error), + #[error("$HOME must be set")] + HomeNotSet, + #[error("herbstluft client command error: {0}")] + CommandError(#[from] CommandError), + #[error("non-utf8 string error: {0}")] + Utf8StringError(#[from] FromUtf8Error), + #[error("failed parsing keybind: {0}")] + KeyParseError(#[from] KeyParseError), + #[error("failed parsing value from string: {0}")] + StringParseError(#[from] StringParseError), +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Config { + pub font: Option, + pub font_bold: Option, + pub mod_key: Key, + pub keybinds: Vec, + pub mousebinds: Vec, + // services won't be spawned through HlwmCommand::Spawn + // so that the running instance can hold the handles + // and a reset would kill them + pub services: Vec, + pub tags: Vec, + pub theme: Theme, + pub rules: Vec, + pub attributes: Vec, + pub settings: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SetAttribute { + pub path: String, + pub value: Attribute, +} + +impl SetAttribute { + pub fn new>(path: S, value: Attribute) -> Self { + Self { + path: path.into(), + value, + } + } +} + +impl Display for SetAttribute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "({}){}={}", + self.value.type_string(), + &self.path, + self.value.to_string() + ) + } +} + +impl FromStr for SetAttribute { + type Err = StringParseError; + + fn from_str(s: &str) -> Result { + let mut parts = s.split(')'); + let type_string = parts.next().map(|p| p.strip_prefix('(')).flatten().ok_or( + StringParseError::RequiredArgMissing("attribute type".into()), + )?; + let mut parts = parts + .next() + .ok_or(StringParseError::RequiredArgMissing( + "attribute path/value".into(), + ))? + .split('=') + .map(|v| v.trim()); + let path = parts + .next() + .ok_or(StringParseError::RequiredArgMissing( + "attribute path".into(), + ))? + .to_string(); + let value_string = parts.collect::>().join("="); + if value_string.is_empty() { + return Err(StringParseError::RequiredArgMissing( + "attribute value".into(), + )); + } + let value = Attribute::new(type_string, &value_string)?; + Ok(Self { path, value }) + } +} + +impl Serialize for SetAttribute { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for SetAttribute { + fn deserialize(deserializer: D) -> Result + 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 Config { + const FONT: &'static str = "theme.my_font"; + const FONT_BOLD: &'static str = "theme.my_font_bold"; + const SERVICES: &'static str = "settings.my_services"; + const MOD_KEY: &'static str = "settings.my_mod_key"; + + pub fn from_file(path: &Path) -> Result { + info!( + "reading config from: {}", + path.to_str().unwrap_or("") + ); + Self::deserialize(&fs::read_to_string(path)?) + } + + pub fn write_to_file(&self, path: &Path) -> Result<(), ConfigError> { + info!( + "saving config to: {}", + path.to_str().unwrap_or("") + ); + Ok(fs::write(path, self.serialize()?)?) + } + + pub fn serialize(&self) -> Result { + Ok(toml::ser::to_string_pretty(self)?) + } + + pub fn deserialize(str: &str) -> Result { + Ok(toml::from_str(&str)?) + } + + pub fn default_path() -> Result { + Ok(format!( + "{home}/.config/herbstluftwm/hlctl.toml", + home = env::var("XDG_CONFIG_HOME") + .unwrap_or(env::var("HOME").map_err(|_| ConfigError::HomeNotSet)?) + )) + } + + fn new_or_set_attr(path: String, attr: Attribute) -> HlwmCommand { + HlwmCommand::Or { + separator: Separator::Comma, + commands: vec![ + HlwmCommand::NewAttr { + path: path.clone(), + attr: attr.clone().into(), + }, + HlwmCommand::SetAttr { + path, + new_value: attr, + }, + ], + } + } + + fn attrs_set(&self) -> Result, ConfigError> { + info!("loading attr settings command set"); + Ok([ + (Self::FONT, self.font.clone()), + (Self::MOD_KEY, Some(self.mod_key.to_string())), + (Self::FONT_BOLD, self.font_bold.clone()), + ( + Self::SERVICES, + if self.services.len() == 0 { + None + } else { + Some(serde_json::ser::to_string(&self.services)?) + }, + ), + ] + .into_iter() + .filter(|(_, attr)| attr.is_some()) + .map(|(name, attr)| { + Self::new_or_set_attr(name.to_string(), Attribute::String(attr.unwrap())) + }) + .collect()) + } + + fn theme_command_set(&self) -> Vec { + info!("loading theme attr command set"); + [ + HlwmCommand::SetAttr { + path: "theme.reset".into(), + new_value: Attribute::String("1".into()), + }, + HlwmCommand::SetAttr { + path: "theme.tiling.reset".into(), + new_value: Attribute::String("1".into()), + }, + HlwmCommand::SetAttr { + path: "theme.floating.reset".into(), + new_value: Attribute::String("1".into()), + }, + ] + .into_iter() + .chain( + (&self.theme.attributes) + .into_iter() + .map(|attr| HlwmCommand::SetAttr { + path: attr.attr_path(), + new_value: Attribute::from(attr.clone()).into(), + }), + ) + .collect() + } + + fn settings_command_set(&self) -> Vec { + info!("loading settings command set"); + self.settings + .clone() + .into_iter() + .map(|s| HlwmCommand::Set(s)) + .collect() + } + + fn service_command_set(&self) -> Vec { + info!("loading service command set"); + self.services + .clone() + .into_iter() + .map(|s| { + [ + HlwmCommand::Spawn { + executable: "pkill".into(), + args: vec!["-9".into(), s.name.clone()], + } + .to_try() + .silent(), + HlwmCommand::Spawn { + executable: s.name, + args: s.arguments, + } + .to_try(), + ] + }) + .flatten() + .collect() + } + + fn rule_command_set(&self) -> Vec { + info!("loading rule command set"); + self.rules + .clone() + .into_iter() + .map(|r| HlwmCommand::Rule(r)) + .collect() + } + + pub fn to_command_set(&self) -> Result, ConfigError> { + Ok([ + HlwmCommand::EmitHook(Hook::Reload), + HlwmCommand::Keyunbind(KeyUnbind::All), + HlwmCommand::Mouseunbind, + ] + .into_iter() + .chain( + self.keybinds + .clone() + .into_iter() + .map(|key| HlwmCommand::Keybind(key)), + ) + .chain( + self.mousebinds + .clone() + .into_iter() + .map(|mb| HlwmCommand::Mousebind(mb)), + ) + .chain(self.attrs_set()?) + .chain(self.theme_command_set()) + .chain(self.tag_command_set()) + .chain(self.settings_command_set()) + .chain(self.rule_command_set()) + .chain(self.service_command_set()) + .collect()) + } + + fn tag_command_set(&self) -> Vec { + info!("loading tag command set"); + (&self.tags) + .into_iter() + .map(|tag| (tag, tag.key())) + .filter(|(_, key)| key.is_some()) + .map(|(tag, key)| { + let tag_name = tag.to_string(); + let key = key.unwrap(); + [ + HlwmCommand::AddTag(tag_name.clone()), + HlwmCommand::Keybind(Keybind::new( + [self.mod_key, key], + HlwmCommand::UseTag(tag_name.clone()), + )), + HlwmCommand::Keybind(Keybind::new( + [self.mod_key, Key::Shift, key], + HlwmCommand::MoveTag(tag_name), + )), + ] + }) + .flatten() + .chain([ + HlwmCommand::And { + separator: Separator::Comma, + commands: vec![ + HlwmCommand::Compare { + attribute: "tags.by-name.default.index".into(), + operator: Operator::Equal, + value: "tags.focus.index".into(), + }, + HlwmCommand::UseIndex { + index: Index::Relative(1), + skip_visible: false, + }, + ], + } + .to_try(), + HlwmCommand::MergeTag { + tag: "default".to_string(), + target: None, + } + .to_try(), + ]) + .collect() + } + + /// Create a config gathered from the herbstluftwm configs, + /// using default mouse binds/tags + pub fn from_herbstluft() -> Self { + fn setting Result>( + name: &str, + f: F, + default: T, + ) -> T { + match Client::new().get_attr(name.to_string()) { + Ok(setting) => f(&setting.to_string()).unwrap_or(default), + Err(_) => default, + } + } + let client = Client::new(); + let default = Config::default(); + Config { + font: setting( + Self::FONT, + |f| -> Result<_, Infallible> { Ok(Some(f.to_string())) }, + default.font, + ), + mod_key: setting(Self::MOD_KEY, Key::from_str, default.mod_key), + font_bold: Some( + client + .get_attr(ThemeAttr::TitleFont(String::new()).attr_path()) + .map(|a| a.to_string()) + .unwrap_or(default.font_bold.unwrap()), + ), + services: setting( + Self::SERVICES, + |v| serde_json::de::from_str(v), + default.services, + ), + theme: Theme { + attributes: (|| -> Vec<_> { + ThemeAttr::iter() + .map(|attr| { + let attr_path = attr.attr_path(); + let default = (&default.theme.attributes) + .into_iter() + .find(|f| (*f).eq(&attr)) + .unwrap(); + ThemeAttr::from_raw_parts( + &attr_path, + &client + .get_attr(attr_path.clone()) + .map(|t| t.to_string()) + .unwrap_or(default.to_string()), + ) + .unwrap_or(default.clone()) + }) + .collect() + })(), + }, + keybinds: Self::active_keybinds(true).unwrap_or(default.keybinds), + tags: (|| -> Result, _> { + Result::<_, ConfigError>::Ok({ + let mut tags = client + .tag_status()? + .into_iter() + .map(|tag| { + let tag_result: Result<_, Infallible> = tag.name().parse(); + tag_result.unwrap() + }) + .collect::>(); + tags.sort_by(|lhs: &Tag, rhs| match lhs.partial_cmp(rhs) { + Some(ord) => ord, + None => Ordering::Less, + }); + + tags + }) + })() + .unwrap_or(default.tags), + rules: (|| -> Result, ConfigError> { + Ok( + String::from_utf8(client.execute(HlwmCommand::ListRules)?.stdout)? + .split('\n') + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .map(|line| Rule::from_str(line)) + .collect::>()?, + ) + })() + .unwrap_or(default.rules), + settings: (|| -> Result, CommandError> { + default + .settings + .clone() + .into_iter() + .map(|s| Ok(client.get_setting(s.into())?)) + .collect::, CommandError>>() + })() + .unwrap_or(default.settings), + ..default + } + } + + fn active_keybinds(omit_tag_binds: bool) -> Result, ConfigError> { + String::from_utf8(Client::new().execute(HlwmCommand::ListKeybinds)?.stdout)? + .split("\n") + .filter(|i| { + !omit_tag_binds + || match i.split("\t").skip(1).next() { + Some(command) => { + command + != HlwmCommand::UseIndex { + index: Index::Absolute(0), + skip_visible: false, + } + .to_string() + && command + != HlwmCommand::MoveIndex { + index: Index::Absolute(0), + skip_visible: false, + } + .to_string() + && command != HlwmCommand::UseTag(String::new()).to_string() + && command != HlwmCommand::MoveTag(String::new()).to_string() + } + None => false, + } + }) + .map(|row: &str| Keybind::from_str(row).map_err(|err| err.into())) + .collect::, ConfigError>>() + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Theme { + pub attributes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Error)] +pub enum InclusiveError { + #[error("out of range")] + OutOfRange, +} + +impl Expected for InclusiveError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + InclusiveError::OutOfRange => write!(f, "out of range"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Inclusive(u8); + +impl Inclusive<10> { + fn char(&self) -> char { + match self.0 { + 1 => '1', + 2 => '2', + 3 => '3', + 4 => '4', + 5 => '5', + 6 => '6', + 7 => '7', + 8 => '8', + 9 => '9', + 0 => '0', + _ => unreachable!(), + } + } + + fn f_key(&self) -> Option { + match self.0 { + 1 => Some(Key::F1), + 2 => Some(Key::F2), + 3 => Some(Key::F3), + 4 => Some(Key::F4), + 5 => Some(Key::F5), + 6 => Some(Key::F6), + 7 => Some(Key::F7), + 8 => Some(Key::F8), + 9 => Some(Key::F9), + _ => None, + } + } +} + +impl Display for Inclusive { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Deref for Inclusive { + type Target = u8; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom for Inclusive { + type Error = InclusiveError; + + fn try_from(value: u8) -> Result { + if value > MAX { + return Err(InclusiveError::OutOfRange); + } + Ok(Inclusive(value)) + } +} + +impl From> for u8 { + fn from(value: Inclusive) -> Self { + value.0 + } +} + +impl Serialize for Inclusive { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + u8::serialize(&self.0, serializer) + } +} + +impl<'de, const MAX: u8> Deserialize<'de> for Inclusive { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let val = u8::deserialize(deserializer)?; + if val > MAX { + return Err(serde::de::Error::invalid_value( + Unexpected::Unsigned(val as u64), + &InclusiveError::OutOfRange, + )); + } + + Ok(Inclusive(val)) + } +} + +impl Inclusive {} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Tag { + FrontRow(Inclusive<10>), + FunctionRow(Inclusive<10>), + Other(String), +} + +impl Tag { + pub fn key(&self) -> Option { + match self { + Tag::FrontRow(idx) => Some(Key::Char(idx.char())), + Tag::FunctionRow(idx) => Some(match idx.f_key() { + Some(f) => f, + None => return None, + }), + Tag::Other(_) => None, + } + } +} + +impl PartialEq for Tag { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::FrontRow(l0), Self::FrontRow(r0)) => l0 == r0, + (Self::FunctionRow(l0), Self::FunctionRow(r0)) => l0 == r0, + (Self::Other(l0), Self::Other(r0)) => l0 == r0, + _ => false, + } + } +} + +impl PartialOrd for Tag { + fn partial_cmp(&self, other: &Self) -> Option { + match (self, other) { + (Tag::FrontRow(lhs), Tag::FrontRow(rhs)) => Some(lhs.0.cmp(&rhs.0)), + (Tag::FrontRow(_), Tag::FunctionRow(_)) => Some(Ordering::Greater), + (Tag::FrontRow(_), Tag::Other(_)) => Some(Ordering::Greater), + (Tag::FunctionRow(_), Tag::FrontRow(_)) => Some(Ordering::Less), + (Tag::FunctionRow(lhs), Tag::FunctionRow(rhs)) => Some(lhs.0.cmp(&rhs.0)), + (Tag::FunctionRow(_), Tag::Other(_)) => Some(Ordering::Greater), + (Tag::Other(_), Tag::FrontRow(_)) | (Tag::Other(_), Tag::FunctionRow(_)) => { + Some(Ordering::Less) + } + (Tag::Other(_), Tag::Other(_)) => None, + } + } +} + +impl FromStr for Tag { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + let mut chars = s.chars(); + let indicator = match chars.next() { + Some(i) => i, + None => return Ok(Self::Other(s.to_string())), + }; + let number = u8::from_str(&chars.next().unwrap_or_default().to_string()).ok(); + if number.is_none() { + return Ok(Self::Other(s.to_string())); + } + let number = match number.unwrap().try_into() { + Ok(number) => number, + Err(_) => return Ok(Self::Other(s.to_string())), + }; + match indicator { + '.' => Ok(Self::FrontRow(number)), + 'F' => Ok(Self::FunctionRow(number)), + _ => unreachable!(), + } + } +} + +impl Display for Tag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Tag::FrontRow(tag) => write!(f, ".{tag}"), + Tag::FunctionRow(tag) => write!(f, "F{tag}"), + Tag::Other(tag) => f.write_str(tag), + } + } +} + +impl Default for Config { + fn default() -> Self { + let resize_step = 0.1; + let mod_key = Key::Mod4Super; + let font_bold = String::from("-*-fixed-*-*-*-*-13-*-*-*-*-*-*-*"); + let active_color = Color::from_hex("#800080").expect("default active color"); + let normal_color = Color::from_hex("#330033").expect("default normal color"); + let urgent_color = Color::from_hex("#7811A1").expect("default urgent color"); + let text_color = Color::from_hex("#898989").expect("default text color"); + + Self { + mod_key, + font: Some(String::from("-*-fixed-medium-*-*-*-12-*-*-*-*-*-*-*")), + font_bold: Some(font_bold.clone()), + keybinds: vec![ + Keybind::new( + [mod_key, Key::Shift, Key::Char('r')].into_iter(), + HlwmCommand::Reload, + ), + Keybind::new( + [mod_key, Key::Shift, Key::Char('c')].into_iter(), + HlwmCommand::Close { window: None }, + ), + Keybind::new( + [mod_key, Key::Char('s')].into_iter(), + HlwmCommand::Spawn { + executable: String::from("flameshot"), + args: vec![String::from("gui")], + }, + ), + Keybind::new( + [mod_key, Key::Return].into_iter(), + HlwmCommand::Spawn { + executable: String::from("dmenu_run"), + args: vec![], + }, + ), + Keybind::new( + [mod_key, Key::Left].into_iter(), + HlwmCommand::Focus(Direction::Left), + ), + Keybind::new( + [mod_key, Key::Right].into_iter(), + HlwmCommand::Focus(Direction::Right), + ), + Keybind::new( + [mod_key, Key::Up].into_iter(), + HlwmCommand::Focus(Direction::Up), + ), + Keybind::new( + [mod_key, Key::Down].into_iter(), + HlwmCommand::Focus(Direction::Down), + ), + Keybind::new( + [mod_key, Key::Shift, Key::Left].into_iter(), + HlwmCommand::Shift(Direction::Left), + ), + Keybind::new( + [mod_key, Key::Shift, Key::Right].into_iter(), + HlwmCommand::Shift(Direction::Right), + ), + Keybind::new( + [mod_key, Key::Shift, Key::Up].into_iter(), + HlwmCommand::Shift(Direction::Up), + ), + Keybind::new( + [mod_key, Key::Shift, Key::Down].into_iter(), + HlwmCommand::Shift(Direction::Down), + ), + Keybind::new( + [mod_key, Key::Char('u')].into_iter(), + HlwmCommand::Split(Align::Bottom(Some(0.5))), + ), + Keybind::new( + [mod_key, Key::Char('o')].into_iter(), + HlwmCommand::Split(Align::Right(Some(0.5))), + ), + Keybind::new([mod_key, Key::Char('r')].into_iter(), HlwmCommand::Remove), + Keybind::new( + [mod_key, Key::Char('f')].into_iter(), + HlwmCommand::Fullscreen(ToggleBool::Toggle), + ), + Keybind::new( + [mod_key, Key::Space].into_iter(), + HlwmCommand::Or { + separator: Separator::Comma, + commands: vec![ + HlwmCommand::And { + separator: Separator::Period, + commands: vec![ + HlwmCommand::Compare { + attribute: String::from("tags.focus.curframe_wcount"), + operator: Operator::Equal, + value: String::from("2"), + }, + HlwmCommand::CycleLayout { + delta: Some(Index::Relative(1)), + layouts: vec![ + FrameLayout::Vertical, + FrameLayout::Horizontal, + FrameLayout::Max, + FrameLayout::Vertical, + FrameLayout::Grid, + ], + }, + ], + }, + HlwmCommand::CycleLayout { + delta: Some(Index::Relative(1)), + layouts: vec![], + }, + ], + }, + ), + Keybind::new( + [mod_key, Key::Control, Key::Left].into_iter(), + HlwmCommand::Resize { + direction: Direction::Left, + fraction_delta: Some(Index::Relative(resize_step)), + }, + ), + Keybind::new( + [mod_key, Key::Control, Key::Down].into_iter(), + HlwmCommand::Resize { + direction: Direction::Down, + fraction_delta: Some(Index::Relative(resize_step)), + }, + ), + Keybind::new( + [mod_key, Key::Control, Key::Up].into_iter(), + HlwmCommand::Resize { + direction: Direction::Up, + fraction_delta: Some(Index::Relative(resize_step)), + }, + ), + Keybind::new( + [mod_key, Key::Control, Key::Right].into_iter(), + HlwmCommand::Resize { + direction: Direction::Right, + fraction_delta: Some(Index::Relative(resize_step)), + }, + ), + Keybind::new([mod_key, Key::Tab].into_iter(), HlwmCommand::Cycle), + Keybind::new( + [mod_key, Key::Char('i')].into_iter(), + HlwmCommand::JumpTo(Window::Urgent), + ), + ], + services: vec![Service { + name: String::from("fcitx5"), + arguments: vec![], + }], + tags: (1..=5) + .into_iter() + .map(|idx| Tag::FrontRow(idx.try_into().unwrap())) + .chain(vec![ + Tag::FunctionRow(1.try_into().unwrap()), + Tag::FunctionRow(2.try_into().unwrap()), + Tag::FunctionRow(3.try_into().unwrap()), + Tag::FunctionRow(4.try_into().unwrap()), + Tag::FunctionRow(5.try_into().unwrap()), + ]) + .collect(), + mousebinds: vec![ + Mousebind::new( + mod_key, + [Key::Mouse(MouseButton::Button1)].into_iter(), + MousebindAction::Move, + ), + Mousebind::new( + mod_key, + [Key::Mouse(MouseButton::Button2)].into_iter(), + MousebindAction::Zoom, + ), + Mousebind::new( + mod_key, + [Key::Mouse(MouseButton::Button3)].into_iter(), + MousebindAction::Resize, + ), + ], + theme: Theme { + attributes: (|| -> Vec { + ThemeAttr::iter() + .map(|attr| { + let mut attr = attr; + match attr.borrow_mut() { + ThemeAttr::TitleWhen(when) => *when = "multiple_tabs".into(), + ThemeAttr::TitleFont(font) => *font = font_bold.clone(), + ThemeAttr::TitleDepth(depth) => *depth = 3, + ThemeAttr::InnerWidth(inw) => *inw = 0, + ThemeAttr::TitleHeight(h) => *h = 15, + ThemeAttr::BorderWidth(w) => *w = 1, + ThemeAttr::TilingOuterWidth(w) => *w = 1, + ThemeAttr::FloatingOuterWidth(w) => *w = 1, + ThemeAttr::FloatingBorderWidth(w) => *w = 4, + ThemeAttr::InnerColor(col) => *col = Color::BLACK, + ThemeAttr::TitleColor(col) => *col = text_color.clone(), + ThemeAttr::ActiveColor(col) => *col = active_color.clone(), + ThemeAttr::NormalColor(col) => *col = normal_color.clone(), + ThemeAttr::UrgentColor(col) => *col = urgent_color.clone(), + ThemeAttr::BackgroundColor(col) => *col = Color::BLACK, + ThemeAttr::ActiveInnerColor(col) => *col = active_color.clone(), + ThemeAttr::ActiveOuterColor(col) => *col = active_color.clone(), + ThemeAttr::NormalInnerColor(col) => *col = normal_color.clone(), + ThemeAttr::NormalOuterColor(col) => *col = normal_color.clone(), + ThemeAttr::NormalTitleColor(col) => *col = normal_color.clone(), + ThemeAttr::UrgentInnerColor(col) => *col = urgent_color.clone(), + ThemeAttr::UrgentOuterColor(col) => *col = urgent_color.clone(), + } + attr + }) + .collect() + })(), + }, + rules: vec![ + rule!( + window_types!(Dialog, Utility, Splash), + Consequence::Floating(true) + ), + rule!(Consequence::Focus(true)), + rule!(Consequence::FloatPlacement(FloatPlacement::Smart)), + rule!(window_types!(Dialog), Consequence::Floating(true)), + rule!( + window_types!(Notification, Dock, Desktop), + Consequence::Manage(false) + ), + rule!(Condition::FixedSize, Consequence::Floating(true)), + ], + attributes: vec![], + settings: vec![ + Setting::FrameBorderWidth(2), + Setting::ShowFrameDecorations(ShowFrameDecoration::FocusedIfMultiple), + Setting::TreeStyle(String::from("╾│ ├└╼─┐")), + Setting::FrameBgTransparent(true.into()), + Setting::SmartWindowSurroundings(false.into()), + Setting::SmartFrameSurroundings(SmartFrameSurroundings::HideAll), + Setting::FrameTransparentWidth(5), + Setting::FrameGap(3), + Setting::WindowGap(0), + Setting::FramePadding(0), + Setting::MouseRecenterGap(0), + Setting::FrameBorderActiveColor(active_color.clone()), + Setting::FrameBgActiveColor(active_color.clone()), + ], + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Service { + pub name: String, + pub arguments: Vec, +} + +impl Service { + pub fn spawn(&self) -> Result<(), CommandError> { + Client::new().execute(HlwmCommand::Spawn { + executable: self.name.clone(), + args: self.arguments.clone(), + })?; + Ok(()) + } +} + +#[cfg(test)] +mod test { + use pretty_assertions::assert_eq; + use serde::{Deserialize, Serialize}; + + use crate::hlwm::{ + attribute::Attribute, + color::{Color, X11Color}, + }; + + use super::{Config, SetAttribute}; + + #[test] + fn config_serialize_deserialize() { + let mut cfg = Config::default(); + // Add extra attrs for testing + cfg.attributes.push(SetAttribute { + path: "my.attr.path".into(), + value: Attribute::Color(Color::X11(X11Color::NavajoWhite3)), + }); + let cfg_serialized = cfg.serialize().unwrap(); + let parsed_cfg = Config::deserialize(&cfg_serialized).unwrap(); + + assert_eq!(cfg, parsed_cfg,) + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct AttrWrapper { + attr: SetAttribute, + } + + #[test] + fn set_attribute_serialize_deserialize() { + let attr = AttrWrapper { + attr: SetAttribute { + path: "MyCool.Path".into(), + value: Attribute::Color(Color::X11(X11Color::LemonChiffon2)), + }, + }; + let serialized = toml::to_string_pretty(&attr).expect("serialize"); + let parsed: AttrWrapper = toml::from_str(&serialized).expect("unserailize"); + + assert_eq!(attr.attr, parsed.attr,) + } +} diff --git a/src/hlwm/attribute.rs b/src/hlwm/attribute.rs new file mode 100644 index 0000000..e2a2e11 --- /dev/null +++ b/src/hlwm/attribute.rs @@ -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), + Color(Option), + Int(Option), + String(Option), + Uint(Option), + Rectangle(Option<(u32, u32)>), + WindowID(Option), +} + +impl Default for AttributeOption { + fn default() -> Self { + Self::Int(None) + } +} + +impl AttributeOption { + pub fn new(type_string: &str, value_string: &Option) -> Result { + 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 { + 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 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 for Attribute { + fn from(value: i32) -> Self { + Self::Int(value) + } +} + +impl From for Attribute { + fn from(value: u32) -> Self { + Self::Uint(value) + } +} + +impl From 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 for Attribute { + fn from(value: Color) -> Self { + Self::Color(value) + } +} + +impl From 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 { + 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::>(); + 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 { + 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::()), + '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::iter() + .find(|t| t.to_string() == s) + .ok_or(StringParseError::UnknownValue) + } +} diff --git a/src/hlwm/color.rs b/src/hlwm/color.rs new file mode 100644 index 0000000..8ba26e4 --- /dev/null +++ b/src/hlwm/color.rs @@ -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(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Color { + fn deserialize(deserializer: D) -> Result + 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 { + 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 { + 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::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)); + } +} diff --git a/src/hlwm/command.rs b/src/hlwm/command.rs new file mode 100644 index 0000000..64b244e --- /dev/null +++ b/src/hlwm/command.rs @@ -0,0 +1,1014 @@ +use std::{io, process::ExitStatus, str::FromStr, string::FromUtf8Error}; +use strum::IntoEnumIterator; + +use serde::{de::Expected, Deserialize, Serialize}; +use thiserror::Error; + +use crate::{gen_parse, hlwm::Client}; + +use super::{ + attribute::{Attribute, AttributeError, AttributeOption}, + hlwmbool::ToggleBool, + hook::Hook, + key::{KeyUnbind, Keybind, Mousebind}, + rule::Rule, + setting::{FrameLayout, Setting, SettingName}, + split, + window::Window, + Align, Direction, Index, Monitor, Operator, Separator, StringParseError, Tag, ToCommandString, +}; + +#[derive(Debug, Clone, strum::Display, strum::EnumIter, PartialEq)] +#[strum(serialize_all = "snake_case")] +pub enum HlwmCommand { + /// Quits herbstluftwm. + Quit, + /// Prints the version of the running herbstluftwm instance + Version, + /// Ignores all arguments and always returns success, i.e. 0 + True, + /// Ignores all arguments and always returns failure, i.e. 1. + False, + /// Prints all given `args` separated by a single space and a newline afterwards + Echo(Vec), + /// Executes the autostart file. + Reload, + /// Closes the specified `window` gracefully or the focused window + /// if none is given explicitly + Close { + window: Option, + }, + /// Spawns an `executable` with its `args`. + Spawn { + executable: String, + args: Vec, + }, + /// List currently configured monitors with their index, + /// area (as rectangle), name (if named) and currently viewed tag. + ListMonitors, + /// Lists all bound keys with their associated command + ListKeybinds, + /// Lists all active rules. + ListRules, + /// Increases the monitors_locked setting. + /// Use this if you want to do multiple window actions at once + /// (i.e. without repainting between the single steps). + /// + /// See also: [Command::Unlock] + Lock, + /// Decreases the monitors_locked setting. + /// If monitors_locked is changed to 0, then all monitors are repainted again. + /// See also: [Command::Lock] + Unlock, + /// Print the value of the specified attribute + GetAttr(String), + /// Assign `new_value` to the specified `attribute` + SetAttr { + path: String, + new_value: Attribute, + }, + /// Prints the children and attributes of the given object addressed by `path`. + /// If `path` is an attribute, then print the attribute value. + /// If `new_value` is given, assign `new_value` to the attribute given by `path`. + Attr { + path: String, + new_value: Option, + }, + /// Creates a new attribute with the name and in the object specified by `path`. + /// Its type is specified by `attr`. + /// The attribute name has to begin with my_. + /// If `value` is supplied, then it is written to the attribute + /// (if this fails the attribute still remains). + NewAttr { + path: String, + attr: AttributeOption, + }, + /// Print the type of the specified attribute + AttrType(String), + /// Removes the user defined attribute + RemoveAttr(String), + /// Sets the specified [Setting]. + /// Allowed values for boolean settings are on or true for on, + /// off or false for off, toggle to toggle its value + Set(Setting), + Get(SettingName), + /// Emits a custom `hook` to all idling herbstclients + EmitHook(Hook), + Substitute, + Keybind(Keybind), + Keyunbind(KeyUnbind), + Mousebind(Mousebind), + /// Removes all mouse bindings + Mouseunbind, + UseIndex { + index: Index, + skip_visible: bool, + }, + MoveIndex { + index: Index, + skip_visible: bool, + }, + #[strum(serialize = "jumpto")] + JumpTo(Window), + /// Creates a new empty tag with the given name + #[strum(serialize = "add")] + AddTag(String), + /// Switches the focused monitor to specified tag + #[strum(serialize = "use")] + UseTag(String), + /// Moves the focused window to the tag with the given name + #[strum(serialize = "move")] + MoveTag(String), + /// Removes tag named `tag` and moves all its windows to tag `target`. + /// If `target` is None, the focused tag will be used + MergeTag { + tag: String, + target: Option, + }, + Cycle, + Focus(Direction), + Shift(Direction), + Split(Align), + Remove, + Fullscreen(ToggleBool), + CycleLayout { + delta: Option>, + layouts: Vec, + }, + Resize { + direction: Direction, + fraction_delta: Option>, + }, + Watch, + Or { + separator: Separator, + commands: Vec, + }, + And { + separator: Separator, + commands: Vec, + }, + Compare { + attribute: String, + operator: Operator, + value: String, + }, + /// Print a tab separated list of all tags for the specified `monitor` index. + /// If no `monitor` index is given, the focused monitor is used. + TagStatus { + monitor: Option, + }, + /// Defines a rule which will be applied to all new clients + Rule(Rule), + /// executes the provided command, prints its output, but always returns success, i.e. 0 + Try(Box), + /// executes the provided command, but discards its output and only returns its exit code. + Silent(Box), +} + +impl FromStr for Box { + type Err = CommandParseError; + + fn from_str(s: &str) -> Result { + Ok(Box::new(HlwmCommand::from_str(s)?)) + } +} + +impl Serialize for HlwmCommand { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_command_string().replace("\t", " ")) + } +} + +impl<'de> Deserialize<'de> for HlwmCommand { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + pub enum Expect { + NotEmpty, + } + impl Expected for Expect { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Expect::NotEmpty => write!(f, "value not being empty"), + } + } + } + + let str_val: String = Deserialize::deserialize(deserializer)?; + + let parts = split::tab_or_space(&str_val); + if parts.is_empty() { + return Err(serde::de::Error::invalid_length(0, &Expect::NotEmpty)); + } + let mut parts = parts.into_iter(); + let command = parts.next().unwrap(); + let args = parts.collect(); + + Ok(Self::from_raw_parts(&command, args).map_err(|err| { + serde::de::Error::invalid_value(serde::de::Unexpected::Str(&str_val), &err) + })?) + } +} + +impl FromStr for HlwmCommand { + type Err = CommandParseError; + + fn from_str(s: &str) -> Result { + let mut parts = s.split("\t"); + let command = parts + .next() + .ok_or(CommandParseError::UnknownCommand(s.to_string()))?; + let args = parts.map(String::from).collect(); + HlwmCommand::from_raw_parts(command, args) + } +} + +impl Default for HlwmCommand { + fn default() -> Self { + HlwmCommand::Quit + } +} + +#[derive(Debug, Error)] +pub enum CommandParseError { + #[error("unknown command [{0}]")] + UnknownCommand(String), + #[error("bad argument for command [{command}]")] + BadArgument { command: String }, + #[error("missing required argument")] + MissingArgument, + #[error("invalid argument count [{0}] at [{1}]")] + InvalidArgumentCount(usize, String), + #[error("error parsing attribute: [{0}]")] + AttributeError(#[from] AttributeError), + #[error("command execution error: [{0}]")] + CommandError(#[from] CommandError), + #[error("string utf8 error")] + StringUtf8Error(#[from] FromUtf8Error), + #[error("parsing string value error: [{0}]")] + StringParseError(#[from] StringParseError), +} + +impl serde::de::Expected for CommandParseError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "expected valid command string. Got error: {self}") + } +} + +fn trim_quotes(itm: String) -> String { + if itm.starts_with('"') && itm.ends_with('"') { + itm.trim_matches('"').to_string() + } else { + itm + } +} + +impl HlwmCommand { + pub fn silent(self) -> HlwmCommand { + HlwmCommand::Silent(Box::new(self)) + } + + pub fn to_try(self) -> HlwmCommand { + HlwmCommand::Try(Box::new(self)) + } + + pub fn from_raw_parts(command: &str, args: Vec) -> Result { + let command = HlwmCommand::iter() + .find(|cmd| cmd.to_string() == command) + .ok_or(CommandParseError::UnknownCommand(command.to_string()))?; + + gen_parse!(command, args); + + let parsed_command = match command { + HlwmCommand::Quit + | HlwmCommand::Lock + | HlwmCommand::Cycle + | HlwmCommand::Watch + | HlwmCommand::Reload + | HlwmCommand::Remove + | HlwmCommand::Unlock + | HlwmCommand::Version + | HlwmCommand::ListRules + | HlwmCommand::False + | HlwmCommand::True + | HlwmCommand::Substitute + | HlwmCommand::Mouseunbind + | HlwmCommand::ListMonitors + | HlwmCommand::ListKeybinds => Ok::<_, CommandParseError>(command.clone()), + HlwmCommand::Echo(_) => Ok(Self::Echo(args)), + HlwmCommand::Close { window: _ } => parse!(window: [Option] => Close), + HlwmCommand::Spawn { + executable: _, + args: _, + } => { + parse!(executable: String, args: [Vec] => Spawn).map(|spawn| match spawn { + HlwmCommand::Spawn { executable, args } => HlwmCommand::Spawn { + executable: trim_quotes(executable), + args: args.into_iter().map(trim_quotes).collect(), + }, + _ => unreachable!(), + }) + } + HlwmCommand::GetAttr(_) => parse!(String => GetAttr), + HlwmCommand::SetAttr { + path: _, + new_value: _, + } => { + let mut args = args.into_iter(); + let path = args.next().ok_or(CommandParseError::BadArgument { + command: command.to_string(), + })?; + Ok(HlwmCommand::SetAttr { + path: path.clone(), + new_value: { + Attribute::new( + &String::from_utf8( + Client::new() + .execute(HlwmCommand::AttrType(path.clone()))? + .stdout, + )? + .split('\n') + .next() + .ok_or(CommandParseError::CommandError(CommandError::Empty))?, + &args.collect::>().join(" "), + )? + }, + }) + } + HlwmCommand::Attr { + path: _, + new_value: _, + } => { + let mut args = args.into_iter(); + Ok(HlwmCommand::Attr { + path: args.next().ok_or(CommandParseError::BadArgument { + command: command.to_string(), + })?, + new_value: { + let args = args.collect::>(); + if args.is_empty() { + None + } else { + Some(Attribute::guess_type(&args.join("\t"))) + } + }, + }) + } + HlwmCommand::NewAttr { path: _, attr: _ } => { + let mut args = args.into_iter(); + let attr_type = args.next().ok_or(CommandParseError::BadArgument { + command: command.to_string(), + })?; + let path = args.next().ok_or(CommandParseError::BadArgument { + command: command.to_string(), + })?; + Ok(HlwmCommand::NewAttr { + path, + attr: { + let attr = args.collect::>(); + let attr = if attr.len() == 0 { + None + } else { + Some(attr.join("\t")) + }; + AttributeOption::new(&attr_type, &attr)? + }, + }) + } + HlwmCommand::AttrType(_) => parse!(String => AttrType), + HlwmCommand::RemoveAttr(_) => parse!(String => RemoveAttr), + HlwmCommand::Set(_) => parse!(FromStrAll => Set), + HlwmCommand::EmitHook(_) => parse!(FromStr => EmitHook), + HlwmCommand::Keybind(_) => parse!(FromStrAll => Keybind), + HlwmCommand::Keyunbind(_) => parse!(FromStr => Keyunbind), + HlwmCommand::Mousebind(_) => parse!(FromStrAll => Mousebind), + HlwmCommand::JumpTo(_) => parse!(FromStr => JumpTo), + HlwmCommand::AddTag(_) => parse!(String => AddTag), + HlwmCommand::MergeTag { tag: _, target: _ } => { + parse!(tag: String, target: [Option] => MergeTag) + } + HlwmCommand::Focus(_) => parse!(FromStr => Focus), + HlwmCommand::Shift(_) => parse!(FromStr => Shift), + HlwmCommand::Split(_) => parse!(FromStrAll => Split), + HlwmCommand::Fullscreen(_) => parse!(FromStr => Fullscreen), + HlwmCommand::CycleLayout { + delta: _, + layouts: _, + } => { + if args.is_empty() { + Ok(HlwmCommand::CycleLayout { + delta: None, + layouts: vec![], + }) + } else { + let first = args.first().ok_or(CommandParseError::BadArgument { + command: command.to_string(), + })?; + match FrameLayout::from_str(first) { + Ok(_) => { + // only frame layouts + Ok(HlwmCommand::CycleLayout { + delta: None, + layouts: args + .into_iter() + .map(|i| FrameLayout::from_str(&i)) + .collect::>() + .map_err(|_| CommandParseError::BadArgument { + command: command.to_string(), + })?, + }) + } + Err(_) => { + // Has index + let mut args = args.into_iter(); + Ok(HlwmCommand::CycleLayout { + delta: Some(args.next().unwrap().parse().map_err(|_| { + CommandParseError::BadArgument { + command: command.to_string(), + } + })?), + layouts: args + .map(|i| FrameLayout::from_str(&i)) + .collect::>() + .map_err(|_| CommandParseError::BadArgument { + command: command.to_string(), + })?, + }) + } + } + } + } + HlwmCommand::Resize { + direction: _, + fraction_delta: _, + } => parse!(direction: FromStr, fraction_delta: [Option] => Resize), + HlwmCommand::Or { + separator: _, + commands: _, + } => parse!(And_Or => Or), + HlwmCommand::And { + separator: _, + commands: _, + } => parse!(And_Or => And), + HlwmCommand::Compare { + attribute: _, + operator: _, + value: _, + } => parse!(attribute: String, operator: FromStr, value: String => Compare), + HlwmCommand::TagStatus { monitor: _ } => { + parse!(monitor: [Option] => TagStatus) + } + HlwmCommand::Rule(_) => parse!(FromStrAll => Rule), + HlwmCommand::Get(_) => parse!(FromStr => Get), + HlwmCommand::MoveIndex { + index: _, + skip_visible: _, + } => { + if args.contains(&"--skip-visible".to_string()) { + Ok(HlwmCommand::MoveIndex { + index: args + .into_iter() + .filter(|a| a != "--skip-visible") + .next() + .ok_or(CommandParseError::BadArgument { + command: command.to_string(), + })? + .parse()?, + skip_visible: true, + }) + } else { + Ok(HlwmCommand::MoveIndex { + index: args + .into_iter() + .next() + .ok_or(CommandParseError::BadArgument { + command: command.to_string(), + })? + .parse()?, + skip_visible: false, + }) + } + } + HlwmCommand::UseIndex { + index: _, + skip_visible: _, + } => { + if args.contains(&"--skip-visible".to_string()) { + Ok(HlwmCommand::UseIndex { + index: args + .into_iter() + .filter(|a| a != "--skip-visible") + .next() + .ok_or(CommandParseError::BadArgument { + command: command.to_string(), + })? + .parse()?, + skip_visible: true, + }) + } else { + Ok(HlwmCommand::UseIndex { + index: args + .into_iter() + .next() + .ok_or(CommandParseError::BadArgument { + command: command.to_string(), + })? + .parse()?, + skip_visible: false, + }) + } + } + HlwmCommand::UseTag(_) => parse!(String => UseTag), + HlwmCommand::MoveTag(_) => parse!(String => MoveTag), + HlwmCommand::Try(_) => parse!(FromStrAll => Try), + HlwmCommand::Silent(_) => parse!(FromStrAll => Silent), + }?; + + assert_eq!(command.to_string(), parsed_command.to_string()); + + Ok(parsed_command) + } +} + +impl HlwmCommand { + #[inline(always)] + pub(crate) fn args(&self) -> Vec { + if let Self::Spawn { executable, args } = self { + return vec!["spawn".to_string(), executable.to_string()] + .into_iter() + .chain(args.into_iter().cloned()) + .collect(); + } + self.to_command_string() + .split('\t') + .map(|a| a.to_string()) + .collect() + } +} + +#[derive(Debug, Error)] +pub enum CommandError { + #[error("IO error")] + IoError(#[from] io::Error), + #[error("exited with status code {0}")] + StatusCode(i32, Option), + #[error("killed by signal ({signal}); core dumped: {core_dumped}")] + KilledBySignal { signal: i32, core_dumped: bool }, + #[error("stopped by signal ({0})")] + StoppedBySignal(i32), + #[error("exit status not checked: {0:?}")] + OtherExitStatus(ExitStatus), + #[error("invalid utf8 string in response: {0:?}")] + UtfError(#[from] FromUtf8Error), + #[error("attribute error: {0:?}")] + AttributeError(#[from] AttributeError), + #[error("string parse error: {0}")] + StringParseError(#[from] StringParseError), + #[error("unexpected empty result")] + Empty, +} + +impl ToCommandString for HlwmCommand { + fn to_command_string(&self) -> String { + let cmd_string = match self { + HlwmCommand::Quit + | HlwmCommand::Lock + | HlwmCommand::Cycle + | HlwmCommand::Unlock + | HlwmCommand::Remove + | HlwmCommand::Reload + | HlwmCommand::Version + | HlwmCommand::ListRules + | HlwmCommand::Substitute + | HlwmCommand::Mouseunbind + | HlwmCommand::True + | HlwmCommand::False + | HlwmCommand::ListMonitors + | HlwmCommand::ListKeybinds + | HlwmCommand::Watch => self.to_string(), + HlwmCommand::Echo(args) => format!("{self}\t{}", args.join("\t")), + HlwmCommand::Close { window } => format!( + "{self}\t{}", + window.as_ref().map(|w| w.to_string()).unwrap_or_default(), + ), + HlwmCommand::Spawn { executable, args } => { + format!( + "{self}\t\"{executable}\"\t{}", + (&args) + .into_iter() + .map(|arg| format!("\"{arg}\"")) + .collect::>() + .join("\t") + ) + } + HlwmCommand::GetAttr(attr) => format!("{self}\t{attr}"), + HlwmCommand::SetAttr { + path: attribute, + new_value, + } => format!("{self}\t{attribute}\t{new_value}"), + HlwmCommand::Attr { path, new_value } => { + format!( + "{self}\t{path}\t{}", + new_value + .as_ref() + .map(|val| val.to_string()) + .unwrap_or_default() + ) + } + HlwmCommand::NewAttr { path, attr } => { + format!( + "{self}\t{ty}\t{path}\t{attr}", + ty = attr.type_string(), + attr = attr.value_string().unwrap_or_default() + ) + } + HlwmCommand::AttrType(attr) | HlwmCommand::RemoveAttr(attr) => { + format!("{self}\t{attr}") + } + HlwmCommand::Set(setting) => format!("{self}\t{}", setting.to_command_string()), + HlwmCommand::EmitHook(hook) => format!("{self}\t{}", hook.to_command_string()), + HlwmCommand::Keybind(keybind) => { + format!("{self}\t{}", keybind.to_command_string()) + } + HlwmCommand::Mousebind(mousebind) => format!("{self}\t{}", mousebind.to_string()), + HlwmCommand::Keyunbind(key_unbind) => format!("{self}\t{key_unbind}"), + HlwmCommand::MoveIndex { + index, + skip_visible, + } => format!( + "{self}\t{index}{}", + if *skip_visible { + "\t--skip-visible" + } else { + "" + } + ), + HlwmCommand::JumpTo(win) => format!("{self}\t{win}"), + HlwmCommand::AddTag(tag) => format!("{self}\t{tag}"), + HlwmCommand::MergeTag { tag, target } => format!( + "{self}\t{tag}\t{}", + target.as_ref().map(|t| t.to_string()).unwrap_or_default() + ), + HlwmCommand::Focus(dir) | HlwmCommand::Shift(dir) => format!("{self}\t{dir}"), + HlwmCommand::Split(split) => format!("{self}\t{}", split.to_command_string()), + HlwmCommand::Fullscreen(option) => format!( + "{self}\t{}", + match option { + ToggleBool::Bool(b) => match b { + true => "on", + false => "off", + }, + ToggleBool::Toggle => "toggle", + } + ), + HlwmCommand::And { + separator, + commands, + } + | HlwmCommand::Or { + separator, + commands, + } => format!( + "{self}\t{separator}\t{}", + commands + .into_iter() + .map(|c| c.to_command_string()) + .collect::>() + .join(&format!("\t{separator}\t")) + ), + HlwmCommand::Compare { + attribute, + operator, + value, + } => format!("{self}\t{attribute}\t{operator}\t{value}"), + HlwmCommand::CycleLayout { delta, layouts } => { + let mut command = self.to_string(); + if let Some(delta) = delta { + command = format!("{command}\t{delta}"); + } + if layouts.len() != 0 { + command = format!( + "{command}\t{}", + layouts + .into_iter() + .map(|l| l.to_string()) + .collect::>() + .join("\t") + ) + } + command + } + HlwmCommand::Resize { + direction, + fraction_delta, + } => format!( + "{self}\t{direction}\t{}", + fraction_delta + .as_ref() + .map(|d| d.to_string()) + .unwrap_or_default() + ), + HlwmCommand::TagStatus { monitor } => { + format!("{self}\t{}", monitor.unwrap_or_default()) + } + HlwmCommand::Rule(rule) => format!("{self}\t{}", rule.to_command_string()), + HlwmCommand::Get(setting) => format!("{self}\t{setting}"), + HlwmCommand::UseIndex { + index, + skip_visible, + } => { + if *skip_visible { + format!("{self}\t{index}\t--skip-visible") + } else { + format!("{self}\t{index}") + } + } + HlwmCommand::UseTag(tag) | HlwmCommand::MoveTag(tag) => format!("{self}\t{tag}"), + HlwmCommand::Try(cmd) | HlwmCommand::Silent(cmd) => { + format!("{self}\t{}", cmd.to_command_string()) + } + }; + if let Some(s) = cmd_string.strip_suffix('\t') { + return s.to_string(); + } + cmd_string + } +} + +#[cfg(test)] +mod test { + + use strum::IntoEnumIterator; + + use crate::hlwm::{ + attribute::{Attribute, AttributeOption}, + command::{FrameLayout, HlwmCommand, Index, Operator, Separator}, + hlwmbool::ToggleBool, + hook::Hook, + key::{Key, KeyUnbind, MouseButton, Mousebind, MousebindAction}, + rule::{Condition, Consequence, Rule, RuleOperator}, + setting::{Setting, SettingName}, + window::Window, + Align, Direction, Tag, ToCommandString, + }; + use pretty_assertions::assert_eq; + + #[test] + fn hlwm_command_string_and_back() { + let commands = HlwmCommand::iter() + .map(|cmd| match cmd { + HlwmCommand::Quit + | HlwmCommand::Version + | HlwmCommand::Reload + | HlwmCommand::Lock + | HlwmCommand::Remove + | HlwmCommand::Cycle + | HlwmCommand::ListMonitors + | HlwmCommand::ListRules + | HlwmCommand::ListKeybinds + | HlwmCommand::Unlock + | HlwmCommand::True + | HlwmCommand::False + | HlwmCommand::Substitute + | HlwmCommand::Mouseunbind => (cmd.clone(), cmd.to_string()), + HlwmCommand::Echo(_) => ( + HlwmCommand::Echo(vec!["Hello world!".into()]), + "echo\tHello world!".into(), + ), + HlwmCommand::Close { window: _ } => ( + HlwmCommand::Close { + window: Some(Window::LastMinimized), + }, + "close\tlast-minimized".into(), + ), + HlwmCommand::Spawn { + executable: _, + args: _, + } => ( + HlwmCommand::Spawn { + executable: "grep".into(), + args: vec!["content".into()], + }, + "spawn\t\"grep\"\t\"content\"".into(), + ), + HlwmCommand::GetAttr(_) => ( + HlwmCommand::GetAttr("my_attr".into()), + "get_attr\tmy_attr".into(), + ), + HlwmCommand::SetAttr { + path: _, + new_value: _, + } => ( + HlwmCommand::SetAttr { + path: "theme.color".into(), + new_value: Attribute::Color("#000000".parse().unwrap()), + }, + "set_attr\ttheme.color\t#000000".into(), + ), + HlwmCommand::Attr { + path: _, + new_value: _, + } => ( + HlwmCommand::Attr { + path: "my_attr".into(), + new_value: Some(Attribute::String("hello".into())), + }, + "attr\tmy_attr\thello".into(), + ), + HlwmCommand::NewAttr { path: _, attr: _ } => ( + HlwmCommand::NewAttr { + path: "my_attr".into(), + attr: AttributeOption::String(Some("hello".into())), + }, + "new_attr\tstring\tmy_attr\thello".into(), + ), + HlwmCommand::AttrType(_) => ( + HlwmCommand::AttrType("my_attr".into()), + "attr_type\tmy_attr".into(), + ), + HlwmCommand::RemoveAttr(_) => ( + HlwmCommand::RemoveAttr("my_attr".into()), + "remove_attr\tmy_attr".into(), + ), + HlwmCommand::Set(_) => ( + HlwmCommand::Set(Setting::AutoDetectMonitors(ToggleBool::Toggle)), + "set\tauto_detect_monitors\ttoggle".into(), + ), + HlwmCommand::EmitHook(_) => ( + HlwmCommand::EmitHook(Hook::Reload), + "emit_hook\treload".into(), + ), + HlwmCommand::Keybind(_) => ( + HlwmCommand::Keybind("Mod4+1\treload".parse().unwrap()), + "keybind\tMod4+1\treload".into(), + ), + HlwmCommand::Keyunbind(_) => ( + HlwmCommand::Keyunbind(KeyUnbind::All), + "keyunbind\t--all".into(), + ), + HlwmCommand::Mousebind(_) => ( + HlwmCommand::Mousebind(Mousebind { + keys: vec![Key::Mod4Super, Key::Mouse(MouseButton::Button1)], + action: MousebindAction::Move, + }), + "mousebind\tMod4-Button1\tmove".into(), + ), + HlwmCommand::MoveIndex { + index: _, + skip_visible: _, + } => ( + HlwmCommand::MoveIndex { + index: Index::Absolute(1), + skip_visible: true, + }, + "move_index\t1\t--skip-visible".into(), + ), + HlwmCommand::JumpTo(_) => ( + HlwmCommand::JumpTo(Window::LastMinimized), + "jumpto\tlast-minimized".into(), + ), + HlwmCommand::AddTag(_) => ( + HlwmCommand::AddTag("tag_name".into()), + "add\ttag_name".into(), + ), + HlwmCommand::Focus(_) => { + (HlwmCommand::Focus(Direction::Down), "focus\tdown".into()) + } + HlwmCommand::Shift(_) => (HlwmCommand::Shift(Direction::Up), "shift\tup".into()), + HlwmCommand::Split(_) => ( + HlwmCommand::Split(Align::Right(Some(0.5))), + "split\tright\t0.5".into(), + ), + HlwmCommand::Fullscreen(_) => ( + HlwmCommand::Fullscreen(ToggleBool::Toggle), + "fullscreen\ttoggle".into(), + ), + HlwmCommand::MergeTag { tag: _, target: _ } => ( + HlwmCommand::MergeTag { + tag: "my_tag".into(), + target: Some(Tag::Name("other_tag".into())), + }, + "merge_tag\tmy_tag\tother_tag".into(), + ), + HlwmCommand::CycleLayout { + delta: _, + layouts: _, + } => ( + HlwmCommand::CycleLayout { + delta: Some(Index::Absolute(1)), + layouts: vec![FrameLayout::Vertical, FrameLayout::Max, FrameLayout::Grid], + }, + "cycle_layout\t1\tvertical\tmax\tgrid".into(), + ), + HlwmCommand::Resize { + direction: _, + fraction_delta: _, + } => ( + HlwmCommand::Resize { + direction: Direction::Down, + fraction_delta: Some(Index::Absolute(0.5)), + }, + "resize\tdown\t0.5".into(), + ), + HlwmCommand::Watch => (HlwmCommand::Watch, "watch".into()), + HlwmCommand::Or { + separator: _, + commands: _, + } => ( + HlwmCommand::Or { + separator: Separator::Comma, + commands: vec![HlwmCommand::Reload, HlwmCommand::Quit], + }, + "or\t,\treload\t,\tquit".into(), + ), + HlwmCommand::And { + separator: _, + commands: _, + } => ( + HlwmCommand::And { + separator: Separator::Period, + commands: vec![HlwmCommand::Reload, HlwmCommand::Quit], + }, + "and\t.\treload\t.\tquit".into(), + ), + HlwmCommand::Compare { + attribute: _, + operator: _, + value: _, + } => ( + HlwmCommand::Compare { + attribute: "my_attr".to_string(), + operator: Operator::Equal, + value: "my_value".to_string(), + }, + "compare\tmy_attr\t=\tmy_value".into(), + ), + HlwmCommand::TagStatus { monitor: _ } => ( + HlwmCommand::TagStatus { monitor: Some(1) }, + "tag_status\t1".into(), + ), + HlwmCommand::Rule(_) => ( + HlwmCommand::Rule(Rule::new( + Some(Condition::Class { + operator: RuleOperator::Equal, + value: "Netscape".to_string(), + }), + vec![Consequence::Tag("6".into()), Consequence::Focus(false)], + None, + None, + )), + "rule\t--class=Netscape\t--tag=6\t--focus=off".into(), + ), + HlwmCommand::Get(_) => ( + HlwmCommand::Get(SettingName::AutoDetectMonitors), + "get\tauto_detect_monitors".into(), + ), + HlwmCommand::UseIndex { + index: _, + skip_visible: _, + } => ( + HlwmCommand::UseIndex { + index: Index::Absolute(1), + skip_visible: true, + }, + "use_index\t1\t--skip-visible".into(), + ), + HlwmCommand::UseTag(_) => (HlwmCommand::UseTag("tag".into()), "use\ttag".into()), + HlwmCommand::MoveTag(_) => (HlwmCommand::MoveTag("tag".into()), "move\ttag".into()), + HlwmCommand::Try(_) => ( + HlwmCommand::Try(Box::new(HlwmCommand::MergeTag { + tag: "default".into(), + target: None, + })), + "try\tmerge_tag\tdefault".into(), + ), + HlwmCommand::Silent(_) => ( + HlwmCommand::Silent(Box::new(HlwmCommand::MergeTag { + tag: "default".into(), + target: None, + })), + "silent\tmerge_tag\tdefault".into(), + ), + }) + .collect::>(); + for (command, expected_string) in commands { + let actual_string = command.to_command_string(); + assert_eq!( + expected_string, actual_string, + "\n1.\n\tExpected [{expected_string}]\n\tGot [{actual_string}]" + ); + let actual: HlwmCommand = actual_string + .parse() + .expect(&format!("\n2.\n\tparsing string: [{actual_string}]")); + assert_eq!( + command, actual, + "\n3.\n\tcomparing commands:\n\t\tleft: [{command:?}]\n\t\tright: [{actual:?}]" + ) + } + } +} diff --git a/src/hlwm/hex.rs b/src/hlwm/hex.rs new file mode 100644 index 0000000..8a0dd93 --- /dev/null +++ b/src/hlwm/hex.rs @@ -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; +} + +pub trait ParseHex { + fn parse_hex(&self) -> Result; +} + +impl> ParseHex for V +where + T: FromHex, +{ + fn parse_hex(&self) -> Result { + T::from_hex(self.as_ref()) + } +} + +macro_rules! impl_from_hex { + ($ty:ty) => { + impl FromHex for $ty { + fn from_hex(hex: &str) -> Result { + if let Some(without_prefix) = hex.strip_prefix("0x") { + return Self::from_hex(without_prefix); + } + Ok(hex + .chars() + .map(|c| hex_digit!(c)) + .collect::, _>>()? + .into_iter() + .rev() + .enumerate() + .map(|(idx, value)| -> Result<_, TryFromIntError> { + Ok((16 as $ty).pow(idx.try_into()?) * value) + }) + .collect::, _>>()? + .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); + } + } +} diff --git a/src/hlwm/hlwmbool.rs b/src/hlwm/hlwmbool.rs new file mode 100644 index 0000000..b61f4ed --- /dev/null +++ b/src/hlwm/hlwmbool.rs @@ -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 { + 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 for ToggleBool { + fn from(value: bool) -> Self { + Self::Bool(value) + } +} + +impl FromStr for ToggleBool { + type Err = StringParseError; + + fn from_str(s: &str) -> Result { + 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"), + } + } +} diff --git a/src/hlwm/hook.rs b/src/hlwm/hook.rs new file mode 100644 index 0000000..ebb6ee4 --- /dev/null +++ b/src/hlwm/hook.rs @@ -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 reloaded — and 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) -> Result { + 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 { + 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::>() + .join("\t") + } +} diff --git a/src/hlwm/key.rs b/src/hlwm/key.rs new file mode 100644 index 0000000..0fd79d0 --- /dev/null +++ b/src/hlwm/key.rs @@ -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(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Key { + fn deserialize(deserializer: D) -> Result + 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 { + 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 { + 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), +} + +impl Serialize for MousebindAction { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_command_string()) + } +} + +impl<'de> Deserialize<'de> for MousebindAction { + fn deserialize(deserializer: D) -> Result + 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 { + 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 for KeyParseError { + fn from(value: StringParseError) -> Self { + KeyParseError::StringParseError(value.to_string()) + } +} + +impl From for KeyParseError { + fn from(value: CommandParseError) -> Self { + KeyParseError::CommandParseError(value.to_string()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Keybind { + pub keys: Vec, + pub command: Box, +} + +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 { + let parts = s.split('\t').collect::>(); + 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::, _>>()?; + + let command = parts.next().unwrap(); + let args: Vec = 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>(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::>() + .join("+"), + ) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Mousebind { + pub keys: Vec, + pub action: MousebindAction, +} + +impl FromStr for Mousebind { + type Err = StringParseError; + + fn from_str(s: &str) -> Result { + let mut parts = s.split("\t"); + let keys = parts + .next() + .ok_or(StringParseError::UnknownValue)? + .split("-") + .map(|key| key.parse()) + .collect::, _>>()?; + 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>(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::>() + .join("-"), + action = self.action.to_command_string(), + ) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum KeyUnbind { + Keybind(Vec), + All, +} + +impl FromStr for KeyUnbind { + type Err = StringParseError; + + fn from_str(s: &str) -> Result { + match s { + "--all" => Ok(Self::All), + _ => Ok(KeyUnbind::Keybind( + s.split("-") + .map(|key| key.parse()) + .collect::>()?, + )), + } + } +} + +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::>() + .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); + } + } +} diff --git a/src/hlwm/macros.rs b/src/hlwm/macros.rs new file mode 100644 index 0000000..c265555 --- /dev/null +++ b/src/hlwm/macros.rs @@ -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::>(); + 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::, _>>()?; + 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::>() + .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]) => { + { + 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]) => { + $$args.collect::>() + }; + (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]) => { + { + let args = $$args.collect::>(); + if args.len() == 0 { + None + } else { + Some(args.join("\t")) + } + } + }; + (Argument $$args:ident: [Option>]) => { + { + let args = $$args.collect::>(); + if args.len() == 0 { + None + } else { + Some(args) + } + } + }; + (Argument $$args:ident: [Option]) => { + { + let args = $$args.map(|item| item.parse().map_err(|_| CommandParseError::BadArgument { + command: $command.to_string(), + })).collect::, _>>()?; + if args.len() == 0 { + None + } else { + Some(args) + } + } + }; + (Argument $$args:ident: [Vec]) => { + { + $$args.map(|item| item.parse().map_err(|_| CommandParseError::BadArgument { + command: $command.to_string(), + })).collect::, _>>()? + } + }; + } + }; +} diff --git a/src/hlwm/mod.rs b/src/hlwm/mod.rs new file mode 100644 index 0000000..ff45eea --- /dev/null +++ b/src/hlwm/mod.rs @@ -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 { + 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(&self, commands: I) -> Result<(), (HlwmCommand, CommandError)> + where + I: IntoIterator, + { + 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 { + 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 { + 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 { + Ok(Setting::from_str(&String::from_utf8( + self.execute(HlwmCommand::Get(setting))?.stdout, + )?) + .map_err(|_| StringParseError::UnknownValue)?) + } + + pub fn query(&self, command: HlwmCommand) -> Result, 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::>(); + if lines.is_empty() { + return Err(CommandError::Empty); + } + Ok(lines) + } + + pub fn tag_status(&self) -> Result, 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::, _>>()?) + } +} + +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 { + 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 +where + N: PartialEq, +{ + Relative(N), + Absolute(N), +} + +impl FromStr for Index +where + N: PartialEq + FromStr, +{ + type Err = StringParseError; + + fn from_str(s: &str) -> Result { + let mut chars = s.chars(); + let prefix = chars.next().ok_or(StringParseError::UnknownValue)?; + match prefix { + '+' => Ok(Self::Relative( + N::from_str(&chars.collect::()) + .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 Default for Index +where + N: PartialEq + Default, +{ + fn default() -> Self { + Self::Absolute(Default::default()) + } +} + +impl Display for Index +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::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::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::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), + Left(Option), + Right(Option), + Bottom(Option), + Explode, + Auto, +} + +impl FromStr for Align { + type Err = StringParseError; + + fn from_str(s: &str) -> Result { + 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") + } +} diff --git a/src/hlwm/octal.rs b/src/hlwm/octal.rs new file mode 100644 index 0000000..8377407 --- /dev/null +++ b/src/hlwm/octal.rs @@ -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; +} + +pub trait ParseOctal { + fn parse_octal(&self) -> Result; +} + +impl> ParseOctal for V +where + T: FromOctal, +{ + fn parse_octal(&self) -> Result { + T::from_octal(self.as_ref()) + } +} + +macro_rules! impl_from_octal { + ($ty:ty) => { + impl FromOctal for $ty { + fn from_octal(octal: &str) -> Result { + if let Some(without_prefix) = octal.strip_prefix("0o") { + return Self::from_octal(without_prefix); + } + Ok(octal + .chars() + .map(|c| octal_digit!(c)) + .collect::, _>>()? + .into_iter() + .rev() + .enumerate() + .map(|(idx, value)| -> Result<_, TryFromIntError> { + Ok((8 as $ty).pow(idx.try_into()?) * value) + }) + .collect::, _>>()? + .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); + } + } +} diff --git a/src/hlwm/rule.rs b/src/hlwm/rule.rs new file mode 100644 index 0000000..c7c2b0d --- /dev/null +++ b/src/hlwm/rule.rs @@ -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, + flag: Option, + /// 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, + consequences: Vec, +} + +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::>() + .join("\t"), + ); + + args.join("\t") + } +} + +impl Rule { + pub fn new( + condition: Option, + consequences: Vec, + label: Option, + flag: Option, + ) -> Self { + Self { + label, + flag, + condition, + consequences, + } + } +} + +impl FromStr for Rule { + type Err = StringParseError; + + fn from_str(s: &str) -> Result { + let parts = split::tab_or_space(s); + if parts.is_empty() { + return Err(StringParseError::InvalidLength(0, "parse rule")); + } + let mut condition: Option = None; + let mut consequences = vec![]; + let mut label: Option = None; + let mut flag: Option = 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::>(); + 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!("<>\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!("<>\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(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_command_string().replace("\t", " ")) + } +} + +impl<'de> Deserialize<'de> for Rule { + fn deserialize(deserializer: D) -> Result + 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 client’s property is matched by the regex value. + Regex, + /// = matches if client’s 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 for RuleOperator { + type Error = StringParseError; + + fn try_from(value: char) -> Result { + 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 { + 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 client’s WM_CLASS. + Instance { + operator: RuleOperator, + value: String, + }, + /// the second entry in client’s WM_CLASS. + Class { + operator: RuleOperator, + value: String, + }, + /// client’s window title. + Title { + operator: RuleOperator, + value: String, + }, + /// the client’s 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 client’s 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 { + // 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 client’s tag will be shown or not. + /// If the tag is shown on any monitor but is not focused, the client’s 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 client’s 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 { + 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::>(); + 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::>().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::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 { + 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); + } + } +} diff --git a/src/hlwm/setting.rs b/src/hlwm/setting.rs new file mode 100644 index 0000000..bab5d7f --- /dev/null +++ b/src/hlwm/setting.rs @@ -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 aren’t 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 client’s 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 there’s 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 herbstluftwm’s 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::iter() + .find(|i| i.to_string() == s) + .ok_or(StringParseError::UnknownValue) + } +} + +impl Serialize for Setting { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_command_string().replace("\t", " = ")) + } +} + +impl<'de> Deserialize<'de> for Setting { + fn deserialize(deserializer: D) -> Result + 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 { + 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 { + 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() + ) + } +} diff --git a/src/hlwm/split.rs b/src/hlwm/split.rs new file mode 100644 index 0000000..2f5087e --- /dev/null +++ b/src/hlwm/split.rs @@ -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 +where + S: Into>, +{ + let value = s.into(); + let mut chars = value.chars(); + let mut working: Vec = Vec::with_capacity(value.len()); + let mut out: Vec = 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 = 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)) + } + } +} diff --git a/src/hlwm/theme.rs b/src/hlwm/theme.rs new file mode 100644 index 0000000..158a080 --- /dev/null +++ b/src/hlwm/theme.rs @@ -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 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 { + match ThemeAttr::iter() + .find(|attr| attr.attr_path() == path) + .map(|attr| -> Result { + 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::>().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(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&format!( + "{} = {}", + self.attr_path(), + Attribute::from(self.clone()) + )) + } +} + +impl<'de> Deserialize<'de> for ThemeAttr { + fn deserialize(deserializer: D) -> Result + 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); + } +} diff --git a/src/hlwm/window.rs b/src/hlwm/window.rs new file mode 100644 index 0000000..0a7f081 --- /dev/null +++ b/src/hlwm/window.rs @@ -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 { + 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 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 { + 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 for TagState { + type Error = StringParseError; + + fn try_from(value: char) -> Result { + 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::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>(types: I) -> Condition { + Condition::WindowType { + operator: RuleOperator::Regex, + value: format!( + "_NET_WM_WINDOW_TYPE_({})", + types.map(|t| t.to_string()).collect::>().join("|") + ), + } + } +} + +#[macro_export] +macro_rules! window_types { + ($($ty:tt),+) => { + crate::hlwm::window::WindowType::or([$( + crate::hlwm::window::WindowType::$ty + ),+].into_iter()) + }; +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f34e26f --- /dev/null +++ b/src/main.rs @@ -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"); +} diff --git a/svcctl/svc.go b/svcctl/svc.go deleted file mode 100644 index 8b96971..0000000 --- a/svcctl/svc.go +++ /dev/null @@ -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) - } - } -} \ No newline at end of file