From 1806c6f912475578fa89953a1175b1c146fb9257 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:23:31 +0800 Subject: [PATCH 1/9] Bind each bar to its output palette and finish the island chrome One layer-shell window per Hyprland output now loads that output's bread-theme palette. Notifications, history, and OSD follow the monitor they appear on. Pin bread-theme to v0.7.4. --- Cargo.lock | 187 +++-- Cargo.toml | 2 +- src/bar/clock.rs | 18 +- src/bar/control.rs | 1 + src/bar/stats.rs | 49 +- src/bar/wifi.rs | 111 ++- src/bar/workspaces.rs | 315 +++++++- src/main.rs | 1354 ++++++++++++++++++++++------------ src/notifications/history.rs | 10 +- src/notifications/popup.rs | 6 +- src/osd.rs | 7 +- src/screenshot.rs | 51 +- src/theme.rs | 286 +++++-- src/widgets/render.rs | 9 +- 14 files changed, 1652 insertions(+), 754 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a1122a..3bc508e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,9 +123,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -185,8 +185,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" dependencies = [ "dirs 5.0.1", "gtk4", @@ -265,7 +265,7 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -286,9 +286,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.4" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -296,9 +296,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -564,9 +564,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -579,9 +579,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -589,15 +589,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -606,9 +606,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -625,32 +625,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -685,7 +685,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -718,7 +718,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -771,7 +771,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", "windows-sys 0.61.2", ] @@ -835,7 +835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -846,7 +846,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -867,7 +867,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -898,7 +898,7 @@ dependencies = [ "graphene-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -924,9 +924,9 @@ dependencies = [ [[package]] name = "gtk4-layer-shell" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" dependencies = [ "bitflags", "gdk4", @@ -939,15 +939,15 @@ dependencies = [ [[package]] name = "gtk4-layer-shell-sys" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" dependencies = [ "gdk4-sys", "glib-sys", "gtk4-sys", "libc", - "system-deps", + "system-deps 8.0.0", ] [[package]] @@ -978,7 +978,7 @@ dependencies = [ "gsk4-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1058,9 +1058,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -1093,9 +1093,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -1214,7 +1214,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1243,9 +1243,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "png" @@ -1321,7 +1321,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1624,6 +1624,19 @@ dependencies = [ "version-compare", ] +[[package]] +name = "system-deps" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83779a5c956bcb6ba627a4ecf0a9d7625db47d7537e0892d97f712ac995648a3" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 1.1.4+spec-1.1.0", + "version-compare", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -1654,11 +1667,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -1674,9 +1687,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -1728,13 +1741,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1919,9 +1932,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -1943,9 +1956,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -1956,9 +1969,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1966,9 +1979,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -1979,9 +1992,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -2087,9 +2100,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xmlwriter" @@ -2099,9 +2112,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "zbus" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-recursion", @@ -2129,14 +2142,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zbus_names", "zvariant", "zvariant_utils", @@ -2153,6 +2166,15 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zmij" version = "1.0.23" @@ -2161,40 +2183,41 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" dependencies = [ "endi", "enumflags2", "serde", "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.5.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.119", + "syn 3.0.3", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index 1648851..1e24f17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } # Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs diff --git a/src/bar/clock.rs b/src/bar/clock.rs index 4501fde..45e25e6 100644 --- a/src/bar/clock.rs +++ b/src/bar/clock.rs @@ -1,11 +1,21 @@ use crate::{App, AppInput}; use relm4::ComponentSender; +pub fn now() -> gtk4::glib::DateTime { + gtk4::glib::DateTime::now_local().expect("local time") +} + +pub fn time() -> String { + let dt = now(); + format!("{:02}:{:02}", dt.hour(), dt.minute()) +} + +pub fn date() -> String { + now().format("%a %d/%m").expect("date format").to_string() +} + pub fn current() -> String { - let dt = gtk4::glib::DateTime::now_local().expect("local time"); - let date = dt.format("%a %d/%m").expect("date format"); - let time = format!("{:02}:{:02}", dt.hour(), dt.minute()); - format!("{} {}", date, time) + format!("{} {}", date(), time()) } pub fn spawn_ticker(sender: ComponentSender) { diff --git a/src/bar/control.rs b/src/bar/control.rs index ed99568..f480057 100644 --- a/src/bar/control.rs +++ b/src/bar/control.rs @@ -121,6 +121,7 @@ pub fn spawn_set_brightness(v: f64) { }); } +#[allow(dead_code)] pub fn spawn_set_sink(name: String) { relm4::spawn(async move { let _ = tokio::process::Command::new("pactl") diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 764d758..892f792 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -25,6 +25,14 @@ pub const WIFI_MEDIUM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), " pub const WIFI_WEAK: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Weak.svg")); pub const WIFI_OFF: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Disconnect.svg")); +/// Adwaita symbolic names — these are drawn for 16px status bars, not our +/// hand-cropped Lucide arcs. +pub const WIFI_ICON_EXCELLENT: &str = "network-wireless-signal-excellent-symbolic"; +pub const WIFI_ICON_GOOD: &str = "network-wireless-signal-good-symbolic"; +pub const WIFI_ICON_OK: &str = "network-wireless-signal-ok-symbolic"; +pub const WIFI_ICON_WEAK: &str = "network-wireless-signal-weak-symbolic"; +pub const WIFI_ICON_OFF: &str = "network-wireless-offline-symbolic"; + pub const BAT_HIGH: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 3 Bars.svg")); pub const BAT_MID: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 2 Bars.svg")); pub const BAT_LOW: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 1 Bar.svg")); @@ -65,6 +73,7 @@ pub struct Stats { pub gpu_temp: Option, pub net_rx_kbs: f32, pub net_tx_kbs: f32, + pub volume_pct: u8, } struct CpuSnapshot { @@ -76,7 +85,7 @@ static PREV_CPU: OnceLock> = OnceLock::new(); static BAT_PATH: OnceLock> = OnceLock::new(); static AC_PATH: OnceLock> = OnceLock::new(); static WIFI_CACHE: LazyLock> = - LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_OFF))); + LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_ICON_OFF))); static WIFI_TICK: AtomicU8 = AtomicU8::new(0); fn read_cpu() -> f32 { @@ -271,7 +280,7 @@ fn wifi_iface() -> Option<&'static str> { async fn read_wifi() -> (String, &'static str) { let Some(iface) = wifi_iface() else { - return ("—".into(), WIFI_OFF); + return ("—".into(), WIFI_ICON_OFF); }; let link_out = tokio::process::Command::new("iw") @@ -281,7 +290,7 @@ async fn read_wifi() -> (String, &'static str) { .ok(); let link_stdout = match link_out { Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(), - _ => return ("—".into(), WIFI_OFF), + _ => return ("—".into(), WIFI_ICON_OFF), }; let mut ssid = None; @@ -296,13 +305,14 @@ async fn read_wifi() -> (String, &'static str) { } let Some(ssid) = ssid else { - return ("—".into(), WIFI_OFF); + return ("—".into(), WIFI_ICON_OFF); }; let icon = match rssi { - Some(r) if r >= -55 => WIFI_STRONG, - Some(r) if r >= -70 => WIFI_MEDIUM, - _ => WIFI_WEAK, + Some(r) if r >= -55 => WIFI_ICON_EXCELLENT, + Some(r) if r >= -70 => WIFI_ICON_GOOD, + Some(r) if r >= -80 => WIFI_ICON_OK, + _ => WIFI_ICON_WEAK, }; (ssid, icon) @@ -413,7 +423,8 @@ pub async fn poll() -> Stats { let power_watts = read_power(); let power = power_watts.map_or_else(|| "—W".into(), |w| format!("{w:.1}W")); let pct = read_battery(); - let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}%")); + // Demo bar prints the bare number ("83"), not "83%". + let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}")); let bat_icon = pct.map_or(BAT_MID, bat_level_icon); let ac_connected = read_ac(); // BT and WiFi both refresh every 8 cycles (~16 s); cache in between. @@ -442,6 +453,7 @@ pub async fn poll() -> Stats { let gpu_usage = read_gpu_usage(); let gpu_temp = read_gpu_temp(); let (net_rx_kbs, net_tx_kbs) = read_net_throughput(); + let volume_pct = read_volume_pct(); Stats { cpu: format!("{cpu:.0}%"), cpu_pct: cpu, @@ -465,9 +477,30 @@ pub async fn poll() -> Stats { gpu_temp, net_rx_kbs, net_tx_kbs, + volume_pct, } } +/// `wpctl get-volume` prints `Volume: 0.44 [MUTED]`. Scale to a 0–150 percent +/// for the bar chip. Missing pipewire / wpctl degrades to 0 rather than +/// blocking the rest of the poll. +fn read_volume_pct() -> u8 { + let out = std::process::Command::new("wpctl") + .args(["get-volume", "@DEFAULT_AUDIO_SINK@"]) + .output() + .ok(); + let Some(o) = out.filter(|o| o.status.success()) else { + return 0; + }; + String::from_utf8_lossy(&o.stdout) + .trim() + .strip_prefix("Volume:") + .and_then(|s| s.split_whitespace().next()) + .and_then(|s| s.parse::().ok()) + .map(|v| (v * 100.0).round().clamp(0.0, 150.0) as u8) + .unwrap_or(0) +} + pub fn spawn_poller(sender: ComponentSender) { relm4::spawn(async move { loop { diff --git a/src/bar/wifi.rs b/src/bar/wifi.rs index 6531ece..cf5e272 100644 --- a/src/bar/wifi.rs +++ b/src/bar/wifi.rs @@ -24,6 +24,8 @@ pub struct ScanEntry { pub struct WifiPopoverData { pub profiles: Vec<(String, bool)>, // (name, is_active) pub scan: Vec, + /// False while nmcli is still listing APs — profiles must still be usable. + pub scan_ready: bool, } async fn fetch_status() -> Option { @@ -73,31 +75,57 @@ async fn fetch_profile_list() -> Vec<(String, bool)> { .collect() } +async fn saved_ssids() -> std::collections::HashSet { + let out = tokio::process::Command::new("nmcli") + .args(["-t", "-f", "NAME,TYPE", "connection", "show"]) + .output() + .await; + let Ok(o) = out else { + return std::collections::HashSet::new(); + }; + String::from_utf8_lossy(&o.stdout) + .lines() + .filter_map(|line| { + let (name, ty) = line.rsplit_once(':')?; + if ty == "802-11-wireless" || ty == "wifi" { + Some(name.to_string()) + } else { + None + } + }) + .collect() +} + +/// Cached AP list (no rescan). Fast enough to paint next to profiles. async fn fetch_scan() -> Vec { - let Ok(Ok(out)) = tokio::time::timeout( - Duration::from_secs(10), - tokio::process::Command::new("breadcrumbs") - .args(["scan-list", "--json"]) + let out = tokio::time::timeout( + Duration::from_secs(4), + tokio::process::Command::new("nmcli") + .args(["-t", "-f", "SSID,SIGNAL,IN-USE", "device", "wifi", "list"]) .output(), ) - .await - else { + .await; + let Ok(Ok(o)) = out else { return vec![]; }; - let arr: Vec = - serde_json::from_slice(&out.stdout).unwrap_or_default(); - arr.into_iter() - .filter_map(|v| { - let ssid = v["ssid"].as_str()?.to_string(); - if ssid.is_empty() { + let saved = saved_ssids().await; + let mut seen = std::collections::HashSet::new(); + String::from_utf8_lossy(&o.stdout) + .lines() + .filter_map(|line| { + let mut parts = line.rsplitn(3, ':'); + let _in_use = parts.next()?; + let signal = parts.next()?.parse::().ok().unwrap_or(0); + let ssid = parts.next()?.replace("\\:", ":"); + if ssid.is_empty() || ssid == "--" || !seen.insert(ssid.clone()) { return None; } - let signal = v["signal"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - let saved = v["saved"].as_bool().unwrap_or(false); - Some(ScanEntry { ssid, signal, saved }) + let saved = saved.contains(&ssid); + Some(ScanEntry { + ssid, + signal, + saved, + }) }) .collect() } @@ -114,11 +142,32 @@ pub fn spawn_status_poller(sender: ComponentSender) { }); } -/// Called when the popover opens — loads profiles + scan in parallel. +/// Profiles first (so you can switch Home/Away immediately), then the +/// cached AP list. A background rescan refreshes the list if it finds more. pub fn spawn_popover_load(sender: ComponentSender) { relm4::spawn(async move { - let (profiles, scan) = tokio::join!(fetch_profile_list(), fetch_scan()); - sender.input(AppInput::WifiPopoverData(WifiPopoverData { profiles, scan })); + let profiles = fetch_profile_list().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles: profiles.clone(), + scan: vec![], + scan_ready: false, + })); + let scan = fetch_scan().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles: profiles.clone(), + scan: scan.clone(), + scan_ready: true, + })); + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "rescan"]) + .output() + .await; + let scan = fetch_scan().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles, + scan, + scan_ready: true, + })); }); } @@ -132,29 +181,27 @@ pub fn spawn_profile_set(name: String) { }); } -/// Fire-and-forget: connect to a specific saved SSID via `breadcrumbs join`. +/// Fire-and-forget: connect to a known SSID via NetworkManager. pub fn spawn_join(ssid: String) { relm4::spawn(async move { - let _ = tokio::process::Command::new("breadcrumbs") - .args(["join", &ssid]) + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "connect", &ssid]) .output() .await; }); } -/// Fire-and-forget: save a new network with its password, then join it. +/// Save in breadcrumbs (if the CLI still accepts `add`) and connect with nmcli. pub fn spawn_add_and_join(ssid: String, password: String) { relm4::spawn(async move { - let added = tokio::process::Command::new("breadcrumbs") + let _ = tokio::process::Command::new("breadcrumbs") .args(["add", &ssid, &password]) .output() .await; - if matches!(added, Ok(o) if o.status.success()) { - let _ = tokio::process::Command::new("breadcrumbs") - .args(["join", &ssid]) - .output() - .await; - } + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "connect", &ssid, "password", &password]) + .output() + .await; }); } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 67942f8..1181f02 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -1,7 +1,12 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Instant; + use futures_lite::StreamExt; +use gtk4::glib::ControlFlow; use gtk4::prelude::*; use hyprland::{ - data::{Workspace, Workspaces}, + data::{Monitors, Workspaces}, event_listener::{Event, EventStream}, prelude::*, shared::WorkspaceId, @@ -10,16 +15,62 @@ use relm4::ComponentSender; use crate::AppInput; -/// Fetches the current workspace list + active workspace and pushes both to -/// the app — used both for the initial state and to re-sync after the event -/// stream reconnects (state may have changed while we were disconnected). +/// Stock Hyprland accepts `hyprctl dispatch workspace N`. Lua-config +/// Hyprland (BOS) rewrites that as `hl.dispatch(workspace N)`, which is +/// a syntax error — the working form is `hl.dsp.focus({workspace=N})`. +async fn switch_workspace(id: hyprland::shared::WorkspaceId) { + let arg = id.to_string(); + let stock = tokio::process::Command::new("hyprctl") + .args(["dispatch", "workspace", &arg]) + .output() + .await; + if let Ok(o) = &stock { + let err = String::from_utf8_lossy(&o.stderr); + let out = String::from_utf8_lossy(&o.stdout); + if o.status.success() && !err.contains("hl.dispatch") && !out.contains("hl.dispatch") { + return; + } + } + let expr = format!("hl.dispatch(hl.dsp.focus({{workspace={arg}}}))"); + let lua = tokio::process::Command::new("hyprctl") + .args(["eval", &expr]) + .output() + .await; + match lua { + Ok(o) if o.status.success() => {} + Ok(o) => eprintln!( + "breadbar: workspace {arg}: {}", + String::from_utf8_lossy(&o.stderr) + ), + Err(e) => eprintln!("breadbar: workspace {arg}: {e}"), + } +} + +/// Stretch to the old→new span, then snap onto the destination — CSS +/// transitions cannot widen a pill across two buttons, so the trail's +/// Fixed allocation is interpolated on the frame clock instead. +const STRETCH_MS: f64 = 220.0; +const SNAP_MS: f64 = 380.0; + +/// Full workspace + per-monitor active snapshot. Each bar filters this to +/// its own output so a second display does not inherit the laptop's set. async fn sync_state(sender: &ComponentSender) { - if let Ok(ws) = Workspaces::get_async().await { - sender.input(AppInput::WorkspaceList(ws.to_vec())); - } - if let Ok(active) = Workspace::get_active_async().await { - sender.input(AppInput::ActiveWorkspace(active.id)); + let workspaces = Workspaces::get_async() + .await + .map(|w| w.to_vec()) + .unwrap_or_default(); + let mut actives = std::collections::HashMap::new(); + if let Ok(mons) = Monitors::get_async().await { + for m in mons { + if !m.disabled { + actives.insert(m.name, m.active_workspace.id); + } + } } + sender.input(AppInput::WorkspaceSync { + workspaces, + actives, + }); } pub fn spawn_watcher(sender: ComponentSender) { @@ -41,13 +92,21 @@ pub fn spawn_watcher(sender: ComponentSender) { while let Some(Ok(event)) = stream.next().await { backoff = std::time::Duration::from_millis(500); match event { - Event::WorkspaceChanged(data) => { - sender.input(AppInput::ActiveWorkspace(data.id)); + Event::WorkspaceChanged(_) + | Event::WorkspaceAdded(_) + | Event::WorkspaceDeleted(_) => { + sync_state(&sender).await; } - Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => { - if let Ok(ws) = Workspaces::get_async().await { - sender.input(AppInput::WorkspaceList(ws.to_vec())); - } + Event::MonitorAdded(data) => { + sender.input(AppInput::MonitorAdded(data.name)); + sync_state(&sender).await; + } + Event::MonitorRemoved(name) => { + sender.input(AppInput::MonitorRemoved(name)); + sync_state(&sender).await; + } + Event::ActiveWindowChanged(_) => { + sender.input(AppInput::DismissPanels); } _ => {} } @@ -65,17 +124,233 @@ pub fn spawn_watcher(sender: ComponentSender) { }); } -pub fn make_button(id: WorkspaceId, name: &str, active: WorkspaceId) -> gtk4::Button { +pub fn make_button( + id: WorkspaceId, + name: &str, + active: WorkspaceId, + occupied: bool, +) -> gtk4::Button { let btn = gtk4::Button::with_label(name); btn.add_css_class("workspace-btn"); + if occupied { + btn.add_css_class("occupied"); + } if id == active { btn.add_css_class("active"); } + btn.set_valign(gtk4::Align::Center); + btn.set_vexpand(false); + btn.set_size_request(-1, crate::CHIP_HEIGHT); btn.connect_clicked(move |_| { - use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial}; - let _ = Dispatch::call(DispatchType::Workspace(WorkspaceIdentifierWithSpecial::Id( - id, - ))); + relm4::spawn(async move { + switch_workspace(id).await; + }); }); btn } + +#[derive(Clone, Copy)] +struct Geom { + x: f64, + y: f64, + w: f64, + h: f64, +} + +struct TrailInner { + tick: Option, + geom: Geom, +} + +/// Overlay + Fixed pill sitting *behind* the workspace buttons. The +/// Overlay's measured size comes from the button row; the pill is the +/// main child so it paints underneath and never steals clicks. +pub struct WorkspaceTrail { + pub overlay: gtk4::Overlay, + pub buttons: gtk4::Box, + host: gtk4::Fixed, + pill: gtk4::Box, + inner: Rc>, +} + +impl WorkspaceTrail { + pub fn new() -> Self { + let overlay = gtk4::Overlay::new(); + overlay.add_css_class("workspace-overlay"); + overlay.set_valign(gtk4::Align::Center); + overlay.set_vexpand(false); + + let host = gtk4::Fixed::new(); + host.set_can_target(false); + + let pill = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + pill.add_css_class("workspace-trail"); + pill.set_can_target(false); + pill.set_visible(false); + host.put(&pill, 0.0, 0.0); + + let buttons = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); + buttons.set_halign(gtk4::Align::Fill); + buttons.set_valign(gtk4::Align::Center); + buttons.set_vexpand(false); + + overlay.set_child(Some(&host)); + overlay.add_overlay(&buttons); + overlay.set_measure_overlay(&buttons, true); + + let inner = Rc::new(RefCell::new(TrailInner { + tick: None, + geom: Geom { + x: 0.0, + y: 0.0, + w: 0.0, + h: 0.0, + }, + })); + + Self { + overlay, + buttons, + host, + pill, + inner, + } + } + + pub fn cancel(&self) { + if let Some(id) = self.inner.borrow_mut().tick.take() { + id.remove(); + } + } + + pub fn clear(&self) { + self.cancel(); + self.pill.set_visible(false); + self.inner.borrow_mut().geom.w = 0.0; + } + + pub fn place(&self, btn: >k4::Button) { + self.cancel(); + if let Some(g) = button_geom(btn, &self.overlay) { + self.apply(&g); + return; + } + // First map: the button exists but has no allocation yet. + let pill = self.pill.clone(); + let host = self.host.clone(); + let btn = btn.clone(); + let inner = self.inner.clone(); + let id = self.overlay.add_tick_callback(move |ov, _| { + let Some(g) = button_geom(&btn, ov) else { + return ControlFlow::Continue; + }; + apply_geom(&host, &pill, &inner, &g); + inner.borrow_mut().tick = None; + ControlFlow::Break + }); + self.inner.borrow_mut().tick = Some(id); + } + + pub fn stretch(&self, from: Option<>k4::Button>, to: >k4::Button) { + let from_g = self.from_geom(from); + if let Some(to_g) = button_geom(to, &self.overlay) { + self.stretch_geom(from_g, to_g); + return; + } + // Destination just appeared (empty workspace becoming active) and + // has no allocation yet. Wait one layout pass, then stretch from + // the last pill — don't snap via place(). + self.cancel(); + let overlay = self.overlay.clone(); + let pill = self.pill.clone(); + let host = self.host.clone(); + let inner = self.inner.clone(); + let btn = to.clone(); + let id = self.overlay.add_tick_callback(move |ov, _| { + let Some(to_g) = button_geom(&btn, ov) else { + return ControlFlow::Continue; + }; + inner.borrow_mut().tick = None; + stretch_geom_on(&overlay, &host, &pill, &inner, from_g, to_g); + ControlFlow::Break + }); + self.inner.borrow_mut().tick = Some(id); + } + + fn from_geom(&self, from: Option<>k4::Button>) -> Option { + let st = self.inner.borrow(); + if self.pill.is_visible() && st.geom.w > 0.5 { + return Some(st.geom); + } + drop(st); + from.and_then(|b| button_geom(b, &self.overlay)) + } + + fn stretch_geom(&self, from_g: Option, to_g: Geom) { + stretch_geom_on( + &self.overlay, + &self.host, + &self.pill, + &self.inner, + from_g, + to_g, + ); + } + + fn apply(&self, g: &Geom) { + apply_geom(&self.host, &self.pill, &self.inner, g); + } +} + +fn button_geom(btn: >k4::Button, overlay: >k4::Overlay) -> Option { + let r = btn.compute_bounds(overlay)?; + let w = f64::from(r.width()); + let h = f64::from(r.height()); + if w < 1.0 || h < 1.0 { + return None; + } + Some(Geom { + x: f64::from(r.x()), + y: f64::from(r.y()), + w, + h, + }) +} + +fn apply_geom(host: >k4::Fixed, pill: >k4::Box, inner: &Rc>, g: &Geom) { + inner.borrow_mut().geom = Geom { + x: g.x, + y: g.y, + w: g.w, + h: g.h, + }; + pill.set_size_request(g.w.max(1.0).round() as i32, g.h.max(1.0).round() as i32); + host.move_(pill, g.x, g.y); + pill.set_visible(true); +} + +fn lerp(a: f64, b: f64, t: f64) -> f64 { + a + (b - a) * t +} + +fn lerp_geom(a: &Geom, b: &Geom, t: f64) -> Geom { + Geom { + x: lerp(a.x, b.x, t), + y: lerp(a.y, b.y, t), + w: lerp(a.w, b.w, t), + h: lerp(a.h, b.h, t), + } +} + +fn ease(t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Approximates the demo's cubic-bezier(.22, 1.4, .36, 1) snap. +fn ease_overshoot(t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + let c = 1.4; + let t1 = t - 1.0; + 1.0 + t1 * t1 * ((c + 1.0) * t1 + c) +} diff --git a/src/main.rs b/src/main.rs index 0ce99a2..ad09dc3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,39 +7,56 @@ macro_rules! asset { mod bar; mod notifications; mod osd; +mod panel; mod screenshot; mod theme; mod widgets; -/// Thresholds above which the bar's CPU/RAM/power-draw readouts appear at -/// all — see `AppInput::StatsUpdate`. Below these, the bar stays quiet. -const CPU_ATTENTION_THRESHOLD: f32 = 70.0; -const MEM_ATTENTION_THRESHOLD: f32 = 80.0; -const POWER_ATTENTION_THRESHOLD: f32 = 30.0; +/// Floating island bar: widget height, layer-shell inset, reserved zone. +/// Exclusive zone is height + top margin so tiled clients sit below the gap. +pub const BAR_HEIGHT: i32 = 44; +pub const BAR_MARGIN_TOP: i32 = 12; +pub const BAR_MARGIN_SIDES: i32 = 16; +/// Chip / workspace-pill height. Must stay smaller than `BAR_HEIGHT` so +/// hover/active highlights hug the glyphs instead of filling the island. +pub const CHIP_HEIGHT: i32 = 32; +pub const ICON_PX: i32 = 24; use gtk4::prelude::*; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use hyprland::data::Workspace; use hyprland::shared::WorkspaceId; use relm4::prelude::*; +use relm4::{Component, ComponentController, Controller}; use std::cell::Cell; use std::rc::Rc; +pub struct BarInit { + pub screenshot: Option, + pub monitor: Option, + pub primary: bool, +} + pub struct App { + monitor: String, + primary: bool, + satellites: Vec<(String, Controller)>, + // ── Workspaces ──────────────────────────────────────────────────────── workspaces: Vec, active_ws: WorkspaceId, workspace_box: gtk4::Box, + workspace_trail: bar::workspaces::WorkspaceTrail, button_map: std::collections::HashMap, // ── Clock ───────────────────────────────────────────────────────────── time_str: String, - clock_lbl: gtk4::Label, + clock_digits: Vec, + date_lbl: gtk4::Label, // ── Stats bar ───────────────────────────────────────────────────────── - // The system-stats trio (CPU/RAM/power draw) only shows up when a value - // crosses a "you probably want to know about this" threshold — otherwise - // the bar stays quiet. See AppInput::StatsUpdate. + // Island chrome matches the Liquid Motion demo: volume / wifi / battery + // / hamburger. CPU/RAM/power live in the control panel, not on the bar. system_stats_box: gtk4::Box, system_sep: gtk4::Separator, cpu_pair: gtk4::Box, @@ -48,6 +65,7 @@ pub struct App { cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, + vol_lbl: gtk4::Label, bat_lbl: gtk4::Label, bat_img: gtk4::Image, bat_textures: std::collections::HashMap, @@ -56,7 +74,6 @@ pub struct App { bt_textures: std::collections::HashMap, wifi_lbl: gtk4::Label, wifi_img: gtk4::Image, - wifi_textures: std::collections::HashMap, // ── WiFi popover ────────────────────────────────────────────────────── wifi_pane: gtk4::Box, @@ -77,19 +94,9 @@ pub struct App { media_paused_at: Option, // ── Control panel ───────────────────────────────────────────────────── - control_popover: gtk4::Popover, panel_vol_slider: gtk4::Scale, panel_bright_slider: gtk4::Scale, panel_loading: Rc>, - panel_sink_store: gtk4::StringList, - panel_sink_dropdown: gtk4::DropDown, - panel_sink_signal: Option, - panel_sinks: Vec, - panel_cpu_lbl: gtk4::Label, - panel_mem_lbl: gtk4::Label, - panel_pwr_lbl: gtk4::Label, - panel_gpu_lbl: gtk4::Label, - panel_net_lbl: gtk4::Label, // ── Tray ────────────────────────────────────────────────────────────── tray_section: gtk4::Box, @@ -101,16 +108,21 @@ pub struct App { // One container per WidgetPlacement (see bread_shared::widget), fully // rebuilt on every AppInput::WidgetsUpdate — see widgets::client's // module doc for why that's simpler than incremental patching here. - widget_containers: - std::collections::HashMap, + widget_containers: std::collections::HashMap, widget_tray_section: gtk4::Box, widget_tray_sep: gtk4::Separator, + + panels: panel::PanelSet, } #[derive(Debug)] pub enum AppInput { - WorkspaceList(Vec), - ActiveWorkspace(WorkspaceId), + WorkspaceSync { + workspaces: Vec, + actives: std::collections::HashMap, + }, + MonitorAdded(String), + MonitorRemoved(String), ClockTick, StatsUpdate(bar::stats::Stats), TrayUpdate(bar::tray::TrayUpdate), @@ -121,11 +133,13 @@ pub enum AppInput { MediaUpdate(bar::media::MediaState), ControlPanelData(bar::control::ControlPanelData), WidgetsUpdate(Vec), + ReconcileMonitors, + DismissPanels, } #[relm4::component(pub)] impl SimpleComponent for App { - type Init = Option; + type Init = BarInit; type Input = AppInput; type Output = (); @@ -133,7 +147,7 @@ impl SimpleComponent for App { gtk::ApplicationWindow { add_css_class: "breadbar", set_title: Some("breadbar"), - set_default_height: 32, + set_default_height: BAR_HEIGHT, #[name = "center_box"] gtk::CenterBox { @@ -142,25 +156,53 @@ impl SimpleComponent for App { } fn init( - screenshot_req: Self::Init, + init: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { + let screenshot_req = init.screenshot; + let monitor_name = init + .monitor + .clone() + .or_else(primary_hypr_monitor) + .unwrap_or_else(|| "eDP-1".into()); + root.init_layer_shell(); root.set_namespace(Some("breadbar")); root.set_layer(Layer::Top); root.set_anchor(Edge::Top, true); root.set_anchor(Edge::Left, true); root.set_anchor(Edge::Right, true); - root.set_exclusive_zone(32); + root.set_margin(Edge::Top, BAR_MARGIN_TOP); + root.set_margin(Edge::Left, BAR_MARGIN_SIDES); + root.set_margin(Edge::Right, BAR_MARGIN_SIDES); + root.set_exclusive_zone(BAR_HEIGHT + BAR_MARGIN_TOP); + eprintln!( + "breadbar: init monitor={monitor_name} primary={}", + init.primary + ); + if screenshot_req.is_none() && !bind_layer_monitor(&root, &monitor_name) { + // Unbound satellites must not map on the compositor default + // (that stacks a second exclusive-zone bar on the laptop). + root.set_exclusive_zone(-1); + if !init.primary { + root.set_visible(false); + } + } // ── Workspace row (left) ──────────────────────────────────────── // Built imperatively (not via the view! macro) so a widget // container can sit as a plain sibling of workspace_box — see - // WidgetPlacement::RightOfWorkspaces below. - let workspace_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + // WidgetPlacement::RightOfWorkspaces below. The Overlay trail + // lives behind the buttons; rebuild_buttons only touches the + // button box, never the trail host. + let workspace_trail = bar::workspaces::WorkspaceTrail::new(); + let workspace_box = workspace_trail.buttons.clone(); let workspace_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - workspace_row.append(&workspace_box); + workspace_row.set_margin_start(8); + workspace_row.set_valign(gtk4::Align::Center); + workspace_row.set_vexpand(false); + workspace_row.append(&workspace_trail.overlay); // ── Lua-declared widget containers ────────────────────────────── // One per WidgetPlacement; positioned into the layout below as each @@ -182,8 +224,7 @@ impl SimpleComponent for App { // ── SVG icon sets ──────────────────────────────────────────────── use bar::stats::{ - AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, WIFI_MEDIUM, - WIFI_OFF, WIFI_STRONG, WIFI_WEAK, + AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, ICON_VOLUME, }; let bat_textures: std::collections::HashMap = [BAT_HIGH, BAT_MID, BAT_LOW] @@ -195,26 +236,26 @@ impl SimpleComponent for App { .into_iter() .map(|p| (p.as_ptr() as usize, svg_texture(p))) .collect(); - let wifi_textures: std::collections::HashMap = - [WIFI_STRONG, WIFI_MEDIUM, WIFI_WEAK, WIFI_OFF] - .into_iter() - .map(|p| (p.as_ptr() as usize, svg_texture(p))) - .collect(); - // ── Stat labels ────────────────────────────────────────────────── let cpu_lbl = stat_label(); let mem_lbl = stat_label(); let pwr_lbl = stat_label(); + let vol_lbl = stat_label(); let bat_lbl = stat_label(); + let vol_img = svg_image(ICON_VOLUME); + vol_img.add_css_class("stat-icon"); let bat_img = gtk4::Image::from_paintable(Some( bat_textures.get(&(BAT_MID.as_ptr() as usize)).unwrap(), )); - let ac_img = gtk4::Image::from_paintable(Some(&svg_texture(AC_POWER))); + prepare_icon(&bat_img, ICON_PX); + let ac_img = svg_image(AC_POWER); ac_img.set_visible(false); let bt_img = gtk4::Image::from_paintable(Some( bt_textures.get(&(BT_OFF.as_ptr() as usize)).unwrap(), )); + prepare_icon(&bt_img, ICON_PX); + bt_img.set_visible(false); // ── WiFi pair + popover ────────────────────────────────────────── let wifi_lbl = gtk4::Label::new(None); @@ -223,9 +264,11 @@ impl SimpleComponent for App { wifi_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); wifi_lbl.set_max_width_chars(28); wifi_lbl.set_xalign(0.0); - let wifi_img = - gtk4::Image::from_paintable(Some(&svg_texture(asset!("WiFi Connecting.svg")))); - + // SSID lives in the popover + tooltip — a 20-char network name + // crowding the tray is the opposite of a glass workbench bar. + wifi_lbl.set_visible(false); + let wifi_img = gtk4::Image::from_icon_name(bar::stats::WIFI_ICON_EXCELLENT); + prepare_icon(&wifi_img, ICON_PX); wifi_img.add_css_class("stat-icon"); // Content pane only — this becomes a tab inside the merged @@ -239,10 +282,18 @@ impl SimpleComponent for App { // ── Media widget (center) ──────────────────────────────────────── let media_widget = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); media_widget.add_css_class("media-widget"); + bar_chip(&media_widget); media_widget.set_visible(false); - let media_indicator = gtk4::Label::new(Some("▶")); - media_indicator.add_css_class("media-indicator"); + let media_eq = gtk4::Box::new(gtk4::Orientation::Horizontal, 3); + media_eq.add_css_class("media-eq"); + media_eq.set_valign(gtk4::Align::Center); + for _ in 0..4 { + let bar = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + bar.add_css_class("media-eq-bar"); + bar.set_valign(gtk4::Align::End); + media_eq.append(&bar); + } let media_track_lbl = gtk4::Label::new(None); media_track_lbl.add_css_class("media-track-lbl"); @@ -250,7 +301,7 @@ impl SimpleComponent for App { media_track_lbl.set_max_width_chars(42); media_track_lbl.set_xalign(0.0); - media_widget.append(&media_indicator); + media_widget.append(&media_eq); media_widget.append(&media_track_lbl); // Media controls popover @@ -262,14 +313,12 @@ impl SimpleComponent for App { media_controls_box.set_margin_end(4); let prev_btn = gtk4::Button::new(); - prev_btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture( - asset!("Previous.svg"), - ))))); + prev_btn.set_child(Some(&svg_image(asset!("Previous.svg")))); prev_btn.add_css_class("flat"); prev_btn.add_css_class("media-btn"); prev_btn.connect_clicked(|_| bar::media::spawn_cmd("previous")); - let media_play_icon = gtk4::Image::from_paintable(Some(&svg_texture(asset!("Pause.svg")))); + let media_play_icon = svg_image(asset!("Pause.svg")); let media_play_btn = gtk4::Button::new(); media_play_btn.set_child(Some(&media_play_icon)); media_play_btn.add_css_class("flat"); @@ -278,9 +327,7 @@ impl SimpleComponent for App { media_play_btn.connect_clicked(|_| bar::media::spawn_cmd("play-pause")); let next_btn = gtk4::Button::new(); - next_btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture( - asset!("Next.svg"), - ))))); + next_btn.set_child(Some(&svg_image(asset!("Next.svg")))); next_btn.add_css_class("flat"); next_btn.add_css_class("media-btn"); next_btn.connect_clicked(|_| bar::media::spawn_cmd("next")); @@ -289,37 +336,47 @@ impl SimpleComponent for App { media_controls_box.append(&media_play_btn); media_controls_box.append(&next_btn); - let media_popover = gtk4::Popover::new(); - media_popover.add_css_class("media-popover"); - media_popover.set_child(Some(&media_controls_box)); - media_popover.set_parent(&media_widget); - - let mpop = media_popover.clone(); - let mgesture = gtk4::GestureClick::new(); - mgesture.connect_released(move |_, _, _, _| { - if mpop.is_visible() { mpop.popdown(); } else { mpop.popup(); } - }); - media_widget.add_controller(mgesture); - - // Clock label - let clock_lbl = gtk4::Label::new(Some(&bar::clock::current())); - clock_lbl.add_css_class("clock-label"); + // Clock: time is the hero, date sits beside it quieter. + // Per-glyph labels so a minute rollover can flip only the digits + // that changed — same motion as the Liquid Motion demo. + let clock_time = bar::clock::time(); + let clock_digits = make_clock_digits(&clock_time); + let clock_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + clock_box.add_css_class("clock-box"); + clock_box.add_css_class("clock-label"); + clock_box.set_valign(gtk4::Align::Center); + clock_box.set_vexpand(false); + // Varela Round's em box sits optically high in the 44px island. + clock_box.set_margin_top(3); + for digit in &clock_digits { + clock_box.append(digit); + } + let date_lbl = gtk4::Label::new(Some(&bar::clock::date())); + date_lbl.add_css_class("date-label"); + date_lbl.set_visible(false); // Center area: [media_widget · widgets · clock · widgets] - let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); center_area.add_css_class("center-area"); + center_area.set_valign(gtk4::Align::Center); + center_area.set_vexpand(false); center_area.append(&media_widget); center_area.append(&widget_left_of_clock); - center_area.append(&clock_lbl); + center_area.append(&clock_box); center_area.append(&widget_right_of_clock); // ── Stats box (right side) ─────────────────────────────────────── - let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + // Demo order: [vol 64] [wifi] [bat 83] [☰] + let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); stats_box.add_css_class("stats-box"); + stats_box.set_margin_end(2); + stats_box.set_valign(gtk4::Align::Center); + stats_box.set_vexpand(false); stats_box.append(&widget_left_of_stats); - // CPU/RAM/power draw: hidden by default (see StatsUpdate), so this - // whole sub-group — plus its separator — collapses away when quiet. + // CPU/RAM/power draw stay built (control panel + screenshots still + // read the labels) but never mount on the island — the demo bar + // does not show them. let cpu_pair = stat_pair(asset!("CPU.svg"), &cpu_lbl); let mem_pair = stat_pair(asset!("RAM Usage.svg"), &mem_lbl); let pwr_pair = stat_pair(asset!("Power Draw.svg"), &pwr_lbl); @@ -328,22 +385,26 @@ impl SimpleComponent for App { system_stats_box.append(&mem_pair); system_stats_box.append(&pwr_pair); system_stats_box.set_visible(false); - stats_box.append(&system_stats_box); let system_sep = gtk4::Separator::new(gtk4::Orientation::Vertical); system_sep.add_css_class("bar-sep"); system_sep.set_visible(false); - stats_box.append(&system_sep); + + let vol_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + vol_box.add_css_class("stat-pair"); + bar_chip(&vol_box); + vol_lbl.add_css_class("stat-label"); + vol_box.append(&vol_img); + vol_box.append(&vol_lbl); + stats_box.append(&vol_box); let bat_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); bat_box.add_css_class("stat-pair"); + bar_chip(&bat_box); bat_img.add_css_class("stat-icon"); bat_lbl.add_css_class("stat-label"); ac_img.add_css_class("stat-icon"); - ac_img.set_margin_start(6); bat_box.append(&bat_img); bat_box.append(&bat_lbl); - bat_box.append(&ac_img); - stats_box.append(&bat_box); bt_img.add_css_class("bt-icon"); @@ -362,41 +423,38 @@ impl SimpleComponent for App { // Boxes with manual visibility toggling, which left stale width // behind on reopen. // - // hhomogeneous/vhomogeneous are ON (GTK's default), even though that - // means the popover is always sized to the *larger* of the two panes - // (visible empty space under the shorter one) — the alternative, - // sizing to just the active pane, means the popup has to resize - // itself in place when you switch tabs while it's open. Under - // gtk4-layer-shell that in-place resize doesn't reliably reach the - // compositor as a proper xdg_popup reposition; switching from the - // shorter tab to the taller one after a close/reopen cycle made the - // whole popover silently vanish instead of growing. A constant - // footprint sidesteps the resize entirely. + // Scrollport is a fixed 300px so tab-switch / first scan cannot + // resize the xdg_popup (that vanish-on-grow bug). Nearby networks + // live in the scroll, not a clipped 240px well. let content_stack = gtk4::Stack::new(); content_stack.set_hhomogeneous(true); - content_stack.set_vhomogeneous(true); - // Reserve enough room up front for the tallest realistic content - // (WiFi tab with a handful of nearby networks). Homogeneous sizing - // alone still lets the *first* real data load (Scanning… → populated - // list) trigger a live resize while the popup is mapped, which hits - // the same reposition fragility as the tab-switch case — claiming - // the space before anything is shown avoids that resize too. - content_stack.set_size_request(220, 420); + content_stack.set_vhomogeneous(false); + content_stack.set_transition_type(gtk4::StackTransitionType::Crossfade); + content_stack.set_transition_duration(220); content_stack.add_named(&wifi_pane, Some("wifi")); content_stack.add_named(&bt_pane, Some("bluetooth")); content_stack.set_visible_child_name("wifi"); - let wifi_tab_btn = gtk4::ToggleButton::with_label("Wi-Fi"); - wifi_tab_btn.add_css_class("popover-tab"); + // Fixed scrollport so nearby networks stay reachable without + // resizing the xdg_popup (that vanish-on-grow bug). + let content_scroll = gtk4::ScrolledWindow::new(); + content_scroll.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); + content_scroll.set_propagate_natural_width(true); + content_scroll.set_min_content_height(300); + content_scroll.set_max_content_height(300); + content_scroll.set_child(Some(&content_stack)); + + let wifi_tab_btn = popover_tab("Wi-Fi"); wifi_tab_btn.set_active(true); - let bt_tab_btn = gtk4::ToggleButton::with_label("Bluetooth"); - bt_tab_btn.add_css_class("popover-tab"); + let bt_tab_btn = popover_tab("Bluetooth"); bt_tab_btn.set_group(Some(&wifi_tab_btn)); - let tab_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + let tab_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); tab_row.add_css_class("popover-tab-row"); + tab_row.set_homogeneous(true); tab_row.append(&wifi_tab_btn); tab_row.append(&bt_tab_btn); + let wifi_caret = popover_caret(); let stack_for_wifi = content_stack.clone(); wifi_tab_btn.connect_toggled(move |btn| { @@ -414,170 +472,53 @@ impl SimpleComponent for App { let connectivity_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); connectivity_inner.add_css_class("wifi-popover-inner"); connectivity_inner.append(&tab_row); - connectivity_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - connectivity_inner.append(&content_stack); - - let connectivity_popover = gtk4::Popover::new(); - connectivity_popover.add_css_class("wifi-popover"); - connectivity_popover.set_child(Some(&connectivity_inner)); + connectivity_inner.append(&wifi_caret); + connectivity_inner.append(&content_scroll); let connectivity_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); connectivity_pair.add_css_class("stat-pair"); connectivity_pair.add_css_class("wifi-pair"); - connectivity_pair.append(&bt_img); + connectivity_pair.add_css_class("icon-only"); + bar_chip(&connectivity_pair); + wifi_img.set_halign(gtk4::Align::Center); + wifi_img.set_hexpand(false); connectivity_pair.append(&wifi_img); - connectivity_pair.append(&wifi_lbl); - // Anchored to wifi_lbl specifically, not the connectivity_pair row — - // a Popover parented to a multi-child Box via set_parent() balloons - // that Box's own allocation after a popup→popdown→popup cycle (its - // width roughly quadrupled in testing, shoving every bar item to its - // left further left). Anchoring to a single leaf widget instead - // sidesteps whatever GTK4/gtk4-layer-shell interaction causes that; - // the click target below still covers the whole row regardless. - connectivity_popover.set_parent(&wifi_lbl); stats_box.append(&connectivity_pair); - - let cpop = connectivity_popover.clone(); - let gesture = gtk4::GestureClick::new(); - gesture.connect_released(move |_, _, _, _| { - if cpop.is_visible() { cpop.popdown(); } else { cpop.popup(); } - }); - connectivity_pair.add_controller(gesture); - - let sender_conn = sender.clone(); - connectivity_popover.connect_show(move |_| { - bar::wifi::spawn_popover_load(sender_conn.clone()); - bar::bluetooth::spawn_popover_load(sender_conn.clone()); - }); + stats_box.append(&bat_box); // ── Control panel popover ──────────────────────────────────────── + // Liquid Motion chrome: CONTROL / vol / bl / lock·sleep·off. + // SNI tray + Lua tray widgets still mount here, but only as a + // headerless icon row when something actually registers. let panel_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); panel_inner.add_css_class("control-panel-inner"); - // Volume row - let vol_row = build_slider_row(bar::stats::ICON_VOLUME, 0.0, 1.5, 0.02); + let panel_header = gtk4::Label::new(Some("CONTROL")); + panel_header.add_css_class("control-panel-header"); + panel_header.set_xalign(0.0); + panel_inner.append(&panel_header); + panel_inner.append(&popover_caret()); + + let vol_row = build_slider_row("vol", 0.0, 1.5, 0.02); let panel_vol_slider = vol_row.1.clone(); panel_inner.append(&vol_row.0); - // Brightness row - let bright_row = build_slider_row(bar::stats::ICON_BRIGHTNESS, 0.0, 1.0, 0.02); + let bright_row = build_slider_row("bl", 0.0, 1.0, 0.02); let panel_bright_slider = bright_row.1.clone(); panel_inner.append(&bright_row.0); - panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - - // Stats section - let stats_section = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - stats_section.add_css_class("control-panel-stats"); - - let panel_cpu_lbl = gtk4::Label::new(Some("CPU —")); - panel_cpu_lbl.add_css_class("control-panel-stat"); - panel_cpu_lbl.set_xalign(0.0); - - let panel_mem_lbl = gtk4::Label::new(Some("RAM —")); - panel_mem_lbl.add_css_class("control-panel-stat"); - panel_mem_lbl.set_xalign(0.0); - - let panel_pwr_lbl = gtk4::Label::new(Some("PWR —")); - panel_pwr_lbl.add_css_class("control-panel-stat"); - panel_pwr_lbl.set_xalign(0.0); - - let panel_gpu_lbl = gtk4::Label::new(Some("GPU —")); - panel_gpu_lbl.add_css_class("control-panel-stat"); - panel_gpu_lbl.set_xalign(0.0); - - let panel_net_lbl = gtk4::Label::new(Some("↓ — ↑ —")); - panel_net_lbl.add_css_class("control-panel-stat"); - panel_net_lbl.set_xalign(0.0); - - stats_section.append(&panel_cpu_lbl); - stats_section.append(&panel_mem_lbl); - stats_section.append(&panel_pwr_lbl); - stats_section.append(&panel_gpu_lbl); - stats_section.append(&panel_net_lbl); - panel_inner.append(&stats_section); - - panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - - // Audio output section - let sink_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - sink_section.add_css_class("control-panel-section"); - let sink_header = gtk4::Label::new(Some("Audio Output")); - sink_header.add_css_class("control-panel-section-header"); - sink_header.set_xalign(0.0); - - let panel_sink_store = gtk4::StringList::new(&[]); - let panel_sink_dropdown = gtk4::DropDown::new( - Some(panel_sink_store.clone().upcast::()), - Option::::None, - ); - panel_sink_dropdown.add_css_class("control-panel-sink-dropdown"); - panel_sink_dropdown.set_hexpand(true); - - sink_section.append(&sink_header); - sink_section.append(&panel_sink_dropdown); - panel_inner.append(&sink_section); - - panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - - // Tray section - let tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - tray_section.add_css_class("control-panel-section"); - let tray_header = gtk4::Label::new(Some("Apps")); - tray_header.add_css_class("control-panel-section-header"); - tray_header.set_xalign(0.0); - let tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - tray_box.add_css_class("tray-box"); - tray_section.append(&tray_header); - tray_section.append(&tray_box); - // Collapsed (along with its separator) until an SNI app actually - // registers — an empty "Apps" section heading is dead weight. - tray_section.set_visible(false); - panel_inner.append(&tray_section); - - let tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); - tray_sep.set_visible(false); - panel_inner.append(&tray_sep); - - // Widgets section — Lua-declared widgets with placement = "tray". - // Same collapse-when-empty idiom as the Apps section above. - let widget_tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - widget_tray_section.add_css_class("control-panel-section"); - let widget_tray_header = gtk4::Label::new(Some("Widgets")); - widget_tray_header.add_css_class("control-panel-section-header"); - widget_tray_header.set_xalign(0.0); - let widget_tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_tray_box.add_css_class("tray-box"); - widget_tray_section.append(&widget_tray_header); - widget_tray_section.append(&widget_tray_box); - widget_tray_section.set_visible(false); - panel_inner.append(&widget_tray_section); - - let widget_tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); - widget_tray_sep.set_visible(false); - panel_inner.append(&widget_tray_sep); - - // Power section - let power_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - power_section.add_css_class("control-panel-section"); - let power_header = gtk4::Label::new(Some("Power")); - power_header.add_css_class("control-panel-section-header"); - power_header.set_xalign(0.0); - power_section.append(&power_header); - - let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); power_row.add_css_class("power-row"); - for (icon_svg, cmd) in [ + power_row.set_halign(gtk4::Align::Center); + for (label, cmd) in [ // breadlock is the ecosystem's own screen locker — hyprlock is // the thing it was built to replace; the bar shouldn't still // be pointing at it. - (bar::stats::ICON_LOCK, vec!["breadlock"]), - (bar::stats::ICON_SLEEP, vec!["systemctl", "suspend"]), - (bar::stats::ICON_RESTART, vec!["systemctl", "reboot"]), - (bar::stats::ICON_SHUTDOWN, vec!["systemctl", "poweroff"]), + ("lock", vec!["breadlock"]), + ("sleep", vec!["systemctl", "suspend"]), + ("off", vec!["systemctl", "poweroff"]), ] { - let btn = gtk4::Button::new(); - btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture(icon_svg))))); + let btn = gtk4::Button::with_label(label); btn.add_css_class("flat"); btn.add_css_class("power-btn"); btn.connect_clicked(move |_| { @@ -590,49 +531,121 @@ impl SimpleComponent for App { }); power_row.append(&btn); } - power_section.append(&power_row); - panel_inner.append(&power_section); + panel_inner.append(&power_row); - let control_popover = gtk4::Popover::new(); - control_popover.add_css_class("control-panel"); - control_popover.set_child(Some(&panel_inner)); + // SNI / Lua tray sit under the demo chrome so a single icon cannot + // split the sliders from the power chips. + let tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + tray_section.add_css_class("control-panel-section"); + let tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + tray_box.add_css_class("tray-box"); + tray_box.set_halign(gtk4::Align::Center); + tray_section.append(&tray_box); + tray_section.set_visible(false); + panel_inner.append(&tray_section); + let tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); + tray_sep.set_visible(false); - // Hamburger button + let widget_tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + widget_tray_section.add_css_class("control-panel-section"); + let widget_tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_tray_box.add_css_class("tray-box"); + widget_tray_box.set_halign(gtk4::Align::Center); + widget_tray_section.append(&widget_tray_box); + widget_tray_section.set_visible(false); + panel_inner.append(&widget_tray_section); + let widget_tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); + widget_tray_sep.set_visible(false); + + // Hamburger button — same chip chrome as volume / wifi / battery. let hamburger_btn = gtk4::Button::with_label("☰"); hamburger_btn.add_css_class("flat"); hamburger_btn.add_css_class("control-panel-btn"); - - control_popover.set_parent(&hamburger_btn); - - let cpop = control_popover.clone(); - hamburger_btn.connect_clicked(move |_| { - if cpop.is_visible() { cpop.popdown(); } else { cpop.popup(); } - }); - - let sender_cp = sender.clone(); - control_popover.connect_show(move |_| { - bar::control::spawn_load(sender_cp.clone()); - }); + hamburger_btn.add_css_class("stat-pair"); + hamburger_btn.add_css_class("icon-only"); + bar_chip(&hamburger_btn); // Slider signals — use Rc> to suppress feedback during data load let panel_loading = Rc::new(Cell::new(false)); let loading_v = panel_loading.clone(); + let vol_lbl_live = vol_lbl.clone(); panel_vol_slider.connect_value_changed(move |s| { - if loading_v.get() { return; } + vol_lbl_live.set_label(&format!("{:.0}", s.value() * 100.0)); + if loading_v.get() { + return; + } bar::control::spawn_set_volume(s.value()); }); let loading_b = panel_loading.clone(); panel_bright_slider.connect_value_changed(move |s| { - if loading_b.get() { return; } + if loading_b.get() { + return; + } bar::control::spawn_set_brightness(s.value()); }); stats_box.append(&hamburger_btn); + // Standalone layer windows — below the island, slid in by Hyprland. + let panels = panel::PanelSet::new( + &monitor_name, + &connectivity_inner, + &panel_inner, + &media_controls_box, + ); + + let sender_conn = sender.clone(); + panels.connectivity.connect_map(move |_| { + bar::wifi::spawn_popover_load(sender_conn.clone()); + bar::bluetooth::spawn_popover_load(sender_conn.clone()); + }); + let sender_cp = sender.clone(); + panels.control.connect_map(move |_| { + bar::control::spawn_load(sender_cp.clone()); + }); + + { + let panels = panels.clone(); + let win = panels.connectivity.clone(); + let gesture = gtk4::GestureClick::new(); + gesture.connect_released(move |_, _, _, _| { + panels.toggle(&win); + }); + connectivity_pair.add_controller(gesture); + } + { + let panels = panels.clone(); + let win = panels.control.clone(); + hamburger_btn.connect_clicked(move |_| { + panels.toggle(&win); + }); + } + { + let panels = panels.clone(); + let win = panels.control.clone(); + let vol_gesture = gtk4::GestureClick::new(); + vol_gesture.connect_released(move |_, _, _, _| { + panels.toggle(&win); + }); + vol_box.add_controller(vol_gesture); + } + { + let panels = panels.clone(); + let win = panels.media.clone(); + let mgesture = gtk4::GestureClick::new(); + mgesture.connect_released(move |_, _, _, _| { + panels.toggle(&win); + }); + media_widget.add_controller(mgesture); + } + let widget_containers = std::collections::HashMap::from([ - (WidgetPlacement::RightOfWorkspaces, widget_right_of_workspaces), + ( + WidgetPlacement::RightOfWorkspaces, + widget_right_of_workspaces, + ), (WidgetPlacement::LeftOfClock, widget_left_of_clock), (WidgetPlacement::RightOfClock, widget_right_of_clock), (WidgetPlacement::LeftOfStats, widget_left_of_stats), @@ -648,21 +661,37 @@ impl SimpleComponent for App { // Captured before these move into `model` (or are otherwise dropped // as bare locals, never stored on `App` at all) — needed by the // screenshot dispatch just before this function returns. - let control_popover_for_screenshot = control_popover.clone(); - let connectivity_popover_for_screenshot = connectivity_popover.clone(); + let control_panel_for_screenshot = panels.control.clone(); + let connectivity_panel_for_screenshot = panels.connectivity.clone(); let wifi_tab_btn_for_screenshot = wifi_tab_btn.clone(); let bt_tab_btn_for_screenshot = bt_tab_btn.clone(); - let media_popover_for_screenshot = media_popover.clone(); + let media_panel_for_screenshot = panels.media.clone(); let media_widget_for_screenshot = media_widget.clone(); let media_track_lbl_for_screenshot = media_track_lbl.clone(); + // Never launch sibling App windows from inside this init — RelmApp + // is still in GApplication activate, and a same-type launch here + // creates a second primary on the laptop. Idle reconcile after. + let satellites = Vec::new(); + if init.primary && screenshot_req.is_none() { + let later = sender.clone(); + gtk4::glib::idle_add_local_once(move || { + later.input(AppInput::ReconcileMonitors); + }); + } + let model = App { + monitor: monitor_name, + primary: init.primary, + satellites, workspaces: vec![], active_ws: 1, workspace_box, + workspace_trail, button_map: std::collections::HashMap::new(), time_str: bar::clock::current(), - clock_lbl, + clock_digits, + date_lbl, system_stats_box, system_sep, cpu_pair, @@ -671,6 +700,7 @@ impl SimpleComponent for App { cpu_lbl, mem_lbl, pwr_lbl, + vol_lbl, bat_lbl, bat_img, bat_textures, @@ -679,7 +709,6 @@ impl SimpleComponent for App { bt_textures, wifi_lbl, wifi_img, - wifi_textures, wifi_pane, crumbs_status: None, wifi_popover_data: None, @@ -692,19 +721,9 @@ impl SimpleComponent for App { media_play_icon, media_last: None, media_paused_at: None, - control_popover, panel_vol_slider, panel_bright_slider, panel_loading, - panel_sink_store, - panel_sink_dropdown, - panel_sink_signal: None, - panel_sinks: vec![], - panel_cpu_lbl, - panel_mem_lbl, - panel_pwr_lbl, - panel_gpu_lbl, - panel_net_lbl, tray_section, tray_sep, tray_box, @@ -712,16 +731,20 @@ impl SimpleComponent for App { widget_containers, widget_tray_section, widget_tray_sep, + panels, }; theme::apply(); + theme::bind_output(&root, &model.monitor); bar::workspaces::spawn_watcher(sender.clone()); bar::clock::spawn_ticker(sender.clone()); bar::stats::spawn_poller(sender.clone()); - bar::tray::spawn_watcher(sender.clone()); bar::wifi::spawn_status_poller(sender.clone()); bar::media::spawn_poller(sender.clone()); - widgets::client::spawn(sender.clone()); + if init.primary { + bar::tray::spawn_watcher(sender.clone()); + widgets::client::spawn(sender.clone()); + } // Screenshot mode primes these with sample content instead of the // real D-Bus/pactl/backlight sources — see notifications::SampleKind @@ -731,28 +754,37 @@ impl SimpleComponent for App { "notification-critical" => Some(notifications::SampleKind::Critical), _ => None, }); - let notification_window = notifications::spawn(notif_sample); + let notification_window = if init.primary { + Some(notifications::spawn(notif_sample)) + } else { + None + }; let osd_sample = screenshot_req.as_ref().and_then(|r| match r.view.as_str() { "osd-volume" => Some(osd::SampleKind::Volume), "osd-brightness" => Some(osd::SampleKind::Brightness), _ => None, }); - let osd_window = osd::spawn(osd_sample); + let osd_window = if init.primary { + Some(osd::spawn(osd_sample)) + } else { + None + }; if let Some(req) = screenshot_req { - let notification_window = matches!(req.view.as_str(), "notification" | "notification-critical") - .then_some(notification_window); - let osd_window = matches!(req.view.as_str(), "osd-volume" | "osd-brightness") - .then_some(osd_window); + let notification_window = notification_window.filter(|_| { + matches!(req.view.as_str(), "notification" | "notification-critical") + }); + let osd_window = + osd_window.filter(|_| matches!(req.view.as_str(), "osd-volume" | "osd-brightness")); screenshot::dispatch( &root, req, screenshot::Handles { - control_popover: control_popover_for_screenshot, - connectivity_popover: connectivity_popover_for_screenshot, + control_panel: control_panel_for_screenshot, + connectivity_panel: connectivity_panel_for_screenshot, wifi_tab_btn: wifi_tab_btn_for_screenshot, bt_tab_btn: bt_tab_btn_for_screenshot, - media_popover: media_popover_for_screenshot, + media_panel: media_panel_for_screenshot, media_widget: media_widget_for_screenshot, media_track_lbl: media_track_lbl_for_screenshot, notification_window, @@ -766,48 +798,88 @@ impl SimpleComponent for App { fn update(&mut self, msg: Self::Input, sender: ComponentSender) { match msg { - AppInput::WorkspaceList(list) => { - let mut sorted = list; + AppInput::WorkspaceSync { + workspaces, + actives, + } => { + let mut sorted = workspaces; sorted.sort_by_key(|w| w.id); + let new_active = actives + .get(&self.monitor) + .copied() + .unwrap_or(self.active_ws); + // Workspace also carries last-window title/address, which + // change constantly. Only the visible row (id/name/monitor/ + // occupied) should rebuild the pills — otherwise a title + // flicker on switch cancels the trail mid-stretch. + let rows_changed = visible_ws_rows(&sorted, &self.monitor, new_active) + != visible_ws_rows(&self.workspaces, &self.monitor, self.active_ws); + let active_changed = new_active != self.active_ws; self.workspaces = sorted; - self.rebuild_buttons(); + if self.primary { + self.reconcile_satellites(); + } + if rows_changed { + self.active_ws = new_active; + self.rebuild_buttons(active_changed); + } else if active_changed { + let from = self.button_map.get(&self.active_ws).cloned(); + if let Some(old) = &from { + old.remove_css_class("active"); + } + self.active_ws = new_active; + if let Some(btn) = self.button_map.get(&self.active_ws).cloned() { + btn.add_css_class("active"); + self.workspace_trail.stretch(from.as_ref(), &btn); + } + } } - AppInput::ActiveWorkspace(id) => { - if let Some(old) = self.button_map.get(&self.active_ws) { - old.remove_css_class("active"); + AppInput::MonitorAdded(name) => { + if !self.primary || name == self.monitor { + return; } - self.active_ws = id; - if let Some(btn) = self.button_map.get(&self.active_ws) { - btn.add_css_class("active"); + if self.satellites.iter().any(|(n, _)| n == &name) { + return; } + if let Some(ctrl) = spawn_satellite(&name) { + self.satellites.push((name, ctrl)); + } + } + AppInput::MonitorRemoved(name) => { + drop_satellite(&mut self.satellites, &name); } AppInput::ClockTick => { self.time_str = bar::clock::current(); - self.clock_lbl.set_label(&self.time_str); + flip_clock_digits(&self.clock_digits, &bar::clock::time()); + self.date_lbl.set_label(&bar::clock::date()); } AppInput::StatsUpdate(stats) => { self.cpu_lbl.set_label(&stats.cpu); self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); - // Bar information diet: CPU/RAM/power draw only surface once - // they're actually worth knowing about, individually, so a - // hot CPU doesn't drag an idle RAM/power reading along with it. - let cpu_hot = stats.cpu_pct > CPU_ATTENTION_THRESHOLD; - let mem_hot = stats.mem_pct > MEM_ATTENTION_THRESHOLD; - let pwr_hot = stats.power_watts > POWER_ATTENTION_THRESHOLD; - self.cpu_pair.set_visible(cpu_hot); - self.mem_pair.set_visible(mem_hot); - self.pwr_pair.set_visible(pwr_hot); - let any_hot = cpu_hot || mem_hot || pwr_hot; - self.system_stats_box.set_visible(any_hot); - self.system_sep.set_visible(any_hot); + // Island bar never shows the system-stats trio — they live + // in the control panel. Keep the widgets hidden so a later + // re-parent cannot accidentally flash them. + self.cpu_pair.set_visible(false); + self.mem_pair.set_visible(false); + self.pwr_pair.set_visible(false); + self.system_stats_box.set_visible(false); + self.system_sep.set_visible(false); - self.bat_lbl.set_label(&stats.bat); + tick_label(&self.vol_lbl, &stats.volume_pct.to_string()); + self.vol_lbl.set_tooltip_text(Some(&format!("volume {}%", stats.volume_pct))); + tick_label(&self.bat_lbl, &stats.bat); if let Some(tex) = self.bat_textures.get(&(stats.bat_icon.as_ptr() as usize)) { self.bat_img.set_paintable(Some(tex)); } - self.ac_img.set_visible(stats.ac_connected); + let bat_tip = if stats.ac_connected { + format!("{}% · charging", stats.bat) + } else { + format!("{}%", stats.bat) + }; + self.bat_img.set_tooltip_text(Some(&bat_tip)); + self.ac_img.set_visible(false); if let Some(tex) = self.bt_textures.get(&(stats.bt_icon.as_ptr() as usize)) { self.bt_img.set_paintable(Some(tex)); } @@ -822,41 +894,12 @@ impl SimpleComponent for App { .map(|s| s.internet && !s.captive_portal) .unwrap_or(true); let icon = if !internet_ok && stats.wifi_ssid != "—" { - bar::stats::WIFI_OFF + bar::stats::WIFI_ICON_OFF } else { stats.wifi_icon }; - if let Some(tex) = self.wifi_textures.get(&(icon.as_ptr() as usize)) { - self.wifi_img.set_paintable(Some(tex)); - } + self.wifi_img.set_icon_name(Some(icon)); - // Live-update control panel stats while open - if self.control_popover.is_visible() { - let cpu_str = match (stats.cpu_temp, stats.cpu.as_str()) { - (Some(t), pct) => format!("CPU {pct} {t:.0}°C"), - (None, pct) => format!("CPU {pct}"), - }; - self.panel_cpu_lbl.set_label(&cpu_str); - - self.panel_mem_lbl - .set_label(&format!("RAM {:.0}% {}", stats.mem_pct, stats.mem)); - self.panel_pwr_lbl - .set_label(&format!("PWR {}", stats.power)); - - let gpu_str = match (stats.gpu_usage, stats.gpu_temp) { - (Some(u), Some(t)) => format!("GPU {u}% {t:.0}°C"), - (Some(u), None) => format!("GPU {u}%"), - (None, Some(t)) => format!("GPU {t:.0}°C"), - (None, None) => "GPU —".to_string(), - }; - self.panel_gpu_lbl.set_label(&gpu_str); - - self.panel_net_lbl.set_label(&format!( - "↓ {} ↑ {}", - fmt_speed(stats.net_rx_kbs), - fmt_speed(stats.net_tx_kbs), - )); - } } AppInput::TrayUpdate(bar::tray::TrayUpdate::Add { id, icon, title }) => { if self.tray_items.contains_key(&id) { @@ -917,7 +960,14 @@ impl SimpleComponent for App { } else { asset!("Play.svg") }; - self.media_play_icon.set_paintable(Some(&svg_texture(icon_svg))); + self.media_play_icon + .set_paintable(Some(&svg_texture(icon_svg))); + prepare_icon(&self.media_play_icon, ICON_PX); + if state.playing { + self.media_widget.add_css_class("playing"); + } else { + self.media_widget.remove_css_class("playing"); + } if state.playing { self.media_paused_at = None; @@ -929,19 +979,20 @@ impl SimpleComponent for App { .media_paused_at .map_or(true, |t| t.elapsed().as_secs() < 30 * 60); self.media_last = Some(state); - self.media_widget.set_visible(within_linger); + reveal_media(&self.media_widget, within_linger); } else { // Player gone — honour linger from last pause + self.media_widget.remove_css_class("playing"); if let Some(paused_at) = self.media_paused_at { if paused_at.elapsed().as_secs() < 30 * 60 { - self.media_widget.set_visible(true); + reveal_media(&self.media_widget, true); } else { - self.media_widget.set_visible(false); + reveal_media(&self.media_widget, false); self.media_last = None; self.media_paused_at = None; } } else { - self.media_widget.set_visible(false); + reveal_media(&self.media_widget, false); self.media_last = None; } } @@ -952,36 +1003,18 @@ impl SimpleComponent for App { self.panel_vol_slider.set_value(data.volume); self.panel_bright_slider.set_value(data.brightness); self.panel_loading.set(false); - - // Rebuild sink dropdown — disconnect, repopulate, reconnect - if let Some(id) = self.panel_sink_signal.take() { - self.panel_sink_dropdown.disconnect(id); - } - // Clear store - let n = self.panel_sink_store.n_items(); - for i in (0..n).rev() { - self.panel_sink_store.remove(i); - } - for sink in &data.sinks { - self.panel_sink_store.append(&sink.description); - } - if let Some(idx) = data.sinks.iter().position(|s| s.is_default) { - self.panel_sink_dropdown.set_selected(idx as u32); - } - self.panel_sinks = data.sinks; - - let sinks = self.panel_sinks.clone(); - let id = self.panel_sink_dropdown.connect_selected_notify(move |dd| { - let idx = dd.selected() as usize; - if let Some(sink) = sinks.get(idx) { - bar::control::spawn_set_sink(sink.name.clone()); - } - }); - self.panel_sink_signal = Some(id); } AppInput::WidgetsUpdate(specs) => { self.reconcile_widgets(specs); } + AppInput::ReconcileMonitors => { + if self.primary { + self.reconcile_satellites(); + } + } + AppInput::DismissPanels => { + self.panels.hide_all(); + } } } } @@ -1030,34 +1063,84 @@ impl App { container.set_visible(container.first_child().is_some()); } - let has_tray_widgets = specs.iter().any(|s| { - s.visible && s.placement == bread_shared::widget::WidgetPlacement::Tray - }); + let has_tray_widgets = specs + .iter() + .any(|s| s.visible && s.placement == bread_shared::widget::WidgetPlacement::Tray); self.widget_tray_section.set_visible(has_tray_widgets); self.widget_tray_sep.set_visible(has_tray_widgets); } + fn reconcile_satellites(&mut self) { + let live: Vec = hypr_monitor_names() + .into_iter() + .filter(|n| n != &self.monitor) + .collect(); + let stale: Vec = self + .satellites + .iter() + .filter_map(|(n, _)| { + if live.contains(n) { + None + } else { + Some(n.clone()) + } + }) + .collect(); + for name in stale { + drop_satellite(&mut self.satellites, &name); + } + for name in live { + if self.satellites.iter().any(|(n, _)| n == &name) { + continue; + } + if let Some(ctrl) = spawn_satellite(&name) { + self.satellites.push((name, ctrl)); + } + } + } + fn apply_wifi_label(&self) { let label = match &self.wifi_profile { Some(p) => format!("{p} · {}", self.current_ssid), None => self.current_ssid.clone(), }; self.wifi_lbl.set_label(&label); + self.wifi_img.set_tooltip_text(Some(&label)); } - fn rebuild_buttons(&mut self) { + fn rebuild_buttons(&mut self, animate: bool) { + self.workspace_trail.cancel(); + let prev: std::collections::HashSet = + self.button_map.keys().copied().collect(); while let Some(child) = self.workspace_box.first_child() { self.workspace_box.remove(&child); } self.button_map.clear(); for ws in &self.workspaces { - let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws); + if ws.monitor != self.monitor { + continue; + } + // Persistent empty Hyprland workspaces stay off the bar unless + // this output is actually looking at them. + if ws.windows == 0 && ws.id != self.active_ws { + continue; + } + let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0); + if !prev.contains(&ws.id) { + play_once(&btn, "ws-in", 360); + } self.workspace_box.append(&btn); self.button_map.insert(ws.id, btn); } + match self.button_map.get(&self.active_ws).cloned() { + Some(btn) if animate => self.workspace_trail.stretch(None, &btn), + Some(btn) => self.workspace_trail.place(&btn), + None => self.workspace_trail.clear(), + } } fn rebuild_wifi_popover(&mut self, sender: &ComponentSender) { + let panels = self.panels.clone(); while let Some(child) = self.wifi_pane.first_child() { self.wifi_pane.remove(&child); } @@ -1089,7 +1172,11 @@ impl App { parts.push("internet ✗"); } if st.tailscale_required { - parts.push(if st.tailscale_ok { "tailscale ✓" } else { "tailscale ✗" }); + parts.push(if st.tailscale_ok { + "tailscale ✓" + } else { + "tailscale ✗" + }); } let status_lbl = gtk4::Label::new(Some(&parts.join(" "))); status_lbl.add_css_class("wifi-popover-status"); @@ -1101,59 +1188,36 @@ impl App { .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); } - let Some(data) = &self.wifi_popover_data else { + let nh = gtk4::Label::new(Some("NETWORKS")); + nh.add_css_class("wifi-popover-section"); + nh.set_xalign(0.0); + nh.set_margin_top(2); + nh.set_margin_bottom(4); + self.wifi_pane.append(&nh); + + let data = self.wifi_popover_data.as_ref(); + let scan_ready = data.map(|d| d.scan_ready).unwrap_or(false); + let scan = data.map(|d| d.scan.as_slice()).unwrap_or(&[]); + let profiles = data + .map(|d| unique_profiles(&d.profiles)) + .unwrap_or_default(); + + if !scan_ready { let lbl = gtk4::Label::new(Some("Scanning…")); lbl.add_css_class("wifi-popover-loading"); - self.wifi_pane.append(&lbl); - return; - }; - - let ph = gtk4::Label::new(Some("Profiles")); - ph.add_css_class("wifi-popover-section"); - ph.set_xalign(0.0); - ph.set_margin_top(6); - ph.set_margin_bottom(2); - self.wifi_pane.append(&ph); - - for (name, active) in &data.profiles { - let row = gtk4::Button::new(); - row.add_css_class("flat"); - row.add_css_class("wifi-popover-row"); - if *active { - row.add_css_class("wifi-popover-row-active"); - } - let lbl = gtk4::Label::new(Some(&format!( - "{}{}", - if *active { "● " } else { " " }, - name - ))); lbl.set_xalign(0.0); - row.set_child(Some(&lbl)); - - let name_clone = name.clone(); - let sender_clone = sender.clone(); - row.connect_clicked(move |btn| { - sender_clone.input(AppInput::SetProfile(name_clone.clone())); - bar::wifi::spawn_profile_set(name_clone.clone()); - close_parent_popover(btn); - }); - self.wifi_pane.append(&row); - } - - if !data.scan.is_empty() { - self.wifi_pane - .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - let nh = gtk4::Label::new(Some("Nearby")); - nh.add_css_class("wifi-popover-section"); - nh.set_xalign(0.0); - nh.set_margin_top(6); - nh.set_margin_bottom(2); - self.wifi_pane.append(&nh); - - for entry in &data.scan { + self.wifi_pane.append(&lbl); + } else if scan.is_empty() { + let lbl = gtk4::Label::new(Some("No networks found")); + lbl.add_css_class("wifi-popover-loading"); + lbl.set_xalign(0.0); + self.wifi_pane.append(&lbl); + } else { + for (i, entry) in scan.iter().enumerate() { let row = gtk4::Button::new(); row.add_css_class("flat"); row.add_css_class("wifi-popover-row"); + stagger_row(&row, i); if !entry.saved { row.add_css_class("wifi-popover-row-unsaved"); } @@ -1162,31 +1226,61 @@ impl App { row.add_css_class("wifi-popover-row-active"); } - let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - let icon_svg = wifi_icon_for_signal(entry.signal); - if let Some(tex) = self.wifi_textures.get(&(icon_svg.as_ptr() as usize)) { - let img = gtk4::Image::from_paintable(Some(tex)); - img.add_css_class("stat-icon"); - row_box.append(&img); - } - let lbl = gtk4::Label::new(Some(&format!( - "{}{}", - if is_current { "● " } else { " " }, - entry.ssid, - ))); + let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let img = gtk4::Image::from_icon_name(wifi_icon_for_signal(entry.signal)); + prepare_icon(&img, 18); + img.add_css_class("stat-icon"); + row_box.append(&img); + let lbl = gtk4::Label::new(Some(&entry.ssid)); lbl.set_xalign(0.0); + lbl.set_hexpand(true); + lbl.set_valign(gtk4::Align::Center); row_box.append(&lbl); row.set_child(Some(&row_box)); let ssid_clone = entry.ssid.clone(); let saved = entry.saved; + let panels = panels.clone(); row.connect_clicked(move |btn| { if saved { bar::wifi::spawn_join(ssid_clone.clone()); } else { show_add_network_dialog(btn, ssid_clone.clone(), |_| {}); } - close_parent_popover(btn); + panels.hide_all(); + }); + self.wifi_pane.append(&row); + } + } + + if !profiles.is_empty() { + let ph = gtk4::Label::new(Some("PROFILES")); + ph.add_css_class("wifi-popover-section"); + ph.set_xalign(0.0); + ph.set_margin_top(10); + ph.set_margin_bottom(4); + self.wifi_pane.append(&ph); + + for (i, (name, active)) in profiles.into_iter().enumerate() { + let row = gtk4::Button::new(); + row.add_css_class("flat"); + row.add_css_class("wifi-popover-row"); + stagger_row(&row, i); + if active { + row.add_css_class("wifi-popover-row-active"); + } + let lbl = gtk4::Label::new(Some(&name)); + lbl.set_xalign(0.0); + lbl.set_valign(gtk4::Align::Center); + row.set_child(Some(&lbl)); + + let name_clone = name.clone(); + let sender_clone = sender.clone(); + let panels = panels.clone(); + row.connect_clicked(move |_| { + sender_clone.input(AppInput::SetProfile(name_clone.clone())); + bar::wifi::spawn_profile_set(name_clone.clone()); + panels.hide_all(); }); self.wifi_pane.append(&row); } @@ -1213,6 +1307,7 @@ impl App { toggle_lbl.set_hexpand(true); toggle_lbl.set_xalign(0.0); let toggle_switch = gtk4::Switch::new(); + toggle_switch.add_css_class("bt-switch"); toggle_switch.set_active(data.powered); toggle_switch.set_valign(gtk4::Align::Center); toggle_switch.connect_state_set(|_, on| { @@ -1234,26 +1329,24 @@ impl App { lbl.add_css_class("wifi-popover-loading"); self.bt_pane.append(&lbl); } else { - let dh = gtk4::Label::new(Some("Paired")); + let dh = gtk4::Label::new(Some("PAIRED")); dh.add_css_class("wifi-popover-section"); dh.set_xalign(0.0); dh.set_margin_top(2); - dh.set_margin_bottom(2); + dh.set_margin_bottom(4); self.bt_pane.append(&dh); - for dev in &data.devices { + for (i, dev) in data.devices.iter().enumerate() { let row = gtk4::Button::new(); row.add_css_class("flat"); row.add_css_class("wifi-popover-row"); + stagger_row(&row, i); if dev.connected { row.add_css_class("wifi-popover-row-active"); } - let lbl = gtk4::Label::new(Some(&format!( - "{}{}", - if dev.connected { "● " } else { " " }, - dev.name, - ))); + let lbl = gtk4::Label::new(Some(&dev.name)); lbl.set_xalign(0.0); + lbl.set_valign(gtk4::Align::Center); row.set_child(Some(&lbl)); let address = dev.address.clone(); @@ -1275,7 +1368,7 @@ impl App { settings_row.add_css_class("flat"); settings_row.add_css_class("wifi-popover-row"); let settings_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - let settings_icon = gtk4::Image::from_paintable(Some(&svg_texture(bar::stats::ICON_BT_SETTINGS))); + let settings_icon = svg_image(bar::stats::ICON_BT_SETTINGS); settings_icon.add_css_class("stat-icon"); settings_box.append(&settings_icon); settings_box.append(>k4::Label::new(Some("Bluetooth settings"))); @@ -1291,65 +1384,60 @@ impl App { // ── Helpers ─────────────────────────────────────────────────────────────────── -fn build_slider_row(icon_svg: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { +fn build_slider_row(label: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); row.add_css_class("control-panel-row"); - row.set_margin_top(2); - row.set_margin_bottom(2); - // Rendered larger than the standard 16px stat/section icons: these are - // the two things in the panel you actually drag, so they should read as - // primary controls rather than blend in with passive readouts. - let icon = gtk4::Image::from_paintable(Some(&svg_texture_sized(icon_svg, 20))); - icon.add_css_class("control-panel-row-icon"); + let lbl = gtk4::Label::new(Some(label)); + lbl.add_css_class("control-panel-row-label"); + lbl.set_xalign(0.0); + lbl.set_width_chars(3); let slider = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, min, max, step); slider.set_draw_value(false); slider.set_hexpand(true); - slider.set_width_request(180); + slider.set_width_request(160); slider.add_css_class("control-panel-slider"); - row.append(&icon); + row.append(&lbl); row.append(&slider); (row, slider) } -fn fmt_speed(kbs: f32) -> String { - if kbs >= 1024.0 { - format!("{:.1} MB/s", kbs / 1024.0) - } else { - format!("{:.0} KB/s", kbs) - } -} - fn wifi_icon_for_signal(pct: u8) -> &'static str { - use bar::stats::{WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG, WIFI_WEAK}; + use bar::stats::{ + WIFI_ICON_EXCELLENT, WIFI_ICON_GOOD, WIFI_ICON_OFF, WIFI_ICON_OK, WIFI_ICON_WEAK, + }; match pct { - 75..=100 => WIFI_STRONG, - 50..=74 => WIFI_MEDIUM, - 25..=49 => WIFI_WEAK, - _ => WIFI_OFF, + 75..=100 => WIFI_ICON_EXCELLENT, + 50..=74 => WIFI_ICON_GOOD, + 25..=49 => WIFI_ICON_OK, + 1..=24 => WIFI_ICON_WEAK, + _ => WIFI_ICON_OFF, } } -fn close_parent_popover(widget: >k4::Button) { - if let Some(w) = widget.ancestor(gtk4::Popover::static_type()) { - if let Ok(p) = w.downcast::() { - p.popdown(); - } - } -} + /// Small modal prompting for a password, then saves + joins the network via /// `breadcrumbs add` + `breadcrumbs join`. `on_build` runs on the freshly /// built dialog *before* it's presented — screenshot mode's only hook point, /// since `connect_map` registered any later would miss a map that already /// happened. The real call site passes a no-op. -fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_build: impl FnOnce(>k4::Window)) { +fn show_add_network_dialog( + anchor: &impl IsA, + ssid: String, + on_build: impl FnOnce(>k4::Window), +) { let dialog = gtk4::Window::new(); dialog.set_title(Some(&format!("Add “{ssid}”"))); dialog.set_resizable(false); dialog.add_css_class("wifi-add-dialog"); + if let Some(output) = bread_theme::gtk::output_for_widget(anchor) { + theme::bind_output(&dialog, &output); + } else { + theme::bind_auto(&dialog); + } // A bare gtk4::Window with no titlebar set falls back to GTK's own // minimal CSD: a flat bar with plain system-font title text and no // rounding — which is what actually made this look like a stray window @@ -1433,33 +1521,180 @@ fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_bui fn stat_pair(icon_svg: &str, label: >k4::Label) -> gtk4::Box { let pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); pair.add_css_class("stat-pair"); - let img = gtk4::Image::from_paintable(Some(&svg_texture(icon_svg))); + bar_chip(&pair); + let img = svg_image(icon_svg); img.add_css_class("stat-icon"); pair.append(&img); pair.append(label); pair } -pub(crate) fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { - svg_texture_sized(svg_src, 16) +/// Identity of the pills this bar actually draws. Ignores last-window +/// title/address so a tab title change cannot rebuild the row. +fn visible_ws_rows( + workspaces: &[Workspace], + monitor: &str, + active: WorkspaceId, +) -> Vec<(WorkspaceId, String, bool)> { + workspaces + .iter() + .filter(|w| w.monitor == monitor && (w.windows > 0 || w.id == active)) + .map(|w| (w.id, w.name.clone(), w.windows > 0)) + .collect() } -/// Same as `svg_texture` but rendered at an explicit pixel size — used to give -/// primary/interactive icons (e.g. sliders you actually drag) more visual -/// weight than passive informational ones, which otherwise all read as the -/// same flat 16px stroke glyph once emoji stopped providing accidental variety. +fn bar_chip(widget: &impl IsA) { + widget.set_valign(gtk4::Align::Center); + widget.set_vexpand(false); +} + +fn popover_caret() -> gtk4::Box { + let caret = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + caret.add_css_class("popover-caret"); + caret.set_hexpand(true); + caret +} + +fn stagger_row(widget: &impl IsA, i: usize) { + widget.add_css_class("row-in"); + widget.add_css_class(&format!("stagger-{}", i.min(11))); +} + +fn play_once(widget: &impl IsA, class: &str, ms: u64) { + widget.remove_css_class(class); + widget.add_css_class(class); + let w = widget.as_ref().clone(); + let class = class.to_string(); + gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(ms), move || { + w.remove_css_class(&class); + }); +} + +fn make_clock_digits(time: &str) -> Vec { + time.chars() + .map(|ch| { + let lbl = gtk4::Label::new(Some(&ch.to_string())); + lbl.add_css_class("clock-digit"); + if ch == ':' { + lbl.add_css_class("clock-colon"); + } + lbl.set_valign(gtk4::Align::Center); + lbl.set_vexpand(false); + lbl.set_yalign(0.5); + lbl + }) + .collect() +} + +fn flip_clock_digits(digits: &[gtk4::Label], time: &str) { + let chars: Vec = time.chars().collect(); + for (i, lbl) in digits.iter().enumerate() { + let next = chars.get(i).copied().unwrap_or(' '); + let next_s = next.to_string(); + if lbl.label().as_str() == next_s { + continue; + } + lbl.set_label(&next_s); + if next != ':' { + play_once(lbl, "flip", 450); + } + } +} + +fn tick_label(lbl: >k4::Label, text: &str) { + if lbl.label().as_str() == text { + return; + } + lbl.set_label(text); + play_once(lbl, "tick", 360); +} + +fn reveal_media(widget: >k4::Box, show: bool) { + if show && !widget.is_visible() { + play_once(widget, "media-in", 420); + } + widget.set_visible(show); +} + +fn popover_tab(label: &str) -> gtk4::ToggleButton { + let btn = gtk4::ToggleButton::with_label(label); + btn.add_css_class("popover-tab"); + btn.set_hexpand(true); + btn.set_valign(gtk4::Align::Center); + btn.set_vexpand(false); + btn.set_size_request(-1, CHIP_HEIGHT); + if let Some(child) = btn.child() { + child.set_halign(gtk4::Align::Center); + child.set_valign(gtk4::Align::Center); + child.set_hexpand(true); + child.set_vexpand(false); + if let Ok(lbl) = child.downcast::() { + lbl.set_xalign(0.5); + lbl.set_yalign(0.5); + } + } + btn +} + +/// breadcrumbs can list the same profile twice under different case +/// (`Home` / `home`). Keep the active spelling when there is one. +fn unique_profiles(profiles: &[(String, bool)]) -> Vec<(String, bool)> { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for (name, active) in profiles { + if *active { + seen.insert(name.to_ascii_lowercase()); + out.push((name.clone(), true)); + } + } + for (name, active) in profiles { + if seen.insert(name.to_ascii_lowercase()) { + out.push((name.clone(), *active)); + } + } + out +} + +pub(crate) fn prepare_icon(img: >k4::Image, px: i32) { + img.set_pixel_size(px); + img.set_valign(gtk4::Align::Center); + img.set_vexpand(false); +} + +pub(crate) fn svg_image(svg_src: &str) -> gtk4::Image { + svg_image_sized(svg_src, ICON_PX as u32) +} + +pub(crate) fn svg_image_sized(svg_src: &str, px: u32) -> gtk4::Image { + let img = gtk4::Image::from_paintable(Some(&svg_texture_sized(svg_src, px))); + prepare_icon(&img, px as i32); + img +} + +pub(crate) fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { + svg_texture_sized(svg_src, ICON_PX as u32) +} + +/// Rasterise at 2× the display size so Lucide strokes stay sharp when GTK +/// displays the texture at `px` via `Image::set_pixel_size`. pub(crate) fn svg_texture_sized(svg_src: &str, px: u32) -> gtk4::gdk::Texture { use resvg::{tiny_skia, usvg}; + let raster = px.saturating_mul(2).max(1); let fg = theme::fg_color(); - let dim = format!(r#"width="{px}" height="{px}""#); + let dim = format!(r#"width="{raster}" height="{raster}""#); let svg = svg_src .replace("currentColor", &fg) + .replace(r#"stroke-width="2""#, r#"stroke-width="2.35""#) .replace(r#"width="24" height="24""#, &dim); let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("parse svg"); let size = tree.size().to_int_size(); let (w, h) = (size.width(), size.height()); let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap"); - resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut()); + resvg::render( + &tree, + tiny_skia::Transform::identity(), + &mut pixmap.as_mut(), + ); let bytes = gtk4::glib::Bytes::from_owned(pixmap.take()); gtk4::gdk::MemoryTexture::new( w as i32, @@ -1471,6 +1706,8 @@ pub(crate) fn svg_texture_sized(svg_src: &str, px: u32) -> gtk4::gdk::Texture { .upcast() } + + fn stat_label() -> gtk4::Label { let lbl = gtk4::Label::new(None); lbl.add_css_class("stat-label"); @@ -1515,5 +1752,138 @@ fn main() { if screenshot_req.is_some() { app.allow_multiple_instances(true); } - app.run::(screenshot_req); + app.run::(BarInit { + screenshot: screenshot_req, + monitor: None, + primary: true, + }); +} + +/// Live outputs only. `hyprctl monitors all` (and the hyprland crate's +/// `Monitors::get`) keep ghost connectors after a rename — `DVI-I-1` stayed +/// at 0×0 with `disabled=false` after the panel became `DVI-I-2`, and a +/// geometry fallback then stacked a second bar on the laptop. +#[derive(Debug, Clone, serde::Deserialize)] +struct HyprMon { + name: String, + x: i32, + y: i32, + #[serde(default)] + focused: bool, + #[serde(default)] + disabled: bool, +} + +fn hypr_monitors_live() -> Vec { + let output = match std::process::Command::new("hyprctl") + .args(["monitors", "-j"]) + .output() + { + Ok(o) if o.status.success() => o.stdout, + _ => return Vec::new(), + }; + serde_json::from_slice::>(&output) + .unwrap_or_default() + .into_iter() + .filter(|m| !m.disabled) + .collect() +} + +fn primary_hypr_monitor() -> Option { + let mons = hypr_monitors_live(); + mons.iter() + .find(|m| m.focused) + .or_else(|| mons.first()) + .map(|m| m.name.clone()) +} + +fn hypr_monitor_names() -> Vec { + hypr_monitors_live() + .into_iter() + .map(|m| m.name) + .collect() +} + +fn hypr_monitor_origin(name: &str) -> Option<(i32, i32)> { + hypr_monitors_live() + .into_iter() + .find(|m| m.name == name) + .map(|m| (m.x, m.y)) +} + +/// Hyprland connector names and GDK connector names can disagree after a +/// hotplug (`DVI-I-1` vs `DVI-I-2`). Match the connector first, then the +/// output's origin — transform swaps width/height so size is not reliable. +/// Never steal a GDK output whose connector is already a live Hyprland name. +fn gdk_monitor_for_hypr(name: &str) -> Option { + use gtk4::gdk::prelude::MonitorExt; + use gtk4::gio::prelude::ListModelExt; + let display = gtk4::gdk::Display::default()?; + let list = display.monitors(); + for i in 0..list.n_items() { + let Some(mon) = list.item(i).and_downcast::() else { + continue; + }; + if mon.connector().as_deref() == Some(name) { + return Some(mon); + } + } + let (hx, hy) = hypr_monitor_origin(name)?; + let live = hypr_monitor_names(); + for i in 0..list.n_items() { + let Some(mon) = list.item(i).and_downcast::() else { + continue; + }; + let g = mon.geometry(); + if g.x() != hx || g.y() != hy { + continue; + } + if let Some(conn) = mon.connector() { + if live.iter().any(|n| n != name && n == conn.as_str()) { + return None; + } + } + return Some(mon); + } + None +} + +pub(crate) fn bind_layer_monitor(window: &impl LayerShell, name: &str) -> bool { + match gdk_monitor_for_hypr(name) { + Some(mon) => { + window.set_monitor(Some(&mon)); + true + } + None => { + eprintln!("breadbar: no GDK monitor for {name}"); + false + } + } +} + +fn spawn_satellite(name: &str) -> Option> { + if gdk_monitor_for_hypr(name).is_none() { + eprintln!("breadbar: skip bar on {name}: no matching GDK output"); + return None; + } + let ctrl = App::builder() + .launch(BarInit { + screenshot: None, + monitor: Some(name.to_string()), + primary: false, + }) + .detach(); + ctrl.widget().present(); + Some(ctrl) +} + +fn drop_satellite(satellites: &mut Vec<(String, Controller)>, name: &str) { + satellites.retain(|(n, ctrl)| { + if n == name { + ctrl.widget().set_visible(false); + false + } else { + true + } + }); } diff --git a/src/notifications/history.rs b/src/notifications/history.rs index 0e158cc..61adc0f 100644 --- a/src/notifications/history.rs +++ b/src/notifications/history.rs @@ -175,13 +175,15 @@ pub fn build_window(store: Store) -> Ui { let window = gtk4::Window::new(); window.add_css_class("breadbar-history"); window.init_layer_shell(); + window.set_namespace(Some("breadbar-notif")); window.set_layer(Layer::Overlay); window.set_anchor(Edge::Top, true); window.set_anchor(Edge::Right, true); - window.set_margin(Edge::Top, 48); - window.set_margin(Edge::Right, 20); + window.set_margin(Edge::Top, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8); + window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES); window.set_default_width(360); window.set_keyboard_mode(KeyboardMode::OnDemand); + crate::theme::bind_auto(&window); let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 8); outer.set_margin_top(10); @@ -284,8 +286,8 @@ fn make_row(entry: &Entry) -> gtk4::Box { } let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - let show_app = !entry.app_name.is_empty() - && !entry.app_name.eq_ignore_ascii_case(&entry.summary); + let show_app = + !entry.app_name.is_empty() && !entry.app_name.eq_ignore_ascii_case(&entry.summary); if show_app { let app = gtk4::Label::new(Some(&entry.app_name)); app.add_css_class("notification-app"); diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index f221524..5753b65 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -170,15 +170,17 @@ fn create_window() -> gtk4::Window { let window = gtk4::Window::new(); window.add_css_class("breadbar-notification"); window.init_layer_shell(); + window.set_namespace(Some("breadbar-notif")); window.set_layer(Layer::Overlay); window.set_anchor(Edge::Top, true); window.set_anchor(Edge::Right, true); - window.set_margin(Edge::Top, 20); - window.set_margin(Edge::Right, 20); + window.set_margin(Edge::Top, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8); + window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES); window.set_default_width(320); // OnDemand so an inline-reply GtkEntry can take keys without the popup // stealing every keystroke the rest of the time. window.set_keyboard_mode(KeyboardMode::OnDemand); + crate::theme::bind_auto(&window); window } diff --git a/src/osd.rs b/src/osd.rs index 5deb1d4..d438292 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -163,9 +163,7 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver) { container.set_margin_end(14); window.set_child(Some(&container)); - let icon = gtk4::Image::from_paintable(Some(&crate::svg_texture( - crate::bar::stats::ICON_VOLUME, - ))); + let icon = crate::svg_image(crate::bar::stats::ICON_VOLUME); icon.add_css_class("osd-icon"); container.append(&icon); @@ -184,6 +182,7 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver) { }; icon.set_paintable(Some(&crate::svg_texture(icon_svg))); + crate::prepare_icon(&icon, crate::ICON_PX); if muted { icon.add_css_class("osd-icon-muted"); } else { @@ -209,9 +208,11 @@ fn create_window() -> gtk4::Window { let window = gtk4::Window::new(); window.add_css_class("breadbar-osd"); window.init_layer_shell(); + window.set_namespace(Some("breadbar-osd")); window.set_layer(Layer::Overlay); window.set_anchor(Edge::Bottom, true); window.set_margin(Edge::Bottom, 80); window.set_default_width(180); + crate::theme::bind_auto(&window); window } diff --git a/src/screenshot.rs b/src/screenshot.rs index d3848eb..05e9af6 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -20,11 +20,9 @@ use gtk4::prelude::*; use std::path::PathBuf; use std::time::Duration; -/// Settle time for views whose content depends on `bar::stats::spawn_poller`'s -/// 2-second background loop (control-panel's CPU/RAM/PWR/GPU/network labels, -/// gated on popover visibility) or a similar live-data popover load -/// (connectivity's wifi/bluetooth scan) — capturing any sooner leaves -/// placeholder dashes/"Scanning…" instead of real content. +/// Settle time for views whose content depends on a live-data popover load +/// (connectivity's wifi/bluetooth scan, control-panel sliders) — capturing +/// any sooner leaves placeholder dashes/"Scanning…" instead of real content. const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200); /// Delay between the bar's own `map` and calling `popover.popup()`. Calling @@ -107,11 +105,11 @@ impl Cli { /// outlive `init()` (never stored on `App`), so they have to be cloned out /// before dispatch time same as `control_popover` always was. pub struct Handles { - pub control_popover: gtk4::Popover, - pub connectivity_popover: gtk4::Popover, + pub control_panel: gtk4::Window, + pub connectivity_panel: gtk4::Window, pub wifi_tab_btn: gtk4::ToggleButton, pub bt_tab_btn: gtk4::ToggleButton, - pub media_popover: gtk4::Popover, + pub media_panel: gtk4::Window, pub media_widget: gtk4::Box, pub media_track_lbl: gtk4::Label, /// Already built and primed with sample content by `main.rs` (via @@ -122,10 +120,10 @@ pub struct Handles { pub osd_window: Option, } -/// The bar's fixed height — matches `root.set_exclusive_zone(32)` / -/// `set_default_height: 32` in `main.rs`. Unlike the other views' full -/// canvas, this never varies with `--width`/`--height`. -const BAR_HEIGHT: i32 = 32; +/// Capture height for the `bar` view: layer-shell top margin + widget +/// height (the exclusive zone). Unlike the other views' full canvas, +/// this never varies with `--width`/`--height`. +const BAR_HEIGHT: i32 = crate::BAR_HEIGHT + crate::BAR_MARGIN_TOP; pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) { let output = req.output; @@ -141,15 +139,15 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: }); } "control-panel" => { - open_popover_on_root_map(root, handles.control_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.control_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); } "connectivity-wifi" => { handles.wifi_tab_btn.set_active(true); - open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); } "connectivity-bluetooth" => { handles.bt_tab_btn.set_active(true); - open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); } "media-popover" => { // Real media state only shows the widget/text when something's @@ -157,8 +155,9 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: // run has nothing playing, so fake enough of it directly on the // widgets to get a representative capture. handles.media_widget.set_visible(true); + handles.media_widget.add_css_class("playing"); handles.media_track_lbl.set_text("Sample Track — Sample Artist"); - open_popover_on_root_map(root, handles.media_popover, SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.media_panel, SETTLE_DELAY, output, width, height); } "notification" | "notification-critical" => { let Some(window) = handles.notification_window else { @@ -196,27 +195,25 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: } } -/// Shared shape for every popover view: force it open shortly after the bar -/// maps (autohide disabled — a programmatic `popup()` has no real input -/// event serial to grab the Wayland seat with), then capture the whole -/// canvas after `settle` once the popover itself maps. -fn open_popover_on_root_map( +/// Shared shape for panel views: present the standalone layer window after +/// the bar maps, then capture the canvas once the panel itself maps. +fn open_panel_on_root_map( root: >k4::ApplicationWindow, - popover: gtk4::Popover, + panel: gtk4::Window, settle: Duration, output: PathBuf, width: i32, height: i32, ) { - let popover_to_open = popover.clone(); + let panel_to_open = panel.clone(); root.connect_map(move |_| { - popover_to_open.set_autohide(false); - let popover_to_open = popover_to_open.clone(); + let panel_to_open = panel_to_open.clone(); gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { - popover_to_open.popup(); + panel_to_open.set_visible(true); + panel_to_open.present(); }); }); - popover.connect_map(move |_| { + panel.connect_map(move |_| { let output = output.clone(); gtk4::glib::timeout_add_local_once(settle, move || { finish(bread_screenshots::capture_region(0, 0, width, height, &output)); diff --git a/src/theme.rs b/src/theme.rs index 11820bf..50f34f8 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,4 +1,5 @@ -use bread_theme::{gtk as bgtk, hex_to_rgba, ink_on, load_palette}; +use bread_theme::{gtk as bgtk, ink_on, load_palette, load_palette_for, Palette}; +use gtk4::prelude::IsA; use gtk4::CssProvider; use std::cell::RefCell; @@ -7,7 +8,6 @@ thread_local! { } fn load_css() -> String { - let p = load_palette(); // breadbar-specific rules only — fonts, base colours, and generic widgets // come from the shared ecosystem stylesheet (applied first in `apply()`). // Colour is set on each surface (bar, active workspace pill, notification @@ -15,37 +15,78 @@ fn load_css() -> String { // pywal hands a given slot. `on_*` are luminance-picked ink (black/white) for // that background — the pywal hues themselves are untouched. // - // Shared tokens: one radius and one padding rhythm reused across every - // popover/card/OSD surface so they read as one design system rather than - // four different ones. `radius_pill` is only for the tiny transient OSD. - let radius = "10px"; - let radius_sm = "0px"; - let radius_pill = "20px"; - let pad = "10px"; + // Glass workbench: 16px island on the bar, 12px cards/popovers, pill OSD. + // Hyprland `layerrule = blur, breadbar` frosts the translucent fills — + // the CSS just leaves alpha. Colours are bread-theme tokens so pywal + // accents (`@accent`) flow through on SIGHUP / `bread-theme reload`. + let radius = "12px"; + let radius_bar = "16px"; + let radius_sm = "9px"; + let radius_pill = "999px"; + let pad = "12px"; format!( - "window.breadbar {{ background-color: {bg_rgba}; color: {on_bg}; border-radius: 0; }}\ - .workspace-btn {{ background: transparent; opacity: 0.45; color: {on_bg};\ - border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\ - min-width: 20px; margin: 5px 2px; padding: 2px 9px; }}\ - .workspace-btn:hover {{ opacity: 0.8; }}\ - .workspace-btn.active {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\ - .stats-box {{ margin-right: 8px; }}\ - .stat-pair {{ margin-right: 14px; }}\ - .stat-icon {{ margin-right: 2px; }}\ - .bt-icon {{ margin-right: 14px; }}\ - separator.bar-sep {{ min-height: 14px; margin: 0 8px 0 0; background: alpha({on_bg}, 0.14); }}\ - window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; }}\ - window.breadbar-history {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg};\ - border-radius: {radius}; }}\ - .notification-card {{ background: {surface}; color: {on_surface}; border-radius: {radius};\ - padding: {pad}; margin-bottom: 8px; border-left: 3px solid transparent; }}\ - .notification-card.urgency-critical {{ border-left-color: {critical}; }}\ - .notification-card.urgency-normal {{ border-left-color: {accent}; }}\ + "@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\ + @keyframes osd-in {{ from {{ opacity: 0; margin-bottom: -8px; }} }}\ + @keyframes media-eq {{ to {{ min-height: 14px; }} }}\ + @keyframes pop-in {{ from {{ opacity: 0; margin-top: -10px; }} to {{ opacity: 1; margin-top: 0; }} }}\ + @keyframes pop-out {{ from {{ opacity: 1; margin-top: 0; }} to {{ opacity: 0; margin-top: -6px; }} }}\ + @keyframes row-in {{ from {{ opacity: 0; margin-top: 8px; }} to {{ opacity: 1; margin-top: 0; }} }}\ + @keyframes digit-flip {{ from {{ opacity: 0; margin-top: 7px; }} to {{ opacity: 1; margin-top: 0; }} }}\ + @keyframes caret-draw {{ from {{ margin-right: 200px; opacity: 0.2; }} to {{ margin-right: 4px; opacity: 1; }} }}\ + window.breadbar {{ background-color: alpha(@bg, 0.72); color: @on-bg;\ + border-radius: {radius_bar}; border: 1px solid alpha(@on-bg, 0.08); }}\ + window.breadbar > centerbox {{ padding: 0 8px 0 6px; }}\ + window.breadbar button {{ min-height: 0; min-width: 0; }}\ + .workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\ + background-color: @accent; border-radius: 999px; }}\ + .workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\ + border-radius: 999px; border: none; outline: none; box-shadow: none;\ + min-width: 32px; min-height: 32px; margin: 0 2px; padding: 0 12px;\ + font-size: 22px; font-weight: bold;\ + transition: opacity 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\ + background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + .workspace-btn:hover {{ opacity: 0.85; background: alpha(@on-bg, 0.08); }}\ + .workspace-btn.occupied {{ opacity: 0.78; }}\ + .workspace-btn.active {{ background: transparent; color: @on-accent; opacity: 1; }}\ + .workspace-btn.active:hover {{ background: transparent; }}\ + .workspace-btn.ws-in {{ animation: row-in 0.32s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\ + .clock-box {{ padding: 0 4px; }}\ + .clock-label {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\ + min-height: 0; padding: 0; margin-top: 3px; }}\ + .clock-digit {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\ + min-width: 15px; min-height: 0; padding: 0; margin: 0; }}\ + .clock-colon {{ min-width: 10px; opacity: 0.7; }}\ + .clock-digit.flip {{ animation: digit-flip 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .date-label {{ font-size: 14px; opacity: 0.52; letter-spacing: 0.04em; }}\ + .stat-label {{ font-size: 14px; letter-spacing: 0.02em; opacity: 0.92; }}\ + .stat-label.tick {{ animation: digit-flip 0.35s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .stats-box {{ margin-right: 0; }}\ + .stat-pair {{ margin: 0; border-radius: 10px; padding: 5px 9px; min-height: 0;\ + transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\ + opacity 0.18s ease; }}\ + .stat-pair:hover {{ background: alpha(@on-bg, 0.12); }}\ + .stat-pair:active {{ background: alpha(@on-bg, 0.18); }}\ + .stat-pair.icon-only {{ padding: 6px; border-radius: 999px; }}\ + .stat-icon {{ margin-right: 6px; }}\ + .stat-pair.icon-only .stat-icon {{ margin-right: 0; }}\ + .bt-icon {{ margin-right: 8px; }} + separator.bar-sep {{ min-height: 12px; min-width: 1px; margin: 0 10px 0 2px;\ + background: alpha(@on-bg, 0.10); }}\ + window.breadbar-notification {{ background-color: transparent; color: @on-bg; }}\ + window.breadbar-history {{ background-color: alpha(@bg, 0.70); color: @on-bg;\ + border-radius: {radius}; border: 1px solid alpha(@on-bg, 0.10);\ + animation: pop-in 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .notification-card {{ background: alpha(@bg, 0.70); color: @on-bg; border-radius: {radius};\ + padding: {pad}; margin-bottom: 8px; border: 1px solid alpha(@on-bg, 0.10);\ + border-left: 3px solid transparent;\ + animation: notif-in 0.45s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\ + .notification-card.urgency-critical {{ border-left-color: @red; }}\ + .notification-card.urgency-normal {{ border-left-color: @accent; }}\ .notification-summary {{ font-weight: bold; }}\ - .notification-app {{ opacity: 0.6; }}\ + .notification-app {{ opacity: 0.55; font-size: 11px; letter-spacing: 0.04em; }}\ .notification-actions {{ margin-top: 6px; }}\ - .notification-action {{ padding: 2px 8px; font-size: 11px; }}\ + .notification-action {{ padding: 2px 8px; font-size: 11px; border-radius: {radius_sm}; }}\ .notification-reply {{ margin-top: 6px; }}\ .notification-reply-entry {{ min-width: 0; }}\ .history-title {{ font-weight: bold; font-size: 13px; }}\ @@ -54,59 +95,126 @@ fn load_css() -> String { .history-time {{ opacity: 0.5; font-size: 11px; }}\ .history-body {{ opacity: 0.75; }}\ .history-card {{ margin-bottom: 6px; }}\ - window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: {radius_pill}; }}\ + window.breadbar-osd {{ background-color: alpha(@bg, 0.70); color: @on-bg;\ + border-radius: {radius_pill}; border: 1px solid alpha(@on-bg, 0.10);\ + animation: osd-in 0.4s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\ .osd-icon {{ opacity: 0.85; margin-right: 8px; }}\ .osd-icon-muted {{ opacity: 0.35; }}\ progressbar.osd-bar {{ min-height: 6px; }}\ - progressbar.osd-bar trough {{ background-image: none; background-color: {trough}; border-radius: 3px; min-height: 6px; }}\ - progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 3px; min-height: 6px; }}\ - .wifi-pair {{ border-radius: {radius_sm}; padding: 0 2px; }}\ - .wifi-pair:hover {{ background: alpha({on_bg}, 0.12); }}\ - .wifi-popover-inner {{ min-width: 200px; padding: {pad}; }}\ - .popover-tab-row {{ margin-bottom: {pad}; }}\ - .popover-tab {{ background: transparent; color: {on_bg}; border: none; box-shadow: none;\ - outline: none; border-radius: {radius_sm}; padding: 4px 10px; font-size: 11px;\ - font-weight: bold; opacity: 0.55; }}\ + progressbar.osd-bar trough {{ background-image: none; background-color: alpha(@accent, 0.25);\ + border-radius: 3px; min-height: 6px; }}\ + progressbar.osd-bar trough progress {{ background-image: none; background-color: @accent;\ + border-radius: 3px; min-height: 6px; }}\ + .wifi-pair {{ padding: 6px; }}\ + window.breadbar-panel {{ background-color: alpha(@bg, 0.72); color: @on-bg;\ + border-radius: 14px; border: 1px solid alpha(@on-bg, 0.12); }}\ + window.breadbar-dismiss, .breadbar-dismiss-hit {{\ + background-color: alpha(#000000, 0.02); }}\ + .popover-caret {{ min-height: 2px; margin: 2px 4px 10px; border-radius: 2px;\ + background-color: @accent;\ + background-image: linear-gradient(90deg, @accent, @teal);\ + animation: caret-draw 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .wifi-popover-inner {{ min-width: 228px; padding: {pad}; }}\ + window.wifi-popover button {{ min-height: 0; min-width: 0; }}\ + .popover-tab-row {{ background: alpha(@on-bg, 0.06); border-radius: 10px;\ + padding: 3px; margin-bottom: 10px; }}\ + .popover-tab {{ background: transparent; color: @on-bg; border: none; box-shadow: none;\ + outline: none; border-radius: 999px; padding: 0 14px; min-height: 32px;\ + font-size: 17px; font-weight: bold; opacity: 0.55;\ + transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\ + opacity 0.22s ease, color 0.22s ease; }}\ .popover-tab:hover {{ opacity: 0.8; }}\ - .popover-tab:checked {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\ - .wifi-popover-ssid {{ font-weight: bold; font-size: 13px; }}\ - .wifi-popover-ip {{ opacity: 0.6; font-size: 11px; }}\ - .wifi-popover-status {{ font-size: 11px; margin-top: 2px; }}\ - .wifi-popover-section {{ font-size: 10px; font-weight: bold; opacity: 0.5; letter-spacing: 0.08em; }}\ + .popover-tab:checked {{ background: alpha(@accent, 0.22); color: @accent; opacity: 1; }}\ + .popover-tab label {{ padding: 0; margin: 0; }}\ + .wifi-popover-ssid {{ font-weight: bold; font-size: 18px; }}\ + .wifi-popover-ip {{ opacity: 0.6; font-size: 16px; }}\ + .wifi-popover-status {{ font-size: 16px; margin-top: 2px; }}\ + .wifi-popover-section {{ font-size: 13px; font-weight: bold; opacity: 0.45;\ + letter-spacing: 0.12em; }}\ .wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\ - border-radius: {radius_sm}; padding: 4px 6px; }}\ - .wifi-popover-row:hover {{ background: alpha({on_bg}, 0.08); }}\ - .wifi-popover-row-active {{ color: {accent}; }}\ + outline: none; border-radius: 10px; padding: 0 12px; min-height: 42px;\ + transition: background-color 0.18s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + .wifi-popover-row label {{ font-size: 18px; }}\ + .wifi-popover-row:hover {{ background: alpha(@on-bg, 0.08); }}\ + .wifi-popover-row-active {{ background: alpha(@accent, 0.14); color: @accent; }}\ + .wifi-popover-row-active:hover {{ background: alpha(@accent, 0.20); }}\ + .row-in {{ animation: row-in 0.32s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .stagger-0 {{ animation-delay: 0ms; }} .stagger-1 {{ animation-delay: 28ms; }}\ + .stagger-2 {{ animation-delay: 56ms; }} .stagger-3 {{ animation-delay: 84ms; }}\ + .stagger-4 {{ animation-delay: 112ms; }} .stagger-5 {{ animation-delay: 140ms; }}\ + .stagger-6 {{ animation-delay: 168ms; }} .stagger-7 {{ animation-delay: 196ms; }}\ + .stagger-8 {{ animation-delay: 224ms; }} .stagger-9 {{ animation-delay: 252ms; }}\ + .stagger-10 {{ animation-delay: 280ms; }} .stagger-11 {{ animation-delay: 308ms; }}\ .wifi-popover-row-unsaved {{ opacity: 0.4; }}\ .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ - window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px;\ - border-radius: {radius}; }}\ - window.wifi-add-dialog headerbar {{ background-color: {bg_rgba}; color: {on_bg};\ + switch.bt-switch, switch.bt-switch:hover, switch.bt-switch:checked,\ + switch.bt-switch:checked:hover {{ min-width: 42px; min-height: 24px; padding: 2px;\ + border: none; outline: none; box-shadow: none; background-image: none;\ + border-radius: 99px; }}\ + switch.bt-switch {{ background-color: alpha(@on-bg, 0.14);\ + transition: background-color 0.25s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + switch.bt-switch:checked {{ background-color: @accent; }}\ + switch.bt-switch slider {{ min-width: 20px; min-height: 20px; margin: 0;\ + border-radius: 99px; border: none; outline: none; box-shadow: none;\ + background-image: none; background-color: @on-bg; }}\ + window.wifi-add-dialog {{ background-color: alpha(@bg, 0.70); color: @on-bg; min-width: 240px;\ + border-radius: {radius}; border: 1px solid alpha(@on-bg, 0.10);\ + animation: pop-in 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + window.wifi-add-dialog headerbar {{ background-color: alpha(@bg, 0.70); color: @on-bg;\ border-top-left-radius: {radius}; border-top-right-radius: {radius};\ - border-bottom: 1px solid alpha({on_bg}, 0.08); box-shadow: none; }}\ + border-bottom: 1px solid alpha(@on-bg, 0.10); box-shadow: none; }}\ .confirm-button {{ background-color: @accent; color: @on-accent; }}\ .confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\ - .media-widget {{ border-radius: {radius_sm}; padding: 0 6px; }}\ - .media-widget:hover {{ background: alpha({on_bg}, 0.10); }}\ - .media-indicator {{ font-size: 11px; opacity: 0.7; margin-right: 2px; }}\ - .media-track-lbl {{ font-size: 12px; }}\ - .media-controls {{ padding: 2px; }}\ - .media-btn {{ min-width: 32px; padding: 4px 8px; }}\ - .control-panel-btn {{ padding: 0 6px; margin-left: 6px; border-radius: {radius_sm}; }}\ + .media-widget {{ border-radius: 10px; padding: 4px 8px; min-height: 0;\ + transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + .media-widget:hover {{ background: alpha(@on-bg, 0.08); }}\ + .media-widget.media-in {{ animation: row-in 0.4s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .media-eq {{ min-height: 14px; margin-right: 4px; }}\ + .media-eq-bar {{ min-width: 3px; min-height: 5px; background-color: @accent;\ + border-radius: 2px; }}\ + .media-widget.playing .media-eq-bar {{\ + animation: media-eq 0.85s ease-in-out infinite alternate; }}\ + .media-widget.playing .media-eq-bar:nth-child(2) {{ animation-delay: 0.1s; min-height: 11px; }}\ + .media-widget.playing .media-eq-bar:nth-child(3) {{ animation-delay: 0.22s; min-height: 7px; }}\ + .media-widget.playing .media-eq-bar:nth-child(4) {{ animation-delay: 0.06s; min-height: 13px; }}\ + .media-track-lbl {{ font-size: 17px; }}\ + .media-controls {{ padding: 4px; }}\ + .media-btn {{ min-width: 32px; padding: 4px 8px; border-radius: {radius_sm};\ + transition: background-color 0.18s ease; }}\ + .media-btn:hover {{ background: alpha(@on-bg, 0.10); }}\ + .control-panel-btn {{ padding: 5px 8px; margin: 0; border-radius: 10px;\ + opacity: 0.92; font-size: 18px; line-height: 1; min-width: 0; min-height: 0;\ + background: transparent; border: none; outline: none; box-shadow: none;\ + transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\ + opacity 0.18s ease; }}\ + .control-panel-btn:hover {{ opacity: 1; background: alpha(@on-bg, 0.10); }}\ + .control-panel-btn:active {{ background: alpha(@on-bg, 0.16); }}\ .control-panel {{ }}\ .control-panel-inner {{ min-width: 220px; padding: {pad}; }}\ - .control-panel-row {{ margin: 4px 0; }}\ - .control-panel-row-icon {{ opacity: 1; margin-right: 4px; }}\ - .control-panel-slider {{ margin: 0; }}\ - .control-panel-stats {{ margin: {pad} 0; }}\ - .control-panel-stat {{ font-size: 12px; opacity: 0.85; margin: 1px 0; }}\ - .control-panel-section {{ margin: {pad} 0; }}\ - .control-panel-section-header {{ font-size: 10px; font-weight: bold; opacity: 0.5;\ - letter-spacing: 0.08em; margin-bottom: 4px; }}\ - .control-panel-sink-dropdown {{ }}\ - .power-row {{ margin-top: 2px; }}\ - .power-btn {{ min-width: 40px; padding: 8px; border-radius: {radius_sm}; }}\ - separator {{ margin: 4px 0; }}\ + .control-panel-header {{ font-size: 12px; font-weight: bold; letter-spacing: 0.12em;\ + opacity: 0.45; margin-bottom: 8px; }}\ + .control-panel-row {{ margin: 8px 0; }}\ + .control-panel-row-label {{ font-size: 16px; opacity: 0.78; }}\ + .control-panel-slider {{ margin: 0; padding: 0; min-height: 18px; }}\ + scale.control-panel-slider trough {{ min-height: 6px; border-radius: 99px;\ + background-image: none; background-color: alpha(@on-bg, 0.12);\ + border: none; outline: none; box-shadow: none; }}\ + scale.control-panel-slider highlight {{ min-height: 6px; border-radius: 99px;\ + background-image: none; background-color: @accent; }}\ + scale.control-panel-slider slider {{ min-width: 0; min-height: 0; margin: 0;\ + padding: 0; opacity: 0; background: transparent; border: none;\ + outline: none; box-shadow: none; }}\ + .control-panel-section {{ margin: 8px 0 0; }}\ + .power-row {{ margin-top: 8px; }}\ + .power-btn {{ min-width: 0; min-height: 0; padding: 8px 10px; border-radius: 8px;\ + background: alpha(@on-bg, 0.08); font-size: 13px; border: none;\ + outline: none; box-shadow: none;\ + transition: background-color 0.2s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + .power-btn:hover {{ background: alpha(@on-bg, 0.14); }}\ + .power-btn:active {{ background: alpha(@accent, 0.22); }}\ + .notification-action {{ transition: background-color 0.18s ease; }}\ + .tray-btn {{ transition: opacity 0.2s ease, background-color 0.2s ease; }}\ + separator {{ margin: 4px 0; background: alpha(@on-bg, 0.10); }}\ /* Lua-declared widgets (see Documentation.md's Widgets §style): the\ slot rule below is what the four inline `.bread-widget-slot`\ containers in main.rs rely on for the same 12px stat-pair rhythm\ @@ -157,14 +265,11 @@ fn load_css() -> String { .bread-padding-xs {{ padding: 4px; }}\ .bread-padding-sm {{ padding: 8px; }}\ .bread-padding-md {{ padding: 12px; }}", - bg_plain = p.background, - bg_rgba = hex_to_rgba(&p.background, 0.92), - surface = p.color0, - accent = p.color4, - critical = p.color1, - on_bg = ink_on(&p.background), - on_surface = ink_on(&p.color0), - trough = hex_to_rgba(&p.color4, 0.25), + radius = radius, + radius_bar = radius_bar, + radius_sm = radius_sm, + radius_pill = radius_pill, + pad = pad, ) } @@ -175,6 +280,31 @@ pub fn fg_color() -> String { ink_on(&load_palette().background).to_string() } +/// Ink colour for the given Hyprland output's wallpaper palette. +#[allow(dead_code)] +pub fn fg_color_for(output: &str) -> String { + ink_on(&load_palette_for(output).background).to_string() +} + +/// Bind this window (and its popover children) to `output`'s palette. +/// +/// App CSS still uses `@accent` / `@on-bg` tokens; `bind_window_with_app_css` +/// resolves them against that output. Display-level [`apply`] stays as the +/// SIGHUP / single-output fallback. +pub fn bind_output(widget: &impl IsA, output: &str) { + bgtk::bind_window_with_app_css(widget, output, load_css_for); +} + +/// Bind a satellite window (notification, history, OSD, wifi dialog) to +/// whichever output it is actually rendered on. +pub fn bind_auto(window: &impl IsA) { + bgtk::bind_window_auto_with_app_css(window, load_css_for); +} + +fn load_css_for(_palette: &Palette) -> String { + load_css() +} + /// Apply (or reload) the theme CSS. Safe to call from `glib::MainContext::invoke`. pub fn apply() { // Shared ecosystem base (fonts, palette, generic widgets) — applied first diff --git a/src/widgets/render.rs b/src/widgets/render.rs index 53de47b..785470b 100644 --- a/src/widgets/render.rs +++ b/src/widgets/render.rs @@ -24,7 +24,8 @@ const DEFAULT_LABEL_MAX_WIDTH_CHARS: i32 = 32; fn bundled_icon(name: &str) -> Option<&'static str> { use crate::bar::stats::{ AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_OFF, BT_ON, ICON_BRIGHTNESS, ICON_LOCK, - ICON_VOLUME, WIFI_OFF, WIFI_STRONG, + ICON_RESTART, ICON_SHUTDOWN, ICON_SLEEP, ICON_VOLUME, WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG, + WIFI_WEAK, }; Some(match name { "ac-power" => AC_POWER, @@ -34,8 +35,13 @@ fn bundled_icon(name: &str) -> Option<&'static str> { "bluetooth-on" => BT_ON, "bluetooth-off" => BT_OFF, "wifi-strong" => WIFI_STRONG, + "wifi-medium" => WIFI_MEDIUM, + "wifi-weak" => WIFI_WEAK, "wifi-off" => WIFI_OFF, "lock" => ICON_LOCK, + "sleep" => ICON_SLEEP, + "restart" => ICON_RESTART, + "shutdown" => ICON_SHUTDOWN, "volume" => ICON_VOLUME, "brightness" => ICON_BRIGHTNESS, _ => return None, @@ -171,6 +177,7 @@ pub fn build_node(node: &WidgetNode, widget_id: &str) -> gtk4::Widget { let px = size.unwrap_or(16).max(1) as u32; let texture = icon_texture(widget_id, name.as_deref(), path.as_deref(), px); let image = gtk4::Image::from_paintable(texture.as_ref()); + crate::prepare_icon(&image, px as i32); image.upcast() } WidgetNode::Progress { value, .. } => { From 96d666b3cb6755453afd4a42bf6b60903ffb6825 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:45:59 +0800 Subject: [PATCH 2/9] Fix CI: ship panel.rs and the workspace trail helper mod panel and stretch_geom_on were referenced on main but not committed, so --locked release builds failed. --- src/bar/workspaces.rs | 163 +++++++++++++++++++++++++++++++----------- src/main.rs | 58 +++++++++++---- src/panel.rs | 154 +++++++++++++++++++++++++++++++++++++++ src/theme.rs | 5 +- 4 files changed, 325 insertions(+), 55 deletions(-) create mode 100644 src/panel.rs diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 1181f02..fd83c79 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -230,49 +230,68 @@ impl WorkspaceTrail { } pub fn place(&self, btn: >k4::Button) { - self.cancel(); - if let Some(g) = button_geom(btn, &self.overlay) { - self.apply(&g); - return; - } - // First map: the button exists but has no allocation yet. let pill = self.pill.clone(); let host = self.host.clone(); - let btn = btn.clone(); let inner = self.inner.clone(); - let id = self.overlay.add_tick_callback(move |ov, _| { - let Some(g) = button_geom(&btn, ov) else { - return ControlFlow::Continue; - }; + self.when_stable(btn, move |g| { apply_geom(&host, &pill, &inner, &g); - inner.borrow_mut().tick = None; - ControlFlow::Break }); - self.inner.borrow_mut().tick = Some(id); } pub fn stretch(&self, from: Option<>k4::Button>, to: >k4::Button) { let from_g = self.from_geom(from); - if let Some(to_g) = button_geom(to, &self.overlay) { - self.stretch_geom(from_g, to_g); - return; - } - // Destination just appeared (empty workspace becoming active) and - // has no allocation yet. Wait one layout pass, then stretch from - // the last pill — don't snap via place(). - self.cancel(); let overlay = self.overlay.clone(); let pill = self.pill.clone(); let host = self.host.clone(); let inner = self.inner.clone(); - let btn = to.clone(); + let dest = to.clone(); + self.when_stable(to, move |to_g| { + stretch_geom_on(&overlay, &host, &pill, &inner, from_g, to_g, Some(dest)); + }); + } + + /// New empty-workspace buttons first allocate at CSS `min-width` (32px) + /// and only then grow to the padded label. Two stable frames of that + /// placeholder is not enough — empty→empty used to shrink the pill to it. + fn when_stable(&self, btn: >k4::Button, then: impl FnOnce(Geom) + 'static) { + self.cancel(); + let btn = btn.clone(); + let inner = self.inner.clone(); + let last = std::cell::Cell::new(None::); + let same = std::cell::Cell::new(0u8); + let frames = std::cell::Cell::new(0u8); + let then = std::cell::Cell::new(Some(then)); let id = self.overlay.add_tick_callback(move |ov, _| { - let Some(to_g) = button_geom(&btn, ov) else { - return ControlFlow::Continue; + frames.set(frames.get().saturating_add(1)); + let n = frames.get(); + let Some(g) = button_geom(&btn, ov) else { + last.set(None); + same.set(0); + return if n > 24 { + inner.borrow_mut().tick = None; + ControlFlow::Break + } else { + ControlFlow::Continue + }; }; - inner.borrow_mut().tick = None; - stretch_geom_on(&overlay, &host, &pill, &inner, from_g, to_g); - ControlFlow::Break + // Still sitting on the 32px min-width slot, or smaller than the + // button's natural request — keep waiting for the real layout. + if still_placeholder(&btn, &g) && n < 20 { + last.set(None); + same.set(0); + return ControlFlow::Continue; + } + let stable = last.get().is_some_and(|p| geom_close(&p, &g)); + last.set(Some(g)); + same.set(if stable { same.get().saturating_add(1) } else { 0 }); + if same.get() >= 2 || n > 22 { + inner.borrow_mut().tick = None; + if let Some(f) = then.take() { + f(g); + } + return ControlFlow::Break; + } + ControlFlow::Continue }); self.inner.borrow_mut().tick = Some(id); } @@ -286,20 +305,68 @@ impl WorkspaceTrail { from.and_then(|b| button_geom(b, &self.overlay)) } - fn stretch_geom(&self, from_g: Option, to_g: Geom) { - stretch_geom_on( - &self.overlay, - &self.host, - &self.pill, - &self.inner, - from_g, - to_g, - ); +} + +fn stretch_geom_on( + overlay: >k4::Overlay, + host: >k4::Fixed, + pill: >k4::Box, + inner: &Rc>, + from_g: Option, + to_g: Geom, + dest: Option, +) { + let Some(from_g) = from_g else { + apply_geom(host, pill, inner, &to_g); + return; + }; + if (from_g.x - to_g.x).abs() < 0.5 && (from_g.w - to_g.w).abs() < 0.5 { + apply_geom(host, pill, inner, &to_g); + return; } - fn apply(&self, g: &Geom) { - apply_geom(&self.host, &self.pill, &self.inner, g); + let span_x = from_g.x.min(to_g.x); + let span_w = (from_g.x + from_g.w).max(to_g.x + to_g.w) - span_x; + let mid = Geom { + x: span_x, + y: to_g.y, + w: span_w, + h: to_g.h, + }; + + if let Some(id) = inner.borrow_mut().tick.take() { + id.remove(); } + let started = Instant::now(); + let pill = pill.clone(); + let host = host.clone(); + let inner_tick = inner.clone(); + let dest = dest.clone(); + let ov = overlay.clone(); + let id = overlay.add_tick_callback(move |_, _| { + let elapsed = started.elapsed().as_secs_f64() * 1000.0; + let (g, done) = if elapsed < STRETCH_MS { + let t = ease(elapsed / STRETCH_MS); + (lerp_geom(&from_g, &mid, t), false) + } else if elapsed < STRETCH_MS + SNAP_MS { + let t = ease_overshoot((elapsed - STRETCH_MS) / SNAP_MS); + (lerp_geom(&mid, &to_g, t), false) + } else { + let end = dest + .as_ref() + .and_then(|b| button_geom(b, &ov)) + .unwrap_or(to_g); + (end, true) + }; + apply_geom(&host, &pill, &inner_tick, &g); + if done { + inner_tick.borrow_mut().tick = None; + ControlFlow::Break + } else { + ControlFlow::Continue + } + }); + inner.borrow_mut().tick = Some(id); } fn button_geom(btn: >k4::Button, overlay: >k4::Overlay) -> Option { @@ -324,11 +391,27 @@ fn apply_geom(host: >k4::Fixed, pill: >k4::Box, inner: &Rc bool { + let (min_w, nat_w, _, _) = btn.measure(gtk4::Orientation::Horizontal, -1); + g.w <= f64::from(min_w) + 1.0 || g.w + 0.5 < f64::from(nat_w) +} + +fn geom_close(a: &Geom, b: &Geom) -> bool { + (a.x - b.x).abs() < 0.5 + && (a.y - b.y).abs() < 0.5 + && (a.w - b.w).abs() < 0.5 + && (a.h - b.h).abs() < 0.5 +} + fn lerp(a: f64, b: f64, t: f64) -> f64 { a + (b - a) * t } diff --git a/src/main.rs b/src/main.rs index ad09dc3..21c4646 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,9 +62,11 @@ pub struct App { cpu_pair: gtk4::Box, mem_pair: gtk4::Box, pwr_pair: gtk4::Box, + gpu_pair: gtk4::Box, cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, + gpu_lbl: gtk4::Label, vol_lbl: gtk4::Label, bat_lbl: gtk4::Label, bat_img: gtk4::Image, @@ -240,6 +242,7 @@ impl SimpleComponent for App { let cpu_lbl = stat_label(); let mem_lbl = stat_label(); let pwr_lbl = stat_label(); + let gpu_lbl = stat_label(); let vol_lbl = stat_label(); let bat_lbl = stat_label(); @@ -380,11 +383,22 @@ impl SimpleComponent for App { let cpu_pair = stat_pair(asset!("CPU.svg"), &cpu_lbl); let mem_pair = stat_pair(asset!("RAM Usage.svg"), &mem_lbl); let pwr_pair = stat_pair(asset!("Power Draw.svg"), &pwr_lbl); - let system_stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - system_stats_box.append(&cpu_pair); - system_stats_box.append(&mem_pair); - system_stats_box.append(&pwr_pair); - system_stats_box.set_visible(false); + let gpu_pair = stat_pair(asset!("GPU.svg"), &gpu_lbl); + for pair in [&cpu_pair, &mem_pair, &pwr_pair, &gpu_pair] { + pair.add_css_class("sys-stat"); + pair.set_hexpand(true); + } + gpu_pair.set_visible(false); + let system_stats_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + system_stats_box.add_css_class("sys-grid"); + let sys_row1 = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + sys_row1.append(&cpu_pair); + sys_row1.append(&mem_pair); + let sys_row2 = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + sys_row2.append(&gpu_pair); + sys_row2.append(&pwr_pair); + system_stats_box.append(&sys_row1); + system_stats_box.append(&sys_row2); let system_sep = gtk4::Separator::new(gtk4::Orientation::Vertical); system_sep.add_css_class("bar-sep"); system_sep.set_visible(false); @@ -507,6 +521,13 @@ impl SimpleComponent for App { let panel_bright_slider = bright_row.1.clone(); panel_inner.append(&bright_row.0); + let sys_header = gtk4::Label::new(Some("SYSTEM")); + sys_header.add_css_class("control-panel-header"); + sys_header.set_xalign(0.0); + sys_header.set_margin_top(10); + panel_inner.append(&sys_header); + panel_inner.append(&system_stats_box); + let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); power_row.add_css_class("power-row"); power_row.set_halign(gtk4::Align::Center); @@ -697,9 +718,11 @@ impl SimpleComponent for App { cpu_pair, mem_pair, pwr_pair, + gpu_pair, cpu_lbl, mem_lbl, pwr_lbl, + gpu_lbl, vol_lbl, bat_lbl, bat_img, @@ -854,17 +877,24 @@ impl SimpleComponent for App { self.date_lbl.set_label(&bar::clock::date()); } AppInput::StatsUpdate(stats) => { - self.cpu_lbl.set_label(&stats.cpu); + let cpu = match stats.cpu_temp { + Some(t) => format!("{} · {:.0}°", stats.cpu, t), + None => stats.cpu, + }; + self.cpu_lbl.set_label(&cpu); self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); - - // Island bar never shows the system-stats trio — they live - // in the control panel. Keep the widgets hidden so a later - // re-parent cannot accidentally flash them. - self.cpu_pair.set_visible(false); - self.mem_pair.set_visible(false); - self.pwr_pair.set_visible(false); - self.system_stats_box.set_visible(false); + match stats.gpu_usage { + Some(g) => { + let gpu = match stats.gpu_temp { + Some(t) => format!("{g}% · {t:.0}°"), + None => format!("{g}%"), + }; + self.gpu_lbl.set_label(&gpu); + self.gpu_pair.set_visible(true); + } + None => self.gpu_pair.set_visible(false), + } self.system_sep.set_visible(false); tick_label(&self.vol_lbl, &stats.volume_pct.to_string()); diff --git a/src/panel.rs b/src/panel.rs new file mode 100644 index 0000000..c1bcd75 --- /dev/null +++ b/src/panel.rs @@ -0,0 +1,154 @@ +//! Standalone layer-shell panels for wifi / control / media. +//! +//! GTK `Popover` is an xdg_popup child of the island, so it paints over the +//! bar and Hyprland can only fade it. These are their own surfaces, parked +//! *below* the exclusive zone, and Hyprland slides `breadbar-panel` in from +//! the right. + +use gtk4::gdk::Key; +use gtk4::prelude::*; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; + +use crate::{bind_layer_monitor, theme, BAR_HEIGHT, BAR_MARGIN_SIDES, BAR_MARGIN_TOP}; + +const BELOW_BAR: i32 = BAR_MARGIN_TOP + BAR_HEIGHT + 8; + +#[derive(Clone)] +pub struct PanelSet { + pub connectivity: gtk4::Window, + pub control: gtk4::Window, + pub media: gtk4::Window, + dismiss: gtk4::Window, +} + +impl PanelSet { + pub fn new( + monitor: &str, + connectivity_child: &impl IsA, + control_child: &impl IsA, + media_child: &impl IsA, + ) -> Self { + let connectivity = make_panel("wifi-popover", connectivity_child, monitor); + let control = make_panel("control-panel", control_child, monitor); + let media = make_panel("media-popover", media_child, monitor); + let dismiss = make_dismiss(monitor); + + let set = Self { + connectivity, + control, + media, + dismiss, + }; + set.wire_dismiss(); + set.wire_escape(); + set + } + + pub fn toggle(&self, which: >k4::Window) { + if which.is_visible() { + self.hide_all(); + } else { + self.show(which); + } + } + + pub fn show(&self, which: >k4::Window) { + self.hide_panels(); + // Dismiss first so the panel maps above it (same Overlay layer). + self.dismiss.set_visible(true); + self.dismiss.present(); + which.set_visible(true); + which.present(); + } + + pub fn hide_all(&self) { + self.hide_panels(); + self.dismiss.set_visible(false); + } + + fn hide_panels(&self) { + self.connectivity.set_visible(false); + self.control.set_visible(false); + self.media.set_visible(false); + } + + fn wire_dismiss(&self) { + let set = self.clone(); + let click = gtk4::GestureClick::new(); + click.set_button(0); + click.connect_pressed(move |_, _, _, _| { + set.hide_all(); + }); + if let Some(child) = self.dismiss.child() { + child.add_controller(click); + } else { + self.dismiss.add_controller(click); + } + } + + fn wire_escape(&self) { + for win in [&self.connectivity, &self.control, &self.media] { + let set = self.clone(); + let keys = gtk4::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, _| { + if key == Key::Escape { + set.hide_all(); + gtk4::glib::Propagation::Stop + } else { + gtk4::glib::Propagation::Proceed + } + }); + win.add_controller(keys); + } + } +} + +fn make_panel(class: &str, child: &impl IsA, monitor: &str) -> gtk4::Window { + let window = gtk4::Window::new(); + window.add_css_class("breadbar-panel"); + window.add_css_class(class); + window.set_decorated(false); + window.set_resizable(false); + window.init_layer_shell(); + window.set_namespace(Some("breadbar-panel")); + window.set_layer(Layer::Overlay); + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Right, true); + window.set_margin(Edge::Top, BELOW_BAR); + window.set_margin(Edge::Right, BAR_MARGIN_SIDES); + window.set_exclusive_zone(-1); + window.set_keyboard_mode(KeyboardMode::OnDemand); + window.set_child(Some(child)); + bind_layer_monitor(&window, monitor); + theme::bind_output(&window, monitor); + window.set_visible(false); + window +} + +fn make_dismiss(monitor: &str) -> gtk4::Window { + let window = gtk4::Window::new(); + window.add_css_class("breadbar-dismiss"); + window.init_layer_shell(); + window.set_namespace(Some("breadbar-dismiss")); + // Overlay with the panels, but mapped first so they sit above it. + // Top margin keeps the island's chips clickable. + window.set_layer(Layer::Overlay); + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Bottom, true); + window.set_anchor(Edge::Left, true); + window.set_anchor(Edge::Right, true); + window.set_margin(Edge::Top, BAR_MARGIN_TOP + BAR_HEIGHT); + window.set_exclusive_zone(-1); + window.set_keyboard_mode(KeyboardMode::None); + // An empty window never maps a hit region. A filling child + a hair of + // alpha is what actually receives the click-away. + let hit = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + hit.add_css_class("breadbar-dismiss-hit"); + hit.set_hexpand(true); + hit.set_vexpand(true); + window.set_child(Some(&hit)); + bind_layer_monitor(&window, monitor); + theme::bind_output(&window, monitor); + window.set_visible(false); + window +} diff --git a/src/theme.rs b/src/theme.rs index 50f34f8..3a2ecc7 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -190,7 +190,10 @@ fn load_css() -> String { .control-panel-btn:hover {{ opacity: 1; background: alpha(@on-bg, 0.10); }}\ .control-panel-btn:active {{ background: alpha(@on-bg, 0.16); }}\ .control-panel {{ }}\ - .control-panel-inner {{ min-width: 220px; padding: {pad}; }}\ + .control-panel-inner {{ min-width: 248px; padding: {pad}; }}\ + .sys-grid {{ margin: 2px 0 6px; }}\ + .sys-stat {{ padding: 4px 2px; background: transparent; }}\ + .sys-stat:hover {{ background: transparent; }}\ .control-panel-header {{ font-size: 12px; font-weight: bold; letter-spacing: 0.12em;\ opacity: 0.45; margin-bottom: 8px; }}\ .control-panel-row {{ margin: 8px 0; }}\ From 241dfd17a75e486df31fa61b495b8bf3fc68fa0b Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:49:52 +0800 Subject: [PATCH 3/9] Include GPU.svg so the bar release build can compile --- assets/GPU.svg | 1 + 1 file changed, 1 insertion(+) create mode 100644 assets/GPU.svg diff --git a/assets/GPU.svg b/assets/GPU.svg new file mode 100644 index 0000000..03c7f1c --- /dev/null +++ b/assets/GPU.svg @@ -0,0 +1 @@ + From e4c12e9b62cc570d410626abc24c38ac672b74ba Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 14:04:05 +0800 Subject: [PATCH 4/9] Add audio output switching and fix the workspace trail The control panel lists PipeWire sinks and sets the default, moving playing streams so the change is immediate. The workspace pill starts on switch without a layout wait, measures against the Fixed host so it stays centered on the digit, and ignores row-wide bounds that used to stretch it across several chips. --- src/bar/control.rs | 21 +++- src/bar/workspaces.rs | 216 +++++++++++++++++++----------------------- src/main.rs | 51 ++++++++++ src/theme.rs | 12 ++- 4 files changed, 172 insertions(+), 128 deletions(-) diff --git a/src/bar/control.rs b/src/bar/control.rs index f480057..43cb6c3 100644 --- a/src/bar/control.rs +++ b/src/bar/control.rs @@ -121,12 +121,29 @@ pub fn spawn_set_brightness(v: f64) { }); } -#[allow(dead_code)] -pub fn spawn_set_sink(name: String) { +pub fn spawn_set_sink(name: String, sender: ComponentSender) { relm4::spawn(async move { let _ = tokio::process::Command::new("pactl") .args(["set-default-sink", &name]) .output() .await; + // Default sink alone leaves already-playing streams on the old + // device — move them too so the switch is audible immediately. + if let Ok(o) = tokio::process::Command::new("pactl") + .args(["list", "short", "sink-inputs"]) + .output() + .await + { + for line in String::from_utf8_lossy(&o.stdout).lines() { + let Some(id) = line.split_whitespace().next() else { + continue; + }; + let _ = tokio::process::Command::new("pactl") + .args(["move-sink-input", id, &name]) + .output() + .await; + } + } + spawn_load(sender); }); } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index fd83c79..2c05a15 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -139,8 +139,14 @@ pub fn make_button( btn.add_css_class("active"); } btn.set_valign(gtk4::Align::Center); + btn.set_halign(gtk4::Align::Center); btn.set_vexpand(false); + btn.set_hexpand(false); btn.set_size_request(-1, crate::CHIP_HEIGHT); + if let Some(child) = btn.child() { + child.set_halign(gtk4::Align::Center); + child.set_valign(gtk4::Align::Center); + } btn.connect_clicked(move |_| { relm4::spawn(async move { switch_workspace(id).await; @@ -189,7 +195,7 @@ impl WorkspaceTrail { pill.set_visible(false); host.put(&pill, 0.0, 0.0); - let buttons = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); + let buttons = gtk4::Box::new(gtk4::Orientation::Horizontal, 1); buttons.set_halign(gtk4::Align::Fill); buttons.set_valign(gtk4::Align::Center); buttons.set_vexpand(false); @@ -230,150 +236,125 @@ impl WorkspaceTrail { } pub fn place(&self, btn: >k4::Button) { + self.cancel(); + if let Some(g) = button_geom(btn, &self.host) { + apply_geom(&self.host, &self.pill, &self.inner, &inset_pill(g)); + return; + } let pill = self.pill.clone(); let host = self.host.clone(); let inner = self.inner.clone(); - self.when_stable(btn, move |g| { - apply_geom(&host, &pill, &inner, &g); + let btn = btn.clone(); + let id = self.overlay.add_tick_callback(move |_, _| { + let Some(g) = button_geom(&btn, &host) else { + return ControlFlow::Continue; + }; + apply_geom(&host, &pill, &inner, &inset_pill(g)); + inner.borrow_mut().tick = None; + ControlFlow::Break }); + self.inner.borrow_mut().tick = Some(id); } pub fn stretch(&self, from: Option<>k4::Button>, to: >k4::Button) { - let from_g = self.from_geom(from); - let overlay = self.overlay.clone(); + self.cancel(); + let Some(from_g) = self.from_geom(from) else { + self.place(to); + return; + }; + let dest = to.clone(); let pill = self.pill.clone(); let host = self.host.clone(); let inner = self.inner.clone(); - let dest = to.clone(); - self.when_stable(to, move |to_g| { - stretch_geom_on(&overlay, &host, &pill, &inner, from_g, to_g, Some(dest)); - }); - } - - /// New empty-workspace buttons first allocate at CSS `min-width` (32px) - /// and only then grow to the padded label. Two stable frames of that - /// placeholder is not enough — empty→empty used to shrink the pill to it. - fn when_stable(&self, btn: >k4::Button, then: impl FnOnce(Geom) + 'static) { - self.cancel(); - let btn = btn.clone(); - let inner = self.inner.clone(); - let last = std::cell::Cell::new(None::); - let same = std::cell::Cell::new(0u8); - let frames = std::cell::Cell::new(0u8); - let then = std::cell::Cell::new(Some(then)); - let id = self.overlay.add_tick_callback(move |ov, _| { - frames.set(frames.get().saturating_add(1)); - let n = frames.get(); - let Some(g) = button_geom(&btn, ov) else { - last.set(None); - same.set(0); - return if n > 24 { - inner.borrow_mut().tick = None; - ControlFlow::Break - } else { - ControlFlow::Continue - }; - }; - // Still sitting on the 32px min-width slot, or smaller than the - // button's natural request — keep waiting for the real layout. - if still_placeholder(&btn, &g) && n < 20 { - last.set(None); - same.set(0); - return ControlFlow::Continue; - } - let stable = last.get().is_some_and(|p| geom_close(&p, &g)); - last.set(Some(g)); - same.set(if stable { same.get().saturating_add(1) } else { 0 }); - if same.get() >= 2 || n > 22 { - inner.borrow_mut().tick = None; - if let Some(f) = then.take() { - f(g); + let started = Instant::now(); + let id = self.overlay.add_tick_callback(move |_, _| { + let to_g = resolved_dest(&dest, &host, &from_g); + let mid = { + let span_x = from_g.x.min(to_g.x); + let span_w = (from_g.x + from_g.w).max(to_g.x + to_g.w) - span_x; + Geom { + x: span_x, + y: to_g.y, + w: span_w, + h: to_g.h, } - return ControlFlow::Break; + }; + let elapsed = started.elapsed().as_secs_f64() * 1000.0; + let (g, done) = if elapsed < STRETCH_MS { + let t = ease(elapsed / STRETCH_MS); + (lerp_geom(&from_g, &mid, t), false) + } else if elapsed < STRETCH_MS + SNAP_MS { + let t = ease_overshoot((elapsed - STRETCH_MS) / SNAP_MS); + (lerp_geom(&mid, &to_g, t), false) + } else { + (to_g, true) + }; + apply_geom(&host, &pill, &inner, &g); + if done { + inner.borrow_mut().tick = None; + ControlFlow::Break + } else { + ControlFlow::Continue } - ControlFlow::Continue }); self.inner.borrow_mut().tick = Some(id); } fn from_geom(&self, from: Option<>k4::Button>) -> Option { let st = self.inner.borrow(); - if self.pill.is_visible() && st.geom.w > 0.5 { + // A leftover mid-stretch can be as wide as the whole row — never + // treat that as the start of the next animation. + if self.pill.is_visible() && st.geom.w > 0.5 && st.geom.w <= MAX_CHIP_W { return Some(st.geom); } drop(st); - from.and_then(|b| button_geom(b, &self.overlay)) + from.and_then(|b| button_geom(b, &self.host).map(inset_pill)) } - } -fn stretch_geom_on( - overlay: >k4::Overlay, - host: >k4::Fixed, - pill: >k4::Box, - inner: &Rc>, - from_g: Option, - to_g: Geom, - dest: Option, -) { - let Some(from_g) = from_g else { - apply_geom(host, pill, inner, &to_g); - return; - }; - if (from_g.x - to_g.x).abs() < 0.5 && (from_g.w - to_g.w).abs() < 0.5 { - apply_geom(host, pill, inner, &to_g); - return; - } +/// Keep the trail slimmer than the hit target so the fill doesn't look +/// like a second, fatter button. +const PILL_INSET_X: f64 = 5.0; +const PILL_INSET_Y: f64 = 3.0; +/// One workspace chip is a digit + padding. Wider than this is the overlay +/// or the whole button row leaking through `compute_bounds`. +const MAX_CHIP_W: f64 = 72.0; - let span_x = from_g.x.min(to_g.x); - let span_w = (from_g.x + from_g.w).max(to_g.x + to_g.w) - span_x; - let mid = Geom { - x: span_x, - y: to_g.y, - w: span_w, - h: to_g.h, - }; - - if let Some(id) = inner.borrow_mut().tick.take() { - id.remove(); +fn inset_pill(g: Geom) -> Geom { + let w = (g.w - PILL_INSET_X * 2.0).max(10.0); + let h = (g.h - PILL_INSET_Y * 2.0).max(18.0); + Geom { + x: g.x + (g.w - w) * 0.5, + y: g.y + (g.h - h) * 0.5, + w, + h, } - let started = Instant::now(); - let pill = pill.clone(); - let host = host.clone(); - let inner_tick = inner.clone(); - let dest = dest.clone(); - let ov = overlay.clone(); - let id = overlay.add_tick_callback(move |_, _| { - let elapsed = started.elapsed().as_secs_f64() * 1000.0; - let (g, done) = if elapsed < STRETCH_MS { - let t = ease(elapsed / STRETCH_MS); - (lerp_geom(&from_g, &mid, t), false) - } else if elapsed < STRETCH_MS + SNAP_MS { - let t = ease_overshoot((elapsed - STRETCH_MS) / SNAP_MS); - (lerp_geom(&mid, &to_g, t), false) - } else { - let end = dest - .as_ref() - .and_then(|b| button_geom(b, &ov)) - .unwrap_or(to_g); - (end, true) - }; - apply_geom(&host, &pill, &inner_tick, &g); - if done { - inner_tick.borrow_mut().tick = None; - ControlFlow::Break - } else { - ControlFlow::Continue +} + +fn resolved_dest(btn: >k4::Button, host: >k4::Fixed, from: &Geom) -> Geom { + match button_geom(btn, host) { + Some(g) if !still_placeholder(btn, &g) => inset_pill(g), + Some(g) => { + let centered = inset_pill(g); + Geom { + x: centered.x + (centered.w - from.w) * 0.5, + y: centered.y + (centered.h - from.h) * 0.5, + w: from.w, + h: from.h, + } } - }); - inner.borrow_mut().tick = Some(id); + None => *from, + } } -fn button_geom(btn: >k4::Button, overlay: >k4::Overlay) -> Option { - let r = btn.compute_bounds(overlay)?; +/// Position in the Fixed host's space — that's what `host.move_` uses. +/// Measuring against the Overlay instead left the pill a few px left of +/// the digit whenever the host and overlay origins disagreed. +fn button_geom(btn: >k4::Button, host: >k4::Fixed) -> Option { + let r = btn.compute_bounds(host)?; let w = f64::from(r.width()); let h = f64::from(r.height()); - if w < 1.0 || h < 1.0 { + if w < 8.0 || h < 8.0 || w > MAX_CHIP_W { return None; } Some(Geom { @@ -405,13 +386,6 @@ fn still_placeholder(btn: >k4::Button, g: &Geom) -> bool { g.w <= f64::from(min_w) + 1.0 || g.w + 0.5 < f64::from(nat_w) } -fn geom_close(a: &Geom, b: &Geom) -> bool { - (a.x - b.x).abs() < 0.5 - && (a.y - b.y).abs() < 0.5 - && (a.w - b.w).abs() < 0.5 - && (a.h - b.h).abs() < 0.5 -} - fn lerp(a: f64, b: f64, t: f64) -> f64 { a + (b - a) * t } diff --git a/src/main.rs b/src/main.rs index 21c4646..309a8fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,6 +99,8 @@ pub struct App { panel_vol_slider: gtk4::Scale, panel_bright_slider: gtk4::Scale, panel_loading: Rc>, + sink_box: gtk4::Box, + sink_section: gtk4::Box, // ── Tray ────────────────────────────────────────────────────────────── tray_section: gtk4::Box, @@ -517,6 +519,18 @@ impl SimpleComponent for App { let panel_vol_slider = vol_row.1.clone(); panel_inner.append(&vol_row.0); + let sink_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + sink_section.add_css_class("control-panel-section"); + let sink_header = gtk4::Label::new(Some("OUTPUT")); + sink_header.add_css_class("control-panel-header"); + sink_header.set_xalign(0.0); + sink_header.set_margin_top(6); + let sink_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + sink_section.append(&sink_header); + sink_section.append(&sink_box); + sink_section.set_visible(false); + panel_inner.append(&sink_section); + let bright_row = build_slider_row("bl", 0.0, 1.0, 0.02); let panel_bright_slider = bright_row.1.clone(); panel_inner.append(&bright_row.0); @@ -747,6 +761,8 @@ impl SimpleComponent for App { panel_vol_slider, panel_bright_slider, panel_loading, + sink_box, + sink_section, tray_section, tray_sep, tray_box, @@ -1033,6 +1049,7 @@ impl SimpleComponent for App { self.panel_vol_slider.set_value(data.volume); self.panel_bright_slider.set_value(data.brightness); self.panel_loading.set(false); + self.rebuild_sinks(&data.sinks, &sender); } AppInput::WidgetsUpdate(specs) => { self.reconcile_widgets(specs); @@ -1129,6 +1146,40 @@ impl App { } } + fn rebuild_sinks( + &mut self, + sinks: &[bar::control::AudioSink], + sender: &ComponentSender, + ) { + while let Some(child) = self.sink_box.first_child() { + self.sink_box.remove(&child); + } + self.sink_section.set_visible(!sinks.is_empty()); + for (i, sink) in sinks.iter().enumerate() { + let row = gtk4::Button::new(); + row.add_css_class("flat"); + row.add_css_class("wifi-popover-row"); + row.add_css_class("sink-row"); + if sink.is_default { + row.add_css_class("wifi-popover-row-active"); + } + stagger_row(&row, i); + let lbl = gtk4::Label::new(Some(&sink.description)); + lbl.set_xalign(0.0); + lbl.set_hexpand(true); + lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); + lbl.set_max_width_chars(22); + lbl.set_valign(gtk4::Align::Center); + row.set_child(Some(&lbl)); + let name = sink.name.clone(); + let sender = sender.clone(); + row.connect_clicked(move |_| { + bar::control::spawn_set_sink(name.clone(), sender.clone()); + }); + self.sink_box.append(&row); + } + } + fn apply_wifi_label(&self) { let label = match &self.wifi_profile { Some(p) => format!("{p} · {}", self.current_ssid), diff --git a/src/theme.rs b/src/theme.rs index 3a2ecc7..f11b8d2 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -39,10 +39,10 @@ fn load_css() -> String { window.breadbar > centerbox {{ padding: 0 8px 0 6px; }}\ window.breadbar button {{ min-height: 0; min-width: 0; }}\ .workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\ - background-color: @accent; border-radius: 999px; }}\ + background-color: @accent; border-radius: 12px; }}\ .workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\ - border-radius: 999px; border: none; outline: none; box-shadow: none;\ - min-width: 32px; min-height: 32px; margin: 0 2px; padding: 0 12px;\ + border-radius: 12px; border: none; outline: none; box-shadow: none;\ + min-width: 28px; min-height: 28px; margin: 0; padding: 0 7px;\ font-size: 22px; font-weight: bold;\ transition: opacity 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\ background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ @@ -67,9 +67,10 @@ fn load_css() -> String { opacity 0.18s ease; }}\ .stat-pair:hover {{ background: alpha(@on-bg, 0.12); }}\ .stat-pair:active {{ background: alpha(@on-bg, 0.18); }}\ - .stat-pair.icon-only {{ padding: 6px; border-radius: 999px; }}\ + .stat-pair.icon-only {{ padding: 4px; border-radius: 999px;\ + min-width: 32px; min-height: 32px; }}\ .stat-icon {{ margin-right: 6px; }}\ - .stat-pair.icon-only .stat-icon {{ margin-right: 0; }}\ + .stat-pair.icon-only .stat-icon {{ margin: 0; }}\ .bt-icon {{ margin-right: 8px; }} separator.bar-sep {{ min-height: 12px; min-width: 1px; margin: 0 10px 0 2px;\ background: alpha(@on-bg, 0.10); }}\ @@ -208,6 +209,7 @@ fn load_css() -> String { padding: 0; opacity: 0; background: transparent; border: none;\ outline: none; box-shadow: none; }}\ .control-panel-section {{ margin: 8px 0 0; }}\ + .sink-row label {{ font-size: 15px; }}\ .power-row {{ margin-top: 8px; }}\ .power-btn {{ min-width: 0; min-height: 0; padding: 8px 10px; border-radius: 8px;\ background: alpha(@on-bg, 0.08); font-size: 13px; border: none;\ From cd7465a18bb90bf138c648fddd32d56d2b0b1bfb Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 14:09:04 +0800 Subject: [PATCH 5/9] Bump version to v0.3.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3bc508e..d7ff339 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -207,7 +207,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.3.1" +version = "0.3.3" dependencies = [ "anyhow", "bread-screenshots", diff --git a/Cargo.toml b/Cargo.toml index 1e24f17..f5bfbb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.3.1" +version = "0.3.3" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" From 9c4205b1b25904d16a1cf828de3b11c69f49d19e Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 6 Aug 2026 08:47:05 +0800 Subject: [PATCH 6/9] clippy: fix char-comparison and map_or lints (cherry picked from commit 2e393082a744fe85f1ca1f8c6af48ce288e64b6c) --- src/bar/stats.rs | 2 +- src/main.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 892f792..26358e0 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -402,7 +402,7 @@ fn read_crumbs_profile() -> Option { for line in text.lines() { if let Some(rest) = line.trim().strip_prefix("profile") { let val = rest - .trim_start_matches(|c: char| c == ' ' || c == '=') + .trim_start_matches([' ', '=']) .trim_matches('"'); if !val.is_empty() { return Some(val.to_string()); diff --git a/src/main.rs b/src/main.rs index 309a8fd..5210546 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1023,7 +1023,7 @@ impl SimpleComponent for App { let within_linger = self .media_paused_at - .map_or(true, |t| t.elapsed().as_secs() < 30 * 60); + .is_none_or(|t| t.elapsed().as_secs() < 30 * 60); self.media_last = Some(state); reveal_media(&self.media_widget, within_linger); } else { From 700fc3ed16b54aa68b88ca70b260fe738e485f18 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 15:02:51 +0800 Subject: [PATCH 7/9] Unify bread-ecosystem crate pins at v0.7.4 bread-theme was already pinned to v0.7.4 while bread-utils and bread-screenshots trailed at v0.7.2, even though bread-ecosystem locks all workspace packages together as of a9754d9. Bump the two lagging pins so all three crates from that repo resolve to the same tag/commit. (cherry picked from commit f0de82aafb547134e0edc9dac8d72c01586db57c) --- Cargo.lock | 8 ++++---- Cargo.toml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d7ff339..14765ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,8 +152,8 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-screenshots" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#a9754d90ed32efcc26765abd01c9f441bfb01b1e" dependencies = [ "anyhow", "bread-utils", @@ -196,8 +196,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#a9754d90ed32efcc26765abd01c9f441bfb01b1e" dependencies = [ "bread-shared 0.7.0", "dirs 5.0.1", diff --git a/Cargo.toml b/Cargo.toml index f5bfbb6..1825aa8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,12 +15,12 @@ bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs # instead of hand-parsing serde_json::Value. See src/widgets/. -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["bread-client"] } # v0.8.0-rc.1 carries bread_shared::widget; keep this bread tag even if # ecosystem crates move independently. bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } From 3049a20be3a24fdd4fe2ee68935acdc74e1e8793 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 15:07:51 +0800 Subject: [PATCH 8/9] gitignore: exclude graphify-out local tool cache --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 816e2ad..4f90dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ logs/ # Internal design documents (not for distribution) aster-brief.md + +# graphify knowledge-graph output (local tool cache, not for commit) +graphify-out/ From 297207a7aad1e85fb8633983f150119f93a5239c Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 13:11:20 +0800 Subject: [PATCH 9/9] notifications: stop toast popups from stealing focus or blocking clicks Toasts never grab keyboard focus and pass every pointer event through to whatever's underneath, via an empty layer-shell input region. --- src/notifications/popup.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 5753b65..9c767b6 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -177,9 +177,16 @@ fn create_window() -> gtk4::Window { window.set_margin(Edge::Top, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8); window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES); window.set_default_width(320); - // OnDemand so an inline-reply GtkEntry can take keys without the popup - // stealing every keystroke the rest of the time. - window.set_keyboard_mode(KeyboardMode::OnDemand); + // Toasts are purely informational for now: never grab keyboard focus... + window.set_keyboard_mode(KeyboardMode::None); + // ...and click through entirely — an empty input region means every + // pointer event passes straight to whatever's underneath instead of + // hitting the toast. + window.connect_map(|win| { + if let Some(surface) = win.surface() { + surface.set_input_region(Some(>k4::cairo::Region::create())); + } + }); crate::theme::bind_auto(&window); window }