diff --git a/CHANGELOG.md b/CHANGELOG.md index 7339931..c3f2985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,43 @@ All notable changes to this project will be documented in this file. -## [3.0.0] - 2024-02-19 +## [v3.1.0] - 2025-01-22 + +### Bug Fixes + +- Fix [multi-monitor window dragging issue specific to i3](https://github.com/roosta/i3wsr/issues/34) + - Sway doesn't trigger any events on window drag + +### Features + +- Add sway support +- Add `--verbose` cmdline flag for easier debugging in case of issues + +#### Sway + +Support for [Sway](https://github.com/swaywm/sway) is added, new config key +addition `app_id` in place of `class` when running native Wayland applications: + +``` +[aliases.app_id] +firefox-developer-edition = "Firefox Developer" +``` +`i3wsr` will still check for `name`, `instance`, and `class` for `Xwayland` +windows, where applicable. So some rules can be preserved. To migrate replace +`[aliases.class]` with `[aliases.app_id]`, keep in mind that `app_id` and +`class` aren't always interchangeable , so some additional modifications is +usually needed. + +> A useful script figuring out `app_id` can be found [here](https://gist.github.com/crispyricepc/f313386043395ff06570e02af2d9a8e0#file-wlprop-sh), it works like `xprop` but for Wayland. + +### Deprecations + +I've flagged `--icons` as deprecated, it will not exit the application but it +no longer works. I'd be surprised if anyone actually used that preset, as it +was only ever for demonstration purposes, and kept around as a holdover from +previous versions. + +## [v3.0.0] - 2024-02-19 **BREAKING**: Config syntax changes, see readme for new syntax but in short `wm_property` is no longer, and have been replaced by scoped aliases that are @@ -97,13 +133,13 @@ display_property = "instance" # class, instance, name - Pin regex to 1.9.1 - Pin endoing to 0.2.33 -## [2.1.1] - 2022-03-15 +## [v2.1.1] - 2022-03-15 ### Bug Fixes - Use with_context() instead of context() -## [2.1.0] - 2022-03-14 +## [v2.1.0] - 2022-03-14 ### Bug Fixes @@ -114,10 +150,13 @@ display_property = "instance" # class, instance, name - Add examples of workspace assignment - Document about the default config file -## [1.0.0] - 2017-12-19 +## [v1.0.0] - 2017-12-19 ### WIP - Attempt to use xlib FFI to get win props + +[v3.1.0]: https://github.com/roosta/i3wsr/compare/v3.0.0...v3.1.0 +[v3.0.0]: https://github.com/roosta/herb/compare/v2.1.1...v3.0.0 diff --git a/Cargo.lock b/Cargo.lock index edbf709..6c82f92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,88 +1,70 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aho-corasick" -version = "1.0.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] name = "anstream" -version = "0.3.2" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", - "is-terminal", + "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" -version = "1.0.1" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" -version = "0.2.1" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.0.0" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" dependencies = [ - "windows-sys", + "windows-sys 0.59.0", ] [[package]] name = "anstyle-wincon" -version = "1.0.1" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" dependencies = [ "anstyle", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" - -[[package]] -name = "byteorder" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" - -[[package]] -name = "cc" -version = "1.0.79" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "cfg-if" @@ -92,20 +74,19 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.3.12" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eab9e8ceb9afdade1ab3f0fd8dbce5b1b2f468ad653baf10e771781b2b67b73" +checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" dependencies = [ "clap_builder", "clap_derive", - "once_cell", ] [[package]] name = "clap_builder" -version = "4.3.12" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f2763db829349bf00cfc06251268865ed4363b93a943174f638daf3ecdba2cd" +checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" dependencies = [ "anstream", "anstyle", @@ -115,9 +96,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.3.12" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a9bb5758fc5dfe728d1019941681eccaf0cf8a4189b692a0ee2f2ecf90a050" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" dependencies = [ "heck", "proc-macro2", @@ -127,15 +108,25 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.5.0" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "colorchoice" -version = "1.0.0" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "colored" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] [[package]] name = "dirs" @@ -155,14 +146,14 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] name = "either" -version = "1.8.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "equivalent" @@ -170,32 +161,11 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" -[[package]] -name = "errno" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" -dependencies = [ - "errno-dragonfly", - "libc", - "windows-sys", -] - -[[package]] -name = "errno-dragonfly" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "getrandom" -version = "0.2.10" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", @@ -204,111 +174,89 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.0" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" [[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.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" - -[[package]] -name = "i3ipc" -version = "0.10.1" -source = "git+https://github.com/roosta/i3ipc-rs#7aad30d162dc4fd8a649c959c9ad888a646e07f2" -dependencies = [ - "byteorder", - "log", - "serde", - "serde_json", -] +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "i3wsr" -version = "3.0.0" +version = "3.1.0" dependencies = [ "clap", + "colored", "dirs", - "i3ipc", "itertools", "regex", "serde", + "swayipc", + "thiserror", "toml", ] [[package]] name = "indexmap" -version = "2.0.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", "hashbrown", ] [[package]] -name = "is-terminal" -version = "0.4.9" +name = "is_terminal_polyfill" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" -dependencies = [ - "hermit-abi", - "rustix", - "windows-sys", -] +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itertools" -version = "0.11.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.8" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b02a5381cc465bd3041d84623d0fa3b66738b52b8e2fc3bab8ad63ab032f4a" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] -name = "libc" -version = "0.2.147" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "linux-raw-sys" -version = "0.4.3" +name = "libc" +version = "0.2.168" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" +checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" [[package]] -name = "log" -version = "0.4.19" +name = "libredox" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", +] [[package]] name = "memchr" -version = "2.5.0" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" - -[[package]] -name = "once_cell" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "option-ext" @@ -318,47 +266,38 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "proc-macro2" -version = "1.0.64" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78803b62cbf1f46fde80d7c0e803111524b9877184cfe7c3033659490ac7a7da" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.29" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "573015e8ab27661678357f27dc26460738fd2b6c86e46f386fde94cb5d913105" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "redox_users" -version = "0.4.3" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom", - "redox_syscall", + "libredox", "thiserror", ] [[package]] name = "regex" -version = "1.9.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -368,9 +307,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.3.3" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39354c10dd07468c2e73926b23bb9c2caca74c5501e38a35da70406f1d923310" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -379,43 +318,30 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2" - -[[package]] -name = "rustix" -version = "0.38.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a962918ea88d644592894bc6dc55acc6c0956488adcebbfb6e273506b7fd6e5" -dependencies = [ - "bitflags 2.3.3", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "ryu" -version = "1.0.14" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe232bdf6be8c8de797b22184ee71118d63780ea42ac85b61d1baa6d3b782ae9" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "serde" -version = "1.0.171" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30e27d1e4fd7659406c492fd6cfaf2066ba8773de45ca75e855590f856dc34a9" +checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.171" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389894603bd18c46fa56231694f8d827779c0951a667087194cf9de94ed24682" +checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" dependencies = [ "proc-macro2", "quote", @@ -424,35 +350,58 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.102" +version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5062a995d481b2308b6064e9af76011f2921c35f97b0468811ed9f6cd91dfed" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] [[package]] name = "serde_spanned" -version = "0.6.3" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96426c9936fd7a0124915f9185ea1d20aa9445cc9821142f0a73bc9207a2e186" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" dependencies = [ "serde", ] [[package]] name = "strsim" -version = "0.10.0" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swayipc" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8c50cb2e98e88b52066a35ef791fffd8f6fa631c3a4983de18ba41f718c736" +dependencies = [ + "serde", + "serde_json", + "swayipc-types", +] + +[[package]] +name = "swayipc-types" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "551233c60323e87cfb8194c21cc44577ab848d00bb7fa2d324a2c7f52609eaff" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] [[package]] name = "syn" -version = "2.0.25" +version = "2.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15e3fc8c0c74267e2df136e5e5fb656a464158aa57624053375eb9c8c6e25ae2" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" dependencies = [ "proc-macro2", "quote", @@ -461,18 +410,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.43" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35fc5b8971143ca348fa6df4f024d4d55264f3468c71ad1c2f365b0a4d58c42" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.43" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "463fe12d7993d3b327787537ce8dd4dfa058de32fc2b195ef3cde03dc4771e8f" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", @@ -481,9 +430,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17e963a819c331dcacd7ab957d80bc2b9a9c1e71c804826d2f283dd65306542" +checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" dependencies = [ "serde", "serde_spanned", @@ -493,18 +442,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.3" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.19.13" +version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f8751d9c1b03c6500c387e96f81f815a4f8e72d142d2d4a9ffa6fedd51ddee7" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap", "serde", @@ -515,15 +464,15 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22049a19f4a68748a168c0fc439f9516686aa045927ff767eca0a85101fb6e73" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" [[package]] name = "utf8parse" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "wasi" @@ -537,71 +486,144 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] name = "windows-targets" -version = "0.48.1" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 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", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows_aarch64_gnullvm" -version = "0.48.0" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" -version = "0.48.0" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" -version = "0.48.0" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.5.0" +version = "0.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fac9742fd1ad1bd9643b991319f72dd031016d44b77039a26977eb667141e7" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index b56fa3d..c71e7f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,29 +1,32 @@ [package] edition = "2021" name = "i3wsr" -version = "3.0.0" -description = "Change i3-wm workspace names based on its contents" +version = "3.1.0" +description = "A dynamic workspace renamer for i3 and Sway that updates names to reflect their active applications." authors = ["Daniel Berg "] repository = "https://github.com/roosta/i3wsr" documentation = "https://github.com/roosta/i3wsr" readme = "README.md" -keywords = ["i3-wm", "window-manager", "workspaces", "linux"] -categories = ["command-line-utilities"] +keywords = ["i3", "workspaces", "linux", "wayland", "sway", "xorg"] +categories = ["gui", "command-line-utilities", "config"] license = "MIT" exclude = ["/script", "/assets/*", "Vagrantfile"] -[badges] -travis-ci = { repository = "roosta/i3wsr" } +[lib] +name = "i3wsr_core" +path = "src/lib.rs" -[dependencies] -clap = { version = "4.3.11", features = ["derive"] } -toml = "0.7.6" -serde = { version = "1.0.171", features = ["derive"] } -itertools = "0.11.0" -regex = "1.9.1" -dirs = "5.0.1" -# log = "0.4" +[[bin]] +name = "i3wsr" +path = "src/main.rs" -[dependencies.i3ipc] -git = 'https://github.com/roosta/i3ipc-rs' -# path = "../i3ipc-rs" +[dependencies] +clap = { version = "4.5", features = ["derive"] } +toml = "0.7" +serde = { version = "1.0", features = ["derive"] } +itertools = "0.13" +regex = "1.11" +dirs = "5.0" +thiserror = "1.0" +swayipc = "3.0" +colored = "2" diff --git a/LICENSE b/LICENSE index a5a1e4e..fef2a4d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Daniel Berg +Copyright (c) 2017 Daniel Berg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 7ed8a74..2ea2c89 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,39 @@ i3wsr - i3 workspace renamer ====== -[![Test Status](https://github.com/roosta/i3wsr/actions/workflows/test.yaml/badge.svg?branch=develop)](https://github.com/roosta/i3wsr/actions) +[![Test Status](https://github.com/roosta/i3wsr/actions/workflows/test.yaml/badge.svg?branch=main)](https://github.com/roosta/i3wsr/actions) [![Crates.io](https://img.shields.io/crates/v/i3wsr)](https://crates.io/crates/i3wsr) +A dynamic workspace renamer for i3 and Sway that updates names to reflect their +active applications. + +`i3wsr` can be configured through command-line flags or a `TOML` config file, +offering extensive customization of workspace names, icons, aliases, and +display options. + +## Preview + +![preview](https://raw.githubusercontent.com/roosta/i3wsr/main/assets/preview.gif) + +## Rebrand and Wayland support + +Now that `i3wsr` works with [Sway](https://swaywm.org/) as well as +[I3](https://i3wm.org/), the name is a bit misleading, and could do with a +change. Shame to lose the metrics, but it might help further discovery now that +it supports multiple display servers. + +I've not thought of anything yet, but will advertise it here in the README +before publishing anything under a new name. + +Development forward will focus on Sway, but backward compatibility with I3 will +be maintained. -`i3wsr` is a small program that uses [I3's](https://i3wm.org/) [IPC Interface](https://i3wm.org/docs/ipc.html) -to change the name of a workspace based on its contents. - -## TOC - -- [i3wsr - i3 workspace renamer](#i3wsr---i3-workspace-renamer) -- [TOC](#toc) - - [Details](#details) - - [Requirements](#requirements) - - [Installation](#installation) - - [Arch linux](#arch-linux) - - [Usage](#usage) - - [i3 configuration](#i3-configuration) - - [Keeping part of the workspace name](#keeping-part-of-the-workspace-name) - - [Configuration / options](#configuration--options) - - [Aliases](#aliases) - - [Aliases based on property](#aliases-based-on-property) - - [Class](#class) - - [Instance](#instance) - - [Name](#name) - - [Display property](#display-property) - - [Icons](#icons) - - [Separator](#separator) - - [Default icon](#default-icon) - - [Empty label](#empty-label) - - [No icon names](#no-icon-names) - - [No names](#no-names) - - [Remove duplicates](#remove-duplicates) - - [Split at character](#split-at-character) - - [Sway](#sway) - - [Testing](#testing) - - [Attribution](#attribution) - -## Details - -The chosen name for a workspace is a composite of the `WM_CLASS` X11 window -property for each window in a workspace. In action it would look something like this: - -![](https://raw.githubusercontent.com/roosta/i3wsr/main/assets/preview.gif) ## Requirements -i3wsr requires [i3wm](https://i3wm.org/) and [numbered +i3wsr requires [i3](https://i3wm.org/) or [sway](https://swaywm.org/), and +[numbered workspaces](https://i3wm.org/docs/userguide.html#_changing_named_workspaces_moving_to_workspaces), -see [i3-configuration](#i3-configuration) +see [Configuration](#configuration) ## Installation @@ -68,9 +53,11 @@ cargo build --release Then place the built binary, located at `target/release/i3wsr`, somewhere on your `$path`. ### Arch linux + If you're running Arch you can install either [stable](https://aur.archlinux.org/packages/i3wsr/), or [latest](https://aur.archlinux.org/packages/i3wsr-git/) from AUR thanks to reddit user [u/OniTux](https://www.reddit.com/user/OniTux). ## Usage + Just launch the program and it'll listen for events if you are running I3. Another option is to put something like this in your i3 config @@ -81,10 +68,10 @@ exec_always --no-startup-id $HOME/.cargo/bin/i3wsr exec_always --no-startup-id /usr/bin/i3wsr ``` -## i3 configuration +## Configuration This program depends on numbered workspaces, since we're constantly changing the -workspace name. So your I3 configuration need to reflect this: +workspace name. So your I3 or Sway configuration need to reflect this: ``` bindsym $mod+1 workspace number 1 @@ -135,6 +122,10 @@ property ```toml +# For Sway +[aliases.app_id] + +# for i3 [aliases.class] # Exact match @@ -158,26 +149,34 @@ rust string escapes if you want a literal backslash use two slashes `\\d`. ### Aliases based on property -i3wsr supports 3 window properties currently: +i3wsr supports 4 window properties currently: ```toml -[aliases.name] # 1 -[aliases.instance] # 2 -[aliases.class] # 3 +[aliases.name] # 1 i3 / wayland / sway +[aliases.instance] # 2 i3 / xwayland +[aliases.class] # 3 i3 / xwayland +[aliases.app_id] # 3 wayland / sway only ``` These are checked in descending order, so if i3wsr finds a name alias, it'll use that and if not, then check instance, then finally use class -> Deprecation note: previously `wm_property` defined which prop to check for -> aliases, but this newer approach will allow for multiple types of aliases - #### Class -This is the default, and the most succinct. +> Only for Xwayland / i3 + +This is the default for `i3`, and the most succinct. + +#### App id + +> Only for Wayland / Sway + +This is the default for wayland apps, and the most and works largely like class. #### Instance -Use `WM_INSTANCE` instead of `WM_CLASS` when assigning workspace names, +> Only for Xwayland / i3 + +Use `instance` instead of `class` when assigning workspace names, instance is usually more specific. i3wsr will try to get the instance but if it isn't defined will fall back to class. @@ -185,6 +184,7 @@ A use case for this option could be launching `chromium --app="https://web.whatsapp.com"`, and then assign a different icon to whatsapp in your config file, while chrome retains its own alias: ```toml + [icons] "WhatsApp" = "🗩" @@ -197,7 +197,7 @@ Google-chrome = "Chrome" #### Name -Uses `WM_NAME` instead of `WM_INSTANCE` and `WM_CLASS`, this option is very +Uses `name` instead of `instance` and `class|app_id`, this option is very verbose and relies on regex matching of aliases to be of any use. A use-case is running some terminal application, and as default i3wsr will only @@ -210,15 +210,6 @@ So you could do something like this: ".*mutt$" = "Mutt" ``` -You could display whatever the terminal is running, but this comes with one -caveat: i3 has no way of knowing what happens in a terminal and starting say -mutt will not trigger any IPC events. The alias will take effect whenever i3 -receives a window or workspace event. - -It should be possible to write a launcher script, that wraps whatever -command your running with a custom i3 ipc trigger event. If anyone figures out -a nice way of doing it let me know. - ### Display property Which property to display if no aliases is found: @@ -228,22 +219,17 @@ Which property to display if no aliases is found: display_property = "instance" ``` -Possible options are `class`, `instance`, and `name`, and will default to `class` -if not present. +Possible options are `class`, `app_id`, `instance`, and `name`, and will default +to `class` or `app_id` depending on display server if not present. You can alternatively supply cmd argument: - ```sh -i3wsr --display-property instance +i3wsr --display-property name ``` ### Icons -You can configure icons for your WM property, a very basic preset for -font-awesome is configured, to enable it use the option `--icons awesome` -(requires font-awesome to be installed). +You can config icons for your WM property, these are defined in your config file. -A more in depth icon configuration can be setup by using a configuration file. -In there you can define icons for whatever title you'd like. ```toml [icons] Firefox = "🌍" @@ -279,7 +265,7 @@ separator = "  " ### Default icon To use a default icon when no other is defined use: ```toml -[general] +[icons] default_icon = "💀" ``` ### Empty label @@ -333,19 +319,7 @@ keeping the numbered part of a workspace name when renaming. This can give a cleaner config, but I've kept the old behavior as default. - -## Sway - Check [Pedro Scaff](https://github.com/pedroscaff)'s port [swaywsr](https://github.com/pedroscaff/swaywsr). - ## Testing To run tests locally [Vagrant](https://www.vagrantup.com/) is required. Run `script/run_tests.sh` to run tests on ubuntu xenial. - -## Attribution - -This program would not be possible without -[i3ipc-rs](https://github.com/tmerr/i3ipc-rs), a rust library for controlling -i3wm through its IPC interface and -[rust-xcb](https://github.com/rtbo/rust-xcb), a set of rust bindings and -wrappers for [XCB](http://xcb.freedesktop.org/). diff --git a/Vagrantfile b/Vagrantfile index 6c72d61..0c9e20c 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -2,7 +2,7 @@ VAGRANTFILE_API_VERSION = '2' Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| config.vm.define :ubuntu do |ubuntu| - ubuntu.vm.box = 'ubuntu/lunar64' + ubuntu.vm.box = 'ubuntu/xenial64' ubuntu.vm.provision 'shell', path: 'script/vagrant_root.sh' ubuntu.vm.provision 'shell', privileged: false, path: 'script/vagrant_user.sh' end diff --git a/assets/example_config.toml b/assets/example_config.toml index 3f00ef0..61092d0 100644 --- a/assets/example_config.toml +++ b/assets/example_config.toml @@ -10,21 +10,28 @@ Nautilus = "📘" # smile emoji MyNiceProgram = "😛" +# i3 / Xwayland [aliases.class] TelegramDesktop = "Telegram" "Org\\.gnome\\.Nautilus" = "Nautilus" +# Sway only +[aliases.app_id] +"^firefox$" = "Firefox" + +# i3 only [aliases.instance] "open.spotify.com" = "Spotify" +# Both i3 and sway [aliases.name] [general] separator = "  " -default_icon = "" split_at = ":" empty_label = "🌕" display_property = "instance" # class, instance, name +default_icon = "" [options] remove_duplicates = false diff --git a/src/config.rs b/src/config.rs index 6749469..7690eca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,70 +1,120 @@ use serde::Deserialize; use std::collections::HashMap; -use std::error::Error; use std::fs::File; -use std::io::Read; +use std::io::{self, Read}; use std::path::Path; +use thiserror::Error; -#[derive(Deserialize)] +type StringMap = HashMap; +type IconMap = HashMap; +type OptionMap = HashMap; + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("Failed to read config file: {0}")] + IoError(#[from] io::Error), + #[error("Failed to parse TOML: {0}")] + TomlError(#[from] toml::de::Error), +} + +/// Represents aliases for different categories +#[derive(Deserialize, Debug, Clone)] #[serde(default)] pub struct Aliases { - pub class: HashMap, - pub instance: HashMap, - pub name: HashMap, + pub class: StringMap, + pub instance: StringMap, + pub name: StringMap, + pub app_id: StringMap, +} + +impl Aliases { + /// Creates a new empty Aliases instance + pub fn new() -> Self { + Self::default() + } + + /// Gets an alias by category and key + pub fn get_alias(&self, category: &str, key: &str) -> Option<&String> { + match category { + "app_id" => self.app_id.get(key), + "class" => self.class.get(key), + "instance" => self.instance.get(key), + "name" => self.name.get(key), + _ => None, + } + } +} + +impl Default for Aliases { + fn default() -> Self { + Self { + class: StringMap::new(), + instance: StringMap::new(), + name: StringMap::new(), + app_id: StringMap::new(), + } + } } -#[derive(Deserialize)] +/// Main configuration structure +#[derive(Deserialize, Debug, Clone)] #[serde(default)] pub struct Config { - pub icons: HashMap, + pub icons: IconMap, pub aliases: Aliases, - pub general: HashMap, - pub options: HashMap, + pub general: StringMap, + pub options: OptionMap, } impl Config { - pub fn new(filename: &Path, icons_override: &str) -> Result> { - let file_config = read_toml_config(filename)?; - Ok(Config { - icons: file_config - .icons - .into_iter() - .chain(crate::icons::get_icons(icons_override)) - .collect(), - ..file_config - }) + /// Creates a new Config instance from a file + pub fn new(filename: &Path) -> Result { + let config = Self::from_file(filename)?; + Ok(config) } -} -impl Default for Aliases { - fn default() -> Self { - Aliases { - class: HashMap::new(), - instance: HashMap::new(), - name: HashMap::new(), - } + /// Loads configuration from a TOML file + pub fn from_file(filename: &Path) -> Result { + let mut file = File::open(filename)?; + let mut buffer = String::new(); + file.read_to_string(&mut buffer)?; + let config: Config = toml::from_str(&buffer)?; + Ok(config) + } + + /// Gets a general configuration value + pub fn get_general(&self, key: &str) -> Option { + self.general.get(key).map(|s| s.to_string()) + } + + /// Gets an option value + pub fn get_option(&self, key: &str) -> Option { + self.options.get(key).copied() + } + + /// Gets an icon by key + pub fn get_icon(&self, key: &str) -> Option { + self.icons.get(key).map(|s| s.to_string()) + } + + /// Sets a general configuration value + pub fn set_general(&mut self, key: String, value: String) { + self.general.insert(key, value); + } + + /// Sets a an option configuration value + pub fn set_option(&mut self, key: String, value: bool) { + self.options.insert(key, value); } } impl Default for Config { fn default() -> Self { - Config { - icons: HashMap::new(), - aliases: Aliases { - class: HashMap::new(), - instance: HashMap::new(), - name: HashMap::new(), - }, - general: HashMap::new(), - options: HashMap::new(), + Self { + icons: IconMap::new(), + aliases: Aliases::default(), + general: StringMap::new(), + options: OptionMap::new(), } } } - -fn read_toml_config(filename: &Path) -> Result> { - let mut file = File::open(filename)?; - let mut buffer = String::new(); - file.read_to_string(&mut buffer)?; - let config: Config = toml::from_str(&buffer)?; - Ok(config) -} diff --git a/src/icons.rs b/src/icons.rs deleted file mode 100644 index 5a2ee0d..0000000 --- a/src/icons.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::char; -use std::collections::HashMap; - -pub fn get_icons(name: &str) -> HashMap { - match name { - "awesome" => { - return HashMap::from([ - ("Firefox".to_string(), ''), - ("TelegramDesktop".to_string(), ''), - ("Alacritty".to_string(), ''), - ("Thunderbird".to_string(), ''), - ("KeeWeb".to_string(), ''), - ("Org.gnome.Nautilus".to_string(), ''), - ("Evince".to_string(), ''), - ]); - } - _ => HashMap::new(), - } -} diff --git a/src/lib.rs b/src/lib.rs index b97a000..f8768de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,162 +1,246 @@ -use i3ipc::{ - event::{ - inner::{WindowChange, WorkspaceChange}, - WindowEventInfo, WorkspaceEventInfo, - }, - reply::{Node, NodeType, WindowProperty}, - I3Connection, -}; +//! # i3wsr - i3/Sway Workspace Renamer +//! +//! Internal library functionality for the i3wsr binary. This crate provides the core functionality +//! for renaming i3/Sway workspaces based on their content. +//! +//! ## Note +//! +//! This is primarily a binary crate. The public functions and types are mainly exposed for: +//! - Use by the binary executable +//! - Testing purposes +//! - Internal organization +//! +//! While you could technically use this as a library, it's not designed or maintained for that purpose. use itertools::Itertools; -use std::collections::HashMap; +use swayipc::{ + Connection, Node, NodeType, WindowChange, WindowEvent, WorkspaceChange, WorkspaceEvent, +}; +extern crate colored; +use colored::Colorize; pub mod config; -pub mod icons; pub mod regex; -use config::Config; + +pub use config::Config; use std::error::Error; +use std::fmt; +use std::io; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Global flag to control debug output verbosity. +/// +/// This flag is atomic to allow safe concurrent access without requiring mutex locks. +/// It's primarily used by the binary to enable/disable detailed logging of events +/// and commands. +/// +/// # Usage +/// +/// ```rust +/// use std::sync::atomic::Ordering; +/// +/// // Enable verbose output +/// i3wsr_core::VERBOSE.store(true, Ordering::Relaxed); +/// +/// // Check if verbose is enabled +/// if i3wsr_core::VERBOSE.load(Ordering::Relaxed) { +/// println!("Verbose output enabled"); +/// } +/// ``` +pub static VERBOSE: AtomicBool = AtomicBool::new(false); + +#[derive(Debug)] +pub enum AppError { + Config(config::ConfigError), + Connection(swayipc::Error), + Regex(regex::RegexError), + Event(String), + IoError(io::Error), + Abort(String), +} + +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AppError::Config(e) => write!(f, "Configuration error: {}", e), + AppError::Connection(e) => write!(f, "IPC connection error: {}", e), + AppError::Regex(e) => write!(f, "Regex compilation error: {}", e), + AppError::Event(e) => write!(f, "Event handling error: {}", e), + AppError::IoError(e) => write!(f, "IO error: {}", e), + AppError::Abort(e) => write!(f, "Abort signal, stopping program: {}", e), + } + } +} + +impl Error for AppError {} + +impl From for AppError { + fn from(err: config::ConfigError) -> Self { + AppError::Config(err) + } +} + +impl From for AppError { + fn from(err: swayipc::Error) -> Self { + AppError::Connection(err) + } +} + +impl From for AppError { + fn from(err: regex::RegexError) -> Self { + AppError::Regex(err) + } +} + +impl From for AppError { + fn from(err: io::Error) -> Self { + AppError::IoError(err) + } +} /// Helper fn to get options via config fn get_option(config: &Config, key: &str) -> bool { - return match config.options.get(key) { - Some(v) => *v, - None => false, - }; + config.get_option(key).unwrap_or(false) } -fn get_title( - props: &HashMap, +fn find_alias(value: Option<&String>, patterns: &[(regex::Regex, String)]) -> Option { + value.and_then(|val| { + patterns + .iter() + .find(|(re, _)| re.is_match(val)) + .map(|(_, alias)| alias.clone()) + }) +} + +fn format_with_icon(icon: &str, title: &str, no_names: bool, no_icon_names: bool) -> String { + if no_icon_names || no_names { + icon.to_string() + } else { + format!("{} {}", icon, title) + } +} + +/// Gets a window title by trying to find an alias for the window, eventually falling back on +/// class, or app_id, depending on platform. +pub fn get_title( + node: &Node, config: &Config, res: ®ex::Compiled, ) -> Result> { - let wm_class = props.get(&WindowProperty::Class); - let wm_instance = props.get(&WindowProperty::Instance); - let wm_name = props.get(&WindowProperty::Title); - let display_prop = match config.general.get("display_property") { - Some(prop) => prop, - None => "class", - }; + let display_prop = config + .get_general("display_property") + .unwrap_or_else(|| "class".to_string()); - // Check for aliases using pre-compiled regex - let title = { - if let Some((_, alias)) = - wm_name.and_then(|name| res.name.iter().filter(|(re, _)| re.is_match(&name)).next()) - { - alias - } else if let Some((_, alias)) = wm_instance.and_then(|instance| { - res.instance - .iter() - .filter(|(re, _)| re.is_match(&instance)) - .next() - }) { - alias - } else if let Some((_, alias)) = wm_class.and_then(|class| { - res.class - .iter() - .filter(|(re, _)| re.is_match(&class)) - .next() - }) { - alias - } else { - // Handle display prop, if no alias is located, then check for existiance and - // display_prop to set a fallback title - if wm_name.is_some() && display_prop == "name" { - wm_name.unwrap() - } else if wm_instance.is_some() && display_prop == "instance" { - wm_instance.unwrap() - } else if wm_class.is_some() { - wm_class.unwrap() - } else { - Err(format!( - "failed to get alias, display_prop {}, or class", - display_prop - ))? - } + let title = match &node.window_properties { + // Xwayland / Xorg + Some(props) => { + // First try to find an alias using the window properties + let alias = find_alias(props.title.as_ref(), &res.name) + .or_else(|| find_alias(props.instance.as_ref(), &res.instance)) + .or_else(|| find_alias(props.class.as_ref(), &res.class)); + + // If no alias found, use the configured display property + let title = alias.or_else(|| { + let prop_value = match display_prop.as_str() { + "name" => props.title.clone(), + "instance" => props.instance.clone(), + _ => props.class.clone(), + }; + prop_value + }); + + title.ok_or_else(|| { + format!( + "No title found: tried aliases and display_prop '{}'", + display_prop + ) + })? + } + // Wayland + None => { + let alias = find_alias(node.name.as_ref(), &res.name) + .or_else(|| find_alias(node.app_id.as_ref(), &res.app_id)); + + let title = alias.or_else(|| { + let prop_value = match display_prop.as_str() { + "name" => node.name.clone(), + _ => node.app_id.clone(), + }; + prop_value + }); + title.ok_or_else(|| { + format!( + "No title found: tried aliases and display_prop '{}'", + display_prop + ) + })? } }; - let no_names = get_option(&config, "no_names"); - let no_icon_names = get_option(&config, "no_icon_names"); + // Try to find an alias first + let no_names = get_option(config, "no_names"); + let no_icon_names = get_option(config, "no_icon_names"); - // Format final result - Ok(match config.icons.get(title) { - Some(icon) => { - if no_icon_names || no_names { - format!("{}", icon) - } else { - format!("{} {}", icon, title) - } - } - None => match config.general.get("default_icon") { - Some(default_icon) => { - if no_icon_names || no_names { - format!("{}", default_icon) - } else { - format!("{} {}", default_icon, title) - } - } - None => { - if no_names { - String::new() - } else { - format!("{}", title) - } - } - }, + Ok(if let Some(icon) = config.get_icon(&title) { + format_with_icon(&icon, &title, no_names, no_icon_names) + } else if let Some(default_icon) = config.get_general("default_icon") { + format_with_icon(&default_icon, &title, no_names, no_icon_names) + } else if no_names { + String::new() + } else { + title }) } -/// return a collection of workspace nodes -fn get_workspaces(tree: Node) -> Vec { - let mut out = Vec::new(); - - for output in tree.nodes { - for container in output.nodes { - for workspace in container.nodes { - if let NodeType::Workspace = workspace.nodetype { - match &workspace.name { - Some(name) => { - if !name.eq("__i3_scratch") { - out.push(workspace); - } - } - None => (), - } +/// Filters out special workspaces (like scratchpad) and collects regular workspaces +/// from the window manager tree structure. +pub fn get_workspaces(tree: Node) -> Vec { + let excludes = ["__i3_scratch", "__sway_scratch"]; + + // Helper function to recursively find workspaces in a node + fn find_workspaces(node: Node, excludes: &[&str]) -> Vec { + let mut workspaces = Vec::new(); + + // If this is a workspace node that's not excluded, add it + if matches!(node.node_type, NodeType::Workspace) { + if let Some(name) = &node.name { + if !excludes.contains(&name.as_str()) { + workspaces.push(node.clone()); } } } - } - - out -} - -/// get window ids for any depth collection of nodes -fn get_properties(mut nodes: Vec>) -> Vec> { - let mut window_props = Vec::new(); - while let Some(next) = nodes.pop() { - for n in next { - nodes.push(n.nodes.iter().collect()); - if let Some(w) = &n.window_properties { - window_props.push(w.to_owned()); - } + // Recursively check child nodes + for child in node.nodes { + workspaces.extend(find_workspaces(child, excludes)); } + + workspaces } - window_props + // Start the recursive search from the root + find_workspaces(tree, &excludes) } /// Collect a vector of workspace titles -fn collect_titles(workspace: &Node, config: &Config, res: ®ex::Compiled) -> Vec { - let window_props = { - let mut f = get_properties(vec![workspace.floating_nodes.iter().collect()]); - let mut n = get_properties(vec![workspace.nodes.iter().collect()]); - n.append(&mut f); - n - }; +pub fn collect_titles(workspace: &Node, config: &Config, res: ®ex::Compiled) -> Vec { + let ws_nodes = workspace + .nodes + .iter() + .chain(workspace.floating_nodes.iter().flat_map(|fnode| { + // If the floating node has nested nodes (i3 style), use those + if !fnode.nodes.is_empty() { + fnode.nodes.iter() + // Otherwise use the floating node itself (Sway style) + } else { + std::slice::from_ref(fnode).iter() + } + })) + .cloned() + .collect::>(); let mut titles = Vec::new(); - for props in window_props { - let title = match get_title(&props, config, res) { + for node in &ws_nodes { + let title = match get_title(&node, config, res) { Ok(title) => title, Err(e) => { eprintln!("get_title error: \"{}\" for workspace {:#?}", e, workspace); @@ -169,120 +253,162 @@ fn collect_titles(workspace: &Node, config: &Config, res: ®ex::Compiled) -> V titles } +/// Applies options on titles, like remove duplicates +fn apply_options(titles: Vec, config: &Config) -> Vec { + let mut processed = titles; + + if get_option(config, "remove_duplicates") { + processed = processed.into_iter().unique().collect(); + } + + if get_option(config, "no_names") { + processed = processed.into_iter().filter(|s| !s.is_empty()).collect(); + } + + processed +} + +fn get_split_char(config: &Config) -> char { + config + .get_general("split_at") + .and_then(|s| if s.is_empty() { None } else { s.chars().next() }) + .unwrap_or(' ') +} + +fn format_workspace_name(initial: &str, titles: &str, split_at: char, config: &Config) -> String { + let mut new = String::from(initial); + + // Add colon if needed + if split_at == ':' && !initial.is_empty() && !titles.is_empty() { + new.push(':'); + } + + // Add titles if present + if !titles.is_empty() { + new.push_str(titles); + } else if let Some(empty_label) = config.get_general("empty_label") { + new.push(' '); + new.push_str(&empty_label); + } + + new +} + +/// Internal function to update all workspace names based on their current content. +/// This function is public for testing purposes and binary use only. +/// /// Update all workspace names in tree pub fn update_tree( - i3_conn: &mut I3Connection, + conn: &mut Connection, config: &Config, res: ®ex::Compiled, + focus: bool, ) -> Result<(), Box> { - let tree = i3_conn.get_tree()?; - for workspace in get_workspaces(tree) { - let separator = match config.general.get("separator") { - Some(s) => s, - None => " | ", - }; + let tree = conn.get_tree()?; + let separator = config + .get_general("separator") + .unwrap_or_else(|| " | ".to_string()); + let split_at = get_split_char(config); - let titles = collect_titles(&workspace, config, res); - let titles = if get_option(&config, "remove_duplicates") { - titles.into_iter().unique().collect() - } else { - titles - }; - let titles = if get_option(&config, "no_names") { - titles - .into_iter() - .filter(|s| !s.is_empty()) - .collect::>() - } else { - titles - }; - let titles = titles.join(separator); - let titles = if !titles.is_empty() { - format!(" {}", titles) - } else { - titles - }; - let old: String = workspace.name.to_owned().ok_or_else(|| { + for workspace in get_workspaces(tree) { + // Get the old workspace name + let old = workspace.name.as_ref().ok_or_else(|| { format!( "Failed to get workspace name for workspace: {:#?}", workspace ) })?; - // Get split_at arg - let split_at = match config.general.get("split_at") { - Some(s) => { - if !s.is_empty() { - s.chars().next().unwrap() - } else { - ' ' - } - } - None => ' ', - }; - - // Get the initial element we want to keep - let initial = match old.split(split_at).next() { - Some(i) => i, - None => "", + // Process titles + let titles = collect_titles(&workspace, config, res); + let titles = apply_options(titles, config); + let titles = if !titles.is_empty() { + format!(" {}", titles.join(&separator)) + } else { + String::new() }; - let mut new: String = String::from(initial); + // Get initial part of workspace name + let initial = old.split(split_at).next().unwrap_or(""); - // if we do split on colon we need to insert a new one, cause it gets split out - if split_at == ':' && !initial.is_empty() && !titles.is_empty() { - new.push(':'); - } - // Push new window titles to new string - if !titles.is_empty() { - new.push_str(&titles); - } + // Format new workspace name + let new = format_workspace_name(initial, &titles, split_at, config); - if titles.is_empty() { - match config.general.get("empty_label") { - Some(default_label) => { - new.push_str(" "); - new.push_str(default_label); + // Only send command if name changed + if old != &new { + let command = format!("rename workspace \"{}\" to \"{}\"", old, new); + if VERBOSE.load(Ordering::Relaxed) { + println!("{} {}", "[COMMAND]".blue(), command); + if let Some(output) = &workspace.output { + println!("{} Workspace on output: {}", "[INFO]".cyan(), output); } - None => (), } - } - // Dispatch to i3 - if old != new { - let command = format!("rename workspace \"{}\" to \"{}\"", old, new); - i3_conn.run_command(&command)?; + // Focus on flag, fix for moving floating windows across multiple monitors + if focus { + let focus_cmd = format!("workspace \"{}\"", old); + conn.run_command(&focus_cmd)?; + } + + // Then rename it + conn.run_command(&command)?; } } Ok(()) } -/// handles new and close window events, to set the workspace name based on content +/// Processes various window events (new, close, move, title changes) and updates +/// workspace names accordingly. This is a core part of the event loop in the main binary. pub fn handle_window_event( - e: &WindowEventInfo, - i3_conn: &mut I3Connection, + e: &WindowEvent, + conn: &mut Connection, config: &Config, res: ®ex::Compiled, -) -> Result<(), Box> { +) -> Result<(), AppError> { + if VERBOSE.load(Ordering::Relaxed) { + println!( + "{} Change: {:?}, Container: {:?}", + "[WINDOW EVENT]".yellow(), + e.change, + e.container + ); + } match e.change { - WindowChange::New | WindowChange::Close | WindowChange::Move | WindowChange::Title => { - update_tree(i3_conn, config, res)?; + WindowChange::New + | WindowChange::Close + | WindowChange::Move + | WindowChange::Title + | WindowChange::Floating => { + update_tree(conn, config, res, false) + .map_err(|e| AppError::Event(format!("Tree update failed: {}", e)))?; } _ => (), } Ok(()) } -/// handles ws events, +/// Processes workspace events (empty, focus changes) and updates workspace names +/// as needed. This is a core part of the event loop in the main binary. pub fn handle_ws_event( - e: &WorkspaceEventInfo, - i3_conn: &mut I3Connection, + e: &WorkspaceEvent, + conn: &mut Connection, config: &Config, res: ®ex::Compiled, -) -> Result<(), Box> { +) -> Result<(), AppError> { + if VERBOSE.load(Ordering::Relaxed) { + println!( + "{} Change: {:?}, Current: {:?}, Old: {:?}", + "[WORKSPACE EVENT]".green(), + e.change, + e.current, + e.old + ); + } + match e.change { WorkspaceChange::Empty | WorkspaceChange::Focus => { - update_tree(i3_conn, config, res)?; + update_tree(conn, config, res, e.change == WorkspaceChange::Focus) + .map_err(|e| AppError::Event(format!("Tree update failed: {}", e)))?; } _ => (), } @@ -291,100 +417,100 @@ pub fn handle_ws_event( #[cfg(test)] mod tests { - use i3ipc::reply::{NodeType, WindowProperty}; - use std::collections::HashMap; - use std::env; - use std::error::Error; + use regex::Regex; #[test] - fn connection_tree() -> Result<(), Box> { - env::set_var("DISPLAY", ":99.0"); - let mut i3_conn = super::I3Connection::connect()?; - let config = super::Config::default(); - let res = super::regex::parse_config(&config)?; - assert!(super::update_tree(&mut i3_conn, &config, &res).is_ok()); - let tree = i3_conn.get_tree()?; - let mut name: String = String::new(); - for output in &tree.nodes { - for container in &output.nodes { - for workspace in &container.nodes { - if let NodeType::Workspace = workspace.nodetype { - let ws_n = workspace.name.to_owned(); - name = ws_n.unwrap(); - } - } - } - } - assert_eq!(name, String::from("1 Gpick | XTerm")); - Ok(()) + fn test_find_alias() { + let patterns = vec![ + (Regex::new(r"Firefox").unwrap(), "firefox".to_string()), + (Regex::new(r"Chrome").unwrap(), "chrome".to_string()), + ]; + + // Test matching case + let binding = "Firefox".to_string(); + let value = Some(&binding); + assert_eq!( + super::find_alias(value, &patterns), + Some("firefox".to_string()) + ); + + // Test non-matching case + let binding = "Safari".to_string(); + let value = Some(&binding); + assert_eq!(super::find_alias(value, &patterns), None); + + // Test None case + let value: Option<&String> = None; + assert_eq!(super::find_alias(value, &patterns), None); } #[test] - fn get_title() -> Result<(), Box> { - env::set_var("DISPLAY", ":99.0"); - let mut i3_conn = super::I3Connection::connect()?; - - let tree = i3_conn.get_tree()?; - let mut properties: Vec> = Vec::new(); - let workspaces = super::get_workspaces(tree); - for workspace in &workspaces { - let window_props = { - let mut f = super::get_properties(vec![workspace.floating_nodes.iter().collect()]); - let mut n = super::get_properties(vec![workspace.nodes.iter().collect()]); - n.append(&mut f); - n - }; - for p in window_props { - properties.push(p); - } - } - let config = super::Config::default(); - let res = super::regex::parse_config(&config)?; - let result: Result, _> = properties - .iter() - .map(|props| super::get_title(&props, &config, &res)) - .collect(); - assert_eq!(result?, vec!["Gpick", "XTerm"]); - Ok(()) + fn test_format_with_icon() { + let icon = "🦊"; + let title = "Firefox"; + + // Test normal case + assert_eq!( + super::format_with_icon(&icon, title, false, false), + "🦊 Firefox" + ); + + // Test no_names = true + assert_eq!(super::format_with_icon(&icon, title, true, false), "🦊"); + + // Test no_icon_names = true + assert_eq!(super::format_with_icon(&icon, title, false, true), "🦊"); + + // Test both flags true + assert_eq!(super::format_with_icon(&icon, title, true, true), "🦊"); } #[test] - fn collect_titles() -> Result<(), Box> { - env::set_var("DISPLAY", ":99.0"); - let mut i3_conn = super::I3Connection::connect()?; - let tree = i3_conn.get_tree()?; - let workspaces = super::get_workspaces(tree); - let mut result: Vec> = Vec::new(); - let config = super::Config::default(); - let res = super::regex::parse_config(&config)?; - for workspace in workspaces { - result.push(super::collect_titles(&workspace, &config, &res)); - } - let expected = vec![vec!["Gpick", "XTerm"]]; - assert_eq!(result, expected); - Ok(()) + fn test_get_split_char() { + let mut config = super::Config::default(); + + // Test default (space) + assert_eq!(super::get_split_char(&config), ' '); + + // Test with custom split char + config.set_general("split_at".to_string(), ":".to_string()); + assert_eq!(super::get_split_char(&config), ':'); + + // Test with empty string + config.set_general("split_at".to_string(), "".to_string()); + assert_eq!(super::get_split_char(&config), ' '); } #[test] - fn get_properties() -> Result<(), Box> { - env::set_var("DISPLAY", ":99.0"); - let mut i3_conn = super::I3Connection::connect()?; - let tree = i3_conn.get_tree()?; - let workspaces = super::get_workspaces(tree); - let mut result: Vec> = Vec::new(); - for workspace in workspaces { - let window_props = { - let mut f = super::get_properties(vec![workspace.floating_nodes.iter().collect()]); - let mut n = super::get_properties(vec![workspace.nodes.iter().collect()]); - n.append(&mut f); - n - }; - for props in window_props { - result.push(props) - } - } - let result: usize = result.iter().filter(|v| !v.is_empty()).count(); - assert_eq!(result, 2); - Ok(()) + fn test_format_workspace_name() { + let mut config = super::Config::default(); + + // Test normal case with space + assert_eq!( + super::format_workspace_name("1", " Firefox Chrome", ' ', &config), + "1 Firefox Chrome" + ); + + // Test with colon separator + assert_eq!( + super::format_workspace_name("1", " Firefox Chrome", ':', &config), + "1: Firefox Chrome" + ); + + // Test empty titles with no empty_label + assert_eq!(super::format_workspace_name("1", "", ':', &config), "1"); + + // Test empty titles with empty_label + config.set_general("empty_label".to_string(), "Empty".to_string()); + assert_eq!( + super::format_workspace_name("1", "", ':', &config), + "1 Empty" + ); + + // Test empty initial + assert_eq!( + super::format_workspace_name("", " Firefox Chrome", ':', &config), + " Firefox Chrome" + ); } } diff --git a/src/main.rs b/src/main.rs index 332c138..f8558f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,153 +1,371 @@ +//! # i3wsr - i3/Sway Workspace Renamer +//! +//! +//! A dynamic workspace renamer for i3 and Sway that updates names to reflect their +//! active applications. +//! +//! ## Usage +//! +//! 1. Install using cargo: +//! ```bash +//! cargo install i3wsr +//! ``` +//! +//! 2. Add to your i3/Sway config: +//! ``` +//! exec_always --no-startup-id i3wsr +//! ``` +//! +//! 3. Ensure numbered workspaces in i3/Sway config: +//! ``` +//! bindsym $mod+1 workspace number 1 +//! assign [class="(?i)firefox"] number 1 +//! ``` +//! +//! ## Configuration +//! +//! Configuration can be done via: +//! - Command line arguments +//! - TOML configuration file (default: `$XDG_CONFIG_HOME/i3wsr/config.toml`) +//! +//! ### Config File Sections: +//! +//! ```toml +//! [icons] +//! # Map window classes to icons +//! Firefox = "🌍" +//! default_icon = "💻" +//! +//! [aliases.app_id] +//! "^firefox$" = "Firefox" +//! +//! [aliases.class] +//! # Map window classes to friendly names +//! "Google-chrome" = "Chrome" +//! +//! [aliases.instance] +//! # Map window instances to friendly names +//! "web.whatsapp.com" = "WhatsApp" +//! +//! [aliases.name] +//! # Map window names using regex +//! ".*mutt$" = "Mail" +//! +//! [general] +//! separator = " | " # Separator between window names +//! split_at = ":" # Character to split workspace number +//! empty_label = "🌕" # Label for empty workspaces +//! display_property = "class" # Default property to display (class/app_id/instance/name) +//! +//! [options] +//! remove_duplicates = false # Remove duplicate window names +//! no_names = false # Show only icons +//! no_icon_names = false # Show names only if no icon available +//! ``` +//! +//! ### Command Line Options: +//! +//! - `--verbose`: Enable detailed logging +//! - `--config `: Use alternative config file +//! - `--no-icon-names`: Show only icons when available +//! - `--no-names`: Never show window names +//! - `--remove-duplicates`: Remove duplicate entries +//! - `--display-property `: Window property to use (class/app_id/instance/name) +//! - `--split-at `: Character to split workspace names +//! +//! ### Window Properties: +//! +//! Three window properties can be used for naming: +//! - `class`: Default, most stable (WM_CLASS) +//! - `app_id`: In place of class only for sway/wayland +//! - `instance`: More specific than class (WM_INSTANCE) +//! - `name`: Most detailed but volatile (WM_NAME) +//! +//! Properties are checked in order: name -> instance -> class/app_id +//! +//! ### Special Features: +//! +//! - Regex support in aliases +//! - Custom icons per window +//! - Default icons +//! - Empty workspace labels +//! - Duplicate removal +//! - Custom separators +//! +//! For more details, see the [README](https://github.com/roosta/i3wsr) + use clap::{Parser, ValueEnum}; use dirs::config_dir; -use i3ipc::{event::Event, I3Connection, I3EventListener, Subscription}; -use i3wsr::config::Config; -use std::error::Error; +use i3wsr_core::config::{Config, ConfigError}; +use std::io; use std::path::Path; +use swayipc::{Connection, Event, EventType, Fallible, WorkspaceChange}; +use std::env; -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] -enum Icons { - Awesome, -} +use i3wsr_core::AppError; +/// Window property types that can be used for workspace naming. +/// +/// These properties determine which window attribute is used when displaying +/// window names in workspaces: +/// - `Class`: Uses WM_CLASS (default, most stable) +/// - `Instance`: Uses WM_INSTANCE (more specific than class) +/// - `Name`: Uses WM_NAME (most detailed but volatile) +/// - `AppId`: In place of class only for sway/wayland #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] enum Properties { Class, Instance, Name, + AppId +} + +impl Properties { + fn as_str(&self) -> &'static str { + match self { + Properties::Class => "class", + Properties::Instance => "instance", + Properties::Name => "name", + Properties::AppId => "app_id", + } + } } -/// i3wsr config +/// Command line arguments for i3wsr +/// +/// Configuration can be provided either through command line arguments +/// or through a TOML configuration file. Command line arguments take +/// precedence over configuration file settings. #[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] +#[command( + author, + version, + about = "Dynamic workspace renamer for i3 and Sway window managers" +)] +#[command( + long_about = "Automatically renames workspaces based on their window contents. \ + Supports custom icons, aliases, and various display options. \ + Can be configured via command line flags or a TOML configuration file." +)] struct Args { - /// Path to toml config file - #[arg(short, long)] - config: Option, + /// Enable verbose logging of events and operations + #[arg( + short, + long, + help = "Print detailed information about events and operations" + )] + verbose: bool, - /// Sets icons to be used - #[arg(short, long)] - icons: Option, + /// Deprecated: Icon set option (maintained for backwards compatibility) + #[arg( + long, + value_name = "SET", + help = "[DEPRECATED] Icon set selection - will be removed in future versions" + )] + icons: Option, + /// Path to TOML configuration file + #[arg( + short, + long, + help = "Path to TOML config file (default: $XDG_CONFIG_HOME/i3wsr/config.toml)", + value_name = "FILE" + )] + config: Option, /// Display only icon (if available) otherwise display name - #[arg(short = 'm', long)] + #[arg( + short = 'm', + long, + help = "Show only icons when available, fallback to names otherwise" + )] no_icon_names: bool, - /// Do not display names - #[arg(short, long)] + /// Do not display window names, only show icons + #[arg(short, long, help = "Show only icons, never display window names")] no_names: bool, - /// Remove duplicate entries in workspace - #[arg(short, long)] + /// Remove duplicate entries in workspace names + #[arg( + short, + long, + help = "Remove duplicate window names from workspace labels" + )] remove_duplicates: bool, /// Which window property to use when no alias is found - #[arg(short = 'p', long)] + #[arg( + short = 'p', + long, + value_enum, + help = "Window property to use for naming (class/instance/name)", + value_name = "PROPERTY" + )] display_property: Option, - /// What character used to split the workspace title string - #[arg(short = 'a', long)] + /// Character used to split the workspace title string + #[arg( + short = 'a', + long, + help = "Character that separates workspace number from window names", + value_name = "CHAR" + )] split_at: Option, } -/// Setup program by handling args and populating config -/// Returns result containing config -fn setup() -> Result> { - let args = Args::parse(); +/// Loads configuration from a TOML file or creates default configuration +fn load_config(config_path: Option<&str>) -> Result { + let xdg_config = config_dir() + .ok_or_else(|| { + ConfigError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "Could not determine config directory", + )) + })? + .join("i3wsr/config.toml"); - // icons - // Not really that useful this opt but keeping for posterity - let icons = match args.icons { - Some(icons) => match icons { - Icons::Awesome => "awesome", - }, - None => "", - }; - - // handle config - let xdg_config = config_dir().unwrap().join("i3wsr/config.toml"); - let config_result = match args.config.as_deref() { - Some(filename) => { - println!("{filename}"); - Config::new(Path::new(filename), icons) + match config_path { + Some(path) => { + println!("Loading config from: {path}"); + Config::new(Path::new(path)) } None => { - if (xdg_config).exists() { - Config::new(&xdg_config, icons) + if xdg_config.exists() { + Config::new(&xdg_config) } else { Ok(Config { - icons: i3wsr::icons::get_icons(icons), ..Default::default() }) } } - }; - - let mut config = config_result?; - - // Flags - if args.no_icon_names { - config - .options - .insert("no_icon_names".to_string(), args.no_icon_names); } +} - if args.no_names { - config.options.insert("no_names".to_string(), args.no_names); - } +/// Applies command line arguments to configuration +fn apply_args_to_config(config: &mut Config, args: &Args) { + // Apply boolean options + let options = [ + ("no_icon_names", args.no_icon_names), + ("no_names", args.no_names), + ("remove_duplicates", args.remove_duplicates), + ]; - if args.remove_duplicates { - config - .options - .insert("remove_duplicates".to_string(), args.remove_duplicates); + for (key, value) in options { + if value { + config.options.insert(key.to_string(), value); + } } - if let Some(split_char) = args.split_at { - config.general.insert("split_at".to_string(), split_char); + // Apply general settings + if let Some(split_char) = &args.split_at { + config + .general + .insert("split_at".to_string(), split_char.clone()); } - // wm property - let display_property = match args.display_property { - Some(prop) => match prop { - Properties::Class => String::from("class"), - Properties::Instance => String::from("instance"), - Properties::Name => String::from("name"), - }, - None => String::from("class"), - }; + let display_property = args + .display_property + .as_ref() + .map_or("class", |p| p.as_str()); config .general - .insert("display_property".to_string(), display_property); - Ok(config) + .insert("display_property".to_string(), display_property.to_string()); } -/// Entry main loop: continusly listen to i3 window events and workspace events, or exit on -/// abnormal error. -fn main() -> Result<(), Box> { - let config = setup()?; - let res = i3wsr::regex::parse_config(&config)?; - let mut listener = I3EventListener::connect()?; - let subs = [Subscription::Window, Subscription::Workspace]; +/// Sets up the program by processing arguments and initializing configuration +/// Command line arguments take precedence over configuration file settings. +fn setup() -> Result { + let args = Args::parse(); - listener.subscribe(&subs)?; + // Handle deprecated --icons option + if let Some(icon_set) = &args.icons { + if icon_set == "awesome" { + eprintln!("Warning: The --icons option is deprecated and will be removed in a future version."); + eprintln!("Icons are now configured via the config file in the [icons] section."); + } else { + eprintln!("Warning: Invalid --icons value '{}'. Only 'awesome' is supported for backwards compatibility.", icon_set); + } + } - let mut i3_conn = I3Connection::connect()?; - i3wsr::update_tree(&mut i3_conn, &config, &res)?; + // Set verbose mode if requested + i3wsr_core::VERBOSE.store(args.verbose, std::sync::atomic::Ordering::Relaxed); - for event in listener.listen() { - match event? { - Event::WindowEvent(e) => { - if let Err(error) = i3wsr::handle_window_event(&e, &mut i3_conn, &config, &res) { - eprintln!("handle_window_event error: {}", error); + let mut config = load_config(args.config.as_deref())?; + apply_args_to_config(&mut config, &args); + + Ok(config) +} + +/// Processes window manager events and updates workspace names accordingly +fn handle_event( + event: Fallible, + conn: &mut Connection, + config: &Config, + res: &i3wsr_core::regex::Compiled, +) -> Result<(), AppError> { + match event { + Ok(Event::Window(e)) => { + i3wsr_core::handle_window_event(&e, conn, config, res) + .map_err(|e| AppError::Event(format!("Window event error: {}", e)))?; + } + Ok(Event::Workspace(e)) => { + if e.change == WorkspaceChange::Reload && env::var("SWAYSOCK").is_ok() { + return Err(AppError::Abort(format!("Config reloaded"))); + } + i3wsr_core::handle_ws_event(&e, conn, config, res) + .map_err(|e| AppError::Event(format!("Workspace event error: {}", e)))?; + } + Ok(_) => {} + Err(e) => { + // Check if it's an UnexpectedEof error (common when i3/sway restarts) + if let swayipc::Error::Io(io_err) = &e { + if io_err.kind() == std::io::ErrorKind::UnexpectedEof { + return Err(AppError::Abort("Window manager connection lost (EOF), shutting down...".to_string())); } } - Event::WorkspaceEvent(e) => { - if let Err(error) = i3wsr::handle_ws_event(&e, &mut i3_conn, &config, &res) { - eprintln!("handle_ws_event error: {}", error); + return Err(AppError::Event(format!("IPC event error: {}", e))); + } + } + Ok(()) +} + +/// Main event loop that monitors window manager events +/// The program will continue running and handling events until +/// interrupted or an unrecoverable error occurs. +fn run() -> Result<(), AppError> { + let config = setup()?; + let res = i3wsr_core::regex::parse_config(&config)?; + + let mut conn = Connection::new()?; + let subscriptions = [EventType::Window, EventType::Workspace]; + + i3wsr_core::update_tree(&mut conn, &config, &res, false) + .map_err(|e| AppError::Event(format!("Initial tree update failed: {}", e)))?; + + let event_connection = Connection::new()?; + let events = event_connection.subscribe(&subscriptions)?; + + println!("Started successfully. Listening for events..."); + + for event in events { + if let Err(e) = handle_event(event, &mut conn, &config, &res) { + match &e { + // Exit program on abort, this is because when config gets reloaded, we want the + // old process to exit, letting sway start a new one. + AppError::Abort(_) => { + return Err(e); } + // Continue running despite errors + _ => eprintln!("Error handling event: {}", e), } - _ => {} } } + Ok(()) } + +fn main() { + if let Err(e) = run() { + eprintln!("Fatal error: {}", e); + std::process::exit(1); + } +} diff --git a/src/regex.rs b/src/regex.rs index 105ec99..d3cb02f 100644 --- a/src/regex.rs +++ b/src/regex.rs @@ -1,35 +1,68 @@ use crate::Config; -use regex::Regex; +pub use regex::Regex; +use std::collections::HashMap; use std::error::Error; +use std::fmt; -pub type Point = (Regex, String); +#[derive(Debug)] +pub enum RegexError { + Compilation(regex::Error), + Pattern(String), +} + +impl fmt::Display for RegexError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RegexError::Compilation(e) => write!(f, "Regex compilation error: {}", e), + RegexError::Pattern(e) => write!(f, "{}", e), + } + } +} + +impl Error for RegexError {} + +impl From for RegexError { + fn from(err: regex::Error) -> Self { + RegexError::Compilation(err) + } +} + +/// A compiled regex pattern and its corresponding replacement string +pub type Pattern = (Regex, String); + +/// Holds compiled regex patterns for different window properties +#[derive(Debug)] pub struct Compiled { - pub class: Vec, - pub instance: Vec, - pub name: Vec, + pub class: Vec, + pub instance: Vec, + pub name: Vec, + pub app_id: Vec, +} + +/// Compiles a single regex pattern from a key-value pair +fn compile_pattern((pattern, replacement): (&String, &String)) -> Result { + Ok(( + Regex::new(pattern).map_err(|e| { + RegexError::Pattern(format!("Invalid regex pattern '{}': {}", pattern, e)) + })?, + replacement.to_owned(), + )) } -fn compile((k, v): (&String, &String)) -> Result> { - let re = Regex::new(&format!(r"{}", k))?; - Ok((re, v.to_owned())) +/// Compiles a collection of patterns from a HashMap +fn compile_patterns(patterns: &HashMap) -> Result, RegexError> { + patterns + .iter() + .map(|(k, v)| compile_pattern((k, v))) + .collect() } -pub fn parse_config(config: &Config) -> Result> { - let classes = match config.aliases.class.iter().map(compile).collect() { - Ok(v) => v, - Err(e) => Err(e)?, - }; - let instances = match config.aliases.instance.iter().map(compile).collect() { - Ok(v) => v, - Err(e) => Err(e)?, - }; - let names = match config.aliases.name.iter().map(compile).collect() { - Ok(v) => v, - Err(e) => Err(e)?, - }; - return Ok(Compiled { - class: classes, - instance: instances, - name: names, - }); +/// Parses the configuration into compiled regex patterns +pub fn parse_config(config: &Config) -> Result { + Ok(Compiled { + class: compile_patterns(&config.aliases.class)?, + instance: compile_patterns(&config.aliases.instance)?, + name: compile_patterns(&config.aliases.name)?, + app_id: compile_patterns(&config.aliases.app_id)?, + }) } diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..da83007 --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,74 @@ +use std::env; +use std::error::Error; +use swayipc::{Connection, Node}; +use i3wsr_core::{Config, update_tree}; + +#[test] +fn connection_tree() -> Result<(), Box> { + env::set_var("DISPLAY", ":99.0"); + let mut conn = Connection::new()?; + let config = Config::default(); + let res = i3wsr_core::regex::parse_config(&config)?; + assert!(update_tree(&mut conn, &config, &res, false).is_ok()); + + let tree = conn.get_tree()?; + let workspaces = i3wsr_core::get_workspaces(tree); + + let name = workspaces.first() + .and_then(|ws| ws.name.as_ref()) + .map(|name| name.to_string()) + .unwrap_or_default(); + + assert_eq!(name, String::from("1 Gpick | XTerm")); + Ok(()) +} + +#[test] +fn get_title() -> Result<(), Box> { + env::set_var("DISPLAY", ":99.0"); + let mut conn = swayipc::Connection::new()?; + + let tree = conn.get_tree()?; + let mut ws_nodes: Vec = Vec::new(); + let workspaces = i3wsr_core::get_workspaces(tree); + for workspace in &workspaces { + let nodes = workspace.nodes.iter() + .chain( + workspace.floating_nodes.iter().flat_map(|fnode| { + if !fnode.nodes.is_empty() { + fnode.nodes.iter() + } else { + std::slice::from_ref(fnode).iter() + } + }) + ) + .cloned() + .collect::>(); + ws_nodes.extend(nodes); + } + let config = i3wsr_core::Config::default(); + let res = i3wsr_core::regex::parse_config(&config)?; + let result: Result, _> = ws_nodes + .iter() + .map(|node| i3wsr_core::get_title(node, &config, &res)) + .collect(); + assert_eq!(result?, vec!["Gpick", "XTerm"]); + Ok(()) +} + +#[test] +fn collect_titles() -> Result<(), Box> { + env::set_var("DISPLAY", ":99.0"); + let mut conn = swayipc::Connection::new()?; + let tree = conn.get_tree()?; + let workspaces = i3wsr_core::get_workspaces(tree); + let mut result: Vec> = Vec::new(); + let config = i3wsr_core::Config::default(); + let res = i3wsr_core::regex::parse_config(&config)?; + for workspace in workspaces { + result.push(i3wsr_core::collect_titles(&workspace, &config, &res)); + } + let expected = vec![vec!["Gpick", "XTerm"]]; + assert_eq!(result, expected); + Ok(()) +}