From 1806c6f912475578fa89953a1175b1c146fb9257 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:23:31 +0800 Subject: [PATCH 01/50] 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 02/50] 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 03/50] 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 04/50] 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 05/50] 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 06/50] 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 07/50] 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 08/50] 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 09/50] 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 } From bbb1a6c8d22fcb176c5e40a4fdddfd768e88e0e2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 15:58:25 +0800 Subject: [PATCH 10/50] Drive bar/window geometry and CSS tokens from the shell theme manifest breadbar no longer hardcodes BAR_HEIGHT/BAR_MARGIN_TOP/BAR_MARGIN_SIDES/ CHIP_HEIGHT/ICON_PX or the root window's layer-shell setup: it loads bread_theme::shell::ShellTheme once (theme::shell_theme(), cached in a thread-local) and reads bar.window (anchors, margin, exclusive zone, keyboard mode - now set explicitly instead of relying on the library default) plus tokens.{icon_px,chip_height} from it everywhere those used to be literals. The four satellite surfaces (breadbar-osd, breadbar-notif, breadbar-panel, breadbar-dismiss) now get their anchor/margin/width/layer from the manifest's [surfaces.*] table via a small new surface::apply() helper, narrowly scoped to the three anchor shapes those surfaces actually use. The pre-existing 8px gap between the panel's top margin and the dismiss scrim's is preserved exactly (and now commented) rather than "fixed". theme.rs's load_css() keeps its ~250 lines of hand-written breadbar CSS (notifications, wifi popover, control panel, media widget) but now reads its five radius/pad locals and the two easing curves (spring vs spring_settle - hover/settle transitions were previously miscategorized as the overshoot curve in the constant audit; the actual code already used the settle curve there, confirmed against src) from theme tokens instead of hardcoding them. Also wires bread_theme::shell::watch() so editing the active theme's theme.toml/extra.css hot-reloads CSS tokens without a restart, same as a pywal palette change already does. Window-spec values still need a restart per the plan (read once at window-construction time). Verified pixel-identical: captured all 10 breadbar --screenshot views (bar, control-panel, connectivity-wifi/bluetooth, media-popover, notification/-critical, osd-volume/-brightness, wifi-add-dialog) via bread-capture's isolated headless-Sway harness against both the pre-change and post-change binaries; every view diffs byte-identical at the decoded-pixel level. --- Cargo.lock | 4 +- Cargo.toml | 6 ++ src/bar/workspaces.rs | 2 +- src/main.rs | 93 +++++++++++++++++++-------- src/notifications/history.rs | 12 ++-- src/notifications/popup.rs | 22 ++----- src/osd.rs | 9 +-- src/panel.rs | 25 +++----- src/screenshot.rs | 8 ++- src/surface.rs | 78 +++++++++++++++++++++++ src/theme.rs | 118 ++++++++++++++++++++++++++++------- 11 files changed, 278 insertions(+), 99 deletions(-) create mode 100644 src/surface.rs diff --git a/Cargo.lock b/Cargo.lock index 14765ed..d5be557 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,12 +186,14 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.7.4" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" dependencies = [ + "anyhow", "dirs 5.0.1", "gtk4", "serde", "serde_json", + "toml 0.8.23", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1825aa8..de10b69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,3 +40,9 @@ resvg = { version = "0.47", default-features = false } lto = "thin" codegen-units = 1 strip = "symbols" + +# DEV ONLY — remove before tagging. Points bread-theme at the local +# bread-ecosystem checkout so Phase 2 can build against `bread_theme::shell` +# (feature/shell-theme-manifest) before it is tagged as a release. +[patch."https://git.breadway.dev/Breadway/bread-ecosystem"] +bread-theme = { path = "../bread-ecosystem/bread-theme" } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 2c05a15..611e15e 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -142,7 +142,7 @@ pub fn make_button( btn.set_halign(gtk4::Align::Center); btn.set_vexpand(false); btn.set_hexpand(false); - btn.set_size_request(-1, crate::CHIP_HEIGHT); + btn.set_size_request(-1, crate::theme::shell_theme().tokens().chip_height() as i32); if let Some(child) = btn.child() { child.set_halign(gtk4::Align::Center); child.set_valign(gtk4::Align::Center); diff --git a/src/main.rs b/src/main.rs index 5210546..cf9a5c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,21 +9,13 @@ mod notifications; mod osd; mod panel; mod screenshot; +mod surface; mod theme; mod widgets; -/// 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 bread_theme::shell::{Exclusive, Keyboard}; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, Layer, LayerShell}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use hyprland::data::Workspace; use hyprland::shared::WorkspaceId; use relm4::prelude::*; @@ -151,7 +143,7 @@ impl SimpleComponent for App { gtk::ApplicationWindow { add_css_class: "breadbar", set_title: Some("breadbar"), - set_default_height: BAR_HEIGHT, + set_default_height: bar_height, #[name = "center_box"] gtk::CenterBox { @@ -171,16 +163,51 @@ impl SimpleComponent for App { .or_else(primary_hypr_monitor) .unwrap_or_else(|| "eDP-1".into()); + // `bar.window` (plan §2/§6) — window shape is data, not a closed + // layout enum. Read once and reused below for the layer-shell setup + // and (via `bar_height`, captured for the view! macro above) the + // root window's initial GTK height. + let window_spec = theme::shell_theme().window().clone(); + let bar_height = window_spec.height; + 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_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); + root.set_layer(if window_spec.layer == "overlay" { + Layer::Overlay + } else { + Layer::Top + }); + for anchor in &window_spec.anchors { + match anchor.as_str() { + "top" => root.set_anchor(Edge::Top, true), + "bottom" => root.set_anchor(Edge::Bottom, true), + "left" => root.set_anchor(Edge::Left, true), + "right" => root.set_anchor(Edge::Right, true), + other => eprintln!( + "breadbar: bar.window.anchors entry \"{other}\" is not top|bottom|left|right, ignoring" + ), + } + } + root.set_margin(Edge::Top, window_spec.margin.top); + root.set_margin(Edge::Left, window_spec.margin.left); + root.set_margin(Edge::Right, window_spec.margin.right); + // "auto" reserves height + top margin so tiled clients sit below the + // gap — see WindowSpec::exclusive's doc comment (bread-theme). + let exclusive_zone = match window_spec.exclusive { + Exclusive::Auto => window_spec.height + window_spec.margin.top, + Exclusive::None => -1, + Exclusive::Px(px) => px, + }; + root.set_exclusive_zone(exclusive_zone); + // breadbar never called `set_keyboard_mode` before Phase 2 — it + // relied on gtk4-layer-shell's own default (`KeyboardMode::None`), + // which is exactly what the builtin manifest's `keyboard = "none"` + // resolves to. Same behaviour, no longer implicit. + root.set_keyboard_mode(match window_spec.keyboard { + Keyboard::None => KeyboardMode::None, + Keyboard::OnDemand => KeyboardMode::OnDemand, + Keyboard::Exclusive => KeyboardMode::Exclusive, + }); eprintln!( "breadbar: init monitor={monitor_name} primary={}", init.primary @@ -226,6 +253,10 @@ impl SimpleComponent for App { let widget_left_of_stats = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); widget_left_of_stats.add_css_class("bread-widget-slot"); + // `tokens.icon_px` (plan §4) — bar-chrome icon pixel size; reused + // below for every `prepare_icon` call in this function. + let icon_px = theme::shell_theme().tokens().icon_px() as i32; + // ── SVG icon sets ──────────────────────────────────────────────── use bar::stats::{ AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, ICON_VOLUME, @@ -253,13 +284,13 @@ impl SimpleComponent for App { let bat_img = gtk4::Image::from_paintable(Some( bat_textures.get(&(BAT_MID.as_ptr() as usize)).unwrap(), )); - prepare_icon(&bat_img, ICON_PX); + 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); + prepare_icon(&bt_img, icon_px); bt_img.set_visible(false); // ── WiFi pair + popover ────────────────────────────────────────── @@ -273,7 +304,7 @@ impl SimpleComponent for App { // 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); + prepare_icon(&wifi_img, icon_px); wifi_img.add_css_class("stat-icon"); // Content pane only — this becomes a tab inside the merged @@ -783,6 +814,11 @@ impl SimpleComponent for App { if init.primary { bar::tray::spawn_watcher(sender.clone()); widgets::client::spawn(sender.clone()); + // Optional (plan §10, Phase 2 item 6): live theme.toml/extra.css + // token reload, the same way a pywal palette change already + // hot-reloads via `apply_app_css`. One watch per process, so + // only the primary instance arms it. + theme::watch_hot_reload(); } // Screenshot mode primes these with sample content instead of the @@ -1008,7 +1044,10 @@ impl SimpleComponent for App { }; self.media_play_icon .set_paintable(Some(&svg_texture(icon_svg))); - prepare_icon(&self.media_play_icon, ICON_PX); + prepare_icon( + &self.media_play_icon, + theme::shell_theme().tokens().icon_px() as i32, + ); if state.playing { self.media_widget.add_css_class("playing"); } else { @@ -1703,7 +1742,7 @@ fn popover_tab(label: &str) -> gtk4::ToggleButton { btn.set_hexpand(true); btn.set_valign(gtk4::Align::Center); btn.set_vexpand(false); - btn.set_size_request(-1, CHIP_HEIGHT); + btn.set_size_request(-1, theme::shell_theme().tokens().chip_height() as i32); if let Some(child) = btn.child() { child.set_halign(gtk4::Align::Center); child.set_valign(gtk4::Align::Center); @@ -1743,7 +1782,7 @@ pub(crate) fn prepare_icon(img: >k4::Image, px: i32) { } pub(crate) fn svg_image(svg_src: &str) -> gtk4::Image { - svg_image_sized(svg_src, ICON_PX as u32) + svg_image_sized(svg_src, theme::shell_theme().tokens().icon_px() as u32) } pub(crate) fn svg_image_sized(svg_src: &str, px: u32) -> gtk4::Image { @@ -1753,7 +1792,7 @@ pub(crate) fn svg_image_sized(svg_src: &str, px: u32) -> gtk4::Image { } pub(crate) fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { - svg_texture_sized(svg_src, ICON_PX as u32) + svg_texture_sized(svg_src, theme::shell_theme().tokens().icon_px() as u32) } /// Rasterise at 2× the display size so Lucide strokes stay sharp when GTK diff --git a/src/notifications/history.rs b/src/notifications/history.rs index 61adc0f..e3919bd 100644 --- a/src/notifications/history.rs +++ b/src/notifications/history.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; +use gtk4_layer_shell::{KeyboardMode, LayerShell}; use serde::{Deserialize, Serialize}; use super::Urgency; @@ -176,11 +176,11 @@ pub fn build_window(store: Store) -> Ui { 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, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8); - window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES); + crate::surface::apply(&window, "breadbar-notif"); + // Overrides `[surfaces."breadbar-notif"].width` (320px, the live-toast + // popup's width — see `surface::apply`'s doc comment): the history + // window genuinely wants a different width on the same namespace, and + // that isn't something the manifest schema models today. window.set_default_width(360); window.set_keyboard_mode(KeyboardMode::OnDemand); crate::theme::bind_auto(&window); diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 9c767b6..394e136 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -1,7 +1,7 @@ use std::{cell::RefCell, collections::HashMap, rc::Rc}; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; +use gtk4_layer_shell::{KeyboardMode, LayerShell}; use tokio::sync::mpsc::Receiver; use super::{history, Action, Expire, NotifEvent, Urgency, INLINE_REPLY_KEY}; @@ -171,22 +171,10 @@ fn create_window() -> gtk4::Window { 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, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8); - window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES); - window.set_default_width(320); - // 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::surface::apply(&window, "breadbar-notif"); + // 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 d438292..73ce020 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -1,7 +1,7 @@ use std::{cell::Cell, rc::Rc, time::Duration}; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, Layer, LayerShell}; +use gtk4_layer_shell::LayerShell; use tokio::sync::mpsc; enum OsdEvent { @@ -182,7 +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); + crate::prepare_icon(&icon, crate::theme::shell_theme().tokens().icon_px() as i32); if muted { icon.add_css_class("osd-icon-muted"); } else { @@ -209,10 +209,7 @@ fn create_window() -> gtk4::Window { 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::surface::apply(&window, "breadbar-osd"); crate::theme::bind_auto(&window); window } diff --git a/src/panel.rs b/src/panel.rs index c1bcd75..9070c63 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -7,11 +7,9 @@ use gtk4::gdk::Key; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; +use gtk4_layer_shell::{KeyboardMode, 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; +use crate::{bind_layer_monitor, theme}; #[derive(Clone)] pub struct PanelSet { @@ -111,11 +109,7 @@ fn make_panel(class: &str, child: &impl IsA, monitor: &str) -> gtk 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); + crate::surface::apply(&window, "breadbar-panel"); window.set_exclusive_zone(-1); window.set_keyboard_mode(KeyboardMode::OnDemand); window.set_child(Some(child)); @@ -132,12 +126,13 @@ fn make_dismiss(monitor: &str) -> gtk4::Window { 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); + // + // NOTE — deliberate, not a bug: this surface's top margin is 8px less + // than `breadbar-panel`'s (see `make_panel` above / `[surfaces.*]` in + // the active theme). The panels start 8px lower than the dismiss + // scrim's clickable region. This predates Phase 2 and is preserved + // exactly for pixel-identical rendering — do not "fix" this gap. + crate::surface::apply(&window, "breadbar-dismiss"); 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 diff --git a/src/screenshot.rs b/src/screenshot.rs index 05e9af6..38f857e 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -123,18 +123,22 @@ pub struct Handles { /// 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; +fn bar_capture_height() -> i32 { + let window = crate::theme::shell_theme().window().clone(); + window.height + window.margin.top +} pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) { let output = req.output; let (width, height) = (req.width as i32, req.height as i32); + let bar_height = bar_capture_height(); match req.view.as_str() { "bar" => { root.connect_map(move |_| { let output = output.clone(); gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { - finish(bread_screenshots::capture_region(0, 0, width, BAR_HEIGHT, &output)); + finish(bread_screenshots::capture_region(0, 0, width, bar_height, &output)); }); }); } diff --git a/src/surface.rs b/src/surface.rs new file mode 100644 index 0000000..d2812a2 --- /dev/null +++ b/src/surface.rs @@ -0,0 +1,78 @@ +//! Applies a `[surfaces.]` entry (plan §4/§6, Phase 2) to a +//! satellite layer-shell window: anchor, margin (from `offset`), width and +//! layer. Deliberately narrow — it only understands the three anchor shapes +//! breadbar's four built-in surfaces actually use today ("breadbar-notif", +//! "breadbar-osd", "breadbar-panel", "breadbar-dismiss": `top_right`, +//! `bottom_centre`, `fill`), not a general anchor DSL (the plan's own +//! anti-goal, §2). `exclusive` zone and `keyboard` mode aren't part of the +//! `[surfaces.*]` schema (`bread_theme::shell::Surface` has no such fields) +//! and stay hardcoded at each call site, same as before this refactor. + +use bread_theme::shell::SurfaceWidth; +use gtk4::prelude::*; +use gtk4_layer_shell::{Edge, Layer, LayerShell}; + +/// `namespace` should be a key in the active theme's `[surfaces.*]` table — +/// every call site in this crate passes one of breadbar's own namespace +/// literals, so a miss here means the active theme fell out of sync with +/// the Rust source, not a bad runtime value. Logs and leaves the window at +/// gtk4-layer-shell's own defaults rather than panicking, matching every +/// other "malformed/incomplete theme" fallback in this system. +/// +/// Does not set `set_default_width` for a namespace shared by more than one +/// window with genuinely different widths (`breadbar-notif`'s live toast is +/// 320px, its history sibling is 360px, and only the toast's width is +/// modeled in `[surfaces.*]` — see the Phase 0 constant inventory); callers +/// that need a different width than the theme's own set it explicitly +/// afterward. +pub fn apply(window: >k4::Window, namespace: &str) { + let theme = crate::theme::shell_theme(); + let Some(surf) = theme.surfaces().get(namespace) else { + eprintln!( + "breadbar: no [surfaces.{namespace}] entry in the active theme; \ + window left at layer-shell defaults" + ); + return; + }; + + window.set_layer(if surf.layer == "top" { + Layer::Top + } else { + Layer::Overlay + }); + + match surf.anchor.as_str() { + "top_right" => { + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Right, true); + // offset = [right, top] for this anchor shape. + let right = surf.offset.first().copied().unwrap_or(0.0) as i32; + let top = surf.offset.get(1).copied().unwrap_or(0.0) as i32; + window.set_margin(Edge::Right, right); + window.set_margin(Edge::Top, top); + } + "bottom_centre" => { + window.set_anchor(Edge::Bottom, true); + let bottom = surf.offset.first().copied().unwrap_or(0.0) as i32; + window.set_margin(Edge::Bottom, bottom); + } + "fill" => { + for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { + window.set_anchor(edge, true); + } + // Only a top margin is meaningful here — a fullscreen click-away + // scrim that starts below the bar rather than covering it. + let top = surf.offset.first().copied().unwrap_or(0.0) as i32; + window.set_margin(Edge::Top, top); + } + other => eprintln!( + "breadbar: surfaces.{namespace}.anchor = \"{other}\" is not one of \ + top_right|bottom_centre|fill — breadbar's satellite windows don't \ + understand any other shape yet, leaving this window unanchored" + ), + } + + if let SurfaceWidth::Px(px) = surf.width { + window.set_default_width(px); + } +} diff --git a/src/theme.rs b/src/theme.rs index f11b8d2..768a69d 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,10 +1,38 @@ +use bread_theme::shell::ShellTheme; 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; +use std::rc::Rc; thread_local! { static USER_PROVIDER: RefCell> = const { RefCell::new(None) }; + // Loaded lazily on first access and cached — the underlying + // `bread_theme::shell::load()` call happens at most once per process, + // not once per read site (plan §5/§6, Phase 2). Stored as an `Rc` so + // window/surface-geometry call sites elsewhere in the crate can hold a + // cheap clone rather than re-reading the cell each time. + static SHELL_THEME: RefCell> = + RefCell::new(Rc::new(bread_theme::shell::load())); +} + +/// The active shell theme — window geometry, `[surfaces.*]`, and CSS tokens +/// (plan §5). Every consumer (this module's own `load_css`, plus main.rs, +/// osd.rs, panel.rs, notifications/, and `surface::apply`) reads through +/// this single shared instance instead of calling `bread_theme::shell::load()` +/// itself. +pub fn shell_theme() -> Rc { + SHELL_THEME.with(|cell| cell.borrow().clone()) +} + +/// Replaces the shared shell theme in place. Only used by the optional +/// `theme.toml` hot-reload watch (see `watch_hot_reload` below) — per plan +/// §10, a window-spec change (anchors, margins, exclusive zone, keyboard) +/// still needs a restart to take effect, since those are read once at +/// window-construction time; only CSS token values re-resolve live, the +/// next time `load_css` runs. +pub fn set_shell_theme(theme: ShellTheme) { + SHELL_THEME.with(|cell| *cell.borrow_mut() = Rc::new(theme)); } fn load_css() -> String { @@ -19,11 +47,27 @@ fn load_css() -> String { // 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"; + // + // These ~250 lines are breadbar-specific chrome (notifications, wifi + // popover, control panel, media widget) that `ShellTheme::css()` does + // not template — only the window/workspace/clock chrome the manifest's + // own concepts model does (plan §6 scope note). This function stays + // hand-written CSS; it now just reads its radius/pad/easing numbers from + // the theme's tokens instead of hardcoding them. + let theme = shell_theme(); + let tokens = theme.tokens(); + let radius = format!("{}px", tokens.radius_card()); + let radius_bar = format!("{}px", tokens.radius_bar()); + let radius_sm = format!("{}px", tokens.radius_sm()); + let radius_pill = format!("{}px", tokens.radius_pill()); + let pad = format!("{}px", tokens.pad()); + // Two curves, not one: `spring` is the overshoot/bounce curve (clock + // flips, pop-ins, the workspace caret draw); `spring_settle` is the + // flatter curve used for hovers and background/opacity transitions. + // Do not collapse these — they read differently and cover different + // sites below (see the Phase 0 constant inventory). + let spring = tokens.spring(); + let spring_settle = tokens.spring_settle(); format!( "@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\ @@ -44,26 +88,26 @@ fn load_css() -> String { 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); }}\ + transition: opacity 0.22s {spring_settle},\ + background-color 0.22s {spring_settle}; }}\ .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; }}\ + .workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} 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; }}\ + .clock-digit.flip {{ animation: digit-flip 0.45s {spring} 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; }}\ + .stat-label.tick {{ animation: digit-flip 0.35s {spring} 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),\ + transition: background-color 0.22s {spring_settle},\ opacity 0.18s ease; }}\ .stat-pair:hover {{ background: alpha(@on-bg, 0.12); }}\ .stat-pair:active {{ background: alpha(@on-bg, 0.18); }}\ @@ -77,11 +121,11 @@ fn load_css() -> String { 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; }}\ + animation: pop-in 0.45s {spring} 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; }}\ + animation: notif-in 0.45s {spring_settle} both; }}\ .notification-card.urgency-critical {{ border-left-color: @red; }}\ .notification-card.urgency-normal {{ border-left-color: @accent; }}\ .notification-summary {{ font-weight: bold; }}\ @@ -98,7 +142,7 @@ fn load_css() -> String { .history-card {{ margin-bottom: 6px; }}\ 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; }}\ + animation: osd-in 0.4s {spring_settle} both; }}\ .osd-icon {{ opacity: 0.85; margin-right: 8px; }}\ .osd-icon-muted {{ opacity: 0.35; }}\ progressbar.osd-bar {{ min-height: 6px; }}\ @@ -114,7 +158,7 @@ fn load_css() -> String { .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; }}\ + animation: caret-draw 0.45s {spring} 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;\ @@ -122,7 +166,7 @@ fn load_css() -> String { .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),\ + transition: background-color 0.22s {spring_settle},\ opacity 0.22s ease, color 0.22s ease; }}\ .popover-tab:hover {{ opacity: 0.8; }}\ .popover-tab:checked {{ background: alpha(@accent, 0.22); color: @accent; opacity: 1; }}\ @@ -134,12 +178,12 @@ fn load_css() -> String { letter-spacing: 0.12em; }}\ .wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\ 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); }}\ + transition: background-color 0.18s {spring_settle}; }}\ .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; }}\ + .row-in {{ animation: row-in 0.32s {spring} 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; }}\ @@ -153,23 +197,23 @@ fn load_css() -> String { 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); }}\ + transition: background-color 0.25s {spring_settle}; }}\ 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; }}\ + animation: pop-in 0.45s {spring} 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.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: 10px; padding: 4px 8px; min-height: 0;\ - transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + transition: background-color 0.22s {spring_settle}; }}\ .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-widget.media-in {{ animation: row-in 0.4s {spring} both; }}\ .media-eq {{ min-height: 14px; margin-right: 4px; }}\ .media-eq-bar {{ min-width: 3px; min-height: 5px; background-color: @accent;\ border-radius: 2px; }}\ @@ -186,7 +230,7 @@ fn load_css() -> String { .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),\ + transition: background-color 0.22s {spring_settle},\ 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); }}\ @@ -214,7 +258,7 @@ fn load_css() -> String { .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); }}\ + transition: background-color 0.2s {spring_settle}; }}\ .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; }}\ @@ -275,6 +319,8 @@ fn load_css() -> String { radius_sm = radius_sm, radius_pill = radius_pill, pad = pad, + spring = spring, + spring_settle = spring_settle, ) } @@ -324,3 +370,27 @@ pub fn apply() { let user_path = std::path::PathBuf::from(format!("{home}/.config/breadbar/style.css")); USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell)); } + +thread_local! { + static SHELL_THEME_MONITOR: RefCell> = + const { RefCell::new(None) }; +} + +/// Wires `bread_theme::shell::watch()` (plan §10) so editing the active +/// theme's `theme.toml`/`extra.css` on disk re-resolves CSS tokens without a +/// restart, the same way a pywal palette change already does via +/// `apply_app_css`. Window-spec values (anchors, margins, exclusive zone, +/// keyboard mode) are read once at window-construction time and are *not* +/// re-applied here — per plan §10 those need a restart, since live-swapping +/// a mapped layer-shell surface's anchors/exclusive-zone is a lot of +/// teardown risk for a rare operation. +/// +/// Call once at startup (primary instance only — every satellite window +/// calling this would just re-arm the same watch redundantly). +pub fn watch_hot_reload() { + let monitor = bread_theme::shell::watch(|new_theme| { + set_shell_theme(new_theme); + apply(); + }); + SHELL_THEME_MONITOR.with(|cell| *cell.borrow_mut() = Some(monitor)); +} From c5f7dd1ee39859541e6e07ece8e47e8303316c25 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 17:40:05 +0800 Subject: [PATCH 11/50] bar: assemble modules from [bar.slots] instead of a fixed source order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ModuleRegistry (src/bar/slots.rs), mapping each [bar.slots] module name (workspaces/media/clock/volume/wifi/battery/control) to its already-built widget. main.rs now registers the seven modules once they're constructed, then walks ShellTheme::slots() to append them into the left/centre/right containers in theme order instead of a hardcoded sequence. An unknown module name in a theme manifest is logged and skipped rather than panicking. The Lua-declared widget_* containers keep their fixed interleave (right-of-workspaces, left/right-of-clock, left-of-stats) — that's Phase 3b, not this change. --- src/bar/mod.rs | 1 + src/bar/slots.rs | 53 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 61 +++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 src/bar/slots.rs diff --git a/src/bar/mod.rs b/src/bar/mod.rs index e006a63..0d20edc 100644 --- a/src/bar/mod.rs +++ b/src/bar/mod.rs @@ -2,6 +2,7 @@ pub mod bluetooth; pub mod clock; pub mod control; pub mod media; +pub mod slots; pub mod stats; pub mod tray; pub mod wifi; diff --git a/src/bar/slots.rs b/src/bar/slots.rs new file mode 100644 index 0000000..cb4d359 --- /dev/null +++ b/src/bar/slots.rs @@ -0,0 +1,53 @@ +//! Module registry for the theme manifest's `[bar.slots]` (plan Phase 3a). +//! +//! Each bar module (`workspaces`, `media`, `clock`, `volume`, `wifi`, +//! `battery`, `control`, …) is still built exactly where it always was in +//! `main.rs` — this registry only decouples the ORDER in which those +//! already-constructed widgets get appended into the left/centre/right +//! containers from the fixed source-code order they were built in. main.rs +//! registers each widget by its manifest module name once construction is +//! done, then walks `ShellTheme::slots()` to append them in the theme's +//! order instead of a hardcoded sequence. +//! +//! The Lua-declared `widget_*` containers (`WidgetPlacement`) are NOT part +//! of this registry — their fixed interleave (right-of-workspaces, +//! left/right-of-clock, left-of-stats) stays exactly as it is today. +//! Generalizing their placement is Phase 3b, not this task. + +use gtk4::prelude::*; +use std::collections::HashMap; + +/// Maps a `[bar.slots]` module name to its already-built widget. +#[derive(Default)] +pub struct ModuleRegistry(HashMap<&'static str, gtk4::Widget>); + +impl ModuleRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Registers `widget` under `name` (a `[bar.slots]` module name, e.g. + /// `"workspaces"` or `"clock"`). + pub fn register(&mut self, name: &'static str, widget: &impl IsA) { + self.0.insert(name, widget.clone().upcast()); + } + + /// Appends every module named in `names` (a manifest slot list, in + /// theme order) into `container` via `on_widget`, which lets the + /// caller interleave fixed Lua widget containers around specific + /// modules (e.g. the clock). A name with no registered widget is + /// logged and skipped — an unrecognized or unmapped module in a theme + /// manifest must never crash the bar. + pub fn for_each_in_slot( + &self, + names: &[String], + mut on_widget: impl FnMut(&str, >k4::Widget), + ) { + for name in names { + match self.0.get(name.as_str()) { + Some(widget) => on_widget(name, widget), + None => eprintln!("breadbar: [bar.slots] names unknown module '{name}' — skipping"), + } + } + } +} diff --git a/src/main.rs b/src/main.rs index cf9a5c1..6bf0c6d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -233,7 +233,8 @@ impl SimpleComponent for App { workspace_row.set_margin_start(8); workspace_row.set_valign(gtk4::Align::Center); workspace_row.set_vexpand(false); - workspace_row.append(&workspace_trail.overlay); + // `workspace_trail.overlay` is appended in the "Assemble" section + // below, in the order `[bar.slots].left` names it — not here. // ── Lua-declared widget containers ────────────────────────────── // One per WidgetPlacement; positioned into the layout below as each @@ -243,7 +244,9 @@ impl SimpleComponent for App { use bread_shared::widget::WidgetPlacement; let widget_right_of_workspaces = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); widget_right_of_workspaces.add_css_class("bread-widget-slot"); - workspace_row.append(&widget_right_of_workspaces); + // Also appended in "Assemble" — after the left slot's modules, so + // its fixed position (right of the workspace modules) is preserved + // whatever `[bar.slots].left` contains. let widget_left_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); widget_left_of_clock.add_css_class("bread-widget-slot"); @@ -396,10 +399,9 @@ impl SimpleComponent for App { 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_box); - center_area.append(&widget_right_of_clock); + // `media_widget`/`clock_box` and the `widget_left_of_clock`/ + // `widget_right_of_clock` interleave around the clock module are + // both appended in "Assemble" below, per `[bar.slots].centre`. // ── Stats box (right side) ─────────────────────────────────────── // Demo order: [vol 64] [wifi] [bat 83] [☰] @@ -408,7 +410,9 @@ impl SimpleComponent for App { 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); + // Also appended in "Assemble" — before the right slot's modules, + // preserving its fixed position (left of the stats modules) + // whatever `[bar.slots].right` contains. // CPU/RAM/power draw stay built (control panel + screenshots still // read the labels) but never mount on the island — the demo bar @@ -442,7 +446,7 @@ impl SimpleComponent for App { vol_lbl.add_css_class("stat-label"); vol_box.append(&vol_img); vol_box.append(&vol_lbl); - stats_box.append(&vol_box); + // Appended in "Assemble" below, per `[bar.slots].right`. let bat_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); bat_box.add_css_class("stat-pair"); @@ -530,8 +534,8 @@ impl SimpleComponent for App { wifi_img.set_halign(gtk4::Align::Center); wifi_img.set_hexpand(false); connectivity_pair.append(&wifi_img); - stats_box.append(&connectivity_pair); - stats_box.append(&bat_box); + // `connectivity_pair` and `bat_box` are appended in "Assemble" + // below, per `[bar.slots].right`. // ── Control panel popover ──────────────────────────────────────── // Liquid Motion chrome: CONTROL / vol / bl / lock·sleep·off. @@ -652,7 +656,8 @@ impl SimpleComponent for App { bar::control::spawn_set_brightness(s.value()); }); - stats_box.append(&hamburger_btn); + // `hamburger_btn` is appended in "Assemble" below, per + // `[bar.slots].right`. // Standalone layer windows — below the island, slid in by Hyprland. let panels = panel::PanelSet::new( @@ -707,6 +712,40 @@ impl SimpleComponent for App { media_widget.add_controller(mgesture); } + // ── Assemble: slot-driven module order (plan §11 Phase 3a) ─────── + // Every module widget above is already fully built; only the ORDER + // it lands in its container, and which of left/centre/right it + // lands in, comes from the theme manifest's `[bar.slots]` now. + // The `widget_*` Lua containers keep today's fixed interleave + // (right-of-workspaces, left/right-of-clock, left-of-stats) — + // generalizing their placement is Phase 3b, not this task. + let bar_shell_theme = theme::shell_theme(); + let bar_slots = bar_shell_theme.slots(); + let mut bar_modules = bar::slots::ModuleRegistry::new(); + bar_modules.register("workspaces", &workspace_trail.overlay); + bar_modules.register("media", &media_widget); + bar_modules.register("clock", &clock_box); + bar_modules.register("volume", &vol_box); + bar_modules.register("wifi", &connectivity_pair); + bar_modules.register("battery", &bat_box); + bar_modules.register("control", &hamburger_btn); + + bar_modules.for_each_in_slot(&bar_slots.left, |_, widget| workspace_row.append(widget)); + workspace_row.append(&widget_right_of_workspaces); + + bar_modules.for_each_in_slot(&bar_slots.centre, |name, widget| { + if name == "clock" { + center_area.append(&widget_left_of_clock); + } + center_area.append(widget); + if name == "clock" { + center_area.append(&widget_right_of_clock); + } + }); + + stats_box.append(&widget_left_of_stats); + bar_modules.for_each_in_slot(&bar_slots.right, |_, widget| stats_box.append(widget)); + let widget_containers = std::collections::HashMap::from([ ( WidgetPlacement::RightOfWorkspaces, From 8d80a05d9b80182800eaec0a2d34d5cbb0de5db7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 18:23:24 +0800 Subject: [PATCH 12/50] bar: route Lua widgets to any [bar.slots] slot, not just four fixed spots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3b of the shell theme system. A slot list entry can now be `widget:`, where `` is either a WidgetPlacement alias (right_of_workspaces, left_of_clock, right_of_clock, left_of_stats, tray) or a Lua module name. ModuleRegistry::for_each_in_slot creates each widget container on demand at its slot position; reconcile_widgets routes each WidgetSpec by module name first, falling back to its placement alias, and logs+drops (never panics) a spec with no matching container. bread_shared::widget::WidgetPlacement stays untouched — it's still the wire type breadd sends, unmodified and unshadowed; only the container map that placement now resolves through is theme-driven. --- src/bar/slots.rs | 59 ++++++++++++---- src/main.rs | 177 ++++++++++++++++++++++++++++------------------- 2 files changed, 150 insertions(+), 86 deletions(-) diff --git a/src/bar/slots.rs b/src/bar/slots.rs index cb4d359..87bf915 100644 --- a/src/bar/slots.rs +++ b/src/bar/slots.rs @@ -1,4 +1,5 @@ -//! Module registry for the theme manifest's `[bar.slots]` (plan Phase 3a). +//! Module registry for the theme manifest's `[bar.slots]` (plan Phase 3a), +//! extended in Phase 3b to also route `widget:` entries. //! //! Each bar module (`workspaces`, `media`, `clock`, `volume`, `wifi`, //! `battery`, `control`, …) is still built exactly where it always was in @@ -9,10 +10,14 @@ //! done, then walks `ShellTheme::slots()` to append them in the theme's //! order instead of a hardcoded sequence. //! -//! The Lua-declared `widget_*` containers (`WidgetPlacement`) are NOT part -//! of this registry — their fixed interleave (right-of-workspaces, -//! left/right-of-clock, left-of-stats) stays exactly as it is today. -//! Generalizing their placement is Phase 3b, not this task. +//! A slot entry may also be `widget:`, where `` is either a +//! `WidgetPlacement` alias (`right_of_workspaces`, `left_of_clock`, +//! `right_of_clock`, `left_of_stats`, `tray`) or a Lua module name (see +//! `bread_shared::widget::WidgetSpec::module`) — these route through +//! `for_each_in_slot`'s `on_widget` callback rather than this registry, +//! since their containers are Lua-widget slots created on demand by the +//! caller, not modules registered here. `WidgetPlacement` itself is a wire +//! type from `bread-shared` and is never referenced in this file. use gtk4::prelude::*; use std::collections::HashMap; @@ -32,22 +37,50 @@ impl ModuleRegistry { self.0.insert(name, widget.clone().upcast()); } - /// Appends every module named in `names` (a manifest slot list, in - /// theme order) into `container` via `on_widget`, which lets the - /// caller interleave fixed Lua widget containers around specific - /// modules (e.g. the clock). A name with no registered widget is - /// logged and skipped — an unrecognized or unmapped module in a theme - /// manifest must never crash the bar. + /// Walks every entry named in `names` (a manifest slot list, in theme + /// order). A `widget:` entry calls `on_widget(key)`, letting the + /// caller create-or-fetch that Lua widget container and append it at + /// this exact position. Anything else is looked up as a registered + /// module name and passed to `on_module`; a name with no registered + /// widget is logged and skipped — an unrecognized or unmapped module in + /// a theme manifest must never crash the bar. pub fn for_each_in_slot( &self, names: &[String], - mut on_widget: impl FnMut(&str, >k4::Widget), + mut on_module: impl FnMut(&str, >k4::Widget), + mut on_widget: impl FnMut(&str), ) { for name in names { + if let Some(key) = name.strip_prefix("widget:") { + on_widget(key); + continue; + } match self.0.get(name.as_str()) { - Some(widget) => on_widget(name, widget), + Some(widget) => on_module(name, widget), None => eprintln!("breadbar: [bar.slots] names unknown module '{name}' — skipping"), } } } } + +/// Returns the widget container keyed `key` in `containers`, creating it +/// (a plain horizontal box, styled like every other Lua widget slot) on +/// first use. Called from `for_each_in_slot`'s `on_widget` callback so a +/// `widget:` slot entry gets a container the first time a theme +/// places one there, regardless of whether `key` is a `WidgetPlacement` +/// alias or a Lua module name — `reconcile_widgets` (main.rs) is what +/// gives that distinction meaning when it routes specs into these +/// containers. +pub fn widget_slot_container( + containers: &mut HashMap, + key: &str, +) -> gtk4::Box { + containers + .entry(key.to_string()) + .or_insert_with(|| { + let b = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + b.add_css_class("bread-widget-slot"); + b + }) + .clone() +} diff --git a/src/main.rs b/src/main.rs index 6bf0c6d..37d93f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -101,10 +101,16 @@ pub struct App { tray_items: std::collections::HashMap, // ── Lua-declared widgets ───────────────────────────────────────────── - // One container per WidgetPlacement (see bread_shared::widget), fully - // rebuilt on every AppInput::WidgetsUpdate — see widgets::client's + // One container per `widget:` slot entry (Phase 3b — see + // bar::slots::ModuleRegistry and reconcile_widgets' routing below), + // 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, + // Keyed by the slot entry's key: either a WidgetPlacement alias + // (`right_of_workspaces`, `left_of_clock`, `right_of_clock`, + // `left_of_stats`, `tray`) or a Lua module name. `bread_shared::widget`'s + // `WidgetPlacement` itself never appears here — it's a wire type from + // the bread daemon API and stays untouched. + widget_containers: std::collections::HashMap, widget_tray_section: gtk4::Box, widget_tray_sep: gtk4::Separator, @@ -223,9 +229,9 @@ impl SimpleComponent for App { // ── 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. The Overlay trail - // lives behind the buttons; rebuild_buttons only touches the + // container can sit as a plain sibling of workspace_box — see the + // `widget:*` slot-entry handling in "Assemble" 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(); @@ -237,24 +243,12 @@ impl SimpleComponent for App { // below, in the order `[bar.slots].left` names it — not here. // ── Lua-declared widget containers ────────────────────────────── - // One per WidgetPlacement; positioned into the layout below as each - // surrounding section (workspace row / center area / stats box / - // control popover) is built. Populated by widgets::client's - // events.subscribe-driven refresh loop, started at the end of init. - use bread_shared::widget::WidgetPlacement; - let widget_right_of_workspaces = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_right_of_workspaces.add_css_class("bread-widget-slot"); - // Also appended in "Assemble" — after the left slot's modules, so - // its fixed position (right of the workspace modules) is preserved - // whatever `[bar.slots].left` contains. - - let widget_left_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_left_of_clock.add_css_class("bread-widget-slot"); - let widget_right_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_right_of_clock.add_css_class("bread-widget-slot"); - - let widget_left_of_stats = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_left_of_stats.add_css_class("bread-widget-slot"); + // Phase 3b: a container per `widget:` slot entry is created + // on demand while walking `[bar.slots]` in the "Assemble" section + // below (see `bar::slots::widget_slot_container`), so ANY slot can + // host a Lua widget — not just the four fixed positions Phase 3a + // shipped with. Populated by widgets::client's events.subscribe- + // driven refresh loop, started at the end of init. // `tokens.icon_px` (plan §4) — bar-chrome icon pixel size; reused // below for every `prepare_icon` call in this function. @@ -399,9 +393,9 @@ impl SimpleComponent for App { center_area.add_css_class("center-area"); center_area.set_valign(gtk4::Align::Center); center_area.set_vexpand(false); - // `media_widget`/`clock_box` and the `widget_left_of_clock`/ - // `widget_right_of_clock` interleave around the clock module are - // both appended in "Assemble" below, per `[bar.slots].centre`. + // `media_widget`/`clock_box` and any `widget:*` entries interleaved + // around them are all appended in "Assemble" below, in the exact + // order `[bar.slots].centre` names them. // ── Stats box (right side) ─────────────────────────────────────── // Demo order: [vol 64] [wifi] [bat 83] [☰] @@ -712,13 +706,16 @@ impl SimpleComponent for App { media_widget.add_controller(mgesture); } - // ── Assemble: slot-driven module order (plan §11 Phase 3a) ─────── + // ── Assemble: slot-driven module + widget order (plan §11 Phase 3b) ── // Every module widget above is already fully built; only the ORDER // it lands in its container, and which of left/centre/right it - // lands in, comes from the theme manifest's `[bar.slots]` now. - // The `widget_*` Lua containers keep today's fixed interleave - // (right-of-workspaces, left/right-of-clock, left-of-stats) — - // generalizing their placement is Phase 3b, not this task. + // lands in, comes from the theme manifest's `[bar.slots]` now. A + // `widget:` slot entry gets (or creates) a Lua widget + // container at that exact position — `` is either a + // `WidgetPlacement` alias or a Lua module name; see + // `bar::slots::widget_slot_container` and `reconcile_widgets`' + // routing below. This is how a Lua widget can land in ANY slot, + // not just the four fixed positions Phase 3a shipped with. let bar_shell_theme = theme::shell_theme(); let bar_slots = bar_shell_theme.slots(); let mut bar_modules = bar::slots::ModuleRegistry::new(); @@ -730,32 +727,29 @@ impl SimpleComponent for App { bar_modules.register("battery", &bat_box); bar_modules.register("control", &hamburger_btn); - bar_modules.for_each_in_slot(&bar_slots.left, |_, widget| workspace_row.append(widget)); - workspace_row.append(&widget_right_of_workspaces); + // `tray` never appears in a bar slot — it stays inside the + // control-panel popover (built above, next to the SNI tray) — but + // it's keyed here so `reconcile_widgets`' routing finds it the same + // way as any slot-driven widget container. + let mut widget_containers: std::collections::HashMap = + std::collections::HashMap::new(); + widget_containers.insert("tray".to_string(), widget_tray_box); - bar_modules.for_each_in_slot(&bar_slots.centre, |name, widget| { - if name == "clock" { - center_area.append(&widget_left_of_clock); - } - center_area.append(widget); - if name == "clock" { - center_area.append(&widget_right_of_clock); - } - }); - - stats_box.append(&widget_left_of_stats); - bar_modules.for_each_in_slot(&bar_slots.right, |_, widget| stats_box.append(widget)); - - let widget_containers = std::collections::HashMap::from([ - ( - WidgetPlacement::RightOfWorkspaces, - widget_right_of_workspaces, - ), - (WidgetPlacement::LeftOfClock, widget_left_of_clock), - (WidgetPlacement::RightOfClock, widget_right_of_clock), - (WidgetPlacement::LeftOfStats, widget_left_of_stats), - (WidgetPlacement::Tray, widget_tray_box), - ]); + bar_modules.for_each_in_slot( + &bar_slots.left, + |_, widget| workspace_row.append(widget), + |key| workspace_row.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), + ); + bar_modules.for_each_in_slot( + &bar_slots.centre, + |_, widget| center_area.append(widget), + |key| center_area.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), + ); + bar_modules.for_each_in_slot( + &bar_slots.right, + |_, widget| stats_box.append(widget), + |key| stats_box.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), + ); // ── Assemble ───────────────────────────────────────────────────── let widgets = view_output!(); @@ -1144,6 +1138,22 @@ impl SimpleComponent for App { } } +/// The `widget:` alias `for_each_in_slot` recognizes for each +/// `WidgetPlacement` variant — the fallback a `WidgetSpec` routes through +/// when no `widget:` container claims its module name specifically. +/// Kept in one place since both the builtin manifest's slot lists and +/// `reconcile_widgets`' routing below must agree on these names. +fn placement_alias(placement: bread_shared::widget::WidgetPlacement) -> &'static str { + use bread_shared::widget::WidgetPlacement::*; + match placement { + Tray => "tray", + LeftOfClock => "left_of_clock", + RightOfClock => "right_of_clock", + RightOfWorkspaces => "right_of_workspaces", + LeftOfStats => "left_of_stats", + } +} + impl App { fn reconcile_widgets(&mut self, specs: Vec) { for container in self.widget_containers.values() { @@ -1152,23 +1162,47 @@ impl App { } } - let mut by_placement: std::collections::HashMap< - bread_shared::widget::WidgetPlacement, + // Route each spec to a widget_containers entry: a `widget:` + // slot entry (keyed by WidgetSpec::module) takes priority over the + // spec's placement alias, so a theme can retarget one Lua module's + // widgets without moving every widget that shares its placement. + // A spec whose module AND placement alias both lack a container + // (e.g. a theme's slots omit that placement's widget: entry + // entirely) is logged and dropped rather than silently vanishing — + // WidgetPlacement itself never changes; only which container (if + // any) each spec lands in does. + let mut by_container: std::collections::HashMap< + String, Vec<&bread_shared::widget::WidgetSpec>, > = std::collections::HashMap::new(); for spec in &specs { - by_placement.entry(spec.placement).or_default().push(spec); + let key = if self.widget_containers.contains_key(&spec.module) { + spec.module.clone() + } else { + placement_alias(spec.placement).to_string() + }; + if self.widget_containers.contains_key(&key) { + by_container.entry(key).or_default().push(spec); + } else { + eprintln!( + "breadbar: widget '{}' (module '{}', placement {:?}) has no matching \ + [bar.slots] widget: container — dropping", + spec.id, spec.module, spec.placement + ); + } } - for (placement, mut group) in by_placement { - let Some(container) = self.widget_containers.get(&placement) else { - continue; - }; + let mut has_tray_widgets = false; + for (key, mut group) in by_container { + let container = &self.widget_containers[&key]; group.sort_by_key(|s| s.order); for spec in group { if !spec.visible { continue; } + if key == "tray" { + has_tray_widgets = true; + } let node = widgets::build_node(&spec.root, &spec.id); if let Some(tooltip) = &spec.tooltip { node.set_tooltip_text(Some(tooltip)); @@ -1177,20 +1211,17 @@ impl App { } } - // The Tray placement has its own section/separator (handled below, - // same as the existing SNI tray items) — an empty inline slot has no - // such wrapper, so it must hide itself to stop contributing to - // center_area's `spacing` gap. - for (placement, container) in &self.widget_containers { - if *placement == bread_shared::widget::WidgetPlacement::Tray { + // The "tray" container has its own section/separator (handled + // below, same as the existing SNI tray items) — an empty inline + // slot has no such wrapper, so it must hide itself to stop + // contributing to its parent box's `spacing` gap. + for (key, container) in &self.widget_containers { + if key == "tray" { continue; } 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); self.widget_tray_section.set_visible(has_tray_widgets); self.widget_tray_sep.set_visible(has_tray_widgets); } From df6b568cc12ec3794ae496c4cc0e49cd9a401065 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 23:26:34 +0800 Subject: [PATCH 13/50] bar: implement glass-workbench's three module variants (Phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires modules.workspaces.style/modules.clock.style into the bar for real (Phase 3 shipped the schema but only ever consumed trail/flip), and adds cpu/ram as bar modules, so glass-workbench (bread-theme) renders correctly while liquid-motion's default path is untouched: - workspaces: "pill" never calls WorkspaceTrail::place/stretch (the trail overlay stays invisible) and honours modules.workspaces.show_empty for real, rendering unoccupied non-active workspaces dimmed via CSS instead of filtering them out of the row. "trail" keeps the exact pre-existing filter/place/stretch behaviour regardless of show_empty. - clock: "plain" registers a plain date+time label (date_lbl, built but never parented until now, plus a new clock_plain_lbl) instead of the per-digit flip box. "flip" is untouched. - cpu/ram: two new bar chips (bar_cpu_pair/bar_ram_pair, bar_cpu_lbl/ bar_ram_lbl) fed by the same AppInput::StatsUpdate data as the control panel's cpu_pair/mem_pair. Separate instances rather than reparenting the panel's own pair — a GTK widget can only have one parent, and reparenting would pull them out of the control panel's sys-grid, which no theme asked to change. theme.rs::load_css() now branches on tokens.bar_border() (full-border island vs. flush bar's bottom-only hairline) and modules.workspaces.style (dimmed/translucent trail-fill buttons vs. solid-accent-fill pills), and unconditionally gains .clock-plain/.clock-plain-time rules. The trail/flip branches reproduce today's CSS byte-for-byte. Verification (bread-capture, isolated headless-Sway harness): - Noise floor: same baseline binary against itself varies from AE=0 (same capture minute) up to ~1900px (an in-flight digit-flip/clock-tick or a live Wi-Fi scan straddling two captures) and ~39000px on control-panel (live hardware sensors) — all pre-existing, not introduced by this change. - Regression: this binary vs. the pre-Phase-5 baseline, captured in the same clock-minute to remove the dominant noise source, is AE=0 (bit-for- bit identical) on the bar view under the default liquid-motion theme. - glass-workbench (BREAD_SHELL_THEME=glass-workbench): bar view is 1920x36 (flush, no margin) vs. liquid-motion's 1920x56 (12px margin + 44px island), square corners vs. liquid-motion's rounded island, plain "Mon 24/08 23:24" clock, and cpu/ram/wifi/battery/control chips with no media widget — matches demo 02. The isolated Sway capture harness has no live Hyprland IPC, so it reports zero workspaces under both themes (pre-existing, unrelated to this change) — the pill fill/dim CSS itself is exercised by code review and the trail-untouched regression result, not by a captured pixel with visible buttons. cargo test: 18/18 passing (breadbar), no new clippy/cargo-check warnings beyond the two pre-existing ones this task named up front. --- src/bar/clock.rs | 13 +++++ src/main.rs | 143 +++++++++++++++++++++++++++++++++++++++++++---- src/theme.rs | 85 ++++++++++++++++++++++------ 3 files changed, 213 insertions(+), 28 deletions(-) diff --git a/src/bar/clock.rs b/src/bar/clock.rs index 45e25e6..f7d897b 100644 --- a/src/bar/clock.rs +++ b/src/bar/clock.rs @@ -18,6 +18,19 @@ pub fn current() -> String { format!("{} {}", date(), time()) } +/// `modules.clock.format` rendered against GLib's own `DateTime::format` +/// (a strftime subset — `%H`, `%M`, `%a`, `%d`, `%m`, ... all work). Falls +/// back to [`time`]'s hardcoded "HH:MM" on a malformed format string rather +/// than propagating an error — a broken theme's clock format must degrade, +/// not crash the bar, same as every other "malformed theme" fallback in +/// this system. +pub fn formatted(format: &str) -> String { + now() + .format(format) + .map(|s| s.to_string()) + .unwrap_or_else(|_| time()) +} + pub fn spawn_ticker(sender: ComponentSender) { relm4::spawn(async move { loop { diff --git a/src/main.rs b/src/main.rs index 37d93f2..db91068 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ mod surface; mod theme; mod widgets; -use bread_theme::shell::{Exclusive, Keyboard}; +use bread_theme::shell::{ClockStyle, Exclusive, Keyboard, WorkspaceStyle}; use gtk4::prelude::*; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use hyprland::data::Workspace; @@ -45,6 +45,12 @@ pub struct App { time_str: String, clock_digits: Vec, date_lbl: gtk4::Label, + // `modules.clock.style = "plain"` (glass-workbench, Phase 5): a plain + // "HH:MM" label with no per-digit flip. Built alongside `clock_digits` + // regardless of the active theme's style so switching styles needs no + // recompile; only one of the two ever lands in a `[bar.slots]` module + // registration (see the "Assemble" section). + clock_plain_lbl: gtk4::Label, // ── Stats bar ───────────────────────────────────────────────────────── // Island chrome matches the Liquid Motion demo: volume / wifi / battery @@ -58,6 +64,14 @@ pub struct App { cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, + // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench, Phase + // 5): separate instances from `cpu_pair`/`mem_pair` above, which stay + // parented in the control panel's sys-grid — a GTK widget can only have + // one parent, so reusing those here would mean reparenting them out of + // the panel, changing panel behaviour no theme asked to change. Fed by + // the same `AppInput::StatsUpdate` data. + bar_cpu_lbl: gtk4::Label, + bar_ram_lbl: gtk4::Label, gpu_lbl: gtk4::Label, vol_lbl: gtk4::Label, bat_lbl: gtk4::Label, @@ -388,6 +402,22 @@ impl SimpleComponent for App { date_lbl.add_css_class("date-label"); date_lbl.set_visible(false); + // `modules.clock.style = "plain"` (glass-workbench): date_lbl above + // plus one plain "HH:MM" label, no per-digit flip markup at all. + // Built unconditionally alongside the flip clock so a theme's style + // choice is just which of the two gets registered into the "clock" + // slot below — see "Assemble". + let clock_plain_lbl = gtk4::Label::new(Some(&bar::clock::time())); + clock_plain_lbl.add_css_class("clock-plain-time"); + clock_plain_lbl.set_valign(gtk4::Align::Center); + clock_plain_lbl.set_vexpand(false); + let clock_plain_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + clock_plain_box.add_css_class("clock-plain"); + clock_plain_box.set_valign(gtk4::Align::Center); + clock_plain_box.set_vexpand(false); + clock_plain_box.append(&date_lbl); + clock_plain_box.append(&clock_plain_lbl); + // Center area: [media_widget · widgets · clock · widgets] let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); center_area.add_css_class("center-area"); @@ -420,6 +450,18 @@ impl SimpleComponent for App { pair.set_hexpand(true); } gpu_pair.set_visible(false); + + // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench, + // Phase 5): separate chip instances from `cpu_pair`/`mem_pair` + // above, which stay parented in the control panel's sys-grid below + // — reusing them here would mean reparenting them out of the panel. + // Same icons, same `.stat-pair` chip styling every other bar chip + // (volume/battery) already uses; fed by the same `StatsUpdate` data. + let bar_cpu_lbl = stat_label(); + let bar_ram_lbl = stat_label(); + let bar_cpu_pair = stat_pair(asset!("CPU.svg"), &bar_cpu_lbl); + let bar_ram_pair = stat_pair(asset!("RAM Usage.svg"), &bar_ram_lbl); + 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); @@ -721,11 +763,32 @@ impl SimpleComponent for App { let mut bar_modules = bar::slots::ModuleRegistry::new(); bar_modules.register("workspaces", &workspace_trail.overlay); bar_modules.register("media", &media_widget); - bar_modules.register("clock", &clock_box); + // `modules.clock.style`: "flip" (default, liquid-motion) registers + // the existing per-digit clock_box unchanged; "plain" (glass- + // workbench) registers clock_plain_box instead and reveals date_lbl + // per `show_date` — clock_box/clock_digits are still fully built in + // that case, just never placed in any slot. "none" (Phase 6+, + // unused today) registers neither. + match bar_shell_theme.modules().clock.style { + ClockStyle::Plain => { + date_lbl.set_visible(bar_shell_theme.modules().clock.show_date); + bar_modules.register("clock", &clock_plain_box); + } + ClockStyle::Flip => bar_modules.register("clock", &clock_box), + ClockStyle::None => {} + } bar_modules.register("volume", &vol_box); bar_modules.register("wifi", &connectivity_pair); bar_modules.register("battery", &bat_box); bar_modules.register("control", &hamburger_btn); + // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench): + // registered unconditionally, same as every other module — a theme + // that never names "cpu"/"ram" in a slot (liquid-motion) just never + // walks these entries in `for_each_in_slot` below, so they stay + // built but unparented, exactly like `media_widget` does for + // glass-workbench (which omits "media" entirely). + bar_modules.register("cpu", &bar_cpu_pair); + bar_modules.register("ram", &bar_ram_pair); // `tray` never appears in a bar slot — it stays inside the // control-panel popover (built above, next to the SNI tray) — but @@ -791,6 +854,7 @@ impl SimpleComponent for App { time_str: bar::clock::current(), clock_digits, date_lbl, + clock_plain_lbl, system_stats_box, system_sep, cpu_pair, @@ -800,6 +864,8 @@ impl SimpleComponent for App { cpu_lbl, mem_lbl, pwr_lbl, + bar_cpu_lbl, + bar_ram_lbl, gpu_lbl, vol_lbl, bat_lbl, @@ -938,7 +1004,14 @@ impl SimpleComponent for App { 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); + // Trail style only: pill/dots never call place()/ + // stretch() at all — the "active" CSS class above is + // the whole of their active-workspace treatment + // (solid accent fill, no trail overlay). + if theme::shell_theme().modules().workspaces.style == WorkspaceStyle::Trail + { + self.workspace_trail.stretch(from.as_ref(), &btn); + } } } } @@ -958,8 +1031,25 @@ impl SimpleComponent for App { } AppInput::ClockTick => { self.time_str = bar::clock::current(); - flip_clock_digits(&self.clock_digits, &bar::clock::time()); self.date_lbl.set_label(&bar::clock::date()); + let clock_module = theme::shell_theme().modules().clock.clone(); + match clock_module.style { + // Plain (glass-workbench): one label, no flip animation + // — `flip_clock_digits` would just be wasted work (and + // a pointless 450ms `play_once` timer) on digits that + // are never on screen. + ClockStyle::Plain => { + self.clock_plain_lbl + .set_label(&bar::clock::formatted(&clock_module.format)); + } + // Flip (default, liquid-motion) and None both keep + // exactly today's per-digit-flip update — None has no + // module in a slot to display it, but there's no reason + // to special-case skipping the (cheap, idempotent) work. + ClockStyle::Flip | ClockStyle::None => { + flip_clock_digits(&self.clock_digits, &bar::clock::time()); + } + } } AppInput::StatsUpdate(stats) => { let cpu = match stats.cpu_temp { @@ -969,6 +1059,11 @@ impl SimpleComponent for App { self.cpu_lbl.set_label(&cpu); self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); + // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass- + // workbench): same formatted text, separate chip instances + // (see the App struct field docs for why). + self.bar_cpu_lbl.set_label(&cpu); + self.bar_ram_lbl.set_label(&stats.mem); match stats.gpu_usage { Some(g) => { let gpu = match stats.gpu_temp { @@ -1306,14 +1401,33 @@ impl App { self.workspace_box.remove(&child); } self.button_map.clear(); + let modules = theme::shell_theme().modules().clone(); + let ws_style = modules.workspaces.style; + let show_empty = modules.workspaces.show_empty; for ws in &self.workspaces { 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 empty = ws.windows == 0 && ws.id != self.active_ws; + if empty { + match ws_style { + // Trail (default, liquid-motion): unconditionally off + // the bar, exactly as before this change — regardless + // of `show_empty`, which liquid-motion's own manifest + // declares `true` but this style has never consumed. + // Changing that now would be a real, undesired + // liquid-motion regression, not a Phase 5 fix. + WorkspaceStyle::Trail => continue, + // Pill/Dots: honour `show_empty` for real — demo 02's + // pills render an unoccupied, non-active workspace at + // reduced opacity via the `.workspace-btn:not(.occupied) + // :not(.active)` CSS rule rather than hiding it. + _ => { + if !show_empty { + continue; + } + } + } } let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0); if !prev.contains(&ws.id) { @@ -1322,10 +1436,15 @@ impl App { 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(), + // Trail style only: pill/dots never call place()/stretch()/clear() + // at all — the "active" CSS class `make_button` already applies is + // the whole of their active-workspace treatment. + if ws_style == WorkspaceStyle::Trail { + 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(), + } } } diff --git a/src/theme.rs b/src/theme.rs index 768a69d..57d307f 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -69,6 +69,66 @@ fn load_css() -> String { let spring = tokens.spring(); let spring_settle = tokens.spring_settle(); + // `tokens.bar_border()` (plan §11 Phase 5): "full" (default, liquid- + // motion's floating island) draws a border on all four edges; "bottom" + // (glass-workbench's flush edge-to-edge bar) draws only the hairline + // the demo's `.bar { border-bottom: 1px solid #ffffff12 }` calls for — + // a full border on a bar flush against the screen's top/left/right + // edges would otherwise show as a stray line along those edges an + // island never has to worry about. Reused below for the centerbox's + // horizontal padding too: the flush bar's demo padding (`0 12px`, + // symmetric) differs from the island's own asymmetric `0 8px 0 6px`. + let flush = tokens.bar_border() == "bottom"; + let window_border = if flush { + "border: none; border-bottom: 1px solid alpha(@on-bg, 0.07);".to_string() + } else { + "border: 1px solid alpha(@on-bg, 0.08);".to_string() + }; + let centerbox_padding = if flush { "0 12px" } else { "0 8px 0 6px" }; + + // `modules.workspaces.style` (plan §11 Phase 5): "trail" (default, + // liquid-motion) is exactly today's CSS, unchanged byte-for-byte — + // dimmed/translucent buttons with the gradient trail overlay supplying + // the active fill. "pill"/"dots" (glass-workbench, Phase 6+) render the + // active state as a solid accent fill on the button itself instead, + // since neither style ever calls `WorkspaceTrail::place`/`stretch` + // (see `App::rebuild_buttons`) — the trail's own `.workspace-trail` + // pill CSS is therefore irrelevant for them (it's never made visible). + let workspace_css = match theme.modules().workspaces.style { + bread_theme::shell::WorkspaceStyle::Trail => format!( + ".workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\ + background-color: @accent; border-radius: 12px; }}\ + .workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\ + 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 {spring_settle},\ + background-color 0.22s {spring_settle}; }}\ + .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 {spring_settle} both; }}", + ), + _ => { + let accent = theme.tokens().accent_from(); + format!( + ".workspace-btn {{ background: transparent; opacity: 1; color: alpha(@on-bg, 0.4);\ + border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\ + min-width: 22px; min-height: 20px; margin: 0; padding: 0 6px;\ + font-size: 12px; font-weight: 600;\ + transition: background-color 0.22s {spring_settle},\ + color 0.22s {spring_settle}, opacity 0.22s {spring_settle}; }}\ + .workspace-btn:hover {{ background: alpha(@on-bg, 0.08); }}\ + .workspace-btn.occupied {{ color: alpha(@on-bg, 0.8); }}\ + .workspace-btn:not(.occupied):not(.active) {{ opacity: 0.35; }}\ + .workspace-btn.active {{ background: @{accent}; color: @on-accent; opacity: 1; }}\ + .workspace-btn.active:hover {{ background: @{accent}; }}\ + .workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}", + ) + } + }; + format!( "@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\ @keyframes osd-in {{ from {{ opacity: 0; margin-bottom: -8px; }} }}\ @@ -79,22 +139,10 @@ fn load_css() -> String { @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; }}\ + border-radius: {radius_bar}; {window_border} }}\ + window.breadbar > centerbox {{ padding: {centerbox_padding}; }}\ window.breadbar button {{ min-height: 0; min-width: 0; }}\ - .workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\ - background-color: @accent; border-radius: 12px; }}\ - .workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\ - 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 {spring_settle},\ - background-color 0.22s {spring_settle}; }}\ - .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 {spring_settle} both; }}\ + {workspace_css}\ .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; }}\ @@ -102,7 +150,9 @@ fn load_css() -> String { 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 {spring} both; }}\ - .date-label {{ font-size: 14px; opacity: 0.52; letter-spacing: 0.04em; }}\ + .clock-plain {{ padding: 0 4px; }}\ + .clock-plain-time {{ font-size: 15px; font-weight: 600; letter-spacing: 0.04em; }}\ + .date-label {{ font-size: 12px; opacity: 0.48; letter-spacing: 0.04em; }}\ .stat-label {{ font-size: 14px; letter-spacing: 0.02em; opacity: 0.92; }}\ .stat-label.tick {{ animation: digit-flip 0.35s {spring} both; }}\ .stats-box {{ margin-right: 0; }}\ @@ -321,6 +371,9 @@ fn load_css() -> String { pad = pad, spring = spring, spring_settle = spring_settle, + window_border = window_border, + centerbox_padding = centerbox_padding, + workspace_css = workspace_css, ) } From 1fb10a10cdcc0580204bffbdfb0ca5ecb6cc4721 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 00:29:38 +0800 Subject: [PATCH 14/50] bar: implement spotlight's embedded launcher capsule (Phase 6b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New bread-launcher dep (gtk feature), pinned tag v0.7.4 with a dev-only [patch] entry alongside bread-theme's. - Wire the drawer slot (plan §2/§7): root becomes a vbox whose first row is the existing CenterBox and whose second is drawer_box, appended via the same ModuleRegistry::for_each_in_slot pattern left/centre/right already use. Empty for liquid-motion/glass-workbench (zero children, zero size); theme.rs's window.breadbar > centerbox selector becomes > box > centerbox to follow the new nesting, byte-identical CSS for both existing themes. - modules.workspaces.style = dots: bar::workspaces::make_dot_button, width from modules.workspaces.dot_widths, wired as a third rebuild_buttons arm. - New launcher_entry (plain GtkEntry) and launcher_results (bread_launcher::gtk::ResultsList, sharing breadbox's cache/history via bread_launcher::LAUNCHER_APP) modules, built unconditionally and placed only when a theme's [bar.slots] names them (spotlight today). - Capsule expand/collapse: typing/focus opens the drawer and animates its height via bread_theme::anim::spring_to; Up/Down move the selection, Enter launches (do_launch + record_launch) and collapses, Escape collapses and releases keyboard focus (on_demand ties to GTK's own focus-widget state, so releasing GTK focus hands the compositor keyboard back). - window.width now wires Width::Px into set_default_width (previously unconsumed — Fill-anchored themes never needed it, the capsule does). - Two new --screenshot views: capsule-collapsed, capsule-expanded (the latter focuses launcher_entry, types a query, and captures bar height + the drawer's actual settled height rather than a guessed constant). cargo check/clippy: no new warnings (same two pre-existing: dead_code on system_stats_box/cpu_pair/mem_pair/pwr_pair, from_* on workspaces.rs). cargo test: 18/18 unchanged. --- Cargo.lock | 24 +++- Cargo.toml | 6 + src/bar/workspaces.rs | 39 +++++ src/main.rs | 326 +++++++++++++++++++++++++++++++++++++++++- src/screenshot.rs | 59 ++++++++ src/theme.rs | 68 ++++++++- 6 files changed, 512 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d5be557..c1c3eda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,13 +150,22 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bread-launcher" +version = "0.7.4" +dependencies = [ + "bread-utils 0.7.4", + "gtk4", + "serde_json", +] + [[package]] name = "bread-screenshots" version = "0.7.4" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#a9754d90ed32efcc26765abd01c9f441bfb01b1e" dependencies = [ "anyhow", - "bread-utils", + "bread-utils 0.7.4 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4)", "tracing", ] @@ -196,6 +205,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "bread-utils" +version = "0.7.4" +dependencies = [ + "bread-shared 0.7.0", + "dirs 5.0.1", + "serde", + "serde_json", +] + [[package]] name = "bread-utils" version = "0.7.4" @@ -212,10 +231,11 @@ name = "breadbar" version = "0.3.3" dependencies = [ "anyhow", + "bread-launcher", "bread-screenshots", "bread-shared 0.8.0", "bread-theme", - "bread-utils", + "bread-utils 0.7.4 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4)", "clap", "futures-lite", "gtk4", diff --git a/Cargo.toml b/Cargo.toml index de10b69..7fba78a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,11 @@ bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = 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.4" } +# Headless app-launcher core + GTK4 results-list widget (plan §3/§7): theme +# 04/spotlight embeds the SAME `ResultsList` breadbox's overlay wraps, via +# `bar::launcher_results` — one implementation, two hosts. `gtk` feature for +# the results widget itself. +bread-launcher = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } @@ -46,3 +51,4 @@ strip = "symbols" # (feature/shell-theme-manifest) before it is tagged as a release. [patch."https://git.breadway.dev/Breadway/bread-ecosystem"] bread-theme = { path = "../bread-ecosystem/bread-theme" } +bread-launcher = { path = "../bread-ecosystem/bread-launcher" } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 611e15e..8b30505 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -155,6 +155,45 @@ pub fn make_button( btn } +/// `style = "dots"` (theme 04/spotlight): a label-less pill whose WIDTH +/// encodes `windows` (0/1/2/3-or-more open) via `dot_widths` — see +/// `bread_theme::shell::WorkspacesModule::dot_widths`. Distinct from +/// [`make_button`] (Trail/Pill) rather than a variant of it because dots +/// carry no text at all (`04-spotlight.html`'s `.dots button` has no label); +/// reusing `Button::with_label("")` would still measure/lay out an empty +/// label box that a genuinely childless button doesn't. Width is a hard +/// `set_size_request` snap, not animated — GTK CSS min-width transitions +/// don't participate in a directly-set size request the way an opacity/ +/// background-color transition does, and the plan only calls out the +/// capsule's own expand/collapse as worth the `anim::spring_to` treatment. +pub fn make_dot_button( + id: WorkspaceId, + active: WorkspaceId, + windows: i32, + dot_widths: bread_theme::shell::DotWidths, +) -> gtk4::Button { + let btn = gtk4::Button::new(); + btn.add_css_class("workspace-dot"); + if windows > 0 { + btn.add_css_class("occupied"); + } + if id == active { + 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); + let idx = (windows.max(0) as usize).min(3); + btn.set_size_request(dot_widths[idx], 6); + btn.connect_clicked(move |_| { + relm4::spawn(async move { + switch_workspace(id).await; + }); + }); + btn +} + #[derive(Clone, Copy)] struct Geom { x: f64, diff --git a/src/main.rs b/src/main.rs index db91068..0b794ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,8 @@ mod surface; mod theme; mod widgets; -use bread_theme::shell::{ClockStyle, Exclusive, Keyboard, WorkspaceStyle}; +use bread_launcher::gtk::ResultsList; +use bread_theme::shell::{ClockStyle, Exclusive, Keyboard, Width, WorkspaceStyle}; use gtk4::prelude::*; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use hyprland::data::Workspace; @@ -21,8 +22,21 @@ use hyprland::shared::WorkspaceId; use relm4::prelude::*; use relm4::{Component, ComponentController, Controller}; use std::cell::Cell; +use std::cell::RefCell; use std::rc::Rc; +/// The launched-app event's publisher id and event name — deliberately +/// matching breadbox's OWN constants (`breadbox/src/main.rs`: `APP_ID = +/// "box"`, `LAUNCHED_EVENT = "bread.box.launched"`), NOT breadbar's own +/// `widgets::client::APP_ID` ("bar"). Theme 04/spotlight's capsule IS the +/// launcher wearing a different shell (plan §7), sharing breadbox's cache +/// and history via `bread_launcher::LAUNCHER_APP` — it publishes under that +/// same launcher identity too, so anything downstream listening for "an app +/// was launched via the launcher" sees one event stream regardless of which +/// surface launched it. +const LAUNCHER_APP_ID: &str = "box"; +const LAUNCHER_LAUNCHED_EVENT: &str = "bread.box.launched"; + pub struct BarInit { pub screenshot: Option, pub monitor: Option, @@ -51,6 +65,15 @@ pub struct App { // recompile; only one of the two ever lands in a `[bar.slots]` module // registration (see the "Assemble" section). clock_plain_lbl: gtk4::Label, + // `modules.clock.placeholder_clock` (spotlight, theme 04): when set, + // `AppInput::ClockTick` writes the time into this entry's placeholder + // text instead of (or alongside) any clock label — see that handler. + launcher_entry: gtk4::Entry, + // Whether the capsule's drawer is currently expanded — read by + // `ClockTick` so a live search in progress never has its placeholder + // text stomped (it wouldn't be visible anyway once there's real text, + // but matches the demo's own `if (!open) q.placeholder = t;` guard). + launcher_open: Rc>, // ── Stats bar ───────────────────────────────────────────────────────── // Island chrome matches the Liquid Motion demo: volume / wifi / battery @@ -165,8 +188,27 @@ impl SimpleComponent for App { set_title: Some("breadbar"), set_default_height: bar_height, - #[name = "center_box"] - gtk::CenterBox { + // Root is a vbox (bar row + drawer), not a bare CenterBox, per + // plan §2/§11: `drawer` is the only structural thing Capsule/ + // theme-04 adds over Island/Edge, and it's a slot below the bar + // row, not a separate layout code path. `drawer_box` starts + // empty and zero-height for every theme that never names a + // module in `[bar.slots].drawer` (liquid-motion, glass- + // workbench) — see main.rs's "Assemble" section and + // `theme.rs`'s `window.breadbar > box > centerbox` selector + // update for why this is a no-op for both. + #[name = "root_vbox"] + gtk::Box { + set_orientation: gtk4::Orientation::Vertical, + + #[name = "center_box"] + gtk::CenterBox { + }, + + #[name = "drawer_box"] + gtk::Box { + set_orientation: gtk4::Orientation::Vertical, + }, } } } @@ -211,6 +253,17 @@ impl SimpleComponent for App { root.set_margin(Edge::Top, window_spec.margin.top); root.set_margin(Edge::Left, window_spec.margin.left); root.set_margin(Edge::Right, window_spec.margin.right); + // `Width::Fill` (Island/Edge): unset, exactly as before this + // change — the surface stretches to the anchored left/right edges + // on its own, with no explicit width request needed. `Width::Px` + // (the capsule, anchored top-only): gtk4-layer-shell has nothing to + // stretch it TO, so without this it would size to its natural + // content width instead of the theme's requested 480px — this was + // a schema key declared but never consumed before theme 04 needed + // a real value out of it. + if let Width::Px(px) = window_spec.width { + root.set_default_width(px); + } // "auto" reserves height + top margin so tiled clients sit below the // gap — see WindowSpec::exclusive's doc comment (bread-theme). let exclusive_zone = match window_spec.exclusive { @@ -418,6 +471,66 @@ impl SimpleComponent for App { clock_plain_box.append(&date_lbl); clock_plain_box.append(&clock_plain_lbl); + // ── Launcher entry + results (theme 04/spotlight, plan §7) ─────── + // Built unconditionally, exactly like `clock_plain_box` above — + // placed in a slot only by a theme that names "launcher_entry"/ + // "launcher_results" (spotlight today; see "Assemble" below). + // `bread_launcher::LAUNCHER_APP` ("breadbox") is the launcher's + // shared identity: this reads/writes the SAME icon cache and + // launch history breadbox's own overlay window does, so the + // capsule and breadbox rank a user's apps identically instead of + // forking into two histories just because a different theme + // happens to be active (see that constant's own doc comment). + let launcher_cfg = theme::shell_theme().launcher().clone(); + let launcher_manifest: std::collections::HashMap = + std::fs::read_to_string(bread_launcher::IconCache::manifest_path( + bread_launcher::LAUNCHER_APP, + )) + .ok() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, std::path::PathBuf::from(v))) + .collect(); + let launcher_history = Rc::new(RefCell::new(bread_launcher::LaunchHistory::load( + bread_launcher::LAUNCHER_APP, + ))); + // No per-workspace priority context here — that's breadbox's own + // `Config`/`Context` format (breadbox-shared), not launcher + // substance, and breadbar has no equivalent concept. The capsule + // sorts by launch history then alphabetically, same fallback + // ordering breadbox itself uses once a workspace has no configured + // priority list at all. + let launcher_entries = bread_launcher::load_sorted_entries( + &launcher_manifest, + &[], + &launcher_history.borrow(), + ); + let launcher_results = ResultsList::new( + &launcher_entries, + launcher_cfg.icon_px, + Rc::clone(&launcher_history), + ); + launcher_results.scroller.add_css_class("bread-drawer-scroller"); + + let launcher_entry = gtk4::Entry::new(); + launcher_entry.add_css_class("launcher-entry"); + launcher_entry.set_has_frame(false); + launcher_entry.set_hexpand(true); + gtk4::prelude::EntryExt::set_alignment(&launcher_entry, 0.5); + // `modules.clock.placeholder_clock` (spotlight): the entry's idle + // placeholder IS the clock — no separate clock module renders at + // all under `style = "none"`. Any other theme gets a plain "Search" + // placeholder (never shown today: no other builtin slots + // "launcher_entry" anywhere), so this still degrades sanely if a + // future/user theme places it without also setting the flag. + let placeholder_clock = theme::shell_theme().modules().clock.placeholder_clock; + launcher_entry.set_placeholder_text(Some(if placeholder_clock { + bar::clock::time() + } else { + "Search".to_string() + }.as_str())); + // Center area: [media_widget · widgets · clock · widgets] let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); center_area.add_css_class("center-area"); @@ -789,6 +902,11 @@ impl SimpleComponent for App { // glass-workbench (which omits "media" entirely). bar_modules.register("cpu", &bar_cpu_pair); bar_modules.register("ram", &bar_ram_pair); + // Theme 04/spotlight (plan §7): unconditional, same reasoning — + // liquid-motion/glass-workbench never name either in a slot, so + // both stay built but unparented for them. + bar_modules.register("launcher_entry", &launcher_entry); + bar_modules.register("launcher_results", &launcher_results.scroller); // `tray` never appears in a bar slot — it stays inside the // control-panel popover (built above, next to the SNI tray) — but @@ -820,6 +938,143 @@ impl SimpleComponent for App { widgets.center_box.set_center_widget(Some(¢er_area)); widgets.center_box.set_end_widget(Some(&stats_box)); + // `drawer` slot (plan §2/§7/§11 Phase 6): the only slot list that + // isn't left/centre/right of the CenterBox — appended into the vbox + // row below it instead. Empty for every theme but spotlight, so + // `drawer_box` stays a childless, zero-height box for them (see the + // `window.breadbar > box > centerbox` selector note in theme.rs for + // why the vbox wrapper itself is safe for those two themes too). + widgets.drawer_box.add_css_class("bread-drawer"); + bar_modules.for_each_in_slot( + &bar_slots.drawer, + |_, widget| widgets.drawer_box.append(widget), + |key| { + widgets + .drawer_box + .append(&bar::slots::widget_slot_container(&mut widget_containers, key)) + }, + ); + // Collapsed by default; `Overflow::Hidden` clips the results list + // while its allocated height is below its natural content height, + // same as the demo's `.results { max-height: 0; overflow: hidden }`. + widgets.drawer_box.set_overflow(gtk4::Overflow::Hidden); + widgets.drawer_box.set_size_request(-1, 0); + + // ── Capsule expand/collapse + search wiring (theme 04/spotlight) ── + // Effectively a no-op under every other theme: `launcher_entry` + // never receives focus if it's never in a slot, so `open_fn` is + // simply never invoked. `results.set_query`/select_next`/`select_prev` + // and launching all come straight from `bread-launcher`; only the + // capsule shell (drawer height, entry placeholder/alignment, + // keyboard focus) is this file's own. + let anim: Rc>> = Rc::new(RefCell::new(None)); + let launcher_open: Rc> = Rc::new(Cell::new(false)); + + let open_fn: Rc = Rc::new({ + let drawer_box = widgets.drawer_box.clone(); + let anim = Rc::clone(&anim); + let launcher_open = Rc::clone(&launcher_open); + let entry = launcher_entry.clone(); + move || { + if !launcher_open.get() { + launcher_open.set(true); + entry.add_css_class("searching"); + gtk4::prelude::EntryExt::set_alignment(&entry, 0.0); + } + let target = drawer_target_height(&drawer_box); + let current = drawer_box.size_request().1; + animate_drawer_height(&drawer_box, &anim, current, target); + } + }); + let close_fn: Rc = Rc::new({ + let drawer_box = widgets.drawer_box.clone(); + let anim = Rc::clone(&anim); + let launcher_open = Rc::clone(&launcher_open); + let entry = launcher_entry.clone(); + let root_for_focus = root.clone(); + move || { + if !launcher_open.get() { + return; + } + launcher_open.set(false); + entry.remove_css_class("searching"); + gtk4::prelude::EntryExt::set_alignment(&entry, 0.5); + entry.set_text(""); + if placeholder_clock { + entry.set_placeholder_text(Some(&bar::clock::time())); + } + let current = drawer_box.size_request().1; + animate_drawer_height(&drawer_box, &anim, current, 0); + // `keyboard = "on_demand"` (plan §7) ties the layer-shell + // surface's keyboard grab to GTK's own focus-widget state — + // releasing focus here is what hands the keyboard back. + gtk4::prelude::GtkWindowExt::set_focus(&root_for_focus, None::<>k4::Widget>); + } + }); + + { + let results = launcher_results.clone(); + let open_fn = Rc::clone(&open_fn); + launcher_entry.connect_changed(move |entry| { + results.set_query(entry.text().as_str()); + open_fn(); + }); + } + { + let open_fn = Rc::clone(&open_fn); + let focus_ctrl = gtk4::EventControllerFocus::new(); + focus_ctrl.connect_enter(move |_| open_fn()); + launcher_entry.add_controller(focus_ctrl); + } + { + let results = launcher_results.clone(); + let close_fn = Rc::clone(&close_fn); + let key_ctrl = gtk4::EventControllerKey::new(); + key_ctrl.connect_key_pressed(move |_, key, _, _| { + use gtk4::gdk::Key; + match key { + Key::Escape => { + close_fn(); + gtk4::glib::Propagation::Stop + } + Key::Down => { + results.select_next(); + gtk4::glib::Propagation::Stop + } + Key::Up => { + results.select_prev(); + gtk4::glib::Propagation::Stop + } + Key::Return | Key::KP_Enter => { + if let Some(entry) = results.selected_entry() { + results.record_launch(&entry); + bread_launcher::do_launch( + &entry, + LAUNCHER_APP_ID, + LAUNCHER_LAUNCHED_EVENT, + ); + } + close_fn(); + gtk4::glib::Propagation::Stop + } + _ => gtk4::glib::Propagation::Proceed, + } + }); + launcher_entry.add_controller(key_ctrl); + } + // Row click launches too, same as breadbox's own overlay. + { + let results = launcher_results.clone(); + let close_fn = Rc::clone(&close_fn); + launcher_results.list.connect_row_activated(move |_, row| { + if let Some(entry) = bread_launcher::gtk::row_entry(row) { + results.record_launch(&entry); + bread_launcher::do_launch(&entry, LAUNCHER_APP_ID, LAUNCHER_LAUNCHED_EVENT); + } + close_fn(); + }); + } + // 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. @@ -830,6 +1085,12 @@ impl SimpleComponent for App { 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(); + // Theme 04/spotlight's capsule (plan §6b): `launcher_entry` moves + // into `model` below, `drawer_box` lives only in `widgets` — both + // need a clone out here for the same reason every other + // `_for_screenshot` handle above does. + let launcher_entry_for_screenshot = launcher_entry.clone(); + let drawer_box_for_screenshot = widgets.drawer_box.clone(); // Never launch sibling App windows from inside this init — RelmApp // is still in GApplication activate, and a same-type launch here @@ -855,6 +1116,8 @@ impl SimpleComponent for App { clock_digits, date_lbl, clock_plain_lbl, + launcher_entry, + launcher_open, system_stats_box, system_sep, cpu_pair, @@ -963,6 +1226,8 @@ impl SimpleComponent for App { media_track_lbl: media_track_lbl_for_screenshot, notification_window, osd_window, + launcher_entry: launcher_entry_for_screenshot, + drawer_box: drawer_box_for_screenshot, }, ); } @@ -1050,6 +1315,15 @@ impl SimpleComponent for App { flip_clock_digits(&self.clock_digits, &bar::clock::time()); } } + // `modules.clock.placeholder_clock` (spotlight): the + // capsule's entry IS the clock until focused — matches the + // demo's own `if (!open) q.placeholder = t;` guard so a + // live search in progress never has its placeholder text + // (invisibly, since real text covers it) stomped mid-type. + if clock_module.placeholder_clock && !self.launcher_open.get() { + self.launcher_entry + .set_placeholder_text(Some(&bar::clock::time())); + } } AppInput::StatsUpdate(stats) => { let cpu = match stats.cpu_temp { @@ -1429,7 +1703,17 @@ impl App { } } } - let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0); + let btn = match ws_style { + WorkspaceStyle::Dots => bar::workspaces::make_dot_button( + ws.id, + self.active_ws, + ws.windows as i32, + modules.workspaces.dot_widths, + ), + WorkspaceStyle::Trail | WorkspaceStyle::Pill => { + 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); } @@ -1925,6 +2209,40 @@ fn reveal_media(widget: >k4::Box, show: bool) { widget.set_visible(show); } +/// Drives `drawer_box`'s height from `from` to `to` over 360ms via +/// `bread_theme::anim::spring_to` (plan §7: GTK4 has no CSS height +/// transition on a widget, so the capsule's `.results { max-height: 0 → +/// 420px }` becomes a `set_size_request` interpolation on the frame clock +/// instead). Cancels any run already in flight first — reopening mid-close +/// (or vice versa) must restart from the CURRENT height, not fight a +/// leftover callback still walking toward the old target. +fn animate_drawer_height( + drawer_box: >k4::Box, + anim: &Rc>>, + from: i32, + to: i32, +) { + if let Some(id) = anim.borrow_mut().take() { + id.remove(); + } + let target = drawer_box.clone(); + let id = bread_theme::anim::spring_to(drawer_box, from, to, 360.0, move |h| { + target.set_size_request(-1, h); + }); + *anim.borrow_mut() = Some(id); +} + +/// The drawer's natural content height right now, capped at the demo's own +/// 420px (`04-spotlight.html`: `.searching .results { max-height: 420px }`) +/// — `ResultsList`'s scroller already self-caps at 480px +/// (`max_content_height`), shared with breadbox, so this is a tighter, +/// spotlight-specific ceiling on top of that shared one, not a replacement +/// for it. +fn drawer_target_height(drawer_box: >k4::Box) -> i32 { + let (_, natural, _, _) = drawer_box.measure(gtk4::Orientation::Vertical, -1); + natural.min(420) +} + fn popover_tab(label: &str) -> gtk4::ToggleButton { let btn = gtk4::ToggleButton::with_label(label); btn.add_css_class("popover-tab"); diff --git a/src/screenshot.rs b/src/screenshot.rs index 38f857e..8299ecd 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -45,6 +45,14 @@ const KNOWN_VIEWS: &[&str] = &[ "osd-volume", "osd-brightness", "wifi-add-dialog", + // Theme 04/spotlight's capsule (plan §6b) — see `dispatch`'s two new + // match arms. "capsule-collapsed" captures the same region "bar" + // always has (bar_capture_height already reflects the active theme's + // own window height/margin); it exists as its own name purely so a + // verification script doesn't have to already know "bar" means "the + // capsule, collapsed" under spotlight specifically. + "capsule-collapsed", + "capsule-expanded", ]; #[derive(Parser)] @@ -118,6 +126,15 @@ pub struct Handles { pub notification_window: Option, /// Same deal as `notification_window`, via `osd::spawn(Some(kind))`. pub osd_window: Option, + /// Theme 04/spotlight's capsule centre module — see the + /// "capsule-expanded" view, which focuses it and types a query to + /// drive the drawer open before capturing. + pub launcher_entry: gtk4::Entry, + /// The `drawer` slot's own container — read back after the open + /// animation settles so "capsule-expanded"'s capture height matches + /// however tall the results actually grew, rather than a guessed + /// constant. + pub drawer_box: gtk4::Box, } /// Capture height for the `bar` view: layer-shell top margin + widget @@ -177,6 +194,48 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: }; capture_standalone_window(window, output, width, height); } + "capsule-collapsed" => { + // Identical to "bar" — see KNOWN_VIEWS's doc comment on why + // this has its own name anyway. + root.connect_map(move |_| { + let output = output.clone(); + gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { + finish(bread_screenshots::capture_region(0, 0, width, bar_height, &output)); + }); + }); + } + "capsule-expanded" => { + // Focus + a real query, the same way a person opens the + // capsule (`connect_changed`/`EventControllerFocus::enter` in + // main.rs's capsule wiring do the rest: `open_fn` runs, + // `results.set_query` filters, and `animate_drawer_height` + // grows `drawer_box`). Capture height is bar height + however + // tall the drawer actually settled, not a hardcoded guess — + // this stays correct even if `dot_widths`/font/entry-count + // numbers change later. + let entry = handles.launcher_entry; + let drawer_box = handles.drawer_box; + root.connect_map(move |_| { + let output = output.clone(); + let entry = entry.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + entry.grab_focus(); + entry.set_text("f"); + let output = output.clone(); + let drawer_box = drawer_box.clone(); + // Past the 360ms spring_to run plus a normal capture + // settle, so the drawer's size_request (and the actual + // on-screen layer-shell surface it grows) has reached + // its final height before grabbing pixels. + gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || { + let drawer_h = drawer_box.size_request().1.max(0); + let capture_h = bar_height + drawer_h; + finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output)); + }); + }); + }); + } "wifi-add-dialog" => { let anchor = handles.wifi_tab_btn; root.connect_map(move |_| { diff --git a/src/theme.rs b/src/theme.rs index 57d307f..ebd9884 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -68,6 +68,13 @@ fn load_css() -> String { // sites below (see the Phase 0 constant inventory). let spring = tokens.spring(); let spring_settle = tokens.spring_settle(); + let bg_alpha = tokens.bg_alpha(); + // Palette token NAME (never hex — see every builtin theme.toml's own + // comment on this), used below by the dots/launcher-entry/drawer rules. + // liquid-motion/glass-workbench never render those (see the match arms + // and unconditional-but-unused block below), so this being "accent" vs + // "green" vs "pink" per theme has no visible effect on them. + let accent_from = tokens.accent_from(); // `tokens.bar_border()` (plan §11 Phase 5): "full" (default, liquid- // motion's floating island) draws a border on all four edges; "bottom" @@ -110,7 +117,7 @@ fn load_css() -> String { .workspace-btn.active:hover {{ background: transparent; }}\ .workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}", ), - _ => { + bread_theme::shell::WorkspaceStyle::Pill => { let accent = theme.tokens().accent_from(); format!( ".workspace-btn {{ background: transparent; opacity: 1; color: alpha(@on-bg, 0.4);\ @@ -127,6 +134,30 @@ fn load_css() -> String { .workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}", ) } + // "dots" (theme 04/spotlight): a label-less pill whose WIDTH comes + // from `modules.workspaces.dot_widths` and is set directly via + // `Widget::set_size_request` in `bar::workspaces::make_dot_button` + // — GTK CSS has no per-instance variable width, so unlike the demo's + // `.dots button[data-n="N"]` rules this class only supplies colour/ + // opacity/radius, never a width. `04-spotlight.html`'s own base + // rule (`background: #5a4a54`) is a *dim, desaturated* grey, not the + // bar's ink colour — approximated here as a low-alpha `@on-bg` fill + // so it still tracks pywal instead of hardcoding a hex that would + // clash with a light palette. + bread_theme::shell::WorkspaceStyle::Dots => { + let accent = theme.tokens().accent_from(); + format!( + ".workspace-dot {{ background-color: alpha(@on-bg, 0.35); color: transparent;\ + border-radius: {radius_pill}; border: none; outline: none; box-shadow: none;\ + min-height: 6px; margin: 0; padding: 0;\ + transition: background-color 0.25s {spring_settle},\ + opacity 0.25s {spring_settle}; }}\ + .workspace-dot:hover {{ background-color: alpha(@on-bg, 0.55); }}\ + .workspace-dot:not(.occupied):not(.active) {{ opacity: 0.35; }}\ + .workspace-dot.active {{ background-color: @{accent}; opacity: 1; }}\ + .workspace-dot.active:hover {{ background-color: @{accent}; }}", + ) + } }; format!( @@ -138,9 +169,15 @@ fn load_css() -> String { @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;\ + window.breadbar {{ background-color: alpha(@bg, {bg_alpha}); color: @on-bg;\ border-radius: {radius_bar}; {window_border} }}\ - window.breadbar > centerbox {{ padding: {centerbox_padding}; }}\ + /* `> box > centerbox`, not `> centerbox`: the root is a vbox (bar\ + row + drawer, plan §2) as of the `drawer` slot wiring — every\ + theme's centerbox is now one level deeper than before, this\ + selector just follows it there. Since `padding` doesn't depend\ + on nesting depth, liquid-motion/glass-workbench render byte-\ + identical CSS either way. */\ + window.breadbar > box > centerbox {{ padding: {centerbox_padding}; }}\ window.breadbar button {{ min-height: 0; min-width: 0; }}\ {workspace_css}\ .clock-box {{ padding: 0 4px; }}\ @@ -363,7 +400,28 @@ fn load_css() -> String { .bread-padding-none {{ padding: 0; }}\ .bread-padding-xs {{ padding: 4px; }}\ .bread-padding-sm {{ padding: 8px; }}\ - .bread-padding-md {{ padding: 12px; }}", + .bread-padding-md {{ padding: 12px; }}\ + /* Theme 04/spotlight's embedded launcher (plan §7). Unconditional,\ + like `.clock-plain-time` above: `launcher_entry`/`launcher_results`\ + are built regardless of the active theme (see main.rs's \"Assemble\"\ + section), just never placed in a slot outside spotlight, so these\ + rules render nothing on liquid-motion/glass-workbench. */\ + .launcher-entry {{ background: transparent; color: @on-bg; border: none;\ + outline: none; box-shadow: none; caret-color: @{accent_from};\ + font-size: 13px; font-weight: 500; letter-spacing: 0.06em;\ + padding: 0; margin: 0; min-height: 0; }}\ + .launcher-entry.searching {{ font-size: 15px; letter-spacing: 0; }}\ + .bread-drawer {{ min-height: 0; }}\ + .bread-drawer.open {{ border-top: 1px solid alpha(@on-bg, 0.08);\ + margin-top: 6px; padding-top: 2px; }}\ + .bread-drawer listbox {{ background: transparent; padding: 2px 0; }}\ + .bread-drawer row {{ padding: 8px 14px; border-radius: {radius_sm};\ + color: @on-bg; background-color: transparent; }}\ + .bread-drawer row:hover {{ background-color: alpha(@on-bg, 0.08); }}\ + .bread-drawer row:selected {{ background-color: alpha(@{accent_from}, 0.18);\ + color: @on-bg; }}\ + .bread-drawer .app-name {{ font-size: 14px; font-weight: 500; }}\ + .bread-drawer .app-muted {{ opacity: 0.45; font-size: 11px; }}", radius = radius, radius_bar = radius_bar, radius_sm = radius_sm, @@ -374,6 +432,8 @@ fn load_css() -> String { window_border = window_border, centerbox_padding = centerbox_padding, workspace_css = workspace_css, + bg_alpha = bg_alpha, + accent_from = accent_from, ) } From 88c1bb0b0765a2d00d2dc163d9ee31d7521b4433 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 00:40:26 +0800 Subject: [PATCH 15/50] bar/workspaces: unit-test the dots width-index mapping The isolated screenshot harness has no Hyprland IPC (bread-capture's isolation.rs), so it can never exercise a nonzero window count and prove dot widths visually. Pulled the count->dot_widths-index mapping out of make_dot_button as dot_width_index(), a pure function with no GTK dependency, and unit-tested it directly (0/1/2 pass through, 3+ collapses onto index 3, negative counts clamp instead of underflowing the array index) as the stand-in for that visual proof. --- src/bar/workspaces.rs | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 8b30505..4873431 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -184,8 +184,7 @@ pub fn make_dot_button( btn.set_halign(gtk4::Align::Center); btn.set_vexpand(false); btn.set_hexpand(false); - let idx = (windows.max(0) as usize).min(3); - btn.set_size_request(dot_widths[idx], 6); + btn.set_size_request(dot_widths[dot_width_index(windows)], 6); btn.connect_clicked(move |_| { relm4::spawn(async move { switch_workspace(id).await; @@ -194,6 +193,46 @@ pub fn make_dot_button( btn } +/// Maps an open-window count to a [`bread_theme::shell::DotWidths`] index: +/// 0/1/2 pass through, 3-or-more all collapse onto index 3 (the demo's own +/// `.dots button[data-n="3"]` never has a "4" variant). Pulled out of +/// [`make_dot_button`] as its own pure function purely so it's testable +/// without a GTK display — the isolated screenshot harness +/// (`bread-capture`) has no Hyprland IPC, so it can never exercise a +/// nonzero window count, and this is what stands in for that visual proof +/// (see the task notes on that gap). +fn dot_width_index(windows: i32) -> usize { + (windows.max(0) as usize).min(3) +} + +#[cfg(test)] +mod dot_width_tests { + use super::dot_width_index; + + #[test] + fn zero_and_one_and_two_pass_through() { + assert_eq!(dot_width_index(0), 0); + assert_eq!(dot_width_index(1), 1); + assert_eq!(dot_width_index(2), 2); + } + + #[test] + fn three_or_more_all_collapse_onto_index_three() { + assert_eq!(dot_width_index(3), 3); + assert_eq!(dot_width_index(4), 3); + assert_eq!(dot_width_index(50), 3); + } + + #[test] + fn negative_windows_clamps_to_zero_rather_than_panicking() { + // Hyprland's `windows` count is unsigned (u16) in practice, but + // `make_dot_button` takes a plain i32 — a negative value must + // never underflow the `dot_widths` index and panic. + assert_eq!(dot_width_index(-1), 0); + assert_eq!(dot_width_index(i32::MIN), 0); + } +} + #[derive(Clone, Copy)] struct Geom { x: f64, From 2694dc0e13c0b6724404e26ade6a61026cf058eb Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 10:53:52 +0800 Subject: [PATCH 16/50] capsule: clamp drawer collapse height and warn once per dropped widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drawer's spring easing overshoots past t=1.0 by design — the bounce is the point on expand. On collapse (from=content height, to=0) that same overshoot carried the interpolated value below zero, and set_size_request hard-asserts height >= -1, so a GTK-CRITICAL fired once per frame for the whole 360ms close. Clamp to 0 rather than -1: -1 is GTK's use-natural-height sentinel, which is not what a closing drawer wants. reconcile_widgets re-ran its undeliverable-widget warning on every reconcile, and breadd re-pushes specs continuously for any timer-driven widget, so a one-off diagnostic became unbounded log spam under a theme whose slots don't name that widget's placement. Warn once per widget id instead. --- src/main.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0b794ad..e080f54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -148,6 +148,9 @@ pub struct App { // `WidgetPlacement` itself never appears here — it's a wire type from // the bread daemon API and stays untouched. widget_containers: std::collections::HashMap, + /// Widget ids already reported as undeliverable by `reconcile_widgets`, so + /// the warning fires once per widget instead of once per reconcile. + dropped_widget_warned: std::collections::HashSet, widget_tray_section: gtk4::Box, widget_tray_sep: gtk4::Separator, @@ -1161,6 +1164,7 @@ impl SimpleComponent for App { tray_box, tray_items: std::collections::HashMap::new(), widget_containers, + dropped_widget_warned: std::collections::HashSet::new(), widget_tray_section, widget_tray_sep, panels, @@ -1552,7 +1556,15 @@ impl App { }; if self.widget_containers.contains_key(&key) { by_container.entry(key).or_default().push(spec); - } else { + } else if self.dropped_widget_warned.insert(spec.id.clone()) { + // Warn ONCE per widget id, not once per reconcile: breadd + // re-pushes every spec on each update (a widget on a timer, + // like a git-branch poller, reconciles continuously), which + // turned a legitimate one-off diagnostic into unbounded log + // spam. The set is only added to, so a spec that starts + // resolving again after a theme switch stays quiet — the + // message is about a theme lacking the slot, and repeating it + // every tick tells the reader nothing new. eprintln!( "breadbar: widget '{}' (module '{}', placement {:?}) has no matching \ [bar.slots] widget: container — dropping", @@ -2227,7 +2239,14 @@ fn animate_drawer_height( } let target = drawer_box.clone(); let id = bread_theme::anim::spring_to(drawer_box, from, to, 360.0, move |h| { - target.set_size_request(-1, h); + // `spring_ease` deliberately overshoots past t=1.0 — that bounce is the + // point on expand, but on a collapse (from=content height, to=0) the + // same overshoot carries the interpolated value BELOW zero, and + // `set_size_request` hard-asserts `height >= -1` (GTK-CRITICAL, once + // per frame at 60fps). -1 is GTK's "use natural height" sentinel, not a + // valid animation frame, so clamp to 0 rather than -1: a drawer mid- + // collapse wants zero height, never its natural height. + target.set_size_request(-1, h.max(0)); }); *anim.borrow_mut() = Some(id); } From 33181a74616ea9432fa5ffbd4308da7552db2300 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 10:56:26 +0800 Subject: [PATCH 17/50] capsule: capture key events before the entry consumes Return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventControllerKey defaults to PropagationPhase::Bubble. GtkEntry handles Return in the target phase itself — emitting activate and returning TRUE — which stops propagation before a bubble-phase controller runs. The capsule's Enter-to-launch handler was therefore never reached: the selected app never launched, with no error, because the keypress was consumed upstream. Capture phase puts the controller ahead of the entry for the keys it claims (Return/Up/Down/Escape); everything else still Proceeds to the entry so normal text input is unaffected. --- src/main.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main.rs b/src/main.rs index e080f54..aa0d318 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1033,6 +1033,14 @@ impl SimpleComponent for App { let results = launcher_results.clone(); let close_fn = Rc::clone(&close_fn); let key_ctrl = gtk4::EventControllerKey::new(); + // CAPTURE, not the default BUBBLE. A GtkEntry handles Return in the + // target phase itself — it emits `activate` and returns TRUE, which + // stops propagation before a bubble-phase controller ever runs, so + // Enter silently did nothing and the selected app never launched. + // Capturing puts this controller ahead of the entry's own handling + // for every key it cares about (Return/Up/Down/Escape), and keys it + // doesn't claim still Proceed to the entry for normal text input. + key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); key_ctrl.connect_key_pressed(move |_, key, _, _| { use gtk4::gdk::Key; match key { From c68ed0d7e94c03894d6e19d7ce36fc3ce9e04c84 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 11:47:49 +0800 Subject: [PATCH 18/50] panel: extend the dismiss-scrim pattern for the capsule's click-away (item B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PanelSet's shared breadbar-dismiss surface already handles click-away for the wifi/control/media popovers; this adds the pieces the capsule needs to reuse it rather than a second mechanism: an extra on_dismiss callback invoked alongside hide_all() on every dismiss click, and show_capsule_dismiss/hide_dismiss, which show the scrim with its clickable region starting at a caller-given top margin instead of the popover default. That margin matters: breadbar-dismiss's layer (overlay) always renders above the bar's own (top), so if its clickable region ever reached up into where the drawer is actually drawn, it would swallow clicks meant for a result row instead of the click-away. The capsule's own call site (main.rs, not part of this commit) always passes a margin sized to the drawer's maximum possible height, never its live one, so this can never happen regardless of how tall the drawer currently is. Not yet wired into the capsule's open/close — PanelSet's three new methods are unused until that follow-up commit, hence the transient dead_code warnings. --- src/panel.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/src/panel.rs b/src/panel.rs index 9070c63..b9982ed 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -5,18 +5,31 @@ //! *below* the exclusive zone, and Hyprland slides `breadbar-panel` in from //! the right. +use std::cell::RefCell; +use std::rc::Rc; + use gtk4::gdk::Key; use gtk4::prelude::*; -use gtk4_layer_shell::{KeyboardMode, LayerShell}; +use gtk4_layer_shell::{Edge, KeyboardMode, LayerShell}; use crate::{bind_layer_monitor, theme}; +/// A boxed, ref-counted, optionally-unset click-away callback — see +/// `PanelSet::on_dismiss`'s own doc comment. +type DismissCallback = Rc>>>; + #[derive(Clone)] pub struct PanelSet { pub connectivity: gtk4::Window, pub control: gtk4::Window, pub media: gtk4::Window, dismiss: gtk4::Window, + // Theme 04/spotlight's capsule (plan §7 phase 6c): an extra click-away + // callback invoked alongside the popover-dismiss path below, so the + // SAME `breadbar-dismiss` surface/click-catcher also collapses the + // capsule's drawer — see `show_capsule_dismiss`/`hide_dismiss` and + // `set_on_dismiss`. `None` under every other theme (never set). + on_dismiss: DismissCallback, } impl PanelSet { @@ -36,6 +49,7 @@ impl PanelSet { control, media, dismiss, + on_dismiss: Rc::new(RefCell::new(None)), }; set.wire_dismiss(); set.wire_escape(); @@ -52,6 +66,10 @@ impl PanelSet { pub fn show(&self, which: >k4::Window) { self.hide_panels(); + // A prior capsule search (see `show_capsule_dismiss`) may have left + // the shared dismiss surface's top margin pushed down past its + // popover-shaped default — restore it before this popover uses it. + self.reset_dismiss_margin(); // Dismiss first so the panel maps above it (same Overlay layer). self.dismiss.set_visible(true); self.dismiss.present(); @@ -70,12 +88,55 @@ impl PanelSet { self.media.set_visible(false); } + /// Theme 04/spotlight's capsule (plan §7 phase 6c): registers `cb` to + /// run whenever the shared dismiss surface is clicked, alongside the + /// popovers' own `hide_all`. `cb` is expected to no-op when the capsule + /// isn't actually open (matching `close_fn`'s own guard in main.rs), so + /// this firing on an ordinary popover click-away is harmless. + pub fn set_on_dismiss(&self, cb: impl Fn() + 'static) { + *self.on_dismiss.borrow_mut() = Some(Rc::new(cb)); + } + + /// Shows the dismiss scrim with its clickable region starting at + /// `top_margin` px from the screen top, rather than the theme's own + /// popover-shaped default. See the call site in main.rs's capsule + /// `open_fn` for why this needs to be at least the capsule's own row + /// height plus the drawer's maximum possible height: the dismiss + /// surface's layer (`overlay`) always renders above the bar's own + /// (`top`), so if its clickable region ever reached up into where the + /// drawer is actually drawn, it would swallow clicks meant for a + /// result row instead of forwarding them. + pub fn show_capsule_dismiss(&self, top_margin: i32) { + self.dismiss.set_margin(Edge::Top, top_margin); + self.dismiss.set_visible(true); + self.dismiss.present(); + } + + /// Hides the dismiss scrim and restores its margin to the theme's own + /// popover default, so a later popover `show()` isn't left with a + /// leftover capsule-sized gap. + pub fn hide_dismiss(&self) { + self.reset_dismiss_margin(); + self.dismiss.set_visible(false); + } + + fn reset_dismiss_margin(&self) { + let theme = theme::shell_theme(); + if let Some(surf) = theme.surfaces().get("breadbar-dismiss") { + let top = surf.offset.first().copied().unwrap_or(0.0) as i32; + self.dismiss.set_margin(Edge::Top, top); + } + } + 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(cb) = set.on_dismiss.borrow().as_ref() { + cb(); + } }); if let Some(child) = self.dismiss.child() { child.add_controller(click); From e52c507f321e557ac4d764c85ac17c7cdcfcbadd Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 11:48:09 +0800 Subject: [PATCH 19/50] capsule: keyboard open, query modes, sections, and search-state geometry (phase 6c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the spotlight capsule (theme 04), THEME_SYSTEM_PLAN.md §7: - Keyboard open (item A): a new launcher_command module subscribes to bread.command.box.open — only when the active theme's launcher is Embedded — and focuses launcher_entry on receipt, which the existing EventControllerFocus::connect_enter already turns into an open_fn() call, same path a mouse click into the entry takes. breadbox's own keybind-triggered launch now redirects to this same event under an embedded theme instead of stacking its overlay window on top (see the breadbox commit). - Click-away scrim (item B): open_fn/close_fn now show/hide panel.rs's dismiss surface via the new show_capsule_dismiss/hide_dismiss, and panels.set_on_dismiss(close_fn) wires a click-away into the capsule's own close path. The scrim's clickable region starts at a fixed offset (capsule row height + the drawer's own maximum content height, DRAWER_MAX_HEIGHT_PX) rather than the drawer's live height, so it can never geometrically overlap a rendered result row regardless of breadbar-dismiss's overlay layer always rendering above the bar's own. - Query modes (item C): connect_changed now runs bread_launcher::parse_query against the entry text and, when the parsed prefix is listed in [launcher].modes, swaps launcher_results.scroller for a new mode_list populated by populate_mode_list (calc result / filtered commands / an "open this URL" prompt). key_ctrl routes Up/Down/Return at whichever list is active; a `>`/`.` row's action runs through the new ModeAction enum (RunShell for a command's own fixed exec string, OpenUrl straight to xdg-open) so arbitrary typed text is never passed through a shell. - Sections (item D): ResultsList::new's new `sections` bool is threaded from [launcher].sections. - Search-state geometry (item E): root's own width now spring-animates between [launcher].width and .search_width (animate_capsule_width, same set_size_request-driven technique animate_drawer_height already uses), and a `.searching` class on root switches border-radius to .search_radius via a new CSS rule/transition in theme.rs. Also fixes a real bug this surfaced: drawer_target_height was measuring drawer_box itself, whose size_request is continuously overwritten by animate_drawer_height's own tick callback — GTK clamps a widget's measure() up to at least its own explicit size request, so switching from the (tall) app list to a one-row calc result measured the PREVIOUS frame's forced height instead of the new content's actual size. It now sums each visible child's own natural height directly, which also fixes the same latent issue for narrowing app-search results. screenshot.rs gains two new views (capsule-sections, capsule-calc) to exercise D and C's calc path. --- src/launcher_command.rs | 39 +++++ src/main.rs | 376 +++++++++++++++++++++++++++++++++++++++- src/screenshot.rs | 54 ++++++ src/theme.rs | 26 ++- 4 files changed, 490 insertions(+), 5 deletions(-) create mode 100644 src/launcher_command.rs diff --git a/src/launcher_command.rs b/src/launcher_command.rs new file mode 100644 index 0000000..4d47560 --- /dev/null +++ b/src/launcher_command.rs @@ -0,0 +1,39 @@ +//! Subscribes to `bread.command.box.open` and focuses/opens the capsule — +//! only under `[launcher] mode = "embedded"` (spotlight, THEME_SYSTEM_PLAN.md +//! §7 phase 6c). See `breadbox/EVENTS.md` for the command's existing +//! contract: it's honored today only while `breadbox listen` is running, and +//! is a silent no-op at the bus with no subscriber. breadbar becomes a +//! SECOND subscriber of the exact same verb here — under an embedded theme, +//! breadbox's own `main` (see its doc comment on `dispatch_embedded_open`) +//! redirects a direct launch to this same event instead of mapping its own +//! overlay window, specifically so this module can pick it up. `breadbox +//! listen`'s own handling of the same event is separately made a no-op +//! under an embedded theme (see its `handle_open`) — there is exactly one +//! real handler for this event at a time, whichever theme is active. +//! +//! Same connection pattern as `widgets::client` (this crate's other +//! `BreadClient::subscribe` user): a fire-and-forget connect, a background +//! subscription thread with its own reconnect/backoff, and the handle is +//! leaked rather than threaded through `App` — there's no natural point to +//! stop it before the process exits. + +use crate::{App, AppInput}; +use bread_theme::shell::LauncherMode; +use bread_utils::bread_client::BreadClient; +use relm4::ComponentSender; + +/// Starts the subscription iff the active shell theme's launcher is +/// `Embedded`. A no-op call under every other theme — never connects to +/// breadd at all, matching the "effectively a no-op under every other +/// theme" pattern the rest of the capsule wiring already follows (main.rs's +/// `open_fn`/`close_fn` doc comment). +pub fn spawn(sender: ComponentSender) { + if crate::theme::shell_theme().launcher().mode != LauncherMode::Embedded { + return; + } + let client = BreadClient::connect(crate::widgets::client::APP_ID); + let subscription = client.subscribe("bread.command.box.open", move |_event| { + sender.input(AppInput::OpenLauncher); + }); + std::mem::forget(subscription); +} diff --git a/src/main.rs b/src/main.rs index aa0d318..89e2104 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ macro_rules! asset { } mod bar; +mod launcher_command; mod notifications; mod osd; mod panel; @@ -37,6 +38,14 @@ use std::rc::Rc; const LAUNCHER_APP_ID: &str = "box"; const LAUNCHER_LAUNCHED_EVENT: &str = "bread.box.launched"; +/// The drawer's own content-height ceiling (`04-spotlight.html`: `.searching +/// .results { max-height: 420px }`) — see [`drawer_target_height`]. Also +/// used to size the click-away scrim's dead zone (see `open_fn` in `init`): +/// the scrim's clickable region never reaches higher than the capsule row +/// plus this much, so it can never overlap a real result row regardless of +/// how tall the drawer currently is. +const DRAWER_MAX_HEIGHT_PX: i32 = 420; + pub struct BarInit { pub screenshot: Option, pub monitor: Option, @@ -177,6 +186,13 @@ pub enum AppInput { WidgetsUpdate(Vec), ReconcileMonitors, DismissPanels, + // `bread.command.box.open` (plan §7 phase 6c, `launcher_command` + // module): only ever dispatched when the active theme's launcher is + // `Embedded` — `launcher_command::spawn` never subscribes otherwise. + // Focuses `launcher_entry`, which the existing `EventControllerFocus` + // (`connect_enter`) already turns into an `open_fn()` call, the same + // path a mouse click into the entry takes. + OpenLauncher, } #[relm4::component(pub)] @@ -513,6 +529,7 @@ impl SimpleComponent for App { &launcher_entries, launcher_cfg.icon_px, Rc::clone(&launcher_history), + launcher_cfg.sections, ); launcher_results.scroller.add_css_class("bread-drawer-scroller"); @@ -963,6 +980,22 @@ impl SimpleComponent for App { widgets.drawer_box.set_overflow(gtk4::Overflow::Hidden); widgets.drawer_box.set_size_request(-1, 0); + // ── Query-mode results (plan §7 phase 6c: `=` calc, `>` cmd, `.` + // url) ─────────────────────────────────────────────────────────── + // A second, small list living alongside `launcher_results.scroller` + // in the same drawer — only one of the two is ever visible at a + // time (see `connect_changed` below). Built unconditionally, same + // as `launcher_results` itself, but only ever APPENDED into + // `drawer_box` for an embedded launcher: every other theme must + // keep `drawer_box` exactly as childless as it already is (see the + // comment above `bar_modules.for_each_in_slot(&bar_slots.drawer, ..)`). + let mode_list = gtk4::ListBox::new(); + mode_list.set_selection_mode(gtk4::SelectionMode::Browse); + mode_list.set_visible(false); + if launcher_cfg.mode == bread_theme::shell::LauncherMode::Embedded { + widgets.drawer_box.append(&mode_list); + } + // ── Capsule expand/collapse + search wiring (theme 04/spotlight) ── // Effectively a no-op under every other theme: `launcher_entry` // never receives focus if it's never in a slot, so `open_fn` is @@ -971,18 +1004,53 @@ impl SimpleComponent for App { // capsule shell (drawer height, entry placeholder/alignment, // keyboard focus) is this file's own. let anim: Rc>> = Rc::new(RefCell::new(None)); + // Search-state width (plan §7 phase 6c: `[launcher].search_width`, + // `04-spotlight.html`'s `.searching .capsule { width: 520px }`) — + // a separate animation from the drawer's own height, both driven + // by `bread_theme::anim::spring_to` independently. + let anim_width: Rc>> = Rc::new(RefCell::new(None)); let launcher_open: Rc> = Rc::new(Cell::new(false)); + // The click-away scrim's dead zone (item B, see `DRAWER_MAX_HEIGHT_PX`'s + // own doc comment) — the capsule row's own theme-default dismiss + // offset (52px: `bar.window.height` + `bar.window.margin.top`) plus + // the drawer's own max content height, so the scrim's clickable + // region can never reach up into a rendered result row. + let capsule_dismiss_margin: i32 = theme::shell_theme() + .surfaces() + .get("breadbar-dismiss") + .map(|s| s.offset.first().copied().unwrap_or(0.0) as i32) + .unwrap_or(52) + + DRAWER_MAX_HEIGHT_PX; let open_fn: Rc = Rc::new({ let drawer_box = widgets.drawer_box.clone(); let anim = Rc::clone(&anim); + let anim_width = Rc::clone(&anim_width); let launcher_open = Rc::clone(&launcher_open); let entry = launcher_entry.clone(); + let root = root.clone(); + let panels = panels.clone(); + let idle_width = launcher_cfg.width; + let search_width = launcher_cfg.search_width; move || { if !launcher_open.get() { launcher_open.set(true); entry.add_css_class("searching"); gtk4::prelude::EntryExt::set_alignment(&entry, 0.0); + // `.searching` on the root itself (plan §7 phase 6c): + // toggles `[launcher].search_radius` via CSS (see + // theme.rs's `window.breadbar.searching` rule) and + // marks the width animation's starting point below. + root.add_css_class("searching"); + let current_width = root.width(); + let from = if current_width > 0 { current_width } else { idle_width }; + animate_capsule_width(&root, &anim_width, from, search_width); + // Item B: click-away scrim. Shown at a fixed dead-zone + // offset (`capsule_dismiss_margin`), never the live + // drawer height — see that constant's own doc comment + // for why a live-tracking offset would risk swallowing + // clicks meant for a result row. + panels.show_capsule_dismiss(capsule_dismiss_margin); } let target = drawer_target_height(&drawer_box); let current = drawer_box.size_request().1; @@ -992,9 +1060,13 @@ impl SimpleComponent for App { let close_fn: Rc = Rc::new({ let drawer_box = widgets.drawer_box.clone(); let anim = Rc::clone(&anim); + let anim_width = Rc::clone(&anim_width); let launcher_open = Rc::clone(&launcher_open); let entry = launcher_entry.clone(); let root_for_focus = root.clone(); + let root_for_width = root.clone(); + let panels = panels.clone(); + let idle_width = launcher_cfg.width; move || { if !launcher_open.get() { return; @@ -1006,6 +1078,10 @@ impl SimpleComponent for App { if placeholder_clock { entry.set_placeholder_text(Some(&bar::clock::time())); } + root_for_width.remove_css_class("searching"); + let current_width = root_for_width.width(); + animate_capsule_width(&root_for_width, &anim_width, current_width, idle_width); + panels.hide_dismiss(); let current = drawer_box.size_request().1; animate_drawer_height(&drawer_box, &anim, current, 0); // `keyboard = "on_demand"` (plan §7) ties the layer-shell @@ -1015,11 +1091,55 @@ impl SimpleComponent for App { } }); + panels.set_on_dismiss({ + let close_fn = Rc::clone(&close_fn); + move || close_fn() + }); + + // Which of the four query modes is currently driving `mode_list` + // (plan §7 phase 6c) — read by `key_ctrl` below to route Up/Down/ + // Return at the right list, and whether that mode's row is even + // selectable (an info-only row, e.g. an empty calc expression, + // never is). `launcher_cfg.modes` gates which prefixes actually + // switch mode: a prefix this theme doesn't list in `modes` falls + // through to a literal Apps query, prefix character and all. + let active_mode: Rc> = + Rc::new(Cell::new(bread_launcher::QueryKind::Apps)); + let mode_selectable: Rc> = Rc::new(Cell::new(false)); + let modes = launcher_cfg.modes.clone(); + { let results = launcher_results.clone(); + let mode_list = mode_list.clone(); let open_fn = Rc::clone(&open_fn); + let active_mode = Rc::clone(&active_mode); + let mode_selectable = Rc::clone(&mode_selectable); launcher_entry.connect_changed(move |entry| { - results.set_query(entry.text().as_str()); + let text = entry.text(); + let parsed = bread_launcher::parse_query(&text); + let mode_name = match parsed.kind { + bread_launcher::QueryKind::Calc => "calc", + bread_launcher::QueryKind::Cmd => "cmd", + bread_launcher::QueryKind::Url => "url", + bread_launcher::QueryKind::Apps => "apps", + }; + let kind = if modes.iter().any(|m| m == mode_name) { + parsed.kind + } else { + bread_launcher::QueryKind::Apps + }; + active_mode.set(kind); + if kind == bread_launcher::QueryKind::Apps { + mode_list.set_visible(false); + results.scroller.set_visible(true); + mode_selectable.set(false); + results.set_query(text.as_str()); + } else { + results.scroller.set_visible(false); + let selectable = populate_mode_list(&mode_list, &parsed); + mode_selectable.set(selectable); + mode_list.set_visible(true); + } open_fn(); }); } @@ -1031,7 +1151,10 @@ impl SimpleComponent for App { } { let results = launcher_results.clone(); + let mode_list = mode_list.clone(); let close_fn = Rc::clone(&close_fn); + let active_mode = Rc::clone(&active_mode); + let mode_selectable = Rc::clone(&mode_selectable); let key_ctrl = gtk4::EventControllerKey::new(); // CAPTURE, not the default BUBBLE. A GtkEntry handles Return in the // target phase itself — it emits `activate` and returns TRUE, which @@ -1043,6 +1166,37 @@ impl SimpleComponent for App { key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); key_ctrl.connect_key_pressed(move |_, key, _, _| { use gtk4::gdk::Key; + if active_mode.get() != bread_launcher::QueryKind::Apps { + // Calc/Cmd/Url (plan §7 phase 6c): Up/Down move the + // (possibly single-row) `mode_list` selection; Return + // runs whatever `mode_row_action` finds on the + // selected row — nothing, for a calc result or an + // empty prompt, since those rows carry none. + return match key { + Key::Escape => { + close_fn(); + gtk4::glib::Propagation::Stop + } + Key::Down if mode_selectable.get() => { + listbox_select_next(&mode_list); + gtk4::glib::Propagation::Stop + } + Key::Up if mode_selectable.get() => { + listbox_select_prev(&mode_list); + gtk4::glib::Propagation::Stop + } + Key::Return | Key::KP_Enter => { + if let Some(row) = mode_list.selected_row() { + if let Some(action) = mode_row_action(&row) { + run_mode_action(&action); + close_fn(); + } + } + gtk4::glib::Propagation::Stop + } + _ => gtk4::glib::Propagation::Proceed, + }; + } match key { Key::Escape => { close_fn(); @@ -1073,6 +1227,17 @@ impl SimpleComponent for App { }); launcher_entry.add_controller(key_ctrl); } + // Click on a mode_list row (a `>`-mode command or the `.`-mode + // "open this URL" prompt) acts too, same as a result row's click. + { + let close_fn = Rc::clone(&close_fn); + mode_list.connect_row_activated(move |_, row| { + if let Some(action) = mode_row_action(row) { + run_mode_action(&action); + close_fn(); + } + }); + } // Row click launches too, same as breadbox's own overlay. { let results = launcher_results.clone(); @@ -1188,6 +1353,12 @@ impl SimpleComponent for App { if init.primary { bar::tray::spawn_watcher(sender.clone()); widgets::client::spawn(sender.clone()); + // `bread.command.box.open` (plan §7 phase 6c): one subscriber, + // same reasoning as `widgets::client::spawn` above — a keybind + // should focus ONE capsule, not every satellite monitor's. + // A no-op call under every theme but spotlight (see the + // module's own doc comment). + launcher_command::spawn(sender.clone()); // Optional (plan §10, Phase 2 item 6): live theme.toml/extra.css // token reload, the same way a pywal palette change already // hot-reloads via `apply_app_css`. One watch per process, so @@ -1515,6 +1686,9 @@ impl SimpleComponent for App { AppInput::DismissPanels => { self.panels.hide_all(); } + AppInput::OpenLauncher => { + self.launcher_entry.grab_focus(); + } } } } @@ -2259,6 +2433,30 @@ fn animate_drawer_height( *anim.borrow_mut() = Some(id); } +/// Drives the capsule's own window width from `from` to `to` over 360ms +/// (plan §7 phase 6c: `[launcher].search_width`, `04-spotlight.html`'s +/// `.searching .capsule { width: 520px }`) — the same `spring_to` + +/// `set_size_request` technique `animate_drawer_height` uses for the +/// drawer's height, applied to the root window itself instead of a child +/// box. Unlike a drawer collapse, width never animates toward a negative +/// target (idle/search widths are both positive theme values), so there is +/// no analogous "clamp to 0" concern here. +fn animate_capsule_width( + root: >k4::ApplicationWindow, + anim: &Rc>>, + from: i32, + to: i32, +) { + if let Some(id) = anim.borrow_mut().take() { + id.remove(); + } + let target = root.clone(); + let id = bread_theme::anim::spring_to(root, from, to, 360.0, move |w| { + target.set_size_request(w.max(0), -1); + }); + *anim.borrow_mut() = Some(id); +} + /// The drawer's natural content height right now, capped at the demo's own /// 420px (`04-spotlight.html`: `.searching .results { max-height: 420px }`) /// — `ResultsList`'s scroller already self-caps at 480px @@ -2266,8 +2464,180 @@ fn animate_drawer_height( /// spotlight-specific ceiling on top of that shared one, not a replacement /// for it. fn drawer_target_height(drawer_box: >k4::Box) -> i32 { - let (_, natural, _, _) = drawer_box.measure(gtk4::Orientation::Vertical, -1); - natural.min(420) + // Deliberately never measures `drawer_box` itself. `animate_drawer_height`'s + // tick callback calls `drawer_box.set_size_request(-1, h)` on every + // frame, and GTK clamps a widget's own `measure()` result up to at + // least its own explicit size request — so once an animation has run + // even one frame, `drawer_box.measure()` reports that frame's forced + // height (or the spring's overshoot past it), not whatever its + // children actually need next. This bit spotlight's new query-mode + // rows directly: switching from the (tall) app list to a one-row calc + // result measured "437" instead of "~33", because the PREVIOUS + // animation frame had already forced `drawer_box` to 437px. + // + // Summing each currently-visible child's own natural height instead + // sidesteps this entirely — `launcher_results.scroller` and + // `mode_list` never get an explicit size request of their own, so + // their `measure()` always reflects their actual current content. + let mut total = 0; + let mut child = drawer_box.first_child(); + while let Some(c) = child { + if c.is_visible() { + let (_, natural, _, _) = c.measure(gtk4::Orientation::Vertical, -1); + total += natural; + } + child = c.next_sibling(); + } + total.min(DRAWER_MAX_HEIGHT_PX) +} + +// ── Query-mode rows (plan §7 phase 6c) ────────────────────────────────── +// +// `mode_list`'s rows are NOT `bread_launcher::DesktopEntry`-backed +// (`bread_launcher::gtk::row_entry` returns `None` for every one of +// these), so they're built/read here rather than through that crate. + +/// A single-line, non-interactive row — the calc result, or a "nothing +/// typed yet" placeholder. Reuses `.app-name` so it inherits the same +/// `.bread-drawer row` typography `bread-launcher`'s own rows get. +fn mode_info_row(text: &str) -> gtk4::ListBoxRow { + let row = gtk4::ListBoxRow::new(); + row.set_selectable(false); + row.set_activatable(false); + let lbl = gtk4::Label::new(Some(text)); + lbl.add_css_class("app-name"); + lbl.set_xalign(0.0); + row.set_child(Some(&lbl)); + row +} + +/// A single-line, actionable row (a `>`-mode command, or the `.`-mode +/// "open this URL" prompt) — Enter/click spawns `action` once resolved by +/// [`run_mode_action`]. +fn mode_action_row(text: &str, action: ModeAction) -> gtk4::ListBoxRow { + let row = gtk4::ListBoxRow::new(); + let lbl = gtk4::Label::new(Some(text)); + lbl.add_css_class("app-name"); + lbl.set_xalign(0.0); + row.set_child(Some(&lbl)); + unsafe { row.set_data("mode_action", action) }; + row +} + +/// What Enter/click on a [`mode_action_row`] does. Two variants, not one +/// shell-command string, so a `.`-mode URL (arbitrary user-typed text) +/// never passes through `bash -c` at all — only a `>`-mode command's own +/// fixed, trusted `exec` string does. +#[derive(Clone)] +enum ModeAction { + RunShell(&'static str), + OpenUrl(String), +} + +fn mode_row_action(row: >k4::ListBoxRow) -> Option { + unsafe { row.data::("mode_action").map(|p| p.as_ref().clone()) } +} + +fn run_mode_action(action: &ModeAction) { + match action { + ModeAction::RunShell(cmd) => { + let _ = std::process::Command::new("bash") + .args(["-c", cmd]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + } + ModeAction::OpenUrl(url) => { + // No scheme-adding shell involved — `xdg-open` gets the raw + // argument, so nothing in a `.`-mode query is ever parsed as + // shell syntax. + let target = if url.contains("://") { + url.clone() + } else { + format!("https://{url}") + }; + let _ = std::process::Command::new("xdg-open") + .arg(target) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + } + } +} + +/// Moves `list`'s selection to the next/previous row — unlike +/// `bread_launcher::gtk::ResultsList::select_next`/`select_prev`, `mode_list` +/// never has hidden rows to skip (it's cleared and rebuilt from scratch on +/// every query change), so this is the plain, un-filtered version. +fn listbox_select_next(list: >k4::ListBox) { + let cur = list.selected_row().map(|r| r.index()).unwrap_or(-1); + if let Some(row) = list.row_at_index(cur + 1) { + list.select_row(Some(&row)); + } +} + +fn listbox_select_prev(list: >k4::ListBox) { + let cur = list.selected_row().map(|r| r.index()).unwrap_or(0); + if cur > 0 { + if let Some(row) = list.row_at_index(cur - 1) { + list.select_row(Some(&row)); + } + } +} + +/// Clears `mode_list` and rebuilds it for `parsed` — the calc result, +/// filtered `>`-mode commands, or the `.`-mode "open this URL" prompt. +/// Returns whether anything is now selectable (a real command/URL row, not +/// just an info row) so the caller knows whether Return has anything to do. +fn populate_mode_list(mode_list: >k4::ListBox, parsed: &bread_launcher::ParsedQuery) -> bool { + while let Some(row) = mode_list.row_at_index(0) { + mode_list.remove(&row); + } + match parsed.kind { + bread_launcher::QueryKind::Calc => { + match bread_launcher::eval_calc(&parsed.value) { + Some(result) => mode_list.append(&mode_info_row(&format!("= {result}"))), + None => mode_list.append(&mode_info_row("=")), + } + false + } + bread_launcher::QueryKind::Cmd => { + let matches = bread_launcher::filter_commands( + &parsed.value, + bread_launcher::builtin_commands(), + ); + if matches.is_empty() { + mode_list.append(&mode_info_row("No matching commands")); + false + } else { + for cmd in &matches { + mode_list.append(&mode_action_row(cmd.name, ModeAction::RunShell(cmd.exec))); + } + if let Some(first) = mode_list.row_at_index(0) { + mode_list.select_row(Some(&first)); + } + true + } + } + bread_launcher::QueryKind::Url => { + if parsed.value.is_empty() { + mode_list.append(&mode_info_row(".")); + false + } else { + mode_list.append(&mode_action_row( + &format!("Open {}", parsed.value), + ModeAction::OpenUrl(parsed.value.clone()), + )); + if let Some(first) = mode_list.row_at_index(0) { + mode_list.select_row(Some(&first)); + } + true + } + } + bread_launcher::QueryKind::Apps => false, + } } fn popover_tab(label: &str) -> gtk4::ToggleButton { diff --git a/src/screenshot.rs b/src/screenshot.rs index 8299ecd..1b372fd 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -53,6 +53,13 @@ const KNOWN_VIEWS: &[&str] = &[ // capsule, collapsed" under spotlight specifically. "capsule-collapsed", "capsule-expanded", + // Phase 6c: query sections and the `=` calc mode — see `dispatch`'s + // two new match arms below. "capsule-expanded" already exercises the + // search-state width/radius change (item E: it types a query, which + // now also drives `open_fn`'s `.searching` root class + capsule-width + // spring animation), so that gap doesn't need its own view. + "capsule-sections", + "capsule-calc", ]; #[derive(Parser)] @@ -236,6 +243,53 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: }); }); } + "capsule-sections" => { + // Focus with NO query typed — the idle browse view + // (`ResultsList::new`'s "Recent"/"Apps" header rows are visible + // from construction; `set_query` is what would hide them, and + // it's never called here). Same settle timing as + // "capsule-expanded", just without the `entry.set_text` step. + let entry = handles.launcher_entry; + let drawer_box = handles.drawer_box; + root.connect_map(move |_| { + let output = output.clone(); + let entry = entry.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + entry.grab_focus(); + let output = output.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || { + let drawer_h = drawer_box.size_request().1.max(0); + let capture_h = bar_height + drawer_h; + finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output)); + }); + }); + }); + } + "capsule-calc" => { + // `=` mode (item C): the drawer's `mode_list` shows a single + // evaluated result row instead of `launcher_results.scroller` + // (see `populate_mode_list`'s `QueryKind::Calc` arm). + let entry = handles.launcher_entry; + let drawer_box = handles.drawer_box; + root.connect_map(move |_| { + let output = output.clone(); + let entry = entry.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + entry.grab_focus(); + entry.set_text("=6*7"); + let output = output.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || { + let drawer_h = drawer_box.size_request().1.max(0); + let capture_h = bar_height + drawer_h; + finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output)); + }); + }); + }); + } "wifi-add-dialog" => { let anchor = handles.wifi_tab_btn; root.connect_map(move |_| { diff --git a/src/theme.rs b/src/theme.rs index ebd9884..8e3f37c 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -75,6 +75,13 @@ fn load_css() -> String { // and unconditional-but-unused block below), so this being "accent" vs // "green" vs "pink" per theme has no visible effect on them. let accent_from = tokens.accent_from(); + // `[launcher].search_radius` (plan §7 phase 6c) — `LauncherMode:: + // Embedded` only (spotlight); `.launcher().radius` itself already + // equals `radius_bar` for that theme (see its own theme.toml comment), + // so a theme that omits `search_radius` gets `radius_search == + // radius_bar` here too, i.e. no visible shrink, matching bread-theme's + // own "default to the idle value" fallback. + let radius_search = format!("{}px", theme.launcher().search_radius); // `tokens.bar_border()` (plan §11 Phase 5): "full" (default, liquid- // motion's floating island) draws a border on all four edges; "bottom" @@ -170,7 +177,12 @@ fn load_css() -> String { @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, {bg_alpha}); color: @on-bg;\ - border-radius: {radius_bar}; {window_border} }}\ + border-radius: {radius_bar}; {window_border}\ + transition: border-radius 0.3s {spring_settle}; }}\ + /* `[launcher].search_radius` (plan §7 phase 6c, spotlight only —\ + `launcher_entry` never gets focus under any other theme, so\ + `.searching` never lands on `window.breadbar` there). */\ + window.breadbar.searching {{ border-radius: {radius_search}; }}\ /* `> box > centerbox`, not `> centerbox`: the root is a vbox (bar\ row + drawer, plan §2) as of the `drawer` slot wiring — every\ theme's centerbox is now one level deeper than before, this\ @@ -421,8 +433,18 @@ fn load_css() -> String { .bread-drawer row:selected {{ background-color: alpha(@{accent_from}, 0.18);\ color: @on-bg; }}\ .bread-drawer .app-name {{ font-size: 14px; font-weight: 500; }}\ - .bread-drawer .app-muted {{ opacity: 0.45; font-size: 11px; }}", + .bread-drawer .app-muted {{ opacity: 0.45; font-size: 11px; }}\ + /* `[launcher].sections` (plan §7 phase 6c) — the idle drawer's\ + \"Recent\"/\"Apps\" group labels (`bread_launcher::gtk::\ + build_header_row`). Unconditional, same reasoning as every other\ + launcher rule above: only spotlight ever builds a row with this\ + class at all. */\ + .bread-drawer-section-header {{ padding: 6px 14px 2px; }}\ + .section-header-label {{ font-size: 11px; font-weight: 600;\ + letter-spacing: 0.08em; text-transform: uppercase;\ + opacity: 0.45; }}", radius = radius, + radius_search = radius_search, radius_bar = radius_bar, radius_sm = radius_sm, radius_pill = radius_pill, From a7db4d093dccdbd836a1cb2ff9611c91c813eebb Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 11:58:39 +0800 Subject: [PATCH 20/50] capsule: keep the drawer hidden when collapsed and pin the capsule width Two rendering bugs, both from treating set_size_request as a maximum when it is only a minimum. The drawer was collapsed with set_size_request(-1, 0) but left visible. GTK still allocates a visible box its natural height, and this layer-shell surface has no fixed height, so the window grew to fit the entire results list: the capsule rendered open at idle with a stray result row beneath it. It is now hidden while collapsed, revealed in open_fn before the height animation, and hidden again on the frame the collapse reaches zero so the close stays animated rather than snapping shut. The capsule's width came from set_default_width, which is only a preference a wide child overrides. Combined with the results list propagating its natural width, the pill stretched well past the theme's 480px. The width request is now pinned, and the list no longer propagates width. --- src/main.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/main.rs b/src/main.rs index 89e2104..887333e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -282,6 +282,13 @@ impl SimpleComponent for App { // a real value out of it. if let Width::Px(px) = window_spec.width { root.set_default_width(px); + // set_default_width alone is only a preference — a wide child (the + // results list, whose natural width is its longest app name plus + // icon and wm-class) overrides it, so the capsule rendered far + // wider than the theme's 480px and stopped reading as a pill. + // Pinning the request keeps the surface at the theme's width + // regardless of what the app catalog contains. + root.set_size_request(px, -1); } // "auto" reserves height + top margin so tiled clients sit below the // gap — see WindowSpec::exclusive's doc comment (bread-theme). @@ -979,6 +986,13 @@ impl SimpleComponent for App { // same as the demo's `.results { max-height: 0; overflow: hidden }`. widgets.drawer_box.set_overflow(gtk4::Overflow::Hidden); widgets.drawer_box.set_size_request(-1, 0); + // set_size_request is a MINIMUM, not a maximum: GTK still allocates a + // visible box its natural height, and this layer-shell surface has no + // fixed height, so the window grew to fit the whole results list and + // the capsule sat open-at-idle showing a stray row. A hidden widget + // requests no size at all, which is what "collapsed" actually needs. + // open_fn/close_fn toggle this back on/off around the height animation. + widgets.drawer_box.set_visible(false); // ── Query-mode results (plan §7 phase 6c: `=` calc, `>` cmd, `.` // url) ─────────────────────────────────────────────────────────── @@ -1035,6 +1049,10 @@ impl SimpleComponent for App { move || { if !launcher_open.get() { launcher_open.set(true); + // Reveal before measuring/animating: while hidden the box + // reports no natural height, so the open animation would + // target 0 and nothing would appear. + drawer_box.set_visible(true); entry.add_css_class("searching"); gtk4::prelude::EntryExt::set_alignment(&entry, 0.0); // `.searching` on the root itself (plan §7 phase 6c): @@ -2429,6 +2447,14 @@ fn animate_drawer_height( // valid animation frame, so clamp to 0 rather than -1: a drawer mid- // collapse wants zero height, never its natural height. target.set_size_request(-1, h.max(0)); + // Collapse finished: hide the box so it stops claiming natural height. + // set_size_request is only a minimum, so a visible-but-zero-request + // drawer still gets allocated its children's full height and holds the + // capsule open. Hiding here rather than in close_fn keeps the collapse + // animated instead of snapping shut on the first frame. + if to == 0 && h <= 0 { + target.set_visible(false); + } }); *anim.borrow_mut() = Some(id); } From 6c4df8813cd563174e6075a1cba0cf999ddee2cd Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 12:04:43 +0800 Subject: [PATCH 21/50] capsule: accept the open event breadbox can actually emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app may only publish inside its own bread..* namespace, so breadbox (app id 'box') cannot emit bread.command.box.open — bread-client refuses it and the redirect did nothing but log a warning. The capsule now also subscribes to bread.box.open_requested, which is what breadbox emits. bread.command.box.open is kept as well: that is the addressed-to-an-app command form an external trigger (the bread CLI, a keybind, another app) would legitimately send, so the capsule opens whether it was asked directly or told by breadbox. --- src/launcher_command.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/launcher_command.rs b/src/launcher_command.rs index 4d47560..798f5ec 100644 --- a/src/launcher_command.rs +++ b/src/launcher_command.rs @@ -32,8 +32,22 @@ pub fn spawn(sender: ComponentSender) { return; } let client = BreadClient::connect(crate::widgets::client::APP_ID); - let subscription = client.subscribe("bread.command.box.open", move |_event| { - sender.input(AppInput::OpenLauncher); - }); - std::mem::forget(subscription); + // Two verbs, deliberately. + // + // `bread.box.open_requested` is what breadbox actually emits when the + // active theme is embedded: an app may only publish inside its own + // `bread..*` namespace, so breadbox (app id `box`) cannot emit a + // `bread.command.*` event at all — bread-client refuses it outright. + // + // `bread.command.box.open` is kept because it is the addressed-TO-an-app + // command form, which is what an external trigger (the `bread` CLI, a + // keybind, another app) would legitimately send. Honouring both means the + // capsule opens whether it was asked directly or told by breadbox. + for verb in ["bread.box.open_requested", "bread.command.box.open"] { + let sender = sender.clone(); + let subscription = client.subscribe(verb, move |_event| { + sender.input(AppInput::OpenLauncher); + }); + std::mem::forget(subscription); + } } From 6b15f24fb28127d34653781628a2f2c9778acbdc Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 16:04:49 +0800 Subject: [PATCH 22/50] build: move the dev-only source override out of Cargo.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same change as breadbox's fa85fc1. The [patch] block pointing bread-theme and bread-launcher at ../bread-ecosystem shipped in the committed manifest, where CI cannot resolve it — the workflow clones this repo alone and runs cargo --locked with no sibling checkout. Moved to a gitignored .cargo/config.toml. Hygiene, not a CI fix: bread-launcher does not exist at tag v0.7.4, so the pin cannot resolve without the override until bread-ecosystem is tagged. --- .gitignore | 3 +++ Cargo.toml | 7 ------- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 4f90dfb..af5d71a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ aster-brief.md # graphify knowledge-graph output (local tool cache, not for commit) graphify-out/ + +# Local-only source overrides (see .cargo/config.toml). +.cargo/ diff --git a/Cargo.toml b/Cargo.toml index 7fba78a..fa918e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,10 +45,3 @@ resvg = { version = "0.47", default-features = false } lto = "thin" codegen-units = 1 strip = "symbols" - -# DEV ONLY — remove before tagging. Points bread-theme at the local -# bread-ecosystem checkout so Phase 2 can build against `bread_theme::shell` -# (feature/shell-theme-manifest) before it is tagged as a release. -[patch."https://git.breadway.dev/Breadway/bread-ecosystem"] -bread-theme = { path = "../bread-ecosystem/bread-theme" } -bread-launcher = { path = "../bread-ecosystem/bread-launcher" } From 02ed92ae9d1765e0ea7d12c7c0350afcab7c8347 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 16:18:43 +0800 Subject: [PATCH 23/50] theme: adapt to bread-theme's ThemeWatch return type bread_theme::shell::watch() now returns an opaque ThemeWatch (it re-arms itself onto a new theme's directory when the active theme id changes, rather than staying pinned to whichever directory was active at call time) instead of a bare gio::FileMonitor. Update the stored handle's type to match. --- src/theme.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/theme.rs b/src/theme.rs index 8e3f37c..dacba3c 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -507,7 +507,11 @@ pub fn apply() { } thread_local! { - static SHELL_THEME_MONITOR: RefCell> = + // `bread_theme::shell::ThemeWatch`, not a bare `gio::FileMonitor`: the + // watch now re-arms itself onto a new theme's directory when the active + // theme id changes underneath it (see that type's doc comment), so the + // handle we keep alive is opaque, not a single fixed monitor. + static SHELL_THEME_MONITOR: RefCell> = const { RefCell::new(None) }; } From 1d3081851007c672ae8b58a6e7adfb3c10a50f32 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 16:19:15 +0800 Subject: [PATCH 24/50] surface: pin satellite window width instead of just requesting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit surface::apply()'s SurfaceWidth::Px handling only called set_default_width(), which is advisory — a wide child (an unwrapped app-name label, or a long summary/body with nothing narrower than its natural width to wrap against) overrides it, so breadbar-notif (320px) and breadbar-osd (180px) could render wider than their theme's configured width instead of wrapping. This is the exact 'wide child overrides set_default_width' trap main.rs's capsule Width::Px handling already learned and documented; apply() predates that fix and never got it. Add the same set_size_request(px, -1) pin used there. history.rs's 360px override of the shared breadbar-notif namespace has to override both calls now, not just set_default_width, since the pin from apply() would otherwise win over a bare default-width override. --- src/notifications/history.rs | 6 +++++- src/surface.rs | 22 ++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/notifications/history.rs b/src/notifications/history.rs index e3919bd..66c8783 100644 --- a/src/notifications/history.rs +++ b/src/notifications/history.rs @@ -180,8 +180,12 @@ pub fn build_window(store: Store) -> Ui { // Overrides `[surfaces."breadbar-notif"].width` (320px, the live-toast // popup's width — see `surface::apply`'s doc comment): the history // window genuinely wants a different width on the same namespace, and - // that isn't something the manifest schema models today. + // that isn't something the manifest schema models today. Both calls + // must be overridden, not just `set_default_width` — `apply()` also + // pins `set_size_request` to the toast's 320px, and a bare width alone + // would lose to that pin the same way it lost to a wide child before. window.set_default_width(360); + window.set_size_request(360, -1); window.set_keyboard_mode(KeyboardMode::OnDemand); crate::theme::bind_auto(&window); diff --git a/src/surface.rs b/src/surface.rs index d2812a2..4b69705 100644 --- a/src/surface.rs +++ b/src/surface.rs @@ -19,12 +19,15 @@ use gtk4_layer_shell::{Edge, Layer, LayerShell}; /// gtk4-layer-shell's own defaults rather than panicking, matching every /// other "malformed/incomplete theme" fallback in this system. /// -/// Does not set `set_default_width` for a namespace shared by more than one -/// window with genuinely different widths (`breadbar-notif`'s live toast is -/// 320px, its history sibling is 360px, and only the toast's width is -/// modeled in `[surfaces.*]` — see the Phase 0 constant inventory); callers -/// that need a different width than the theme's own set it explicitly -/// afterward. +/// The width applied here is not authoritative for a namespace shared by +/// more than one window with genuinely different widths (`breadbar-notif`'s +/// live toast is 320px, its history sibling is 360px, and only the toast's +/// width is modeled in `[surfaces.*]` — see the Phase 0 constant inventory); +/// callers that need a different width than the theme's own set it +/// explicitly afterward. Because a `Px` width is pinned with BOTH +/// `set_default_width` and `set_size_request` (see below — the latter is +/// what actually holds against a wide child), such a caller must override +/// both, not just `set_default_width`, or the pin from here wins. pub fn apply(window: >k4::Window, namespace: &str) { let theme = crate::theme::shell_theme(); let Some(surf) = theme.surfaces().get(namespace) else { @@ -74,5 +77,12 @@ pub fn apply(window: >k4::Window, namespace: &str) { if let SurfaceWidth::Px(px) = surf.width { window.set_default_width(px); + // set_default_width alone is only a preference — a wide child (an + // unwrapped app-name label, or a long summary/body before GTK has + // any allocation narrower than its natural width to wrap against) + // overrides it, so the window renders wider than the theme's + // requested px and stops matching the theme. Same trap, same fix, + // as main.rs's capsule `Width::Px` handling — see its comment. + window.set_size_request(px, -1); } } From 110ad2c6f99c933553a1f13ae4fb5cd876b75252 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 16:19:49 +0800 Subject: [PATCH 25/50] notifications: re-apply toast click-through after the surface::apply migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feature/theme-spotlight branched before main's 297207a ('notifications: stop toast popups from stealing focus or blocking clicks') and the notification popup was rewritten in this branch to use surface::apply() for its layer-shell setup, which sets only anchor/margin/width/layer — no input region — and left the window on KeyboardMode::OnDemand. Merging this branch would have silently reintroduced the original bug with no merge conflict to flag it. Re-apply the fix on top of surface::apply(): add surface::click_through(), an opt-in helper that sets an empty layer-shell input region on map so every pointer event passes through to whatever's underneath, and call it from the toast's create_window(). history.rs's window (the genuinely interactive notification-history view) is untouched and correctly keeps OnDemand and normal hit-testing. Also switch the toast itself from KeyboardMode::OnDemand to KeyboardMode::None. The toast's card layout does build action buttons and, when a notification carries an inline-reply hint, a GtkEntry — but with an empty input region nothing on the toast is ever clickable or focusable regardless of keyboard mode, so OnDemand only offered a focus capability with no way to trigger it. Those controls remain reachable from the history window, which is opened deliberately and keeps real hit-testing. --- src/notifications/popup.rs | 18 +++++++++++++++--- src/surface.rs | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 394e136..a45e402 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -172,9 +172,21 @@ fn create_window() -> gtk4::Window { window.init_layer_shell(); window.set_namespace(Some("breadbar-notif")); crate::surface::apply(&window, "breadbar-notif"); - // 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: they never grab keyboard focus... + window.set_keyboard_mode(KeyboardMode::None); + // ...and click through entirely, via an empty input region — every + // pointer event passes straight to whatever's underneath instead of + // hitting the toast. `make_card` below does build action buttons and, + // when a notification carries INLINE_REPLY_KEY, a reply `GtkEntry` — + // but with no input region reaching the toast at all, neither is ever + // clickable or focusable from here regardless of keyboard mode, so + // OnDemand would grant a focus capability nothing can trigger. Those + // controls are only reachable from the history window (history.rs), + // which is opened deliberately and correctly keeps OnDemand + normal + // hit-testing. If the toast itself grows real click interactivity + // later, this needs to become a real (non-empty) input region sized to + // just the interactive rows, not a blanket revert to OnDemand. + crate::surface::click_through(&window); crate::theme::bind_auto(&window); window } diff --git a/src/surface.rs b/src/surface.rs index 4b69705..10adcdc 100644 --- a/src/surface.rs +++ b/src/surface.rs @@ -86,3 +86,28 @@ pub fn apply(window: >k4::Window, namespace: &str) { window.set_size_request(px, -1); } } + +/// Makes `window` fully click-through: an empty layer-shell input region +/// means every pointer event passes to whatever is underneath instead of +/// being consumed by this surface. Deliberately opt-in, not part of +/// `apply()` — like `exclusive` zone and `keyboard` mode (see the module +/// doc comment), this isn't a `[surfaces.*]` schema concept, and most of +/// breadbar's satellites (history, the panel, the dismiss-scrim) genuinely +/// need real hit-testing. Only a purely-informational surface — today just +/// the notification toast — should call this. +/// +/// Must be applied in a `connect_map` handler: the surface (and therefore +/// `window.surface()`) doesn't exist until the window is mapped. +/// +/// This exists so a future migration of a surface's window-setup code +/// (like the one from hand-rolled layer-shell calls to this module) can't +/// silently drop a click-through requirement the way `breadbar-notif` did +/// once already — see the git history of `notifications/popup.rs` around +/// the "stop toast popups from stealing focus or blocking clicks" fix. +pub fn click_through(window: >k4::Window) { + window.connect_map(|win| { + if let Some(surface) = win.surface() { + surface.set_input_region(Some(>k4::cairo::Region::create())); + } + }); +} From 2cbdb58f37f77ce502b30355a4bc55116a7d9848 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 16:20:59 +0800 Subject: [PATCH 26/50] launcher: open the capsule on the currently-focused monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launcher_command::spawn() only runs for the primary App instance, and AppInput::OpenLauncher always grabbed focus on that instance's own capsule — but self.monitor is whichever output was focused ONCE, at that instance's own init(), not re-resolved on every keybind press. Start breadbar while eDP-1 is focused, move to DVI-I-1, press the launcher keybind, and the capsule opened on eDP-1, off-screen from where the user was looking. Re-resolve the focused Hyprland monitor at fire-time via primary_hypr_monitor() (already used for the same purpose at startup) and route to the right instance: the primary's own capsule if the focused monitor is its own, or forward to that monitor's tracked Controller in self.satellites otherwise. Falls back to the local capsule when there's no focused monitor to resolve (screenshot mode, hyprctl unavailable) or no tracked satellite for it yet (a very recent hotplug reconcile hasn't caught up with). Split the routing decision into a pure resolve_launcher_route() helper so it's unit-testable without a live App/GTK/Hyprland stack; covered by four new tests. --- src/main.rs | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 887333e..9a24b96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1705,7 +1705,31 @@ impl SimpleComponent for App { self.panels.hide_all(); } AppInput::OpenLauncher => { - self.launcher_entry.grab_focus(); + // Only the primary instance subscribes to the open command + // (`launcher_command::spawn`), but `self.monitor` here is + // whichever output was focused ONCE, at this instance's own + // `init()` — baked in at process start, not re-resolved on + // every keybind press. If the user has since moved focus to + // a different monitor, blindly grabbing focus on `self` + // would open the capsule on the wrong screen. Re-resolve + // the focused monitor now and route to whichever instance + // actually owns it. + let satellite_names: Vec<&str> = + self.satellites.iter().map(|(n, _)| n.as_str()).collect(); + let focused = primary_hypr_monitor(); + match resolve_launcher_route(focused.as_deref(), &self.monitor, &satellite_names) + { + LauncherRoute::Satellite(name) => { + // resolve_launcher_route only returns a name present in + // satellite_names, so this lookup cannot miss. + if let Some((_, ctrl)) = self.satellites.iter().find(|(n, _)| *n == name) { + ctrl.sender().emit(AppInput::OpenLauncher); + } + } + LauncherRoute::Local => { + self.launcher_entry.grab_focus(); + } + } } } } @@ -2847,6 +2871,35 @@ fn primary_hypr_monitor() -> Option { .map(|m| m.name.clone()) } +/// Where `AppInput::OpenLauncher` should be actually handled: locally (this +/// instance grabs its own capsule's focus), or forwarded to a specific +/// satellite instance. Pure decision logic, split out of the `update` match +/// arm so it's unit-testable without a live `App`/GTK/Hyprland stack. +#[derive(Debug, Clone, PartialEq, Eq)] +enum LauncherRoute { + Local, + Satellite(String), +} + +/// `focused`: the currently-focused Hyprland monitor, re-queried at +/// keybind-fire time (`None` if Hyprland's monitor query failed, e.g. +/// screenshot mode). `own`: this instance's own monitor, fixed at `init()`. +/// `satellites`: names of monitors this (necessarily primary) instance +/// tracks a `Controller` for. +/// +/// Falls back to `Local` whenever forwarding isn't possible or isn't +/// needed, so a caller can always make forward progress: no focused +/// monitor, the focused monitor is this instance's own, or the focused +/// monitor has no tracked satellite yet. +fn resolve_launcher_route(focused: Option<&str>, own: &str, satellites: &[&str]) -> LauncherRoute { + match focused { + Some(name) if name != own && satellites.contains(&name) => { + LauncherRoute::Satellite(name.to_string()) + } + _ => LauncherRoute::Local, + } +} + fn hypr_monitor_names() -> Vec { hypr_monitors_live() .into_iter() @@ -2937,3 +2990,42 @@ fn drop_satellite(satellites: &mut Vec<(String, Controller)>, name: &str) { } }); } + +#[cfg(test)] +mod launcher_route_tests { + use super::{resolve_launcher_route, LauncherRoute}; + + #[test] + fn focused_monitor_is_own_stays_local() { + assert_eq!( + resolve_launcher_route(Some("eDP-1"), "eDP-1", &["DVI-I-1"]), + LauncherRoute::Local + ); + } + + #[test] + fn focused_monitor_is_tracked_satellite_forwards() { + assert_eq!( + resolve_launcher_route(Some("DVI-I-1"), "eDP-1", &["DVI-I-1"]), + LauncherRoute::Satellite("DVI-I-1".to_string()) + ); + } + + #[test] + fn focused_monitor_with_no_tracked_satellite_falls_back_local() { + // e.g. reconcile hasn't caught up with a very recent hotplug yet. + assert_eq!( + resolve_launcher_route(Some("HDMI-A-1"), "eDP-1", &["DVI-I-1"]), + LauncherRoute::Local + ); + } + + #[test] + fn no_focused_monitor_falls_back_local() { + // Hyprland's monitor query failed (screenshot mode, hyprctl missing). + assert_eq!( + resolve_launcher_route(None, "eDP-1", &["DVI-I-1"]), + LauncherRoute::Local + ); + } +} From 030cf280967b9858d4296468ab0a0f51195c03ce Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 16:21:23 +0800 Subject: [PATCH 27/50] launcher: log when a mode-row action fails to launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_mode_action discarded spawn()'s Result with 'let _ =' for both RunShell and OpenUrl, with no log line either way. If xdg-open is missing, or the shell/spawn fails for any reason, pressing Enter on a '>'-command or '.'-URL row did nothing at all with zero diagnostic — worse than logging nothing being silent, there was no way to even suspect what happened. Log to stderr on Err for both variants. Also pull the URL scheme-adding logic out into a pure url_open_target() helper, covered by two new tests, so it's exercised without spawning a real process. --- src/main.rs | 52 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9a24b96..93bb86c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2588,31 +2588,44 @@ fn mode_row_action(row: >k4::ListBoxRow) -> Option { unsafe { row.data::("mode_action").map(|p| p.as_ref().clone()) } } +/// Pure half of the `.`-mode URL action: adds a scheme when the user typed +/// a bare host (`example.com` -> `https://example.com`), leaves anything +/// that already looks like `scheme://...` untouched. +fn url_open_target(url: &str) -> String { + if url.contains("://") { + url.to_string() + } else { + format!("https://{url}") + } +} + fn run_mode_action(action: &ModeAction) { match action { ModeAction::RunShell(cmd) => { - let _ = std::process::Command::new("bash") + if let Err(e) = std::process::Command::new("bash") .args(["-c", cmd]) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) - .spawn(); + .spawn() + { + eprintln!("breadbar: failed to run mode command {cmd:?}: {e}"); + } } ModeAction::OpenUrl(url) => { // No scheme-adding shell involved — `xdg-open` gets the raw // argument, so nothing in a `.`-mode query is ever parsed as // shell syntax. - let target = if url.contains("://") { - url.clone() - } else { - format!("https://{url}") - }; - let _ = std::process::Command::new("xdg-open") - .arg(target) + let target = url_open_target(url); + if let Err(e) = std::process::Command::new("xdg-open") + .arg(&target) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) - .spawn(); + .spawn() + { + eprintln!("breadbar: failed to open url {target:?}: {e}"); + } } } } @@ -3029,3 +3042,22 @@ mod launcher_route_tests { ); } } + +#[cfg(test)] +mod url_open_target_tests { + use super::url_open_target; + + #[test] + fn bare_host_gets_https_scheme() { + assert_eq!(url_open_target("example.com"), "https://example.com"); + } + + #[test] + fn existing_scheme_is_left_untouched() { + assert_eq!(url_open_target("http://example.com"), "http://example.com"); + assert_eq!( + url_open_target("ftp://example.com/file"), + "ftp://example.com/file" + ); + } +} From fecac7671aac9afddbffdc42e4639c609b0fb423 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 20:59:36 +0800 Subject: [PATCH 28/50] launcher: stop the capsule opening itself at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GTK4 auto-assigns keyboard focus to the first can-focus widget in a window as it's first mapped, and launcher_entry was that widget with nothing to stop it — EventControllerFocus's connect_enter turned that spurious focus into an unconditional open_fn() call, so the capsule looked open (and "focused") the moment breadbar started, with no real click ever having reached it. Since keyboard = "on_demand" only grants the layer-shell surface real compositor keyboard focus in response to genuine interaction, this also explains why typing and Escape did nothing in that state: GTK believed the entry had focus, but the compositor never actually handed the surface a keyboard grab. launcher_entry now starts with can_focus(false), so nothing can land focus on it implicitly. The two places that legitimately want it focused now ask for it explicitly: a new GestureClick on the entry (a real, compositor-visible click — the same kind of interaction on-demand keyboard mode is meant to react to) and the OpenLauncher command handler, both flip can_focus back on immediately before calling grab_focus(). --- src/main.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/main.rs b/src/main.rs index 93bb86c..8295e7c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -544,6 +544,20 @@ impl SimpleComponent for App { launcher_entry.add_css_class("launcher-entry"); launcher_entry.set_has_frame(false); launcher_entry.set_hexpand(true); + // Starts non-focusable ("the spotlight theme starts with the + // search open"). GTK4 auto-assigns keyboard focus to the first + // can-focus widget in a window as it's first mapped/shown — with + // nothing else focusable in the bar, that was always + // `launcher_entry`, and `focus_ctrl`'s `connect_enter` below + // (added unconditionally) turns "the entry gained focus" straight + // into `open_fn()`, so the capsule opened itself at startup. A + // widget that can't focus is skipped by BOTH that automatic + // selection and an explicit `grab_focus()` call — see GTK's own + // docs for `Widget::grab_focus`: "if widget is not focusable... + // this function does nothing." Every place that later wants real + // focus (the click gesture below, and `AppInput::OpenLauncher`'s + // handler) flips this back to `true` immediately before grabbing. + launcher_entry.set_can_focus(false); gtk4::prelude::EntryExt::set_alignment(&launcher_entry, 0.5); // `modules.clock.placeholder_clock` (spotlight): the entry's idle // placeholder IS the clock — no separate clock module renders at @@ -1167,6 +1181,34 @@ impl SimpleComponent for App { focus_ctrl.connect_enter(move |_| open_fn()); launcher_entry.add_controller(focus_ctrl); } + // "you can't close it using escape unless you are focused on the + // UI" / "it doesn't grab keyboard for typing": both are the same + // root cause as the startup-open bug above, from the other side. + // `launcher_entry.set_can_focus(false)` means nothing (startup or + // otherwise) can silently steal GTK's own notion of focus any + // more — so it now needs a real, explicit grab. This click gesture + // is that grab for the mouse path: a `GestureClick` press is a + // genuine user-originated pointer event delivered through the + // compositor, which is also exactly the kind of interaction + // `KeyboardMode::OnDemand` (gtk4-layer-shell/wlr-layer-shell) is + // documented to react to — the protocol spec (wlr-layer-shell- + // unstable-v1.xml) leaves *when* an on-demand surface gets the + // compositor's keyboard focus as "implementation-defined", but a + // literal click on the surface is the universal, minimum-common- + // denominator trigger every compositor implementation reacts to + // (it's the same interaction wofi/fuzzel/rofi-wayland rely on). + // Flipping `can_focus` back on right before `grab_focus()` mirrors + // `AppInput::OpenLauncher`'s handler below, which needs the exact + // same two-liner for the hotkey/command path. + { + let entry_for_click = launcher_entry.clone(); + let click = gtk4::GestureClick::new(); + click.connect_pressed(move |_, _, _, _| { + entry_for_click.set_can_focus(true); + entry_for_click.grab_focus(); + }); + launcher_entry.add_controller(click); + } { let results = launcher_results.clone(); let mode_list = mode_list.clone(); @@ -1727,6 +1769,12 @@ impl SimpleComponent for App { } } LauncherRoute::Local => { + // See `launcher_entry.set_can_focus(false)`'s own + // comment above: a hotkey/command-triggered open + // has no pointer click to flip this back on, so + // this path has to do it itself or `grab_focus()` + // below is a silent no-op. + self.launcher_entry.set_can_focus(true); self.launcher_entry.grab_focus(); } } From 55d0e399bd1b6738fe94ac49c55452695ede45a6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 21:05:39 +0800 Subject: [PATCH 29/50] panel: scope the capsule's click-away dead zone to its own column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "it only sometimes is dismissed when you click somewhere else": the dismiss scrim's clickable region started at a fixed offset below the screen top (the capsule row plus the drawer's max possible height, kept generous on purpose so an animating drawer never has its result rows swallowed) but via a plain layer-shell margin, which pushes the scrim's *entire width* down by that much — not just the strip under the capsule. That left a full-screen-wide dead band above it (near 470px tall on a 1200px-tall display) where a click neither dismissed the capsule nor hit anything else, since breadbar-dismiss sits on the overlay layer above the bar's own top layer and nothing else was there to catch it either. show_capsule_dismiss now also takes an optional (x, width) hole, computed from the capsule's own real on-screen column — queried live from `hyprctl layers -j` (ground truth; the layer-shell protocol never hands a client its own assigned position back) rather than assumed — and punches exactly that column out of the scrim's input region via a custom cairo Region, leaving the same vertical safety margin in place but no longer swallowing clicks beside the capsule. Falls back to the old full-width margin if the geometry query fails for any reason. Added capsule_dismiss_hole (pure coordinate math, unit tested) and hypr_capsule_center_x (the live query) to main.rs. --- src/main.rs | 106 +++++++++++++++++++++++++++++++++++++++++++++- src/panel.rs | 117 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 216 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8295e7c..f0cf010 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1060,6 +1060,7 @@ impl SimpleComponent for App { let panels = panels.clone(); let idle_width = launcher_cfg.width; let search_width = launcher_cfg.search_width; + let monitor_name_for_dismiss = monitor_name.clone(); move || { if !launcher_open.get() { launcher_open.set(true); @@ -1081,8 +1082,20 @@ impl SimpleComponent for App { // offset (`capsule_dismiss_margin`), never the live // drawer height — see that constant's own doc comment // for why a live-tracking offset would risk swallowing - // clicks meant for a result row. - panels.show_capsule_dismiss(capsule_dismiss_margin); + // clicks meant for a result row. `hole` scopes that + // dead zone to the capsule's own column instead of the + // full screen width ("it only sometimes is dismissed + // when you click somewhere else") — see + // `capsule_dismiss_hole`'s doc comment. Falls back to + // the old full-width dead zone if the live geometry + // query fails for any reason. + let hole = hypr_capsule_center_x(&monitor_name_for_dismiss).and_then( + |center_x| { + let (origin_x, _) = hypr_monitor_origin(&monitor_name_for_dismiss)?; + Some(capsule_dismiss_hole(center_x, origin_x, search_width)) + }, + ); + panels.show_capsule_dismiss(capsule_dismiss_margin, hole); } let target = drawer_target_height(&drawer_box); let current = drawer_box.size_request().1; @@ -2975,6 +2988,64 @@ fn hypr_monitor_origin(name: &str) -> Option<(i32, i32)> { .map(|m| (m.x, m.y)) } +/// The bar/capsule's own layer-surface geometry for `monitor`, straight +/// from the compositor (`hyprctl layers -j`, ground truth — not derived +/// from anything GTK/gtk4-layer-shell reports client-side, since the +/// wlr-layer-shell protocol never hands a client its own assigned x/y back; +/// only width/height come through `configure`). Matched by `namespace` +/// ("breadbar", set via `root.set_namespace` above), which is unique per +/// output since each monitor gets its own bound `App` instance/window. +/// Returns the surface's horizontal center in Hyprland's global coordinate +/// space. `None` on any parse/lookup failure — callers must fall back to +/// the pre-existing, safe-but-broader dead-zone behaviour rather than +/// guess. +fn hypr_capsule_center_x(monitor: &str) -> Option { + let output = std::process::Command::new("hyprctl") + .args(["layers", "-j"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let root: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; + let levels = root.get(monitor)?.get("levels")?.as_object()?; + for arr in levels.values() { + let Some(items) = arr.as_array() else { + continue; + }; + for item in items { + if item.get("namespace").and_then(|v| v.as_str()) != Some("breadbar") { + continue; + } + let x = item.get("x")?.as_i64()? as i32; + let w = item.get("w")?.as_i64()? as i32; + return Some(x + w / 2); + } + } + None +} + +/// The click-away scrim's capsule-column hole, in coordinates local to the +/// `breadbar-dismiss` surface (see `PanelSet::show_capsule_dismiss`) — +/// pure arithmetic, split out for unit testing. `capsule_center_global` and +/// `monitor_origin_x` are both in Hyprland's global compositor space +/// (`hypr_capsule_center_x`/`hypr_monitor_origin`); `column_width` is the +/// capsule's own *configured* search-state width +/// (`[launcher].search_width`), not a live-queried one — this fires right +/// as `open_fn` starts the width-animation from idle to search width, so a +/// live query at that exact instant would catch it mid-transition. Using +/// the wider, settled target here (like `DRAWER_MAX_HEIGHT_PX` already does +/// for the vertical bound) means the hole is never narrower than the +/// capsule ever actually gets while the scrim is showing. +fn capsule_dismiss_hole( + capsule_center_global: i32, + monitor_origin_x: i32, + column_width: i32, +) -> (i32, i32) { + let local_center = capsule_center_global - monitor_origin_x; + (local_center - column_width / 2, column_width) +} + /// 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. @@ -3091,6 +3162,37 @@ mod launcher_route_tests { } } +#[cfg(test)] +mod capsule_dismiss_hole_tests { + use super::capsule_dismiss_hole; + + #[test] + fn centered_capsule_on_primary_monitor_at_origin() { + // A 520px-wide capsule centered on a 1920px-wide monitor at global + // origin (0,0): global center x = 960, monitor origin x = 0. + let (x, w) = capsule_dismiss_hole(960, 0, 520); + assert_eq!((x, w), (960 - 260, 520)); + } + + #[test] + fn negative_origin_secondary_monitor_converts_to_local() { + // This machine's own DVI-I-1 (`hyprctl layers -j`, quoted in this + // module's doc comments): monitor origin x = -1080. A capsule + // centered on that output's own 1080px-wide span sits at global + // center x = -1080 + 540 = -540. + let (x, w) = capsule_dismiss_hole(-540, -1080, 520); + // Local center is 540 (origin subtracted back out); hole starts + // 260px to either side of it, independent of the monitor's sign. + assert_eq!((x, w), (540 - 260, 520)); + } + + #[test] + fn hole_width_always_matches_requested_column_width() { + let (_, w) = capsule_dismiss_hole(100, 0, 480); + assert_eq!(w, 480); + } +} + #[cfg(test)] mod url_open_target_tests { use super::url_open_target; diff --git a/src/panel.rs b/src/panel.rs index b9982ed..cca16c5 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -5,7 +5,7 @@ //! *below* the exclusive zone, and Hyprland slides `breadbar-panel` in from //! the right. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use gtk4::gdk::Key; @@ -14,10 +14,26 @@ use gtk4_layer_shell::{Edge, KeyboardMode, LayerShell}; use crate::{bind_layer_monitor, theme}; +/// Outside this rectangle's local x/y span, the dismiss window's own real +/// size (Wayland clips an input region to the surface's actual bounds, same +/// as `surface::click_through`'s empty-region trick) — big enough to cover +/// any realistic monitor layout, including a negative-origin secondary +/// output (`hyprctl layers -j` reported `x: -1080` for this machine's own +/// DVI-I-1). Centered on the origin so it's safe regardless of which way a +/// hole's coordinates end up signed. +const HOLE_CANVAS_SPAN: i32 = 20_000; + /// A boxed, ref-counted, optionally-unset click-away callback — see /// `PanelSet::on_dismiss`'s own doc comment. type DismissCallback = Rc>>>; +/// The capsule-column hole punched in the dismiss scrim's input region — +/// local x, y, width, height — see `PanelSet::dismiss_hole`'s own doc +/// comment. Shared (not just passed by value) so `make_dismiss`'s +/// `connect_map` hook and every `show_capsule_dismiss`/`reset_dismiss_margin` +/// call agree on the current value. +type DismissHole = Rc>>; + #[derive(Clone)] pub struct PanelSet { pub connectivity: gtk4::Window, @@ -30,6 +46,16 @@ pub struct PanelSet { // capsule's drawer — see `show_capsule_dismiss`/`hide_dismiss` and // `set_on_dismiss`. `None` under every other theme (never set). on_dismiss: DismissCallback, + // The rectangle (local to `dismiss`'s own surface coordinates) that + // should stay click-through even while the scrim otherwise covers the + // screen — `show_capsule_dismiss`'s own doc comment explains why this + // exists and how it's computed. `None` = no hole, the plain + // margin-based popover behaviour applies instead. Read inside + // `dismiss`'s own `connect_map` (the input region can only be set once + // the surface is real — see `surface::click_through`'s doc comment for + // the same constraint) and, for the case where `dismiss` is already + // mapped from a prior show, applied immediately too. + dismiss_hole: DismissHole, } impl PanelSet { @@ -42,7 +68,8 @@ impl PanelSet { 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 dismiss_hole: DismissHole = Rc::new(Cell::new(None)); + let dismiss = make_dismiss(monitor, &dismiss_hole); let set = Self { connectivity, @@ -50,6 +77,7 @@ impl PanelSet { media, dismiss, on_dismiss: Rc::new(RefCell::new(None)), + dismiss_hole, }; set.wire_dismiss(); set.wire_escape(); @@ -106,8 +134,31 @@ impl PanelSet { /// (`top`), so if its clickable region ever reached up into where the /// drawer is actually drawn, it would swallow clicks meant for a /// result row instead of forwarding them. - pub fn show_capsule_dismiss(&self, top_margin: i32) { - self.dismiss.set_margin(Edge::Top, top_margin); + /// + /// Before this fix, that safety was bought with a `set_margin` that + /// pushed the scrim's *entire width* down by `top_margin` — leaving a + /// full-screen-wide dead band above it (up to ~470px on a 1200px-tall + /// display) where a click neither dismissed nor hit anything else, the + /// "it only sometimes is dismissed when you click somewhere else" + /// report. `hole`, when known (local-to-this-surface x-start/width, in + /// `main.rs`'s `capsule_dismiss_hole`), keeps exactly the same + /// vertical safety margin but scopes the dead band to the capsule's + /// own column instead of the full width, so everywhere else in that + /// band is dismiss-clickable too. `None` (geometry unavailable, e.g. + /// `hyprctl` failed) falls back to the old full-width behaviour rather + /// than risk a hole in the wrong place. + pub fn show_capsule_dismiss(&self, top_margin: i32, hole: Option<(i32, i32)>) { + match hole { + Some((x, w)) if w > 0 => { + self.dismiss.set_margin(Edge::Top, 0); + self.dismiss_hole.set(Some((x, 0, w, top_margin))); + } + _ => { + self.dismiss.set_margin(Edge::Top, top_margin); + self.dismiss_hole.set(None); + } + } + apply_dismiss_hole(&self.dismiss, &self.dismiss_hole); self.dismiss.set_visible(true); self.dismiss.present(); } @@ -126,6 +177,10 @@ impl PanelSet { let top = surf.offset.first().copied().unwrap_or(0.0) as i32; self.dismiss.set_margin(Edge::Top, top); } + // A stale capsule-shaped hole must not leak into a popover's own + // full-width dead zone. + self.dismiss_hole.set(None); + apply_dismiss_hole(&self.dismiss, &self.dismiss_hole); } fn wire_dismiss(&self) { @@ -180,7 +235,7 @@ fn make_panel(class: &str, child: &impl IsA, monitor: &str) -> gtk window } -fn make_dismiss(monitor: &str) -> gtk4::Window { +fn make_dismiss(monitor: &str, hole: &DismissHole) -> gtk4::Window { let window = gtk4::Window::new(); window.add_css_class("breadbar-dismiss"); window.init_layer_shell(); @@ -206,5 +261,57 @@ fn make_dismiss(monitor: &str) -> gtk4::Window { bind_layer_monitor(&window, monitor); theme::bind_output(&window, monitor); window.set_visible(false); + // The underlying `GdkSurface` (and therefore `window.surface()`, which + // `apply_dismiss_hole` needs) doesn't exist until the window is mapped + // — same constraint `surface::click_through` documents. This surface + // gets hidden/shown repeatedly (every popover open/close, every + // capsule search), and GTK4 unmaps-then-remaps a toplevel each time + // its visibility toggles off then on, so re-applying here on every + // `map` (not just the first) is what keeps a freshly (re)shown surface + // honouring whatever hole was set before this particular `present()`. + { + let hole = Rc::clone(hole); + window.connect_map(move |win| apply_dismiss_hole(win, &hole)); + } window } + +/// Sets `dismiss`'s click-away input region to "everywhere" minus `hole` +/// (if any) — see `PanelSet::show_capsule_dismiss`'s doc comment for why. +/// Only takes effect once `dismiss.surface()` is real, i.e. the window is +/// currently mapped; harmlessly no-ops otherwise (the `connect_map` hook in +/// `make_dismiss` re-runs this the moment that stops being true). +fn apply_dismiss_hole(dismiss: >k4::Window, hole: &DismissHole) { + let Some(surface) = dismiss.surface() else { + return; + }; + match hole.get() { + Some((x, y, w, h)) => { + let canvas = gtk4::cairo::RectangleInt::new( + -HOLE_CANVAS_SPAN, + -HOLE_CANVAS_SPAN, + HOLE_CANVAS_SPAN * 2, + HOLE_CANVAS_SPAN * 2, + ); + let region = gtk4::cairo::Region::create_rectangle(&canvas); + let punch = gtk4::cairo::RectangleInt::new(x, y, w, h); + if region.subtract_rectangle(&punch).is_ok() { + surface.set_input_region(Some(®ion)); + } else { + // Punching the hole failed for some reason (an invalid + // cairo status on a plain rectangle op, effectively + // unreachable in practice) — falling back to `None` (the + // protocol's documented "no input region set: whole + // surface hits") is still safer than leaving whatever + // region predates this call in place, which could be + // stale from a completely different mode (e.g. an old + // popover-shaped margin-only region with no hole at all). + eprintln!( + "breadbar: could not punch capsule hole in dismiss scrim's input region" + ); + surface.set_input_region(None); + } + } + None => surface.set_input_region(None), + } +} From 0a0bd5f431c35b95f8e36f1af75921c86047f273 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 21:05:52 +0800 Subject: [PATCH 30/50] wifi chip: fix off-center icon and drop dead CSS rule wifi_img.set_hexpand(false) meant the icon packed at the start of the 32px icon-only chip with no leftover space for halign:Center to work with, sitting visibly left of center on glass-workbench/liquid-motion. hexpand(true) fixes the centering, but that alone silently bubbles the expand flag up through connectivity_pair into the shared right-hand stats box and the centerbox's end slot, blowing the wifi/vol cluster's layout apart (confirmed via a --screenshot bar capture: the whole cluster jumped left against the clock with a huge gap before battery/hamburger). connectivity_pair.set_hexpand(false) pins the box's own expand explicitly so the fix stays contained to this chip. Also drops the dead .wifi-pair { padding: 6px } rule: it can never apply since .stat-pair.icon-only's two-class selector always beats its one-class specificity regardless of source order, so the intended padding never actually rendered. --- src/main.rs | 31 +++++++++++++++++++++++++++++-- src/theme.rs | 1 - 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index f0cf010..3a8618f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -718,11 +718,38 @@ impl SimpleComponent for App { 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.add_css_class("icon-only"); bar_chip(&connectivity_pair); wifi_img.set_halign(gtk4::Align::Center); - wifi_img.set_hexpand(false); + // `.stat-pair.icon-only` forces a 32px min-width on this box, wider + // than the 24px icon's natural size. Without hexpand, a `gtk4::Box` + // packs a non-expanding child at its natural size flush against the + // start edge and leaves the leftover width trailing after it — so + // `halign: Center` had nothing to center within and the glyph sat a + // few pixels left of true center (reported: wifi icon not centered + // on glass-workbench/liquid-motion). `bat_box` never showed this + // because it isn't `icon-only` — no forced min-width wider than its + // (icon + label) content, so there's no leftover space to + // mis-place. hexpand(true) gives the icon a fillable cell spanning + // the full 32px box, which `halign: Center` then centers within, + // matching `bat_box`'s already-centered result. + // + // A bare `set_hexpand(true)` on the image is not enough on its + // own: GTK4 computes a container's *effective* expand by OR-ing in + // its children's hexpand whenever the container's own hexpand + // hasn't been explicitly set, so the flag silently bubbles up + // through `connectivity_pair` into the shared right-hand stats box + // and from there into the centerbox's end slot — which then hands + // that slot most of the bar's remaining width instead of its + // normal packed size. The visible symptom was dramatic, not + // subtle: the whole vol/wifi cluster jumped left to sit against + // the clock, with a huge dead gap before battery/hamburger, in a + // `--screenshot bar` capture. `connectivity_pair.set_hexpand(false)` + // pins this box's own expand explicitly, which stops the + // computation from climbing any further — the child can still + // fill and center within this one box's fixed 32px cell. + wifi_img.set_hexpand(true); + connectivity_pair.set_hexpand(false); connectivity_pair.append(&wifi_img); // `connectivity_pair` and `bat_box` are appended in "Assemble" // below, per `[bar.slots].right`. diff --git a/src/theme.rs b/src/theme.rs index dacba3c..d220180 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -249,7 +249,6 @@ fn load_css() -> String { 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 {{\ From 1c028b59befd3f26aa1e84751765e86356178412 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 21:13:41 +0800 Subject: [PATCH 31/50] stat-pair chips: token-driven radius, drop icon-only circular override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .stat-pair's border-radius was a hardcoded 10px shared identically by all three themes: coincidentally close for liquid-motion (radius_sm 9px, matching its demo's .chip radius almost exactly), flatly wrong for glass-workbench (demo's .chip is 6px, exactly this theme's own radius_sm), and disconnected from spotlight's much rounder capsule language. Chips now round by chip_radius: radius_sm for liquid-motion/ glass-workbench, radius_pill for spotlight (its bar/dots are already that round, so its lone .stat-pair occupant, the battery chip, now reads as part of that same family instead of a stray rounded rect inside a much-rounder capsule). Also drops .stat-pair.icon-only's own 999px radius override. That made wifi and the liquid-motion hamburger — the only two icon-only chips — fully circular while their row neighbours (vol, battery) stayed a rounded rect at .stat-pair's radius: a visible mismatch inside one row, called out against the hamburger specifically. Every demo's .chip class draws vol/wifi/battery/menu identically and none of them circular, so dropping the override just lets the shared radius cascade through unchanged. Verified via bread-capture's isolated headless-Sway harness with a temporary (not committed) debug outline on .stat-pair: liquid-motion's four right-side chips now share one visible corner radius instead of two circular + two rounded-rect, and spotlight's battery chip reads as a rounded pill matching its capsule/dots instead of a sharper rect. --- src/theme.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/theme.rs b/src/theme.rs index d220180..8dba26c 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -100,6 +100,24 @@ fn load_css() -> String { }; let centerbox_padding = if flush { "0 12px" } else { "0 8px 0 6px" }; + // Radius for `.stat-pair` (vol/wifi/battery/hamburger chips): radius_sm + // for liquid-motion (9px) and glass-workbench (6px, exact match to that + // demo's `.chip` radius) reads as "the same small-control rounding this + // theme uses everywhere else" — but spotlight's overall language is + // dramatically rounder (radius_bar 22px, workspace dots at radius_pill + // 999px) than either sibling theme, so its one `.stat-pair` occupant + // (battery — the only slot entry besides a Lua widget under + // `[bar.slots].right`) looked like a stray sharp-cornered rectangle + // dropped inside a capsule and next to fully-round dots (reported: + // "the spotlight battery chip... radii that don't match their + // neighbours"). Keying off the same `WorkspaceStyle` enum + // `workspace_css` below already switches on, rather than the theme id, + // so this stays in step if a future theme ever reuses the "dots" style. + let chip_radius = match theme.modules().workspaces.style { + bread_theme::shell::WorkspaceStyle::Dots => radius_pill.clone(), + _ => radius_sm.clone(), + }; + // `modules.workspaces.style` (plan §11 Phase 5): "trail" (default, // liquid-motion) is exactly today's CSS, unchanged byte-for-byte — // dimmed/translucent buttons with the gradient trail overlay supplying @@ -205,12 +223,38 @@ fn load_css() -> String { .stat-label {{ font-size: 14px; letter-spacing: 0.02em; opacity: 0.92; }}\ .stat-label.tick {{ animation: digit-flip 0.35s {spring} both; }}\ .stats-box {{ margin-right: 0; }}\ - .stat-pair {{ margin: 0; border-radius: 10px; padding: 5px 9px; min-height: 0;\ + /* Radius was a hardcoded 10px here regardless of theme — right by\ + coincidence for liquid-motion's demo (`.chip {{ border-radius:\ + 10px }}`, this theme's radius_sm is 9px, a 1px rounding-off),\ + wrong for glass-workbench (demo's `.chip` is 6px, exactly this\ + theme's radius_sm — the hardcoded 10px never matched it), and\ + wrong-in-spirit for spotlight even though no `.chip` class\ + exists in that demo to compare against: a small, sharp-ish\ + radius reads as a stray rectangle inside a 22px-radius capsule\ + sitting right next to 999px-radius workspace dots (reported:\ + spotlight's battery chip not matching its neighbours). See\ + `chip_radius` above — radius_sm for the other two themes,\ + radius_pill for spotlight, so every theme's stat chips round\ + the way that theme's *other* rounded chrome already does,\ + instead of all three sharing one borrowed hardcoded number. */\ + .stat-pair {{ margin: 0; border-radius: {chip_radius}; padding: 5px 9px; min-height: 0;\ transition: background-color 0.22s {spring_settle},\ 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: 4px; border-radius: 999px;\ + /* No border-radius override here (was a hardcoded 999px, making\ + wifi/hamburger — the only two `.icon-only` chips — fully\ + circular while their row neighbours vol/battery stayed a\ + rounded rect at `.stat-pair`'s own radius: a visible rounding\ + mismatch inside one row, reported against the liquid-motion\ + hamburger specifically). Every demo's `.chip` class (liquid-\ + motion, glass-workbench) draws vol/wifi/bat/menu identically,\ + none of them circular — dropping the override here just lets\ + `.stat-pair`'s own `chip_radius` cascade through unchanged, so\ + the icon-only chips match their siblings instead of standing\ + out (spotlight has no icon-only chip today, but would get the\ + same pill radius as its one `.stat-pair` sibling if it ever did). */\ + .stat-pair.icon-only {{ padding: 4px;\ min-width: 32px; min-height: 32px; }}\ .stat-icon {{ margin-right: 6px; }}\ .stat-pair.icon-only .stat-icon {{ margin: 0; }}\ @@ -447,6 +491,7 @@ fn load_css() -> String { radius_bar = radius_bar, radius_sm = radius_sm, radius_pill = radius_pill, + chip_radius = chip_radius, pad = pad, spring = spring, spring_settle = spring_settle, From 583d73c53cb78812b37458454120d4f37206f43d Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 21:14:57 +0800 Subject: [PATCH 32/50] liquid-motion workspace pills: match demo radius and height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .workspace-trail/.workspace-btn hardcoded border-radius: 12px and min-height: 28px — neither matches this theme's own radius_sm token (9px) nor the 01-liquid-motion.html demo's .ws-btn/.trail rules (26px tall, 9px radius). radius_sm happens to be an exact match for the demo's 9px here. Reported: "the pills on liquid motion just look off". Not visually verified via capture — the isolated headless-Sway harness has no Hyprland IPC, so workspace buttons render empty there regardless of theme (pre-existing harness limit, unrelated to this change). Verified by reading the CSS against 01-liquid-motion.html's