From 5b77a6d8f19500eee21a497ce84db538ded05c00 Mon Sep 17 00:00:00 2001 From: cashmere Date: Wed, 29 Jul 2026 09:10:42 +0200 Subject: add collapsible SNI system tray, rename project to i3bar-mango Tray: the bar acts as org.kde.StatusNotifierWatcher plus host (zbus), rendering items on the right edge with icon/name; [tray] config toggles expanded, show_icon, show_name, show_description. Clicks map to Activate/SecondaryActivate/ContextMenu. Rename: package, binary, config dir, layer-shell namespace and DBus names are now i3bar-mango; mango is the default feature and the first WM provider tried. --- Cargo.lock | 719 +++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 12 +- README.md | 37 +-- nix/package.nix | 2 +- src/bar.rs | 192 ++++++++++++- src/button_manager.rs | 8 +- src/config.rs | 28 +- src/icon.rs | 28 ++ src/main.rs | 1 + src/shared_state.rs | 4 + src/state.rs | 37 +++ src/tray.rs | 615 +++++++++++++++++++++++++++++++++++++++++ src/wm_info_provider.rs | 11 +- 13 files changed, 1649 insertions(+), 45 deletions(-) create mode 100644 src/tray.rs diff --git a/Cargo.lock b/Cargo.lock index 2156e01..6a5789e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,143 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.4.0" @@ -26,6 +163,25 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "cairo-rs" version = "0.20.10" @@ -59,6 +215,12 @@ dependencies = [ "target-lexicon", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.5.38" @@ -88,7 +250,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -97,12 +259,90 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "futures-channel" version = "0.3.31" @@ -135,6 +375,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -143,7 +396,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -166,6 +419,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "gio" version = "0.20.10" @@ -193,7 +457,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -227,7 +491,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -264,15 +528,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "i3bar-river" +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "i3bar-mango" version = "1.1.0" dependencies = [ "anyhow", "cairo-rs", "clap", + "futures-channel", + "futures-lite", "libc", "memchr", "pangocairo", + "parking_lot", "serde", "serde_json", "signal-hook", @@ -280,6 +559,7 @@ dependencies = [ "wayrs-client", "wayrs-protocols", "wayrs-utils", + "zbus", ] [[package]] @@ -298,11 +578,37 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" -version = "0.2.172" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] [[package]] name = "memchr" @@ -319,6 +625,31 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.20.10" @@ -369,6 +700,35 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -381,12 +741,37 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -423,30 +808,80 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" -version = "1.0.219" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -461,6 +896,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "0.6.8" @@ -524,6 +970,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "system-deps" version = "7.0.3" @@ -543,6 +1000,19 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "toml" version = "0.8.22" @@ -574,7 +1044,49 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "winnow", + "winnow 0.7.10", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", ] [[package]] @@ -583,12 +1095,68 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "version-compare" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.101", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wayrs-client" version = "1.3.0" @@ -651,6 +1219,12 @@ dependencies = [ "xcursor", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.59.0" @@ -660,6 +1234,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -733,8 +1316,118 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "xcursor" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ef33da6b1660b4ddbfb3aef0ade110c8b8a781a3b6382fa5f2b5b040fd55f61" + +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.101", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.101", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.101", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index 36d4d93..6546843 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "i3bar-river" -description = "A port of i3bar for wlroots-based compositors " +name = "i3bar-mango" +description = "A port of i3bar for mangowm and other wlroots-based compositors" repository = "https://github.com/MaxVerevkin/i3bar-river" readme = "README.md" version = "1.1.0" @@ -22,13 +22,17 @@ wayrs-protocols = { version = "0.14", features = ["wlr-layer-shell-unstable-v1", wayrs-utils = { version = "0.17", features = ["cursor", "shm_alloc", "seats"] } clap = { version = "4.3", default-features = false, features = ["derive", "std", "help", "usage"] } libc = "0.2" +zbus = "5" +futures-lite = "2" +futures-channel = "0.3" +parking_lot = "0.12.5" [features] -default = ["river", "mango"] +default = ["mango"] +mango = [] river = [] niri = [] hyprland = [] -mango = [] [profile.release] lto = "fat" diff --git a/README.md b/README.md index c507402..909ac19 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# i3bar-river +# i3bar-mango -This is a port of `i3bar` for wlroots-based window managers. Tags/workspaces are implemented for [river](https://codeberg.org/river/river), [hyprland](https://github.com/hyprwm/Hyprland) and [niri](https://github.com/YaLTeR/niri). +This is a port of `i3bar` for [mangowm](https://github.com/mangowm/mango), with support for other wlroots-based window managers ([river](https://codeberg.org/river/river), [hyprland](https://github.com/hyprwm/Hyprland) and [niri](https://github.com/YaLTeR/niri)) available as compile-time features. ## i3bar compatibility @@ -9,36 +9,31 @@ I've tested [`i3status-rs`](https://github.com/greshake/i3status-rust), [`bumble A list of things that are missing (for now): - `border[_top|_right|_bottom|_left]` - Click events lack some info (IDK if anyone actually relies on `x`, `y`, `width`, etc.) -- Tray icons ## Features -- `river` support (obviously) +- `mango` support (obviously) - `short_text` switching is "progressive" (see https://github.com/i3/i3/issues/4113) - Support for rounded corners -- Show/hide with `pkill -SIGUSR1 i3bar-river` +- Show/hide with `pkill -SIGUSR1 i3bar-mango` ## Installation -[![Packaging status](https://repology.org/badge/vertical-allrepos/i3bar-river.svg)](https://repology.org/project/i3bar-river/versions) +[![Packaging status](https://repology.org/badge/vertical-allrepos/i3bar-mango.svg)](https://repology.org/project/i3bar-mango/versions) ### From Source External dependencies: `libpango1.0-dev`. ``` -cargo install --locked i3bar-river +cargo install --locked i3bar-mango ``` ## Configuration -Add this to the end of your river init script: +Add `i3bar-mango` to your mangowm autostart. -``` -riverctl spawn i3bar-river -``` - -The configuration file should be stored in `$XDG_CONFIG_HOME/i3bar-river/config.toml` or `~/.config/i3bar-river/config.toml`. +The configuration file should be stored in `$XDG_CONFIG_HOME/i3bar-mango/config.toml` or `~/.config/i3bar-mango/config.toml`. The default configuration (every parameter is optional): @@ -85,6 +80,14 @@ blend = true # whether tags/blocks colors should blend with bar's background show_mode = true start_hidden = false # whether the bar is initially in the 'hidden' state +# System tray (StatusNotifierItem) +[tray] +enabled = true # host SNI tray icons (the bar acts as the StatusNotifierWatcher) +expanded = false # start collapsed; the ▸/▾ button toggles the tray +show_icon = true # show the item icon +show_name = true # show the item title +show_description = false # append the item's tooltip description after the title + # WM-specific options [wm.river] max_tag = 9 # Show only the first nine tags @@ -106,7 +109,7 @@ to implement, for example, buttons. _Short text management and corner rounding are performed on per-logical-block basis._ -`i3bar-river` defines a logical block as a continuous series of "json blocks" with the same `name`. +`i3bar-mango` defines a logical block as a continuous series of "json blocks" with the same `name`. Also, only the last "json block" is allowed to have a non zero (or absent) `separator_block_width`, all the other "json blocks" should explicitly set it to zero. If you think this definition is not the best one, feel free to open a new github issue. @@ -121,7 +124,7 @@ Sometimes `pango` lives a gap between "powerline separators" and the blocks (see ![Native separators demo](../assets/native_demo.png?raw=true) -`i3bar-river` +`i3bar-mango` ```toml font = "JetBrainsMono Nerd Font 10" @@ -146,7 +149,7 @@ critical_fg = "#fb4934" ![Powerline separators demo](../assets/powerline_demo.png?raw=true) -`i3bar-river` +`i3bar-mango` ```toml font = "JetBrainsMono Nerd Font 10" @@ -165,7 +168,7 @@ theme = "slick" ![Rounded corners demo](../assets/rounded_corners_demo.png?raw=true) -`i3bar-river` +`i3bar-mango` ```toml font = "JetBrainsMono Nerd Font 10" diff --git a/nix/package.nix b/nix/package.nix index a9eace2..9cfa6cd 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -31,6 +31,6 @@ rustPlatform.buildRustPackage { description = cargoToml.package.description; homepage = cargoToml.package.repository; license = lib.licenses.gpl3Only; - mainProgram = "i3bar-river"; + mainProgram = "i3bar-mango"; }; } diff --git a/src/bar.rs b/src/bar.rs index 102978d..84bbecc 100644 --- a/src/bar.rs +++ b/src/bar.rs @@ -14,6 +14,7 @@ use crate::protocol::*; use crate::shared_state::SharedState; use crate::state::State; use crate::text::{self, ComputedText, RenderOptions}; +use crate::tray::TrayCommand; use crate::wm_info_provider::{FocusedWindow, Tag}; pub struct Bar { @@ -39,6 +40,14 @@ pub struct Bar { layout_name_computed: Option, mode_computed: Option, focused_window_computed: Option, + tray_btns: ButtonManager, + tray_toggle_computed: Option, +} + +#[derive(Clone)] +enum TrayBtn { + Toggle, + Item(String), } #[derive(Debug, PartialEq)] @@ -60,7 +69,7 @@ impl Bar { surface, Some(output.wl), state.shared_state.config.layer.into(), - c"i3bar-river".into(), + c"i3bar-mango".into(), layer_surface_cb, ); @@ -87,6 +96,8 @@ impl Bar { layout_name_computed: None, mode_computed: None, focused_window_computed: None, + tray_btns: ButtonManager::default(), + tray_toggle_computed: None, } } @@ -128,7 +139,7 @@ impl Bar { button: PointerBtn, seat: WlSeat, x: f64, - _y: f64, + y: f64, ) -> anyhow::Result<()> { if let Some(tag_id) = self.tags_btns.click(x) { ss.wm_info_provider @@ -136,6 +147,31 @@ impl Bar { } else if self.tags_btns.is_between(x) { ss.wm_info_provider .click_on_tag(conn, &self.output, seat, None, button); + } else if let Some(tray_btn) = self.tray_btns.click(x).cloned() { + match tray_btn { + TrayBtn::Toggle => { + ss.tray_expanded = !ss.tray_expanded; + self.tray_toggle_computed = None; + self.frame(conn, ss); + } + TrayBtn::Item(key) => { + if let Some(tray) = &ss.tray { + let cmd = match button { + PointerBtn::Left => Some(TrayCommand::Activate { key }), + PointerBtn::Middle => Some(TrayCommand::SecondaryActivate { key }), + PointerBtn::Right => Some(TrayCommand::ContextMenu { + key, + x: x as i32, + y: y as i32, + }), + _ => None, + }; + if let Some(cmd) = cmd { + tray.send(cmd); + } + } + } + } } else if let Some((name, instance)) = self.blocks_btns.click(x) { if let Some(cmd) = &mut ss.status_cmd { cmd.send_click_event(&i3bar_protocol::Event { @@ -381,6 +417,14 @@ impl Bar { } } + // Display the tray + let tray_width = if ss.config.tray.enabled { + self.render_tray(&cairo_ctx, ss, width_f, height_f) + } else { + self.tray_btns.clear(); + 0.0 + }; + // Display the blocks render_blocks( &cairo_ctx, @@ -388,7 +432,7 @@ impl Bar { ss.blocks_cache.get_computed(), &mut self.blocks_btns, offset_left, - width_f, + width_f - tray_width, height_f, ); @@ -417,6 +461,148 @@ impl Bar { self.surface.commit(conn); } + fn render_tray( + &mut self, + context: &cairo::Context, + ss: &mut SharedState, + full_width: f64, + full_height: f64, + ) -> f64 { + self.tray_btns.clear(); + if ss.tray_items.is_empty() { + return 0.0; + } + + let cfg = ss.config.tray; + let expanded = ss.tray_expanded; + let icon_size = full_height - 4.0; + let spacing = 8.0; + let bar_height = self.height; + + let toggle = self.tray_toggle_computed.get_or_insert_with(|| { + ComputedText::new( + if expanded { "\u{25BE}" } else { "\u{25B8}" }, + text::Attributes { + font: &ss.config.font, + padding_left: 8.0, + padding_right: 8.0, + min_width: None, + align: Default::default(), + markup: false, + }, + ) + }); + + struct Rendered { + key: String, + icon: Option, + text: Option, + width: f64, + } + + let mut rendered = Vec::new(); + if expanded { + for item in &ss.tray_items { + let label = match (cfg.show_name, cfg.show_description && !item.tooltip.is_empty()) { + (true, true) if item.tooltip != item.title => { + format!("{} \u{2014} {}", item.title, item.tooltip) + } + (true, _) => item.title.clone(), + (false, true) => item.tooltip.clone(), + (false, false) => String::new(), + }; + let text = if label.is_empty() { + None + } else { + Some(ComputedText::new( + &label, + text::Attributes { + font: &ss.config.font, + padding_left: 4.0, + padding_right: 4.0, + min_width: None, + align: Default::default(), + markup: false, + }, + )) + }; + let icon = if cfg.show_icon { + item.pixmap.clone().or_else(|| { + ss.icon_cache + .get_tray_icon(&item.icon_name, &item.icon_theme_path, bar_height) + .cloned() + }) + } else { + None + }; + let mut width = text.as_ref().map_or(0.0, |t| t.width); + if icon.is_some() { + width += icon_size + 4.0; + } + if width > 0.0 { + rendered.push(Rendered { + key: item.key.clone(), + icon, + text, + width, + }); + } + } + } + + let items_width: f64 = rendered.iter().map(|r| r.width + spacing).sum(); + let total_width = items_width + toggle.width; + let mut x = full_width - total_width; + + for r in rendered { + let mut item_x = x; + if let Some(icon) = &r.icon { + let scale = icon_size / (icon.width() as f64).max(icon.height() as f64); + let icon_w = icon.width() as f64 * scale; + let icon_h = icon.height() as f64 * scale; + context.save().unwrap(); + context.translate(item_x + (icon_size - icon_w) / 2.0, (full_height - icon_h) / 2.0); + context.scale(scale, scale); + context.set_source_surface(icon, 0.0, 0.0).unwrap(); + context.paint().unwrap(); + context.restore().unwrap(); + item_x += icon_size + 4.0; + } + if let Some(text) = &r.text { + text.render( + context, + RenderOptions { + x_offset: item_x, + bar_height: full_height, + fg_color: ss.config.color, + bg_color: None, + r_left: 0.0, + r_right: 0.0, + overlap: 0.0, + }, + ); + } + self.tray_btns.push(x, r.width, TrayBtn::Item(r.key)); + x += r.width + spacing; + } + + toggle.render( + context, + RenderOptions { + x_offset: x, + bar_height: full_height, + fg_color: ss.config.color, + bg_color: None, + r_left: 0.0, + r_right: 0.0, + overlap: 0.0, + }, + ); + self.tray_btns.push(x, toggle.width, TrayBtn::Toggle); + + total_width + } + pub fn show(&mut self, conn: &mut Connection, shared_state: &SharedState) { assert!(!self.mapped); diff --git a/src/button_manager.rs b/src/button_manager.rs index 13f9124..9915460 100644 --- a/src/button_manager.rs +++ b/src/button_manager.rs @@ -1,6 +1,12 @@ -#[derive(Debug, Default)] +#[derive(Debug)] pub struct ButtonManager(Vec<(f64, f64, T)>); +impl Default for ButtonManager { + fn default() -> Self { + Self(Vec::new()) + } +} + impl ButtonManager { pub fn push(&mut self, x_offset: f64, width: f64, elem: T) { self.0.push((x_offset, width, elem)); diff --git a/src/config.rs b/src/config.rs index e5c04e7..f93d7a8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -50,6 +50,8 @@ pub struct Config { pub blend: bool, pub show_mode: bool, pub start_hidden: bool, + // tray + pub tray: TrayConfig, // wm-specific pub wm: WmConfig, // overrides @@ -98,6 +100,8 @@ impl Default for Config { show_mode: true, start_hidden: false, + tray: TrayConfig::default(), + wm: WmConfig { river: RiverConfig { max_tag: 9 }, }, @@ -148,7 +152,7 @@ fn config_dir() -> Option { fn config_path() -> Option { let mut path = config_dir()?; - path.push("i3bar-river"); + path.push("i3bar-mango"); path.push("config.toml"); path.exists().then_some(path) } @@ -189,6 +193,28 @@ impl From for zwlr_layer_shell_v1::Layer { } } +#[derive(Deserialize, Debug, Clone, Copy)] +#[serde(deny_unknown_fields, default)] +pub struct TrayConfig { + pub enabled: bool, + pub expanded: bool, + pub show_icon: bool, + pub show_name: bool, + pub show_description: bool, +} + +impl Default for TrayConfig { + fn default() -> Self { + Self { + enabled: true, + expanded: false, + show_icon: true, + show_name: true, + show_description: false, + } + } +} + #[derive(Debug, Deserialize)] pub struct WmConfig { pub river: RiverConfig, diff --git a/src/icon.rs b/src/icon.rs index 4dae1a9..1173629 100644 --- a/src/icon.rs +++ b/src/icon.rs @@ -17,6 +17,34 @@ impl IconCache { .or_insert_with(|| resolve_icon(app_id, bar_height)) .as_ref() } + + /// Get a tray icon by name, preferring the given SNI IconThemePath. + pub fn get_tray_icon( + &mut self, + name: &str, + theme_path: &str, + bar_height: u32, + ) -> Option<&cairo::ImageSurface> { + if name.is_empty() { + return None; + } + self.map + .entry(format!("{theme_path}:{name}")) + .or_insert_with(|| resolve_tray_icon(name, theme_path, bar_height)) + .as_ref() + } +} + +fn resolve_tray_icon(name: &str, theme_path: &str, bar_height: u32) -> Option { + if !theme_path.is_empty() { + let path = PathBuf::from(theme_path).join(format!("{name}.png")); + if let Ok(mut file) = std::fs::File::open(path) + && let Ok(surface) = cairo::ImageSurface::create_from_png(&mut file) + { + return Some(surface); + } + } + resolve_icon(name, bar_height) } fn resolve_icon(app_id: &str, bar_height: u32) -> Option { diff --git a/src/main.rs b/src/main.rs index b43443c..a4751d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod shared_state; mod state; mod status_cmd; mod text; +mod tray; mod utils; mod wm_info_provider; diff --git a/src/shared_state.rs b/src/shared_state.rs index c484d24..3bf05e5 100644 --- a/src/shared_state.rs +++ b/src/shared_state.rs @@ -5,6 +5,7 @@ use crate::{ config::Config, icon::IconCache, status_cmd::StatusCmd, + tray::{TrayHandle, TrayItem}, wm_info_provider::{self, WmInfoProvider}, }; @@ -17,6 +18,9 @@ pub struct SharedState { pub blocks_cache: BlocksCache, pub icon_cache: IconCache, pub wm_info_provider: Box, + pub tray: Option, + pub tray_items: Vec, + pub tray_expanded: bool, } impl SharedState { diff --git a/src/state.rs b/src/state.rs index ccd0d52..2853731 100644 --- a/src/state.rs +++ b/src/state.rs @@ -5,6 +5,7 @@ use crate::protocol::*; use crate::wm_info_provider; use std::fmt::Display; +use std::io; use std::os::unix::io::{AsRawFd, RawFd}; use std::path::Path; @@ -15,6 +16,9 @@ use wayrs_utils::cursor::{CursorImage, CursorShape, CursorTheme, ThemedPointer}; use wayrs_utils::seats::{SeatHandler, Seats}; use wayrs_utils::shm_alloc::ShmAlloc; +use crate::event_loop::Action; +use crate::tray::TrayHandle; + use crate::{ bar::Bar, config::Config, i3bar_protocol::Block, pointer_btn::PointerBtn, shared_state::SharedState, status_cmd::StatusCmd, @@ -83,6 +87,29 @@ impl State { let wm_info_provider = wm_info_provider::bind(conn, &config.wm); wm_info_provider.register(event_loop); + let tray = if config.tray.enabled { + match io::pipe() { + Ok((tray_read, tray_write)) => { + let tray = TrayHandle::spawn(tray_write.into()); + let fd = tray_read.as_raw_fd(); + event_loop.register_with_fd(fd, move |ctx| { + let mut buf = [0u8; 64]; + while io::Read::read(&mut &tray_read, &mut buf).is_ok_and(|n| n == 64) {} + ctx.state.tray_updated(ctx.conn); + Ok(Action::Keep) + }); + Some(tray) + } + Err(e) => { + eprintln!("tray: failed to create wake pipe: {e}"); + None + } + } + } else { + None + }; + let tray_expanded = config.tray.expanded; + let mut this = Self { wl_compositor, layer_shell: conn.bind_singleton(1..=4).unwrap(), @@ -105,6 +132,9 @@ impl State { blocks_cache: BlocksCache::default(), icon_cache: Default::default(), wm_info_provider, + tray, + tray_items: Vec::new(), + tray_expanded, }, cursor_theme, @@ -144,6 +174,13 @@ impl State { } } + pub fn tray_updated(&mut self, conn: &mut Connection) { + if let Some(tray) = &self.shared_state.tray { + self.shared_state.tray_items = tray.items(); + } + self.draw_all(conn); + } + pub fn status_cmd_fd(&self) -> Option { self.shared_state .status_cmd diff --git a/src/tray.rs b/src/tray.rs new file mode 100644 index 0000000..69fa9e2 --- /dev/null +++ b/src/tray.rs @@ -0,0 +1,615 @@ +use std::os::fd::{AsRawFd, OwnedFd}; +use std::sync::Arc; + +use parking_lot::Mutex; +use std::time::Duration; + +use futures_channel::mpsc; +use futures_lite::stream::StreamExt; +use pangocairo::cairo; +use zbus::object_server::SignalEmitter; +use zbus::{Connection, interface, proxy}; + +#[derive(Clone)] +pub struct TrayItem { + pub key: String, + pub title: String, + pub icon_name: String, + pub icon_theme_path: String, + pub tooltip: String, + pub pixmap: Option, +} + +unsafe impl Send for TrayItem {} + +pub enum TrayCommand { + Activate { key: String }, + SecondaryActivate { key: String }, + ContextMenu { key: String, x: i32, y: i32 }, +} + +pub struct TrayHandle { + items: Arc>>, + commands: mpsc::UnboundedSender, +} + +impl TrayHandle { + pub fn spawn(wake: OwnedFd) -> TrayHandle { + let items = Arc::new(Mutex::new(Vec::new())); + let (commands, commands_rx) = mpsc::unbounded(); + let worker_items = items.clone(); + std::thread::spawn(move || worker(wake, worker_items, commands_rx)); + TrayHandle { items, commands } + } + + pub fn items(&self) -> Vec { + self.items.lock().clone() + } + + pub fn send(&self, cmd: TrayCommand) { + let _ = self.commands.unbounded_send(cmd); + } +} + +#[derive(Clone, Copy)] +enum SignalKind { + Title, + Icon, + Status, + ToolTip, + IconThemePath, +} + +enum Event { + Register { + bus_name: String, + path: String, + announced: String, + }, + Vanished(String), + Signal { + key: String, + kind: SignalKind, + }, +} + +enum Input { + Event(Event), + Command(TrayCommand), +} + +enum Method { + Activate, + SecondaryActivate, + ContextMenu, +} + +#[derive(Default)] +struct WatcherShared { + registered: Vec, + hosts: Vec, +} + +struct WatcherService { + shared: Arc>, + events: mpsc::UnboundedSender, +} + +#[interface(name = "org.kde.StatusNotifierWatcher")] +impl WatcherService { + async fn register_status_notifier_item( + &self, + service: &str, + #[zbus(header)] header: zbus::message::Header<'_>, + #[zbus(signal_emitter)] emitter: SignalEmitter<'_>, + ) -> zbus::fdo::Result<()> { + let sender = header.sender().map(|name| name.as_str()).unwrap_or(""); + let (bus_name, path) = parse_registration(service, sender); + let announced = bus_name.clone(); + { + let mut shared = self.shared.lock(); + if shared.registered.contains(&announced) { + return Ok(()); + } + shared.registered.push(announced.clone()); + } + emitter.status_notifier_item_registered(&announced).await?; + self.registered_status_notifier_items_changed(&emitter).await?; + let _ = self.events.unbounded_send(Event::Register { + bus_name, + path, + announced, + }); + Ok(()) + } + + async fn register_status_notifier_host( + &self, + service: &str, + #[zbus(signal_emitter)] emitter: SignalEmitter<'_>, + ) -> zbus::fdo::Result<()> { + { + let mut shared = self.shared.lock(); + if shared.hosts.iter().any(|host| host == service) { + return Ok(()); + } + shared.hosts.push(service.to_string()); + } + emitter.status_notifier_host_registered().await?; + self.is_status_notifier_host_registered_changed(&emitter).await?; + Ok(()) + } + + #[zbus(property)] + fn registered_status_notifier_items(&self) -> Vec { + self.shared.lock().registered.clone() + } + + #[zbus(property)] + fn is_status_notifier_host_registered(&self) -> bool { + !self.shared.lock().hosts.is_empty() + } + + #[zbus(property(emits_changed_signal = "const"))] + fn protocol_version(&self) -> i32 { + 0 + } + + #[zbus(signal)] + async fn status_notifier_item_registered( + emitter: &SignalEmitter<'_>, + service: &str, + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn status_notifier_item_unregistered( + emitter: &SignalEmitter<'_>, + service: &str, + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn status_notifier_host_registered(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; + + #[zbus(signal)] + async fn status_notifier_host_unregistered(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; +} + +#[proxy( + interface = "org.kde.StatusNotifierItem", + gen_blocking = false, + assume_defaults = false +)] +trait StatusNotifierItem { + fn activate(&self, x: i32, y: i32) -> zbus::Result<()>; + fn secondary_activate(&self, x: i32, y: i32) -> zbus::Result<()>; + fn context_menu(&self, x: i32, y: i32) -> zbus::Result<()>; + + #[zbus(property(emits_changed_signal = "false"))] + fn id(&self) -> zbus::Result; + + #[zbus(property(emits_changed_signal = "false"))] + fn title(&self) -> zbus::Result; + + #[zbus(property(emits_changed_signal = "false"))] + fn status(&self) -> zbus::Result; + + #[zbus(property(emits_changed_signal = "false"))] + fn icon_name(&self) -> zbus::Result; + + #[zbus(property(emits_changed_signal = "false"))] + fn icon_theme_path(&self) -> zbus::Result; + + #[zbus(property(emits_changed_signal = "false"))] + fn tool_tip(&self) -> zbus::Result<(String, Vec<(i32, i32, Vec)>, String, String)>; + + #[zbus(property(emits_changed_signal = "false"))] + fn icon_pixmap(&self) -> zbus::Result)>>; + + #[zbus(signal)] + fn new_title(&self); + + #[zbus(signal)] + fn new_icon(&self); + + #[zbus(signal)] + fn new_status(&self); + + #[zbus(signal)] + fn new_tool_tip(&self); + + #[zbus(signal)] + fn new_icon_theme_path(&self); +} + +struct ActiveItem { + key: String, + bus_name: String, + unique_name: String, + announced: String, + status: String, + id: String, + proxy: StatusNotifierItemProxy<'static>, + item: TrayItem, + _tasks: Vec>, +} + +fn worker( + wake: OwnedFd, + snapshot: Arc>>, + mut commands: mpsc::UnboundedReceiver, +) { + loop { + match futures_lite::future::block_on(run(&wake, &snapshot, &mut commands)) { + Ok(()) => break, + Err(err) => eprintln!("i3bar-mango: tray: {err}; retrying in 5s"), + } + std::thread::sleep(Duration::from_secs(5)); + } +} + +async fn run( + wake: &OwnedFd, + snapshot: &Arc>>, + commands: &mut mpsc::UnboundedReceiver, +) -> zbus::Result<()> { + let shared = Arc::new(Mutex::new(WatcherShared::default())); + let (events_tx, mut events_rx) = mpsc::unbounded::(); + + let conn = zbus::connection::Builder::session()? + .serve_at( + "/StatusNotifierWatcher", + WatcherService { + shared: shared.clone(), + events: events_tx.clone(), + }, + )? + .name("org.kde.StatusNotifierWatcher")? + .name(format!( + "org.kde.StatusNotifierHost-i3bar-mango-{}", + std::process::id() + ))? + .build() + .await?; + + let dbus = zbus::fdo::DBusProxy::new(&conn).await?; + + let owner_stream = dbus.receive_name_owner_changed().await?; + let tx = events_tx.clone(); + conn.executor() + .spawn( + async move { + futures_lite::pin!(owner_stream); + while let Some(signal) = owner_stream.next().await { + if let Ok(args) = signal.args() { + if args.new_owner().is_none() { + let _ = tx.unbounded_send(Event::Vanished(args.name().to_string())); + } + } + } + }, + "tray-name-owner", + ) + .detach(); + + let mut items: Vec = Vec::new(); + + loop { + let input = futures_lite::future::or( + async { events_rx.next().await.map(Input::Event) }, + async { commands.next().await.map(Input::Command) }, + ) + .await; + + match input { + None => return Ok(()), + Some(Input::Command(cmd)) => { + let (key, method, x, y) = match cmd { + TrayCommand::Activate { key } => (key, Method::Activate, 0, 0), + TrayCommand::SecondaryActivate { key } => (key, Method::SecondaryActivate, 0, 0), + TrayCommand::ContextMenu { key, x, y } => (key, Method::ContextMenu, x, y), + }; + if let Some(active) = items.iter().find(|item| item.key == key) { + let proxy = active.proxy.clone(); + conn.executor() + .spawn( + async move { + let result = match method { + Method::Activate => proxy.activate(0, 0).await, + Method::SecondaryActivate => { + proxy.secondary_activate(0, 0).await + } + Method::ContextMenu => proxy.context_menu(x, y).await, + }; + if let Err(err) = result { + eprintln!("i3bar-mango: tray: command failed: {err}"); + } + }, + "tray-command", + ) + .detach(); + } + } + Some(Input::Event(Event::Register { + bus_name, + path, + announced, + })) => { + let key = format!("{bus_name}/{path}"); + if items.iter().any(|item| item.key == key) { + continue; + } + let builder = StatusNotifierItemProxy::builder(&conn) + .destination(bus_name.clone()) + .and_then(|builder| builder.path(path.clone())); + let proxy = match builder { + Ok(builder) => match builder.build().await { + Ok(proxy) => proxy, + Err(err) => { + eprintln!("i3bar-mango: tray: failed to reach {key}: {err}"); + unregister(&conn, &shared, &announced).await; + continue; + } + }, + Err(err) => { + eprintln!("i3bar-mango: tray: invalid registration {key}: {err}"); + unregister(&conn, &shared, &announced).await; + continue; + } + }; + let id = match proxy.id().await { + Ok(id) => id, + Err(err) => { + eprintln!("i3bar-mango: tray: failed to read properties of {key}: {err}"); + unregister(&conn, &shared, &announced).await; + continue; + } + }; + let title = proxy.title().await.unwrap_or_default(); + let icon_name = proxy.icon_name().await.unwrap_or_default(); + let icon_theme_path = proxy.icon_theme_path().await.unwrap_or_default(); + let status = proxy.status().await.unwrap_or_default(); + let tooltip = proxy + .tool_tip() + .await + .map(|tooltip| strip_html(&tooltip.3)) + .unwrap_or_default(); + let pixmap = match proxy.icon_pixmap().await { + Ok(pixmaps) => best_pixmap(&pixmaps) + .and_then(|(width, height, data)| pixmap_to_surface(*width, *height, data)), + Err(_) => None, + }; + let title = if title.is_empty() { id.clone() } else { title }; + let unique_name = if bus_name.starts_with(':') { + bus_name.clone() + } else { + match zbus::names::BusName::try_from(bus_name.as_str()) { + Ok(name) => dbus + .get_name_owner(name) + .await + .map(|owner| owner.to_string()) + .unwrap_or_default(), + Err(_) => String::new(), + } + }; + let tasks = spawn_signal_tasks(&conn, &proxy, &key, &events_tx).await; + items.push(ActiveItem { + key: key.clone(), + bus_name, + unique_name, + announced, + status, + id, + proxy, + item: TrayItem { + key, + title, + icon_name, + icon_theme_path, + tooltip, + pixmap, + }, + _tasks: tasks, + }); + update_snapshot(&items, snapshot, wake); + } + Some(Input::Event(Event::Vanished(name))) => { + let removed = if let Some(pos) = items + .iter() + .position(|item| item.bus_name == name || item.unique_name == name) + { + Some(items.remove(pos).announced) + } else { + let is_host = shared.lock().hosts.iter().any(|host| *host == name); + if is_host { + let mut state = shared.lock(); + state.hosts.retain(|host| *host != name); + if let Ok(iface) = conn + .object_server() + .interface::<_, WatcherService>("/StatusNotifierWatcher") + .await + { + let _ = iface.status_notifier_host_unregistered().await; + let guard = iface.get().await; + let _ = guard + .is_status_notifier_host_registered_changed(iface.signal_emitter()) + .await; + } + None + } else if shared.lock().registered.iter().any(|n| *n == name) { + Some(name) + } else { + None + } + }; + if let Some(announced) = removed { + unregister(&conn, &shared, &announced).await; + update_snapshot(&items, snapshot, wake); + } + } + Some(Input::Event(Event::Signal { key, kind })) => { + let Some(index) = items.iter().position(|item| item.key == key) else { + continue; + }; + let active = &mut items[index]; + match kind { + SignalKind::Title => { + if let Ok(title) = active.proxy.title().await { + active.item.title = if title.is_empty() { + active.id.clone() + } else { + title + }; + } + } + SignalKind::Icon => { + if let Ok(icon_name) = active.proxy.icon_name().await { + active.item.icon_name = icon_name; + } + if let Ok(pixmaps) = active.proxy.icon_pixmap().await { + active.item.pixmap = best_pixmap(&pixmaps).and_then( + |(width, height, data)| pixmap_to_surface(*width, *height, data), + ); + } + } + SignalKind::Status => { + if let Ok(status) = active.proxy.status().await { + active.status = status; + } + } + SignalKind::ToolTip => { + if let Ok(tooltip) = active.proxy.tool_tip().await { + active.item.tooltip = strip_html(&tooltip.3); + } + } + SignalKind::IconThemePath => { + if let Ok(path) = active.proxy.icon_theme_path().await { + active.item.icon_theme_path = path; + } + } + } + update_snapshot(&items, snapshot, wake); + } + } + } +} + +async fn unregister(conn: &Connection, shared: &Arc>, announced: &str) { + { + let mut state = shared.lock(); + let Some(pos) = state.registered.iter().position(|name| name == announced) else { + return; + }; + state.registered.remove(pos); + } + if let Ok(iface) = conn + .object_server() + .interface::<_, WatcherService>("/StatusNotifierWatcher") + .await + { + let _ = iface.status_notifier_item_unregistered(announced).await; + let guard = iface.get().await; + let _ = guard + .registered_status_notifier_items_changed(iface.signal_emitter()) + .await; + } +} + +async fn spawn_signal_tasks( + conn: &Connection, + proxy: &StatusNotifierItemProxy<'static>, + key: &str, + events: &mpsc::UnboundedSender, +) -> Vec> { + let mut tasks = Vec::new(); + macro_rules! forward { + ($stream:expr, $kind:expr) => { + if let Ok(stream) = $stream.await { + let key = key.to_string(); + let events = events.clone(); + tasks.push(conn.executor().spawn( + async move { + futures_lite::pin!(stream); + while stream.next().await.is_some() { + let _ = events.unbounded_send(Event::Signal { + key: key.clone(), + kind: $kind, + }); + } + }, + "tray-signal", + )); + } + }; + } + forward!(proxy.receive_new_title(), SignalKind::Title); + forward!(proxy.receive_new_icon(), SignalKind::Icon); + forward!(proxy.receive_new_status(), SignalKind::Status); + forward!(proxy.receive_new_tool_tip(), SignalKind::ToolTip); + forward!(proxy.receive_new_icon_theme_path(), SignalKind::IconThemePath); + tasks +} + +fn update_snapshot(items: &[ActiveItem], snapshot: &Arc>>, wake: &OwnedFd) { + let visible: Vec = items + .iter() + .filter(|item| item.status != "Passive") + .map(|item| item.item.clone()) + .collect(); + *snapshot.lock() = visible; + let result = unsafe { libc::write(wake.as_raw_fd(), [1u8].as_ptr().cast(), 1) }; + if result < 0 { + eprintln!( + "i3bar-mango: tray: wake pipe write failed: {}", + std::io::Error::last_os_error() + ); + } +} + +fn parse_registration(arg: &str, sender: &str) -> (String, String) { + if arg.starts_with('/') { + (sender.to_string(), arg.to_string()) + } else if let Some((name, path)) = arg.split_once('/') { + (name.to_string(), format!("/{path}")) + } else { + (arg.to_string(), "/StatusNotifierItem".to_string()) + } +} + +fn best_pixmap<'a>(pixmaps: &'a [(i32, i32, Vec)]) -> Option<&'a (i32, i32, Vec)> { + pixmaps + .iter() + .filter(|(width, height, data)| { + *width > 0 && *height > 0 && data.len() >= (*width * *height * 4) as usize + }) + .min_by_key(|(width, height, _)| { + let size = (*width).max(*height); + if size >= 24 { size } else { i32::MAX - size } + }) +} + +fn pixmap_to_surface(width: i32, height: i32, data: &[u8]) -> Option { + let stride = width * 4; + let mut buffer = vec![0u8; (height * stride) as usize]; + for (src, dst) in data.chunks_exact(4).zip(buffer.chunks_exact_mut(4)) { + let a = u32::from(src[0]); + let r = (u32::from(src[1]) * a + 127) / 255; + let g = (u32::from(src[2]) * a + 127) / 255; + let b = (u32::from(src[3]) * a + 127) / 255; + dst.copy_from_slice(&((a << 24) | (r << 16) | (g << 8) | b).to_ne_bytes()); + } + cairo::ImageSurface::create_for_data(buffer, cairo::Format::ARgb32, width, height, stride).ok() +} + +fn strip_html(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut in_tag = false; + for ch in text.chars() { + match ch { + '<' => in_tag = true, + '>' if in_tag => in_tag = false, + _ if !in_tag => out.push(ch), + _ => {} + } + } + out.trim().to_string() +} diff --git a/src/wm_info_provider.rs b/src/wm_info_provider.rs index 67e5f83..f46c4e0 100644 --- a/src/wm_info_provider.rs +++ b/src/wm_info_provider.rs @@ -62,7 +62,13 @@ pub trait WmInfoProvider: Any { } } +#[cfg_attr(not(feature = "river"), allow(unused_variables))] pub fn bind(conn: &mut Connection, config: &WmConfig) -> Box { + #[cfg(feature = "mango")] + if let Some(mango) = MangoInfoProvider::new() { + return Box::new(mango); + } + #[cfg(feature = "river")] if let Some(river) = RiverInfoProvider::bind(conn, config) { return Box::new(river); @@ -78,11 +84,6 @@ pub fn bind(conn: &mut Connection, config: &WmConfig) -> Box