├── .github └── workflows │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── Containerfile ├── LICENSE ├── README.md ├── assets ├── example_config.toml └── preview.gif ├── src ├── config.rs ├── lib.rs ├── main.rs └── regex.rs └── tests ├── integration_test.rs └── setup.sh /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Crates.io package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | container: 11 | image: rust:latest 12 | steps: 13 | - name: Checkout repository 14 | - uses: actions/checkout@v4 15 | - name: Install toml-cli 16 | run: cargo install toml-cli 17 | - name: Check version 18 | run: test "v$(toml get -r Cargo.toml package.version)" = "${{ github.ref_name }}" 19 | - name: Publish 20 | - run: cargo publish 21 | env: 22 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'test' 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v4 11 | 12 | - name: Install test dependencies 13 | run: sudo apt-get install -y i3-wm gpick xterm 14 | 15 | - name: Setup test environment 16 | run: tests/setup.sh 17 | 18 | - name: Run tests 19 | run: cargo test 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | **/*.rs.bk 3 | *.log 4 | .vagrant 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [Unreleased] - 2025-05-21 6 | 7 | ### Bug fixes 8 | 9 | - Fix issue with unwanted workspace focus change on rename dispatches. Add 10 | feature flag for the focus fix, now needs to be enabled manually via either 11 | config or cmdline flags 12 | `--focus-fix`. 13 | ```toml 14 | [options] 15 | focus_fix = true 16 | ``` 17 | 18 | 19 | ## [v3.1.1] - 2025-01-25 20 | 21 | ### Bug Fixes 22 | 23 | - Fix an issue where `i3wsr` would not collect `i3` titles correctly. This also 24 | more cleanly handles the differing tree structures between sway and i3 25 | without the need for a conditional. 26 | 27 | ## [v3.1.0] - 2025-01-22 28 | 29 | ### Bug Fixes 30 | 31 | - Fix [multi-monitor window dragging issue specific to i3](https://github.com/roosta/i3wsr/issues/34) 32 | - Sway doesn't trigger any events on window drag 33 | 34 | ### Features 35 | 36 | - Add sway support 37 | - Add `--verbose` cmdline flag for easier debugging in case of issues 38 | 39 | #### Sway 40 | 41 | Support for [Sway](https://github.com/swaywm/sway) is added, new config key 42 | addition `app_id` in place of `class` when running native Wayland applications: 43 | 44 | ``` 45 | [aliases.app_id] 46 | firefox-developer-edition = "Firefox Developer" 47 | ``` 48 | `i3wsr` will still check for `name`, `instance`, and `class` for `Xwayland` 49 | windows, where applicable. So some rules can be preserved. To migrate replace 50 | `[aliases.class]` with `[aliases.app_id]`, keep in mind that `app_id` and 51 | `class` aren't always interchangeable , so some additional modifications is 52 | usually needed. 53 | 54 | > 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. 55 | 56 | ### Deprecations 57 | 58 | I've flagged `--icons` as deprecated, it will not exit the application but it 59 | no longer works. I'd be surprised if anyone actually used that preset, as it 60 | was only ever for demonstration purposes, and kept around as a holdover from 61 | previous versions. 62 | 63 | ## [v3.0.0] - 2024-02-19 64 | 65 | **BREAKING**: Config syntax changes, see readme for new syntax but in short 66 | `wm_property` is no longer, and have been replaced by scoped aliases that are 67 | checked in this order: 68 | ```toml 69 | [aliases.name] # 1 70 | ".*mutt$" = "Mutt" 71 | 72 | [aliases.instance] # 2 73 | "open.spotify.com" = "Spotify" 74 | 75 | [aliases.class] # 3 76 | "^firefoxdeveloperedition$" = "Firefox-dev" 77 | ``` 78 | 79 | If there are no alias defined, `i3wsr` will default class, but this can be 80 | configured with 81 | ``` 82 | --display-property=[class|instance|name]` 83 | ``` 84 | or config file: 85 | 86 | ```toml 87 | [general] 88 | display_property = "instance" # class, instance, name 89 | ``` 90 | 91 | ### Bug Fixes 92 | 93 | - Missing instance in class string 94 | - Remove old file from package exclude 95 | - Tests, update connection namespace 96 | - Clean cache on vagrant machine 97 | - Format source files using rustfmt 98 | - Refresh lock file 99 | - License year to current 100 | - Ignore scratch buffer 101 | - Tests 102 | - Handle no alias by adding display_prop conf 103 | - Add display property as a cmd opt 104 | 105 | ### Documentation 106 | 107 | - Fix readme url (after branch rename) 108 | - Update instance explanation 109 | - Update toc 110 | - Document aliases usage 111 | - Update readme and example config 112 | - Fix badge, update toc, fix section placement 113 | - Fix typo 114 | 115 | ### Features 116 | 117 | - Update casing etc for error msg 118 | - Add split_at option 119 | - [**breaking**] Enable wm_property scoped aliases 120 | - Add empty_label option 121 | 122 | ### Miscellaneous Tasks 123 | 124 | - Add test workflow, update scripts 125 | - Update test branch 126 | - Fix job name 127 | - Remove old travis conf 128 | - Remove leftover cmd opt 129 | 130 | ### Refactor 131 | 132 | - Rewrite failure logic 133 | - Remove lazy_static 134 | - Move i3ipc to dependency section 135 | - Remove unneeded extern declarations 136 | - Update clap, rewrite args parsing 137 | - Move cmd arg parsing to new setup fn 138 | - Replace xcb with i3ipc window_properties 139 | 140 | ### Styling 141 | 142 | - Rustfmt 143 | 144 | ### Testing 145 | 146 | - Update ubuntu version 147 | - Fix tests after failure refactor 148 | 149 | ### Deps 150 | 151 | - Update to latest version of xcb 152 | - Update toml (0.7.6) 153 | - Update serde (1.0.171) 154 | - Update itertools (0.11.0) 155 | - Pin regex to 1.9.1 156 | - Pin endoing to 0.2.33 157 | 158 | ## [v2.1.1] - 2022-03-15 159 | 160 | ### Bug Fixes 161 | 162 | - Use with_context() instead of context() 163 | 164 | ## [v2.1.0] - 2022-03-14 165 | 166 | ### Bug Fixes 167 | 168 | - Build warnings 169 | 170 | ### Documentation 171 | 172 | - Add examples of workspace assignment 173 | - Document about the default config file 174 | 175 | 176 | [Unreleased]: https://github.com/roosta/i3wsr/compare/v3.1.1...HEAD 177 | [v3.1.1]: https://github.com/roosta/i3wsr/compare/v3.1.0...v3.1.1 178 | [v3.1.0]: https://github.com/roosta/i3wsr/compare/v3.0.0...v3.1.0 179 | [v3.0.0]: https://github.com/roosta/i3wsr/compare/v2.1.1...v3.0.0 180 | [v2.1.1]: https://github.com/roosta/i3wsr/compare/v2.1.0...v2.1.1 181 | [v2.1.0]: https://github.com/roosta/i3wsr/compare/v2.1.1...v3.0.0 182 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "anstream" 16 | version = "0.6.18" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 19 | dependencies = [ 20 | "anstyle", 21 | "anstyle-parse", 22 | "anstyle-query", 23 | "anstyle-wincon", 24 | "colorchoice", 25 | "is_terminal_polyfill", 26 | "utf8parse", 27 | ] 28 | 29 | [[package]] 30 | name = "anstyle" 31 | version = "1.0.10" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 34 | 35 | [[package]] 36 | name = "anstyle-parse" 37 | version = "0.2.6" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 40 | dependencies = [ 41 | "utf8parse", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle-query" 46 | version = "1.1.2" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 49 | dependencies = [ 50 | "windows-sys 0.59.0", 51 | ] 52 | 53 | [[package]] 54 | name = "anstyle-wincon" 55 | version = "3.0.6" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" 58 | dependencies = [ 59 | "anstyle", 60 | "windows-sys 0.59.0", 61 | ] 62 | 63 | [[package]] 64 | name = "bitflags" 65 | version = "2.6.0" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 68 | 69 | [[package]] 70 | name = "cfg-if" 71 | version = "1.0.0" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 74 | 75 | [[package]] 76 | name = "clap" 77 | version = "4.5.23" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" 80 | dependencies = [ 81 | "clap_builder", 82 | "clap_derive", 83 | ] 84 | 85 | [[package]] 86 | name = "clap_builder" 87 | version = "4.5.23" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" 90 | dependencies = [ 91 | "anstream", 92 | "anstyle", 93 | "clap_lex", 94 | "strsim", 95 | ] 96 | 97 | [[package]] 98 | name = "clap_derive" 99 | version = "4.5.18" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" 102 | dependencies = [ 103 | "heck", 104 | "proc-macro2", 105 | "quote", 106 | "syn", 107 | ] 108 | 109 | [[package]] 110 | name = "clap_lex" 111 | version = "0.7.4" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 114 | 115 | [[package]] 116 | name = "colorchoice" 117 | version = "1.0.3" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 120 | 121 | [[package]] 122 | name = "colored" 123 | version = "2.2.0" 124 | source = "registry+https://github.com/rust-lang/crates.io-index" 125 | checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" 126 | dependencies = [ 127 | "lazy_static", 128 | "windows-sys 0.59.0", 129 | ] 130 | 131 | [[package]] 132 | name = "dirs" 133 | version = "5.0.1" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" 136 | dependencies = [ 137 | "dirs-sys", 138 | ] 139 | 140 | [[package]] 141 | name = "dirs-sys" 142 | version = "0.4.1" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" 145 | dependencies = [ 146 | "libc", 147 | "option-ext", 148 | "redox_users", 149 | "windows-sys 0.48.0", 150 | ] 151 | 152 | [[package]] 153 | name = "either" 154 | version = "1.13.0" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" 157 | 158 | [[package]] 159 | name = "equivalent" 160 | version = "1.0.1" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 163 | 164 | [[package]] 165 | name = "getrandom" 166 | version = "0.2.15" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 169 | dependencies = [ 170 | "cfg-if", 171 | "libc", 172 | "wasi", 173 | ] 174 | 175 | [[package]] 176 | name = "hashbrown" 177 | version = "0.15.2" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 180 | 181 | [[package]] 182 | name = "heck" 183 | version = "0.5.0" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 186 | 187 | [[package]] 188 | name = "i3wsr" 189 | version = "3.1.1" 190 | dependencies = [ 191 | "clap", 192 | "colored", 193 | "dirs", 194 | "itertools", 195 | "regex", 196 | "serde", 197 | "swayipc", 198 | "thiserror", 199 | "toml", 200 | ] 201 | 202 | [[package]] 203 | name = "indexmap" 204 | version = "2.7.0" 205 | source = "registry+https://github.com/rust-lang/crates.io-index" 206 | checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" 207 | dependencies = [ 208 | "equivalent", 209 | "hashbrown", 210 | ] 211 | 212 | [[package]] 213 | name = "is_terminal_polyfill" 214 | version = "1.70.1" 215 | source = "registry+https://github.com/rust-lang/crates.io-index" 216 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 217 | 218 | [[package]] 219 | name = "itertools" 220 | version = "0.13.0" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 223 | dependencies = [ 224 | "either", 225 | ] 226 | 227 | [[package]] 228 | name = "itoa" 229 | version = "1.0.14" 230 | source = "registry+https://github.com/rust-lang/crates.io-index" 231 | checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" 232 | 233 | [[package]] 234 | name = "lazy_static" 235 | version = "1.5.0" 236 | source = "registry+https://github.com/rust-lang/crates.io-index" 237 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 238 | 239 | [[package]] 240 | name = "libc" 241 | version = "0.2.168" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" 244 | 245 | [[package]] 246 | name = "libredox" 247 | version = "0.1.3" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 250 | dependencies = [ 251 | "bitflags", 252 | "libc", 253 | ] 254 | 255 | [[package]] 256 | name = "memchr" 257 | version = "2.7.4" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 260 | 261 | [[package]] 262 | name = "option-ext" 263 | version = "0.2.0" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 266 | 267 | [[package]] 268 | name = "proc-macro2" 269 | version = "1.0.92" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" 272 | dependencies = [ 273 | "unicode-ident", 274 | ] 275 | 276 | [[package]] 277 | name = "quote" 278 | version = "1.0.37" 279 | source = "registry+https://github.com/rust-lang/crates.io-index" 280 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 281 | dependencies = [ 282 | "proc-macro2", 283 | ] 284 | 285 | [[package]] 286 | name = "redox_users" 287 | version = "0.4.6" 288 | source = "registry+https://github.com/rust-lang/crates.io-index" 289 | checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 290 | dependencies = [ 291 | "getrandom", 292 | "libredox", 293 | "thiserror", 294 | ] 295 | 296 | [[package]] 297 | name = "regex" 298 | version = "1.11.1" 299 | source = "registry+https://github.com/rust-lang/crates.io-index" 300 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 301 | dependencies = [ 302 | "aho-corasick", 303 | "memchr", 304 | "regex-automata", 305 | "regex-syntax", 306 | ] 307 | 308 | [[package]] 309 | name = "regex-automata" 310 | version = "0.4.9" 311 | source = "registry+https://github.com/rust-lang/crates.io-index" 312 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 313 | dependencies = [ 314 | "aho-corasick", 315 | "memchr", 316 | "regex-syntax", 317 | ] 318 | 319 | [[package]] 320 | name = "regex-syntax" 321 | version = "0.8.5" 322 | source = "registry+https://github.com/rust-lang/crates.io-index" 323 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 324 | 325 | [[package]] 326 | name = "ryu" 327 | version = "1.0.18" 328 | source = "registry+https://github.com/rust-lang/crates.io-index" 329 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 330 | 331 | [[package]] 332 | name = "serde" 333 | version = "1.0.216" 334 | source = "registry+https://github.com/rust-lang/crates.io-index" 335 | checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" 336 | dependencies = [ 337 | "serde_derive", 338 | ] 339 | 340 | [[package]] 341 | name = "serde_derive" 342 | version = "1.0.216" 343 | source = "registry+https://github.com/rust-lang/crates.io-index" 344 | checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" 345 | dependencies = [ 346 | "proc-macro2", 347 | "quote", 348 | "syn", 349 | ] 350 | 351 | [[package]] 352 | name = "serde_json" 353 | version = "1.0.133" 354 | source = "registry+https://github.com/rust-lang/crates.io-index" 355 | checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" 356 | dependencies = [ 357 | "itoa", 358 | "memchr", 359 | "ryu", 360 | "serde", 361 | ] 362 | 363 | [[package]] 364 | name = "serde_spanned" 365 | version = "0.6.8" 366 | source = "registry+https://github.com/rust-lang/crates.io-index" 367 | checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" 368 | dependencies = [ 369 | "serde", 370 | ] 371 | 372 | [[package]] 373 | name = "strsim" 374 | version = "0.11.1" 375 | source = "registry+https://github.com/rust-lang/crates.io-index" 376 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 377 | 378 | [[package]] 379 | name = "swayipc" 380 | version = "3.0.3" 381 | source = "registry+https://github.com/rust-lang/crates.io-index" 382 | checksum = "2b8c50cb2e98e88b52066a35ef791fffd8f6fa631c3a4983de18ba41f718c736" 383 | dependencies = [ 384 | "serde", 385 | "serde_json", 386 | "swayipc-types", 387 | ] 388 | 389 | [[package]] 390 | name = "swayipc-types" 391 | version = "1.4.1" 392 | source = "registry+https://github.com/rust-lang/crates.io-index" 393 | checksum = "551233c60323e87cfb8194c21cc44577ab848d00bb7fa2d324a2c7f52609eaff" 394 | dependencies = [ 395 | "serde", 396 | "serde_json", 397 | "thiserror", 398 | ] 399 | 400 | [[package]] 401 | name = "syn" 402 | version = "2.0.90" 403 | source = "registry+https://github.com/rust-lang/crates.io-index" 404 | checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" 405 | dependencies = [ 406 | "proc-macro2", 407 | "quote", 408 | "unicode-ident", 409 | ] 410 | 411 | [[package]] 412 | name = "thiserror" 413 | version = "1.0.69" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 416 | dependencies = [ 417 | "thiserror-impl", 418 | ] 419 | 420 | [[package]] 421 | name = "thiserror-impl" 422 | version = "1.0.69" 423 | source = "registry+https://github.com/rust-lang/crates.io-index" 424 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 425 | dependencies = [ 426 | "proc-macro2", 427 | "quote", 428 | "syn", 429 | ] 430 | 431 | [[package]] 432 | name = "toml" 433 | version = "0.7.8" 434 | source = "registry+https://github.com/rust-lang/crates.io-index" 435 | checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" 436 | dependencies = [ 437 | "serde", 438 | "serde_spanned", 439 | "toml_datetime", 440 | "toml_edit", 441 | ] 442 | 443 | [[package]] 444 | name = "toml_datetime" 445 | version = "0.6.8" 446 | source = "registry+https://github.com/rust-lang/crates.io-index" 447 | checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" 448 | dependencies = [ 449 | "serde", 450 | ] 451 | 452 | [[package]] 453 | name = "toml_edit" 454 | version = "0.19.15" 455 | source = "registry+https://github.com/rust-lang/crates.io-index" 456 | checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" 457 | dependencies = [ 458 | "indexmap", 459 | "serde", 460 | "serde_spanned", 461 | "toml_datetime", 462 | "winnow", 463 | ] 464 | 465 | [[package]] 466 | name = "unicode-ident" 467 | version = "1.0.14" 468 | source = "registry+https://github.com/rust-lang/crates.io-index" 469 | checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" 470 | 471 | [[package]] 472 | name = "utf8parse" 473 | version = "0.2.2" 474 | source = "registry+https://github.com/rust-lang/crates.io-index" 475 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 476 | 477 | [[package]] 478 | name = "wasi" 479 | version = "0.11.0+wasi-snapshot-preview1" 480 | source = "registry+https://github.com/rust-lang/crates.io-index" 481 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 482 | 483 | [[package]] 484 | name = "windows-sys" 485 | version = "0.48.0" 486 | source = "registry+https://github.com/rust-lang/crates.io-index" 487 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 488 | dependencies = [ 489 | "windows-targets 0.48.5", 490 | ] 491 | 492 | [[package]] 493 | name = "windows-sys" 494 | version = "0.59.0" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 497 | dependencies = [ 498 | "windows-targets 0.52.6", 499 | ] 500 | 501 | [[package]] 502 | name = "windows-targets" 503 | version = "0.48.5" 504 | source = "registry+https://github.com/rust-lang/crates.io-index" 505 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 506 | dependencies = [ 507 | "windows_aarch64_gnullvm 0.48.5", 508 | "windows_aarch64_msvc 0.48.5", 509 | "windows_i686_gnu 0.48.5", 510 | "windows_i686_msvc 0.48.5", 511 | "windows_x86_64_gnu 0.48.5", 512 | "windows_x86_64_gnullvm 0.48.5", 513 | "windows_x86_64_msvc 0.48.5", 514 | ] 515 | 516 | [[package]] 517 | name = "windows-targets" 518 | version = "0.52.6" 519 | source = "registry+https://github.com/rust-lang/crates.io-index" 520 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 521 | dependencies = [ 522 | "windows_aarch64_gnullvm 0.52.6", 523 | "windows_aarch64_msvc 0.52.6", 524 | "windows_i686_gnu 0.52.6", 525 | "windows_i686_gnullvm", 526 | "windows_i686_msvc 0.52.6", 527 | "windows_x86_64_gnu 0.52.6", 528 | "windows_x86_64_gnullvm 0.52.6", 529 | "windows_x86_64_msvc 0.52.6", 530 | ] 531 | 532 | [[package]] 533 | name = "windows_aarch64_gnullvm" 534 | version = "0.48.5" 535 | source = "registry+https://github.com/rust-lang/crates.io-index" 536 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 537 | 538 | [[package]] 539 | name = "windows_aarch64_gnullvm" 540 | version = "0.52.6" 541 | source = "registry+https://github.com/rust-lang/crates.io-index" 542 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 543 | 544 | [[package]] 545 | name = "windows_aarch64_msvc" 546 | version = "0.48.5" 547 | source = "registry+https://github.com/rust-lang/crates.io-index" 548 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 549 | 550 | [[package]] 551 | name = "windows_aarch64_msvc" 552 | version = "0.52.6" 553 | source = "registry+https://github.com/rust-lang/crates.io-index" 554 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 555 | 556 | [[package]] 557 | name = "windows_i686_gnu" 558 | version = "0.48.5" 559 | source = "registry+https://github.com/rust-lang/crates.io-index" 560 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 561 | 562 | [[package]] 563 | name = "windows_i686_gnu" 564 | version = "0.52.6" 565 | source = "registry+https://github.com/rust-lang/crates.io-index" 566 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 567 | 568 | [[package]] 569 | name = "windows_i686_gnullvm" 570 | version = "0.52.6" 571 | source = "registry+https://github.com/rust-lang/crates.io-index" 572 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 573 | 574 | [[package]] 575 | name = "windows_i686_msvc" 576 | version = "0.48.5" 577 | source = "registry+https://github.com/rust-lang/crates.io-index" 578 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 579 | 580 | [[package]] 581 | name = "windows_i686_msvc" 582 | version = "0.52.6" 583 | source = "registry+https://github.com/rust-lang/crates.io-index" 584 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 585 | 586 | [[package]] 587 | name = "windows_x86_64_gnu" 588 | version = "0.48.5" 589 | source = "registry+https://github.com/rust-lang/crates.io-index" 590 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 591 | 592 | [[package]] 593 | name = "windows_x86_64_gnu" 594 | version = "0.52.6" 595 | source = "registry+https://github.com/rust-lang/crates.io-index" 596 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 597 | 598 | [[package]] 599 | name = "windows_x86_64_gnullvm" 600 | version = "0.48.5" 601 | source = "registry+https://github.com/rust-lang/crates.io-index" 602 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 603 | 604 | [[package]] 605 | name = "windows_x86_64_gnullvm" 606 | version = "0.52.6" 607 | source = "registry+https://github.com/rust-lang/crates.io-index" 608 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 609 | 610 | [[package]] 611 | name = "windows_x86_64_msvc" 612 | version = "0.48.5" 613 | source = "registry+https://github.com/rust-lang/crates.io-index" 614 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 615 | 616 | [[package]] 617 | name = "windows_x86_64_msvc" 618 | version = "0.52.6" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 621 | 622 | [[package]] 623 | name = "winnow" 624 | version = "0.5.40" 625 | source = "registry+https://github.com/rust-lang/crates.io-index" 626 | checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" 627 | dependencies = [ 628 | "memchr", 629 | ] 630 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | edition = "2021" 3 | name = "i3wsr" 4 | version = "3.1.1" 5 | description = "A dynamic workspace renamer for i3 and Sway that updates names to reflect their active applications." 6 | authors = ["Daniel Berg "] 7 | repository = "https://github.com/roosta/i3wsr" 8 | readme = "README.md" 9 | keywords = ["i3", "workspaces", "linux", "sway"] 10 | categories = ["gui", "command-line-utilities", "config"] 11 | license = "MIT" 12 | exclude = ["/script", "/assets/*", "Vagrantfile"] 13 | 14 | [lib] 15 | name = "i3wsr_core" 16 | path = "src/lib.rs" 17 | 18 | [[bin]] 19 | name = "i3wsr" 20 | path = "src/main.rs" 21 | 22 | [dependencies] 23 | clap = { version = "4.5", features = ["derive"] } 24 | toml = "0.7" 25 | serde = { version = "1.0", features = ["derive"] } 26 | itertools = "0.13" 27 | regex = "1.11" 28 | dirs = "5.0" 29 | thiserror = "1.0" 30 | swayipc = "3.0" 31 | colored = "2" 32 | -------------------------------------------------------------------------------- /Containerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest AS deps 2 | RUN apk update && apk add rust i3wm gpick xterm cargo xvfb bash 3 | 4 | FROM alpine:latest 5 | COPY --from=deps /var/cache/apk /var/cache/apk 6 | COPY . ./app 7 | WORKDIR /app 8 | RUN apk add --no-cache rust i3wm gpick xterm cargo xvfb bash 9 | CMD /app/tests/setup.sh && cargo test 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Daniel Berg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | i3wsr - i3 workspace renamer 2 | ====== 3 | 4 | [![Test Status](https://github.com/roosta/i3wsr/actions/workflows/test.yaml/badge.svg?branch=main)](https://github.com/roosta/i3wsr/actions) 5 | [![Crates.io](https://img.shields.io/crates/v/i3wsr)](https://crates.io/crates/i3wsr) 6 | 7 | A dynamic workspace renamer for i3 and Sway that updates names to reflect their 8 | active applications. 9 | 10 | `i3wsr` can be configured through command-line flags or a `TOML` config file, 11 | offering extensive customization of workspace names, icons, aliases, and 12 | display options. 13 | 14 | ## Preview 15 | 16 | ![preview](https://raw.githubusercontent.com/roosta/i3wsr/main/assets/preview.gif) 17 | 18 | ## Rebrand and Wayland support 19 | 20 | Now that `i3wsr` works with [Sway](https://swaywm.org/) as well as 21 | [I3](https://i3wm.org/), the name is a bit misleading, and could do with a 22 | change. Shame to lose the metrics, but it might help further discovery now that 23 | it supports multiple display servers. 24 | 25 | I've not thought of anything yet, but will advertise it here in the README 26 | before publishing anything under a new name. 27 | 28 | Development forward will focus on Sway, but backward compatibility with I3 will 29 | be maintained. 30 | 31 | ## Requirements 32 | 33 | i3wsr requires [i3](https://i3wm.org/) or [sway](https://swaywm.org/), and 34 | [numbered 35 | workspaces](https://i3wm.org/docs/userguide.html#_changing_named_workspaces_moving_to_workspaces), 36 | see [Configuration](#configuration) 37 | 38 | ## Installation 39 | 40 | [Rust](https://www.rust-lang.org/en-US/), and [Cargo](http://doc.crates.io/) is 41 | required, and `i3wsr` can be installed using cargo like so: 42 | 43 | ```sh 44 | cargo install i3wsr 45 | ``` 46 | 47 | Or alternatively, you can build a release binary, 48 | 49 | ```sh 50 | cargo build --release 51 | ``` 52 | 53 | Then place the built binary, located at `target/release/i3wsr`, somewhere on your `$path`. 54 | 55 | ### Arch linux 56 | 57 | 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). 58 | 59 | ## Usage 60 | 61 | Just launch the program and it'll listen for events if you are running I3 or 62 | Sway. Another option is to put something like this in your i3 or Sway config: 63 | 64 | ``` 65 | # i3 66 | exec_always --no-startup-id i3wsr 67 | 68 | # Sway 69 | exec_always i3wsr 70 | ``` 71 | 72 | > `exec_always` ensures a new instance of `i3wsr` is started when config is reloaded or wm/compositor is restarted. 73 | 74 | ## Configuration 75 | 76 | This program depends on numbered workspaces, since we're constantly changing the 77 | workspace name. So your I3 or Sway configuration need to reflect this: 78 | 79 | ``` 80 | bindsym $mod+1 workspace number 1 81 | assign [class="(?i)firefox"] number 1 82 | ``` 83 | 84 | ### Keeping part of the workspace name 85 | 86 | If you're like me and don't necessarily bind your workspaces to only numbers, 87 | or you want to keep a part of the name constant you can do like this: 88 | 89 | ``` 90 | set $myws "1:[Q]" # my sticky part 91 | bindsym $mod+q workspace number $myws 92 | assign [class="(?i)firefox"] number $myws 93 | ``` 94 | 95 | This way the workspace would look something like this when it gets changed: 96 | 97 | ``` 98 | 1:[Q] Emacs|Firefox 99 | ``` 100 | You can take this a bit further by using a bar that trims the workspace number and be left with only 101 | ``` 102 | [Q] Emacs|Firefox 103 | ``` 104 | 105 | ## Configuration / options 106 | 107 | Configuration for i3wsr can be done using cmd flags, or a config file. A config 108 | file allows for more nuanced settings, and is required to configure icons and 109 | aliases. By default i3wsr looks for the config file at 110 | `$XDG_HOME/.config/i3wsr/config.toml` or `$XDG_CONFIG_HOME/i3wsr/config.toml`. 111 | To specify another path, pass it to the `--config` option on invocation: 112 | ```bash 113 | i3wsr --config ~/my_config.toml 114 | ``` 115 | Example config can be found in 116 | [assets/example\_config.toml](https://github.com/roosta/i3wsr/blob/main/assets/example_config.toml). 117 | 118 | 119 | ### Aliases 120 | 121 | 122 | Sometimes a class, instance or name can be overly verbose, use aliases that 123 | match to window properties to create simpler names instead of showing the full 124 | property 125 | 126 | 127 | ```toml 128 | # For Sway 129 | [aliases.app_id] 130 | 131 | # for i3 132 | [aliases.class] 133 | 134 | # Exact match 135 | "^Google-chrome-unstable$" = "Chrome-dev" 136 | 137 | # Substring match 138 | firefox = "Firefox" 139 | 140 | # Escape if you want to match literal periods 141 | "Org\\.gnome\\.Nautilus" = "Nautilus" 142 | ``` 143 | Alias keys uses regex for matching, so it's possible to get creative: 144 | 145 | ```toml 146 | # This will match gimp regardless of version number reported in class 147 | "Gimp-\\d\\.\\d\\d" = "Gimp" 148 | ``` 149 | 150 | Remember to quote anything but `[a-zA-Z]`, and to escape your slashes. Due to 151 | rust string escapes if you want a literal backslash use two slashes `\\d`. 152 | 153 | ### Aliases based on property 154 | 155 | i3wsr supports 4 window properties currently: 156 | 157 | ```toml 158 | [aliases.name] # 1 i3 / wayland / sway 159 | [aliases.instance] # 2 i3 / xwayland 160 | [aliases.class] # 3 i3 / xwayland 161 | [aliases.app_id] # 3 wayland / sway only 162 | ``` 163 | These are checked in descending order, so if i3wsr finds a name alias, it'll 164 | use that and if not, then check instance, then finally use class 165 | 166 | #### Class 167 | 168 | > Only for Xwayland / i3 169 | 170 | This is the default for `i3`, and the most succinct. 171 | 172 | #### App id 173 | 174 | > Only for Wayland / Sway 175 | 176 | This is the default for wayland apps, and the most and works largely like class. 177 | 178 | #### Instance 179 | 180 | > Only for Xwayland / i3 181 | 182 | Use `instance` instead of `class` when assigning workspace names, 183 | instance is usually more specific. i3wsr will try to get the instance but if it 184 | isn't defined will fall back to class. 185 | 186 | A use case for this option could be launching `chromium 187 | --app="https://web.whatsapp.com"`, and then assign a different icon to whatsapp 188 | in your config file, while chrome retains its own alias: 189 | ```toml 190 | 191 | [icons] 192 | "WhatsApp" = "🗩" 193 | 194 | [aliases.class] 195 | Google-chrome = "Chrome" 196 | 197 | [aliases.instance] 198 | "web\\.whatsapp\\.com" = "Whatsapp" 199 | ``` 200 | 201 | #### Name 202 | 203 | > Sway and i3 204 | 205 | Uses `name` instead of `instance` and `class|app_id`, this option is very 206 | verbose and relies on regex matching of aliases to be of any use. 207 | 208 | A use-case is running some terminal application, and as default i3wsr will only 209 | display class regardless of whats running in the terminal. 210 | 211 | So you could do something like this: 212 | 213 | ```toml 214 | [aliases.name] 215 | ".*mutt$" = "Mutt" 216 | ``` 217 | 218 | ### Display property 219 | 220 | Which property to display if no aliases is found: 221 | 222 | ```toml 223 | [general] 224 | display_property = "instance" 225 | ``` 226 | 227 | Possible options are `class`, `app_id`, `instance`, and `name`, and will default 228 | to `class` or `app_id` depending on display server if not present. 229 | 230 | You can alternatively supply cmd argument: 231 | ```sh 232 | i3wsr --display-property name 233 | ``` 234 | ### Icons 235 | 236 | You can config icons for your WM property, these are defined in your config file. 237 | 238 | ```toml 239 | [icons] 240 | Firefox = "🌍" 241 | 242 | # Use quote when matching anything other than [a-zA-Z] 243 | "Org.gnome.Nautilus" = "📘" 244 | ``` 245 | i3wsr tries to match an icon with an alias first, if none are found it then 246 | checks your `display_property`, and tries to match an icon with a non aliased 247 | `display_property`, lastly it will try to match on class. 248 | 249 | ```toml 250 | [aliases.class] 251 | "Gimp-\\d\\.\\d\\d" = "Gimp" 252 | 253 | [icons] 254 | Gimp = "📄" 255 | ``` 256 | 257 | A font that provides icons is of course recommended, like 258 | [font-awesome](https://fontawesome.com/). Make sure your bar has that font 259 | configured. 260 | 261 | ### Separator 262 | 263 | Normally i3wsr uses the pipe character `|` between class names in a workspace, 264 | but a custom separator can be configured in the config file: 265 | ```toml 266 | [general] 267 | separator = "  " 268 | ``` 269 | 270 | ### Default icon 271 | To use a default icon when no other is defined use: 272 | ```toml 273 | [general] 274 | default_icon = "💀" 275 | ``` 276 | ### Empty label 277 | 278 | Set a label for empty workspaces. 279 | 280 | ```toml 281 | [general] 282 | empty_label = "🌕" 283 | ``` 284 | ### No icon names 285 | To display names only if icon is not available, you can use the 286 | `--no-icon-names` flag, or enable it in your config file like so: 287 | ```toml 288 | [options] 289 | no_icon_names = true 290 | ``` 291 | ### No names 292 | If you don't want i3wsr to display names at all, you can use the 293 | `--no-names` flag, or enable it in your config file like so: 294 | ```toml 295 | [options] 296 | no_names = true 297 | ``` 298 | 299 | ### Remove duplicates 300 | If you want duplicates removed from workspaces use either the flag 301 | `--remove-duplicates`, or configure it in the `options` section of the config 302 | file: 303 | ```toml 304 | [options] 305 | remove_duplicates = true 306 | ``` 307 | 308 | ### Split at character 309 | 310 | By default i3wsr will keep everything until the first `space` character is found, 311 | then replace the remainder with titles. 312 | 313 | If you want to define a different character that is used to split the 314 | numbered/constant part of the workspace and the dynamic content, you can use 315 | the option `--split-at [CHAR]` 316 | 317 | ```toml 318 | [general] 319 | split_at = ":" 320 | ``` 321 | 322 | Here we define colon as the split character, which results in i3wsr only 323 | keeping the numbered part of a workspace name when renaming. 324 | 325 | This can give a cleaner config, but I've kept the old behavior as default. 326 | 327 | ## Testing 328 | 329 | To run unit tests use `cargo test --lib`, to run the full test suite locally 330 | use the [Containerfile](./Containerfile) with [Podman](https://podman.io/) for 331 | example: 332 | 333 | ```sh 334 | # cd project root 335 | podman build -t i3wsr-test . 336 | podman run -t --name i3wsr-test i3wsr-test:latest 337 | ``` 338 | 339 | ## License 340 | 341 | [MIT](./LICENSE) 342 | -------------------------------------------------------------------------------- /assets/example_config.toml: -------------------------------------------------------------------------------- 1 | [icons] 2 | # font awesome 3 | TelegramDesktop = "" 4 | firefox = "" 5 | Alacritty = "" 6 | Termite = "" 7 | Thunderbird = "" 8 | Gpick = "" 9 | Nautilus = "📘" 10 | # smile emoji 11 | MyNiceProgram = "😛" 12 | 13 | # i3 / Xwayland 14 | [aliases.class] 15 | TelegramDesktop = "Telegram" 16 | "Org\\.gnome\\.Nautilus" = "Nautilus" 17 | 18 | # Sway only 19 | [aliases.app_id] 20 | "^firefox$" = "Firefox" 21 | 22 | # i3 only 23 | [aliases.instance] 24 | "open.spotify.com" = "Spotify" 25 | 26 | # Both i3 and sway 27 | [aliases.name] 28 | 29 | [general] 30 | separator = "  " 31 | split_at = ":" 32 | empty_label = "🌕" 33 | display_property = "instance" # class, instance, name 34 | default_icon = "" 35 | 36 | [options] 37 | remove_duplicates = false 38 | no_names = false 39 | no_icon_names = false 40 | focus_fix = false 41 | -------------------------------------------------------------------------------- /assets/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roosta/i3wsr/9d2b27d93f8ebbfa68be5e3cb2d88d2d31f80544/assets/preview.gif -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use std::collections::HashMap; 3 | use std::fs::File; 4 | use std::io::{self, Read}; 5 | use std::path::Path; 6 | use thiserror::Error; 7 | 8 | type StringMap = HashMap; 9 | type IconMap = HashMap; 10 | type OptionMap = HashMap; 11 | 12 | #[derive(Error, Debug)] 13 | pub enum ConfigError { 14 | #[error("Failed to read config file: {0}")] 15 | IoError(#[from] io::Error), 16 | #[error("Failed to parse TOML: {0}")] 17 | TomlError(#[from] toml::de::Error), 18 | } 19 | 20 | /// Represents aliases for different categories 21 | #[derive(Deserialize, Debug, Clone)] 22 | #[serde(default)] 23 | pub struct Aliases { 24 | pub class: StringMap, 25 | pub instance: StringMap, 26 | pub name: StringMap, 27 | pub app_id: StringMap, 28 | } 29 | 30 | impl Aliases { 31 | /// Creates a new empty Aliases instance 32 | pub fn new() -> Self { 33 | Self::default() 34 | } 35 | 36 | /// Gets an alias by category and key 37 | pub fn get_alias(&self, category: &str, key: &str) -> Option<&String> { 38 | match category { 39 | "app_id" => self.app_id.get(key), 40 | "class" => self.class.get(key), 41 | "instance" => self.instance.get(key), 42 | "name" => self.name.get(key), 43 | _ => None, 44 | } 45 | } 46 | } 47 | 48 | impl Default for Aliases { 49 | fn default() -> Self { 50 | Self { 51 | class: StringMap::new(), 52 | instance: StringMap::new(), 53 | name: StringMap::new(), 54 | app_id: StringMap::new(), 55 | } 56 | } 57 | } 58 | 59 | /// Main configuration structure 60 | #[derive(Deserialize, Debug, Clone)] 61 | #[serde(default)] 62 | pub struct Config { 63 | pub icons: IconMap, 64 | pub aliases: Aliases, 65 | pub general: StringMap, 66 | pub options: OptionMap, 67 | } 68 | 69 | impl Config { 70 | /// Creates a new Config instance from a file 71 | pub fn new(filename: &Path) -> Result { 72 | let config = Self::from_file(filename)?; 73 | Ok(config) 74 | } 75 | 76 | /// Loads configuration from a TOML file 77 | pub fn from_file(filename: &Path) -> Result { 78 | let mut file = File::open(filename)?; 79 | let mut buffer = String::new(); 80 | file.read_to_string(&mut buffer)?; 81 | let config: Config = toml::from_str(&buffer)?; 82 | Ok(config) 83 | } 84 | 85 | /// Gets a general configuration value 86 | pub fn get_general(&self, key: &str) -> Option { 87 | self.general.get(key).map(|s| s.to_string()) 88 | } 89 | 90 | /// Gets an option value 91 | pub fn get_option(&self, key: &str) -> Option { 92 | self.options.get(key).copied() 93 | } 94 | 95 | /// Gets an icon by key 96 | pub fn get_icon(&self, key: &str) -> Option { 97 | self.icons.get(key).map(|s| s.to_string()) 98 | } 99 | 100 | /// Sets a general configuration value 101 | pub fn set_general(&mut self, key: String, value: String) { 102 | self.general.insert(key, value); 103 | } 104 | 105 | /// Sets a an option configuration value 106 | pub fn set_option(&mut self, key: String, value: bool) { 107 | self.options.insert(key, value); 108 | } 109 | } 110 | 111 | impl Default for Config { 112 | fn default() -> Self { 113 | Self { 114 | icons: IconMap::new(), 115 | aliases: Aliases::default(), 116 | general: StringMap::new(), 117 | options: OptionMap::new(), 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # i3wsr - i3/Sway Workspace Renamer 2 | //! 3 | //! Internal library functionality for the i3wsr binary. This crate provides the core functionality 4 | //! for renaming i3/Sway workspaces based on their content. 5 | //! 6 | //! ## Note 7 | //! 8 | //! This is primarily a binary crate. The public functions and types are mainly exposed for: 9 | //! - Use by the binary executable 10 | //! - Testing purposes 11 | //! - Internal organization 12 | //! 13 | //! While you could technically use this as a library, it's not designed or maintained for that purpose. 14 | use itertools::Itertools; 15 | use swayipc::{ 16 | Connection, Node, NodeType, WindowChange, WindowEvent, WorkspaceChange, WorkspaceEvent, 17 | }; 18 | extern crate colored; 19 | use colored::Colorize; 20 | 21 | pub mod config; 22 | pub mod regex; 23 | 24 | pub use config::Config; 25 | use std::error::Error; 26 | use std::fmt; 27 | use std::io; 28 | use std::sync::atomic::{AtomicBool, Ordering}; 29 | 30 | /// Global flag to control debug output verbosity. 31 | /// 32 | /// This flag is atomic to allow safe concurrent access without requiring mutex locks. 33 | /// It's primarily used by the binary to enable/disable detailed logging of events 34 | /// and commands. 35 | /// 36 | /// # Usage 37 | /// 38 | /// ```rust 39 | /// use std::sync::atomic::Ordering; 40 | /// 41 | /// // Enable verbose output 42 | /// i3wsr_core::VERBOSE.store(true, Ordering::Relaxed); 43 | /// 44 | /// // Check if verbose is enabled 45 | /// if i3wsr_core::VERBOSE.load(Ordering::Relaxed) { 46 | /// println!("Verbose output enabled"); 47 | /// } 48 | /// ``` 49 | pub static VERBOSE: AtomicBool = AtomicBool::new(false); 50 | 51 | #[derive(Debug)] 52 | pub enum AppError { 53 | Config(config::ConfigError), 54 | Connection(swayipc::Error), 55 | Regex(regex::RegexError), 56 | Event(String), 57 | IoError(io::Error), 58 | Abort(String), 59 | } 60 | 61 | impl fmt::Display for AppError { 62 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 63 | match self { 64 | AppError::Config(e) => write!(f, "Configuration error: {}", e), 65 | AppError::Connection(e) => write!(f, "IPC connection error: {}", e), 66 | AppError::Regex(e) => write!(f, "Regex compilation error: {}", e), 67 | AppError::Event(e) => write!(f, "Event handling error: {}", e), 68 | AppError::IoError(e) => write!(f, "IO error: {}", e), 69 | AppError::Abort(e) => write!(f, "Abort signal, stopping program: {}", e), 70 | } 71 | } 72 | } 73 | 74 | impl Error for AppError {} 75 | 76 | impl From for AppError { 77 | fn from(err: config::ConfigError) -> Self { 78 | AppError::Config(err) 79 | } 80 | } 81 | 82 | impl From for AppError { 83 | fn from(err: swayipc::Error) -> Self { 84 | AppError::Connection(err) 85 | } 86 | } 87 | 88 | impl From for AppError { 89 | fn from(err: regex::RegexError) -> Self { 90 | AppError::Regex(err) 91 | } 92 | } 93 | 94 | impl From for AppError { 95 | fn from(err: io::Error) -> Self { 96 | AppError::IoError(err) 97 | } 98 | } 99 | 100 | /// Helper fn to get options via config 101 | fn get_option(config: &Config, key: &str) -> bool { 102 | config.get_option(key).unwrap_or(false) 103 | } 104 | 105 | fn find_alias(value: Option<&String>, patterns: &[(regex::Regex, String)]) -> Option { 106 | value.and_then(|val| { 107 | patterns 108 | .iter() 109 | .find(|(re, _)| re.is_match(val)) 110 | .map(|(_, alias)| alias.clone()) 111 | }) 112 | } 113 | 114 | fn format_with_icon(icon: &str, title: &str, no_names: bool, no_icon_names: bool) -> String { 115 | if no_icon_names || no_names { 116 | icon.to_string() 117 | } else { 118 | format!("{} {}", icon, title) 119 | } 120 | } 121 | 122 | /// Gets a window title by trying to find an alias for the window, eventually falling back on 123 | /// class, or app_id, depending on platform. 124 | pub fn get_title( 125 | node: &Node, 126 | config: &Config, 127 | res: ®ex::Compiled, 128 | ) -> Result> { 129 | let display_prop = config 130 | .get_general("display_property") 131 | .unwrap_or_else(|| "class".to_string()); 132 | 133 | let title = match &node.window_properties { 134 | // Xwayland / Xorg 135 | Some(props) => { 136 | // First try to find an alias using the window properties 137 | let alias = find_alias(props.title.as_ref(), &res.name) 138 | .or_else(|| find_alias(props.instance.as_ref(), &res.instance)) 139 | .or_else(|| find_alias(props.class.as_ref(), &res.class)); 140 | 141 | // If no alias found, use the configured display property 142 | let title = alias.or_else(|| { 143 | let prop_value = match display_prop.as_str() { 144 | "name" => props.title.clone(), 145 | "instance" => props.instance.clone(), 146 | _ => props.class.clone(), 147 | }; 148 | prop_value 149 | }); 150 | 151 | title.ok_or_else(|| { 152 | format!( 153 | "No title found: tried aliases and display_prop '{}'", 154 | display_prop 155 | ) 156 | })? 157 | } 158 | // Wayland 159 | None => { 160 | let alias = find_alias(node.name.as_ref(), &res.name) 161 | .or_else(|| find_alias(node.app_id.as_ref(), &res.app_id)); 162 | 163 | let title = alias.or_else(|| { 164 | let prop_value = match display_prop.as_str() { 165 | "name" => node.name.clone(), 166 | _ => node.app_id.clone(), 167 | }; 168 | prop_value 169 | }); 170 | title.ok_or_else(|| { 171 | format!( 172 | "No title found: tried aliases and display_prop '{}'", 173 | display_prop 174 | ) 175 | })? 176 | } 177 | }; 178 | 179 | // Try to find an alias first 180 | let no_names = get_option(config, "no_names"); 181 | let no_icon_names = get_option(config, "no_icon_names"); 182 | 183 | Ok(if let Some(icon) = config.get_icon(&title) { 184 | format_with_icon(&icon, &title, no_names, no_icon_names) 185 | } else if let Some(default_icon) = config.get_general("default_icon") { 186 | format_with_icon(&default_icon, &title, no_names, no_icon_names) 187 | } else if no_names { 188 | String::new() 189 | } else { 190 | title 191 | }) 192 | } 193 | 194 | /// Filters out special workspaces (like scratchpad) and collects regular workspaces 195 | /// from the window manager tree structure. 196 | pub fn get_workspaces(tree: Node) -> Vec { 197 | let excludes = ["__i3_scratch", "__sway_scratch"]; 198 | 199 | // Helper function to recursively find workspaces in a node 200 | fn find_workspaces(node: Node, excludes: &[&str]) -> Vec { 201 | let mut workspaces = Vec::new(); 202 | 203 | // If this is a workspace node that's not excluded, add it 204 | if matches!(node.node_type, NodeType::Workspace) { 205 | if let Some(name) = &node.name { 206 | if !excludes.contains(&name.as_str()) { 207 | workspaces.push(node.clone()); 208 | } 209 | } 210 | } 211 | 212 | // Recursively check child nodes 213 | for child in node.nodes { 214 | workspaces.extend(find_workspaces(child, excludes)); 215 | } 216 | 217 | workspaces 218 | } 219 | 220 | // Start the recursive search from the root 221 | find_workspaces(tree, &excludes) 222 | } 223 | 224 | /// Collect a vector of workspace titles, recursively traversing all nested nodes 225 | pub fn collect_titles(workspace: &Node, config: &Config, res: ®ex::Compiled) -> Vec { 226 | fn collect_nodes<'a>(node: &'a Node, nodes: &mut Vec<&'a Node>) { 227 | // Add the current node if it has window properties or app_id 228 | if node.window_properties.is_some() || node.app_id.is_some() { 229 | nodes.push(node); 230 | } 231 | 232 | // Recursively collect from regular nodes 233 | for child in &node.nodes { 234 | collect_nodes(child, nodes); 235 | } 236 | 237 | // Recursively collect from floating nodes 238 | for child in &node.floating_nodes { 239 | collect_nodes(child, nodes); 240 | } 241 | } 242 | 243 | let mut all_nodes = Vec::new(); 244 | collect_nodes(workspace, &mut all_nodes); 245 | 246 | let mut titles = Vec::new(); 247 | for node in all_nodes { 248 | let title = match get_title(node, config, res) { 249 | Ok(title) => title, 250 | Err(e) => { 251 | eprintln!("get_title error: \"{}\" for workspace {:#?}", e, workspace); 252 | continue; 253 | } 254 | }; 255 | titles.push(title); 256 | } 257 | 258 | titles 259 | } 260 | 261 | /// Applies options on titles, like remove duplicates 262 | fn apply_options(titles: Vec, config: &Config) -> Vec { 263 | let mut processed = titles; 264 | 265 | if get_option(config, "remove_duplicates") { 266 | processed = processed.into_iter().unique().collect(); 267 | } 268 | 269 | if get_option(config, "no_names") { 270 | processed = processed.into_iter().filter(|s| !s.is_empty()).collect(); 271 | } 272 | 273 | processed 274 | } 275 | 276 | fn get_split_char(config: &Config) -> char { 277 | config 278 | .get_general("split_at") 279 | .and_then(|s| if s.is_empty() { None } else { s.chars().next() }) 280 | .unwrap_or(' ') 281 | } 282 | 283 | fn format_workspace_name(initial: &str, titles: &str, split_at: char, config: &Config) -> String { 284 | let mut new = String::from(initial); 285 | 286 | // Add colon if needed 287 | if split_at == ':' && !initial.is_empty() && !titles.is_empty() { 288 | new.push(':'); 289 | } 290 | 291 | // Add titles if present 292 | if !titles.is_empty() { 293 | new.push_str(titles); 294 | } else if let Some(empty_label) = config.get_general("empty_label") { 295 | new.push(' '); 296 | new.push_str(&empty_label); 297 | } 298 | 299 | new 300 | } 301 | 302 | /// Internal function to update all workspace names based on their current content. 303 | /// This function is public for testing purposes and binary use only. 304 | /// 305 | /// Update all workspace names in tree 306 | pub fn update_tree( 307 | conn: &mut Connection, 308 | config: &Config, 309 | res: ®ex::Compiled, 310 | focus: bool, 311 | ) -> Result<(), Box> { 312 | let tree = conn.get_tree()?; 313 | let separator = config 314 | .get_general("separator") 315 | .unwrap_or_else(|| " | ".to_string()); 316 | let split_at = get_split_char(config); 317 | 318 | for workspace in get_workspaces(tree) { 319 | // Get the old workspace name 320 | let old = workspace.name.as_ref().ok_or_else(|| { 321 | format!( 322 | "Failed to get workspace name for workspace: {:#?}", 323 | workspace 324 | ) 325 | })?; 326 | 327 | // Process titles 328 | let titles = collect_titles(&workspace, config, res); 329 | let titles = apply_options(titles, config); 330 | let titles = if !titles.is_empty() { 331 | format!(" {}", titles.join(&separator)) 332 | } else { 333 | String::new() 334 | }; 335 | 336 | // Get initial part of workspace name 337 | let initial = old.split(split_at).next().unwrap_or(""); 338 | 339 | // Format new workspace name 340 | let new = format_workspace_name(initial, &titles, split_at, config); 341 | 342 | // Only send command if name changed 343 | if old != &new { 344 | let command = format!("rename workspace \"{}\" to \"{}\"", old, new); 345 | if VERBOSE.load(Ordering::Relaxed) { 346 | println!("{} {}", "[COMMAND]".blue(), command); 347 | if let Some(output) = &workspace.output { 348 | println!("{} Workspace on output: {}", "[INFO]".cyan(), output); 349 | } 350 | } 351 | 352 | // Focus on flag, fix for moving floating windows across multiple monitors 353 | if focus { 354 | let focus_cmd = format!("workspace \"{}\"", old); 355 | conn.run_command(&focus_cmd)?; 356 | } 357 | 358 | // Then rename it 359 | conn.run_command(&command)?; 360 | } 361 | } 362 | Ok(()) 363 | } 364 | 365 | /// Processes various window events (new, close, move, title changes) and updates 366 | /// workspace names accordingly. This is a core part of the event loop in the main binary. 367 | pub fn handle_window_event( 368 | e: &WindowEvent, 369 | conn: &mut Connection, 370 | config: &Config, 371 | res: ®ex::Compiled, 372 | ) -> Result<(), AppError> { 373 | if VERBOSE.load(Ordering::Relaxed) { 374 | println!( 375 | "{} Change: {:?}, Container: {:?}", 376 | "[WINDOW EVENT]".yellow(), 377 | e.change, 378 | e.container 379 | ); 380 | } 381 | match e.change { 382 | WindowChange::New 383 | | WindowChange::Close 384 | | WindowChange::Move 385 | | WindowChange::Title 386 | | WindowChange::Floating => { 387 | update_tree(conn, config, res, false) 388 | .map_err(|e| AppError::Event(format!("Tree update failed: {}", e)))?; 389 | } 390 | _ => (), 391 | } 392 | Ok(()) 393 | } 394 | 395 | /// Processes workspace events (empty, focus changes) and updates workspace names 396 | /// as needed. This is a core part of the event loop in the main binary. 397 | pub fn handle_ws_event( 398 | e: &WorkspaceEvent, 399 | conn: &mut Connection, 400 | config: &Config, 401 | res: ®ex::Compiled, 402 | ) -> Result<(), AppError> { 403 | if VERBOSE.load(Ordering::Relaxed) { 404 | println!( 405 | "{} Change: {:?}, Current: {:?}, Old: {:?}", 406 | "[WORKSPACE EVENT]".green(), 407 | e.change, 408 | e.current, 409 | e.old 410 | ); 411 | } 412 | 413 | let focus_fix = get_option(config, "focus_fix"); 414 | 415 | match e.change { 416 | WorkspaceChange::Empty | WorkspaceChange::Focus => { 417 | update_tree(conn, config, res, e.change == WorkspaceChange::Focus && focus_fix) 418 | .map_err(|e| AppError::Event(format!("Tree update failed: {}", e)))?; 419 | } 420 | _ => (), 421 | } 422 | Ok(()) 423 | } 424 | 425 | #[cfg(test)] 426 | mod tests { 427 | use regex::Regex; 428 | 429 | #[test] 430 | fn test_find_alias() { 431 | let patterns = vec![ 432 | (Regex::new(r"Firefox").unwrap(), "firefox".to_string()), 433 | (Regex::new(r"Chrome").unwrap(), "chrome".to_string()), 434 | ]; 435 | 436 | // Test matching case 437 | let binding = "Firefox".to_string(); 438 | let value = Some(&binding); 439 | assert_eq!( 440 | super::find_alias(value, &patterns), 441 | Some("firefox".to_string()) 442 | ); 443 | 444 | // Test non-matching case 445 | let binding = "Safari".to_string(); 446 | let value = Some(&binding); 447 | assert_eq!(super::find_alias(value, &patterns), None); 448 | 449 | // Test None case 450 | let value: Option<&String> = None; 451 | assert_eq!(super::find_alias(value, &patterns), None); 452 | } 453 | 454 | #[test] 455 | fn test_format_with_icon() { 456 | let icon = "🦊"; 457 | let title = "Firefox"; 458 | 459 | // Test normal case 460 | assert_eq!( 461 | super::format_with_icon(&icon, title, false, false), 462 | "🦊 Firefox" 463 | ); 464 | 465 | // Test no_names = true 466 | assert_eq!(super::format_with_icon(&icon, title, true, false), "🦊"); 467 | 468 | // Test no_icon_names = true 469 | assert_eq!(super::format_with_icon(&icon, title, false, true), "🦊"); 470 | 471 | // Test both flags true 472 | assert_eq!(super::format_with_icon(&icon, title, true, true), "🦊"); 473 | } 474 | 475 | #[test] 476 | fn test_get_split_char() { 477 | let mut config = super::Config::default(); 478 | 479 | // Test default (space) 480 | assert_eq!(super::get_split_char(&config), ' '); 481 | 482 | // Test with custom split char 483 | config.set_general("split_at".to_string(), ":".to_string()); 484 | assert_eq!(super::get_split_char(&config), ':'); 485 | 486 | // Test with empty string 487 | config.set_general("split_at".to_string(), "".to_string()); 488 | assert_eq!(super::get_split_char(&config), ' '); 489 | } 490 | 491 | #[test] 492 | fn test_format_workspace_name() { 493 | let mut config = super::Config::default(); 494 | 495 | // Test normal case with space 496 | assert_eq!( 497 | super::format_workspace_name("1", " Firefox Chrome", ' ', &config), 498 | "1 Firefox Chrome" 499 | ); 500 | 501 | // Test with colon separator 502 | assert_eq!( 503 | super::format_workspace_name("1", " Firefox Chrome", ':', &config), 504 | "1: Firefox Chrome" 505 | ); 506 | 507 | // Test empty titles with no empty_label 508 | assert_eq!(super::format_workspace_name("1", "", ':', &config), "1"); 509 | 510 | // Test empty titles with empty_label 511 | config.set_general("empty_label".to_string(), "Empty".to_string()); 512 | assert_eq!( 513 | super::format_workspace_name("1", "", ':', &config), 514 | "1 Empty" 515 | ); 516 | 517 | // Test empty initial 518 | assert_eq!( 519 | super::format_workspace_name("", " Firefox Chrome", ':', &config), 520 | " Firefox Chrome" 521 | ); 522 | } 523 | } 524 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! # i3wsr - i3/Sway Workspace Renamer 2 | //! 3 | //! 4 | //! A dynamic workspace renamer for i3 and Sway that updates names to reflect their 5 | //! active applications. 6 | //! 7 | //! ## Usage 8 | //! 9 | //! 1. Install using cargo: 10 | //! ```bash 11 | //! cargo install i3wsr 12 | //! ``` 13 | //! 14 | //! 2. Add to your i3/Sway config: 15 | //! ``` 16 | //! exec_always --no-startup-id i3wsr 17 | //! ``` 18 | //! 19 | //! 3. Ensure numbered workspaces in i3/Sway config: 20 | //! ``` 21 | //! bindsym $mod+1 workspace number 1 22 | //! assign [class="(?i)firefox"] number 1 23 | //! ``` 24 | //! 25 | //! ## Configuration 26 | //! 27 | //! Configuration can be done via: 28 | //! - Command line arguments 29 | //! - TOML configuration file (default: `$XDG_CONFIG_HOME/i3wsr/config.toml`) 30 | //! 31 | //! ### Config File Sections: 32 | //! 33 | //! ```toml 34 | //! [icons] 35 | //! # Map window classes to icons 36 | //! Firefox = "🌍" 37 | //! default_icon = "💻" 38 | //! 39 | //! [aliases.app_id] 40 | //! "^firefox$" = "Firefox" 41 | //! 42 | //! [aliases.class] 43 | //! # Map window classes to friendly names 44 | //! "Google-chrome" = "Chrome" 45 | //! 46 | //! [aliases.instance] 47 | //! # Map window instances to friendly names 48 | //! "web.whatsapp.com" = "WhatsApp" 49 | //! 50 | //! [aliases.name] 51 | //! # Map window names using regex 52 | //! ".*mutt$" = "Mail" 53 | //! 54 | //! [general] 55 | //! separator = " | " # Separator between window names 56 | //! split_at = ":" # Character to split workspace number 57 | //! empty_label = "🌕" # Label for empty workspaces 58 | //! display_property = "class" # Default property to display (class/app_id/instance/name) 59 | //! 60 | //! [options] 61 | //! remove_duplicates = false # Remove duplicate window names 62 | //! no_names = false # Show only icons 63 | //! no_icon_names = false # Show names only if no icon available 64 | //! focus_fix = false # Enable experimental focus fix, see #34 for more. Ignore if you don't know you need this. 65 | //! ``` 66 | //! 67 | //! ### Command Line Options: 68 | //! 69 | //! - `--verbose`: Enable detailed logging 70 | //! - `--config `: Use alternative config file 71 | //! - `--no-icon-names`: Show only icons when available 72 | //! - `--no-names`: Never show window names 73 | //! - `--remove-duplicates`: Remove duplicate entries 74 | //! - `--display-property `: Window property to use (class/app_id/instance/name) 75 | //! - `--split-at `: Character to split workspace names 76 | //! 77 | //! ### Window Properties: 78 | //! 79 | //! Three window properties can be used for naming: 80 | //! - `class`: Default, most stable (WM_CLASS) 81 | //! - `app_id`: In place of class only for sway/wayland 82 | //! - `instance`: More specific than class (WM_INSTANCE) 83 | //! - `name`: Most detailed but volatile (WM_NAME) 84 | //! 85 | //! Properties are checked in order: name -> instance -> class/app_id 86 | //! 87 | //! ### Special Features: 88 | //! 89 | //! - Regex support in aliases 90 | //! - Custom icons per window 91 | //! - Default icons 92 | //! - Empty workspace labels 93 | //! - Duplicate removal 94 | //! - Custom separators 95 | //! 96 | //! For more details, see the [README](https://github.com/roosta/i3wsr) 97 | 98 | use clap::{Parser, ValueEnum}; 99 | use dirs::config_dir; 100 | use i3wsr_core::config::{Config, ConfigError}; 101 | use std::io; 102 | use std::path::Path; 103 | use swayipc::{Connection, Event, EventType, Fallible, WorkspaceChange}; 104 | use std::env; 105 | 106 | use i3wsr_core::AppError; 107 | 108 | /// Window property types that can be used for workspace naming. 109 | /// 110 | /// These properties determine which window attribute is used when displaying 111 | /// window names in workspaces: 112 | /// - `Class`: Uses WM_CLASS (default, most stable) 113 | /// - `Instance`: Uses WM_INSTANCE (more specific than class) 114 | /// - `Name`: Uses WM_NAME (most detailed but volatile) 115 | /// - `AppId`: In place of class only for sway/wayland 116 | #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] 117 | enum Properties { 118 | Class, 119 | Instance, 120 | Name, 121 | AppId 122 | } 123 | 124 | impl Properties { 125 | fn as_str(&self) -> &'static str { 126 | match self { 127 | Properties::Class => "class", 128 | Properties::Instance => "instance", 129 | Properties::Name => "name", 130 | Properties::AppId => "app_id", 131 | } 132 | } 133 | } 134 | 135 | /// Command line arguments for i3wsr 136 | /// 137 | /// Configuration can be provided either through command line arguments 138 | /// or through a TOML configuration file. Command line arguments take 139 | /// precedence over configuration file settings. 140 | #[derive(Parser, Debug)] 141 | #[command( 142 | author, 143 | version, 144 | about = "Dynamic workspace renamer for i3 and Sway window managers" 145 | )] 146 | #[command( 147 | long_about = "Automatically renames workspaces based on their window contents. \ 148 | Supports custom icons, aliases, and various display options. \ 149 | Can be configured via command line flags or a TOML configuration file." 150 | )] 151 | struct Args { 152 | /// Enable verbose logging of events and operations 153 | #[arg( 154 | short, 155 | long, 156 | help = "Print detailed information about events and operations" 157 | )] 158 | verbose: bool, 159 | 160 | #[arg( 161 | long, 162 | help = "Enable experimental focus fix, see #34 for more. Ignore if you don't know you need this." 163 | )] 164 | focus_fix: bool, 165 | 166 | /// Deprecated: Icon set option (maintained for backwards compatibility) 167 | #[arg( 168 | long, 169 | value_name = "SET", 170 | help = "[DEPRECATED] Icon set selection - will be removed in future versions" 171 | )] 172 | icons: Option, 173 | /// Path to TOML configuration file 174 | #[arg( 175 | short, 176 | long, 177 | help = "Path to TOML config file (default: $XDG_CONFIG_HOME/i3wsr/config.toml)", 178 | value_name = "FILE" 179 | )] 180 | config: Option, 181 | 182 | /// Display only icon (if available) otherwise display name 183 | #[arg( 184 | short = 'm', 185 | long, 186 | help = "Show only icons when available, fallback to names otherwise" 187 | )] 188 | no_icon_names: bool, 189 | 190 | /// Do not display window names, only show icons 191 | #[arg(short, long, help = "Show only icons, never display window names")] 192 | no_names: bool, 193 | 194 | /// Remove duplicate entries in workspace names 195 | #[arg( 196 | short, 197 | long, 198 | help = "Remove duplicate window names from workspace labels" 199 | )] 200 | remove_duplicates: bool, 201 | 202 | /// Which window property to use when no alias is found 203 | #[arg( 204 | short = 'p', 205 | long, 206 | value_enum, 207 | help = "Window property to use for naming (class/instance/name)", 208 | value_name = "PROPERTY" 209 | )] 210 | display_property: Option, 211 | 212 | /// Character used to split the workspace title string 213 | #[arg( 214 | short = 'a', 215 | long, 216 | help = "Character that separates workspace number from window names", 217 | value_name = "CHAR" 218 | )] 219 | split_at: Option, 220 | } 221 | 222 | /// Loads configuration from a TOML file or creates default configuration 223 | fn load_config(config_path: Option<&str>) -> Result { 224 | let xdg_config = config_dir() 225 | .ok_or_else(|| { 226 | ConfigError::IoError(io::Error::new( 227 | io::ErrorKind::NotFound, 228 | "Could not determine config directory", 229 | )) 230 | })? 231 | .join("i3wsr/config.toml"); 232 | 233 | match config_path { 234 | Some(path) => { 235 | println!("Loading config from: {path}"); 236 | Config::new(Path::new(path)) 237 | } 238 | None => { 239 | if xdg_config.exists() { 240 | Config::new(&xdg_config) 241 | } else { 242 | Ok(Config { 243 | ..Default::default() 244 | }) 245 | } 246 | } 247 | } 248 | } 249 | 250 | /// Applies command line arguments to configuration 251 | fn apply_args_to_config(config: &mut Config, args: &Args) { 252 | // Apply boolean options 253 | let options = [ 254 | ("no_icon_names", args.no_icon_names), 255 | ("no_names", args.no_names), 256 | ("remove_duplicates", args.remove_duplicates), 257 | ("focus_fix", args.focus_fix), 258 | ]; 259 | 260 | for (key, value) in options { 261 | if value { 262 | config.options.insert(key.to_string(), value); 263 | } 264 | } 265 | 266 | // Apply general settings 267 | if let Some(split_char) = &args.split_at { 268 | config 269 | .general 270 | .insert("split_at".to_string(), split_char.clone()); 271 | } 272 | 273 | if let Some(display_property) = &args.display_property { 274 | config 275 | .general 276 | .insert("display_property".to_string(), display_property.as_str().to_string()); 277 | } 278 | } 279 | 280 | /// Sets up the program by processing arguments and initializing configuration 281 | /// Command line arguments take precedence over configuration file settings. 282 | fn setup() -> Result { 283 | let args = Args::parse(); 284 | 285 | // Handle deprecated --icons option 286 | if let Some(icon_set) = &args.icons { 287 | if icon_set == "awesome" { 288 | eprintln!("Warning: The --icons option is deprecated and will be removed in a future version."); 289 | eprintln!("Icons are now configured via the config file in the [icons] section."); 290 | } else { 291 | eprintln!("Warning: Invalid --icons value '{}'. Only 'awesome' is supported for backwards compatibility.", icon_set); 292 | } 293 | } 294 | 295 | // Set verbose mode if requested 296 | i3wsr_core::VERBOSE.store(args.verbose, std::sync::atomic::Ordering::Relaxed); 297 | 298 | let mut config = load_config(args.config.as_deref())?; 299 | apply_args_to_config(&mut config, &args); 300 | 301 | Ok(config) 302 | } 303 | 304 | /// Processes window manager events and updates workspace names accordingly 305 | fn handle_event( 306 | event: Fallible, 307 | conn: &mut Connection, 308 | config: &Config, 309 | res: &i3wsr_core::regex::Compiled, 310 | ) -> Result<(), AppError> { 311 | match event { 312 | Ok(Event::Window(e)) => { 313 | i3wsr_core::handle_window_event(&e, conn, config, res) 314 | .map_err(|e| AppError::Event(format!("Window event error: {}", e)))?; 315 | } 316 | Ok(Event::Workspace(e)) => { 317 | if e.change == WorkspaceChange::Reload && env::var("SWAYSOCK").is_ok() { 318 | return Err(AppError::Abort(format!("Config reloaded"))); 319 | } 320 | i3wsr_core::handle_ws_event(&e, conn, config, res) 321 | .map_err(|e| AppError::Event(format!("Workspace event error: {}", e)))?; 322 | } 323 | Ok(_) => {} 324 | Err(e) => { 325 | // Check if it's an UnexpectedEof error (common when i3/sway restarts) 326 | if let swayipc::Error::Io(io_err) = &e { 327 | if io_err.kind() == std::io::ErrorKind::UnexpectedEof { 328 | return Err(AppError::Abort("Window manager connection lost (EOF), shutting down...".to_string())); 329 | } 330 | } 331 | return Err(AppError::Event(format!("IPC event error: {}", e))); 332 | } 333 | } 334 | Ok(()) 335 | } 336 | 337 | /// Main event loop that monitors window manager events 338 | /// The program will continue running and handling events until 339 | /// interrupted or an unrecoverable error occurs. 340 | fn run() -> Result<(), AppError> { 341 | let config = setup()?; 342 | let res = i3wsr_core::regex::parse_config(&config)?; 343 | 344 | let mut conn = Connection::new()?; 345 | let subscriptions = [EventType::Window, EventType::Workspace]; 346 | 347 | i3wsr_core::update_tree(&mut conn, &config, &res, false) 348 | .map_err(|e| AppError::Event(format!("Initial tree update failed: {}", e)))?; 349 | 350 | let event_connection = Connection::new()?; 351 | let events = event_connection.subscribe(&subscriptions)?; 352 | 353 | println!("Started successfully. Listening for events..."); 354 | 355 | for event in events { 356 | if let Err(e) = handle_event(event, &mut conn, &config, &res) { 357 | match &e { 358 | // Exit program on abort, this is because when config gets reloaded, we want the 359 | // old process to exit, letting sway start a new one. 360 | AppError::Abort(_) => { 361 | return Err(e); 362 | } 363 | // Continue running despite errors 364 | _ => eprintln!("Error handling event: {}", e), 365 | } 366 | } 367 | } 368 | 369 | Ok(()) 370 | } 371 | 372 | fn main() { 373 | if let Err(e) = run() { 374 | eprintln!("Fatal error: {}", e); 375 | std::process::exit(1); 376 | } 377 | } 378 | -------------------------------------------------------------------------------- /src/regex.rs: -------------------------------------------------------------------------------- 1 | use crate::Config; 2 | pub use regex::Regex; 3 | use std::collections::HashMap; 4 | use std::error::Error; 5 | use std::fmt; 6 | 7 | #[derive(Debug)] 8 | pub enum RegexError { 9 | Compilation(regex::Error), 10 | Pattern(String), 11 | } 12 | 13 | impl fmt::Display for RegexError { 14 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 15 | match self { 16 | RegexError::Compilation(e) => write!(f, "Regex compilation error: {}", e), 17 | RegexError::Pattern(e) => write!(f, "{}", e), 18 | } 19 | } 20 | } 21 | 22 | impl Error for RegexError {} 23 | 24 | impl From for RegexError { 25 | fn from(err: regex::Error) -> Self { 26 | RegexError::Compilation(err) 27 | } 28 | } 29 | 30 | /// A compiled regex pattern and its corresponding replacement string 31 | pub type Pattern = (Regex, String); 32 | 33 | /// Holds compiled regex patterns for different window properties 34 | #[derive(Debug)] 35 | pub struct Compiled { 36 | pub class: Vec, 37 | pub instance: Vec, 38 | pub name: Vec, 39 | pub app_id: Vec, 40 | } 41 | 42 | /// Compiles a single regex pattern from a key-value pair 43 | fn compile_pattern((pattern, replacement): (&String, &String)) -> Result { 44 | Ok(( 45 | Regex::new(pattern).map_err(|e| { 46 | RegexError::Pattern(format!("Invalid regex pattern '{}': {}", pattern, e)) 47 | })?, 48 | replacement.to_owned(), 49 | )) 50 | } 51 | 52 | /// Compiles a collection of patterns from a HashMap 53 | fn compile_patterns(patterns: &HashMap) -> Result, RegexError> { 54 | patterns 55 | .iter() 56 | .map(|(k, v)| compile_pattern((k, v))) 57 | .collect() 58 | } 59 | 60 | /// Parses the configuration into compiled regex patterns 61 | pub fn parse_config(config: &Config) -> Result { 62 | Ok(Compiled { 63 | class: compile_patterns(&config.aliases.class)?, 64 | instance: compile_patterns(&config.aliases.instance)?, 65 | name: compile_patterns(&config.aliases.name)?, 66 | app_id: compile_patterns(&config.aliases.app_id)?, 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /tests/integration_test.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::error::Error; 3 | use swayipc::{Connection, Node}; 4 | use i3wsr_core::{Config, update_tree}; 5 | 6 | #[test] 7 | fn connection_tree() -> Result<(), Box> { 8 | env::set_var("DISPLAY", ":99.0"); 9 | let mut conn = Connection::new()?; 10 | let config = Config::default(); 11 | let res = i3wsr_core::regex::parse_config(&config)?; 12 | assert!(update_tree(&mut conn, &config, &res, false).is_ok()); 13 | 14 | let tree = conn.get_tree()?; 15 | let workspaces = i3wsr_core::get_workspaces(tree); 16 | 17 | let name = workspaces.first() 18 | .and_then(|ws| ws.name.as_ref()) 19 | .map(|name| name.to_string()) 20 | .unwrap_or_default(); 21 | 22 | assert_eq!(name, String::from("1 Gpick | XTerm")); 23 | Ok(()) 24 | } 25 | 26 | #[test] 27 | fn get_title() -> Result<(), Box> { 28 | env::set_var("DISPLAY", ":99.0"); 29 | let mut conn = swayipc::Connection::new()?; 30 | 31 | let tree = conn.get_tree()?; 32 | let mut ws_nodes: Vec = Vec::new(); 33 | let workspaces = i3wsr_core::get_workspaces(tree); 34 | for workspace in &workspaces { 35 | let nodes = workspace.nodes.iter() 36 | .chain( 37 | workspace.floating_nodes.iter().flat_map(|fnode| { 38 | if !fnode.nodes.is_empty() { 39 | fnode.nodes.iter() 40 | } else { 41 | std::slice::from_ref(fnode).iter() 42 | } 43 | }) 44 | ) 45 | .cloned() 46 | .collect::>(); 47 | ws_nodes.extend(nodes); 48 | } 49 | let config = i3wsr_core::Config::default(); 50 | let res = i3wsr_core::regex::parse_config(&config)?; 51 | let result: Result, _> = ws_nodes 52 | .iter() 53 | .map(|node| i3wsr_core::get_title(node, &config, &res)) 54 | .collect(); 55 | assert_eq!(result?, vec!["Gpick", "XTerm"]); 56 | Ok(()) 57 | } 58 | 59 | #[test] 60 | fn collect_titles() -> Result<(), Box> { 61 | env::set_var("DISPLAY", ":99.0"); 62 | let mut conn = swayipc::Connection::new()?; 63 | let tree = conn.get_tree()?; 64 | let workspaces = i3wsr_core::get_workspaces(tree); 65 | let mut result: Vec> = Vec::new(); 66 | let config = i3wsr_core::Config::default(); 67 | let res = i3wsr_core::regex::parse_config(&config)?; 68 | for workspace in workspaces { 69 | result.push(i3wsr_core::collect_titles(&workspace, &config, &res)); 70 | } 71 | let expected = vec![vec!["Gpick", "XTerm"]]; 72 | assert_eq!(result, expected); 73 | Ok(()) 74 | } 75 | -------------------------------------------------------------------------------- /tests/setup.sh: -------------------------------------------------------------------------------- 1 | export DISPLAY=:99.0 2 | Xvfb :99.0 & 3 | sleep 3 4 | i3 -c /dev/null & 5 | sleep 3 6 | gpick & 7 | sleep 3 8 | xterm & 9 | sleep 3 10 | DISPLAY=:99.0 i3-msg [class="XTerm"] floating enable 11 | --------------------------------------------------------------------------------