diff --git a/.gitignore b/.gitignore index 4f90dfb..816e2ad 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,3 @@ logs/ # Internal design documents (not for distribution) aster-brief.md - -# graphify knowledge-graph output (local tool cache, not for commit) -graphify-out/ diff --git a/Cargo.lock b/Cargo.lock index 14765ed..1a1122a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,9 +123,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.92" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", @@ -152,8 +152,8 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-screenshots" -version = "0.7.4" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#a9754d90ed32efcc26765abd01c9f441bfb01b1e" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "anyhow", "bread-utils", @@ -185,8 +185,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.7.4" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "dirs 5.0.1", "gtk4", @@ -196,8 +196,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.7.4" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#a9754d90ed32efcc26765abd01c9f441bfb01b1e" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "bread-shared 0.7.0", "dirs 5.0.1", @@ -207,7 +207,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.3.3" +version = "0.3.1" dependencies = [ "anyhow", "bread-screenshots", @@ -265,7 +265,7 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -286,9 +286,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.6" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ "clap_builder", "clap_derive", @@ -296,9 +296,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -564,9 +564,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -579,9 +579,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -589,15 +589,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -606,9 +606,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-lite" @@ -625,32 +625,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -685,7 +685,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -718,7 +718,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -771,7 +771,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 7.0.8", + "system-deps", "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 7.0.8", + "system-deps", ] [[package]] @@ -846,7 +846,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -867,7 +867,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -898,7 +898,7 @@ dependencies = [ "graphene-sys", "libc", "pango-sys", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -924,9 +924,9 @@ dependencies = [ [[package]] name = "gtk4-layer-shell" -version = "0.8.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" +checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" dependencies = [ "bitflags", "gdk4", @@ -939,15 +939,15 @@ dependencies = [ [[package]] name = "gtk4-layer-shell-sys" -version = "0.6.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" +checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" dependencies = [ "gdk4-sys", "glib-sys", "gtk4-sys", "libc", - "system-deps 8.0.0", + "system-deps", ] [[package]] @@ -978,7 +978,7 @@ dependencies = [ "gsk4-sys", "libc", "pango-sys", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -1058,9 +1058,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -1093,9 +1093,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -1214,7 +1214,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -1243,9 +1243,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "png" @@ -1321,7 +1321,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.20", + "thiserror 2.0.19", ] [[package]] @@ -1624,19 +1624,6 @@ 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" @@ -1667,11 +1654,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.19", ] [[package]] @@ -1687,9 +1674,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", @@ -1741,13 +1728,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.2" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.119", ] [[package]] @@ -1932,9 +1919,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.1" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -1956,9 +1943,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -1969,9 +1956,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1979,9 +1966,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -1992,9 +1979,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] @@ -2100,9 +2087,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.29" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "xmlwriter" @@ -2112,9 +2099,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "zbus" -version = "5.19.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" dependencies = [ "async-broadcast", "async-recursion", @@ -2142,14 +2129,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.19.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.119", "zbus_names", "zvariant", "zvariant_utils", @@ -2166,15 +2153,6 @@ 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" @@ -2183,41 +2161,40 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.14.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" dependencies = [ "endi", "enumflags2", "serde", "winnow 1.0.4", - "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.14.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.119", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "4.0.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.3", + "syn 2.0.119", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index 1825aa8..1648851 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.3.3" +version = "0.3.1" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" @@ -10,17 +10,17 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", 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 # instead of hand-parsing serde_json::Value. See src/widgets/. -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["bread-client"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", 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.4" } +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } diff --git a/assets/GPU.svg b/assets/GPU.svg deleted file mode 100644 index 03c7f1c..0000000 --- a/assets/GPU.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/bar/clock.rs b/src/bar/clock.rs index 45e25e6..4501fde 100644 --- a/src/bar/clock.rs +++ b/src/bar/clock.rs @@ -1,21 +1,11 @@ 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 { - format!("{} {}", date(), time()) + 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) } pub fn spawn_ticker(sender: ComponentSender) { diff --git a/src/bar/control.rs b/src/bar/control.rs index 43cb6c3..ed99568 100644 --- a/src/bar/control.rs +++ b/src/bar/control.rs @@ -121,29 +121,11 @@ pub fn spawn_set_brightness(v: f64) { }); } -pub fn spawn_set_sink(name: String, sender: ComponentSender) { +pub fn spawn_set_sink(name: String) { 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/stats.rs b/src/bar/stats.rs index 26358e0..764d758 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -25,14 +25,6 @@ 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")); @@ -73,7 +65,6 @@ pub struct Stats { pub gpu_temp: Option, pub net_rx_kbs: f32, pub net_tx_kbs: f32, - pub volume_pct: u8, } struct CpuSnapshot { @@ -85,7 +76,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_ICON_OFF))); + LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_OFF))); static WIFI_TICK: AtomicU8 = AtomicU8::new(0); fn read_cpu() -> f32 { @@ -280,7 +271,7 @@ fn wifi_iface() -> Option<&'static str> { async fn read_wifi() -> (String, &'static str) { let Some(iface) = wifi_iface() else { - return ("—".into(), WIFI_ICON_OFF); + return ("—".into(), WIFI_OFF); }; let link_out = tokio::process::Command::new("iw") @@ -290,7 +281,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_ICON_OFF), + _ => return ("—".into(), WIFI_OFF), }; let mut ssid = None; @@ -305,14 +296,13 @@ async fn read_wifi() -> (String, &'static str) { } let Some(ssid) = ssid else { - return ("—".into(), WIFI_ICON_OFF); + return ("—".into(), WIFI_OFF); }; let icon = match rssi { - 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, + Some(r) if r >= -55 => WIFI_STRONG, + Some(r) if r >= -70 => WIFI_MEDIUM, + _ => WIFI_WEAK, }; (ssid, icon) @@ -402,7 +392,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([' ', '=']) + .trim_start_matches(|c: char| c == ' ' || c == '=') .trim_matches('"'); if !val.is_empty() { return Some(val.to_string()); @@ -423,8 +413,7 @@ 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(); - // Demo bar prints the bare number ("83"), not "83%". - let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}")); + 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. @@ -453,7 +442,6 @@ 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, @@ -477,30 +465,9 @@ 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 cf5e272..6531ece 100644 --- a/src/bar/wifi.rs +++ b/src/bar/wifi.rs @@ -24,8 +24,6 @@ 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 { @@ -75,57 +73,31 @@ 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 out = tokio::time::timeout( - Duration::from_secs(4), - tokio::process::Command::new("nmcli") - .args(["-t", "-f", "SSID,SIGNAL,IN-USE", "device", "wifi", "list"]) + let Ok(Ok(out)) = tokio::time::timeout( + Duration::from_secs(10), + tokio::process::Command::new("breadcrumbs") + .args(["scan-list", "--json"]) .output(), ) - .await; - let Ok(Ok(o)) = out else { + .await + else { return vec![]; }; - 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()) { + 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() { return None; } - let saved = saved.contains(&ssid); - Some(ScanEntry { - ssid, - signal, - saved, - }) + 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 }) }) .collect() } @@ -142,32 +114,11 @@ pub fn spawn_status_poller(sender: ComponentSender) { }); } -/// Profiles first (so you can switch Home/Away immediately), then the -/// cached AP list. A background rescan refreshes the list if it finds more. +/// Called when the popover opens — loads profiles + scan in parallel. pub fn spawn_popover_load(sender: ComponentSender) { relm4::spawn(async move { - 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, - })); + let (profiles, scan) = tokio::join!(fetch_profile_list(), fetch_scan()); + sender.input(AppInput::WifiPopoverData(WifiPopoverData { profiles, scan })); }); } @@ -181,27 +132,29 @@ pub fn spawn_profile_set(name: String) { }); } -/// Fire-and-forget: connect to a known SSID via NetworkManager. +/// Fire-and-forget: connect to a specific saved SSID via `breadcrumbs join`. pub fn spawn_join(ssid: String) { relm4::spawn(async move { - let _ = tokio::process::Command::new("nmcli") - .args(["device", "wifi", "connect", &ssid]) + let _ = tokio::process::Command::new("breadcrumbs") + .args(["join", &ssid]) .output() .await; }); } -/// Save in breadcrumbs (if the CLI still accepts `add`) and connect with nmcli. +/// Fire-and-forget: save a new network with its password, then join it. pub fn spawn_add_and_join(ssid: String, password: String) { relm4::spawn(async move { - let _ = tokio::process::Command::new("breadcrumbs") + let added = tokio::process::Command::new("breadcrumbs") .args(["add", &ssid, &password]) .output() .await; - let _ = tokio::process::Command::new("nmcli") - .args(["device", "wifi", "connect", &ssid, "password", &password]) - .output() - .await; + if matches!(added, Ok(o) if o.status.success()) { + let _ = tokio::process::Command::new("breadcrumbs") + .args(["join", &ssid]) + .output() + .await; + } }); } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 2c05a15..67942f8 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -1,12 +1,7 @@ -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::{Monitors, Workspaces}, + data::{Workspace, Workspaces}, event_listener::{Event, EventStream}, prelude::*, shared::WorkspaceId, @@ -15,62 +10,16 @@ use relm4::ComponentSender; use crate::AppInput; -/// 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. +/// 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). async fn sync_state(sender: &ComponentSender) { - 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); - } - } + 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)); } - sender.input(AppInput::WorkspaceSync { - workspaces, - actives, - }); } pub fn spawn_watcher(sender: ComponentSender) { @@ -92,21 +41,13 @@ 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(_) - | Event::WorkspaceAdded(_) - | Event::WorkspaceDeleted(_) => { - sync_state(&sender).await; + Event::WorkspaceChanged(data) => { + sender.input(AppInput::ActiveWorkspace(data.id)); } - 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); + Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => { + if let Ok(ws) = Workspaces::get_async().await { + sender.input(AppInput::WorkspaceList(ws.to_vec())); + } } _ => {} } @@ -124,290 +65,17 @@ pub fn spawn_watcher(sender: ComponentSender) { }); } -pub fn make_button( - id: WorkspaceId, - name: &str, - active: WorkspaceId, - occupied: bool, -) -> gtk4::Button { +pub fn make_button(id: WorkspaceId, name: &str, active: WorkspaceId) -> 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_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; - }); + use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial}; + let _ = Dispatch::call(DispatchType::Workspace(WorkspaceIdentifierWithSpecial::Id( + id, + ))); }); 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, 1); - 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.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(); - 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) { - 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 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, - } - }; - 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 - } - }); - self.inner.borrow_mut().tick = Some(id); - } - - fn from_geom(&self, from: Option<>k4::Button>) -> Option { - let st = self.inner.borrow(); - // 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.host).map(inset_pill)) - } -} - -/// 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; - -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, - } -} - -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, - } - } - None => *from, - } -} - -/// 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 < 8.0 || h < 8.0 || w > MAX_CHIP_W { - 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, - }; - let w = g.w.max(1.0).round() as i32; - let h = g.h.max(1.0).round() as i32; - // Clearing first lets GTK shrink; size-request is a minimum. - pill.set_size_request(-1, -1); - pill.set_size_request(w, h); - host.move_(pill, g.x, g.y); - pill.set_visible(true); -} - -fn still_placeholder(btn: >k4::Button, g: &Geom) -> 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 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 5210546..0ce99a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,67 +7,47 @@ macro_rules! asset { mod bar; mod notifications; mod osd; -mod panel; mod screenshot; 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; +/// 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; 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_digits: Vec, - date_lbl: gtk4::Label, + clock_lbl: gtk4::Label, // ── Stats bar ───────────────────────────────────────────────────────── - // Island chrome matches the Liquid Motion demo: volume / wifi / battery - // / hamburger. CPU/RAM/power live in the control panel, not on the 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. system_stats_box: gtk4::Box, system_sep: gtk4::Separator, 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, bat_textures: std::collections::HashMap, @@ -76,6 +56,7 @@ 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, @@ -96,11 +77,19 @@ 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>, - sink_box: gtk4::Box, - sink_section: gtk4::Box, + 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, @@ -112,21 +101,16 @@ 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 { - WorkspaceSync { - workspaces: Vec, - actives: std::collections::HashMap, - }, - MonitorAdded(String), - MonitorRemoved(String), + WorkspaceList(Vec), + ActiveWorkspace(WorkspaceId), ClockTick, StatsUpdate(bar::stats::Stats), TrayUpdate(bar::tray::TrayUpdate), @@ -137,13 +121,11 @@ pub enum AppInput { MediaUpdate(bar::media::MediaState), ControlPanelData(bar::control::ControlPanelData), WidgetsUpdate(Vec), - ReconcileMonitors, - DismissPanels, } #[relm4::component(pub)] impl SimpleComponent for App { - type Init = BarInit; + type Init = Option; type Input = AppInput; type Output = (); @@ -151,7 +133,7 @@ impl SimpleComponent for App { gtk::ApplicationWindow { add_css_class: "breadbar", set_title: Some("breadbar"), - set_default_height: BAR_HEIGHT, + set_default_height: 32, #[name = "center_box"] gtk::CenterBox { @@ -160,53 +142,25 @@ impl SimpleComponent for App { } fn init( - init: Self::Init, + screenshot_req: 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_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); - } - } + root.set_exclusive_zone(32); // ── 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 - // button box, never the trail host. - let workspace_trail = bar::workspaces::WorkspaceTrail::new(); - let workspace_box = workspace_trail.buttons.clone(); + // WidgetPlacement::RightOfWorkspaces below. + let workspace_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); let workspace_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - 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_row.append(&workspace_box); // ── Lua-declared widget containers ────────────────────────────── // One per WidgetPlacement; positioned into the layout below as each @@ -228,7 +182,8 @@ impl SimpleComponent for App { // ── SVG icon sets ──────────────────────────────────────────────── use bar::stats::{ - AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, ICON_VOLUME, + AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, WIFI_MEDIUM, + WIFI_OFF, WIFI_STRONG, WIFI_WEAK, }; let bat_textures: std::collections::HashMap = [BAT_HIGH, BAT_MID, BAT_LOW] @@ -240,27 +195,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 gpu_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(), )); - prepare_icon(&bat_img, ICON_PX); - let ac_img = svg_image(AC_POWER); + let ac_img = gtk4::Image::from_paintable(Some(&svg_texture(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); @@ -269,11 +223,9 @@ 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); - // 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); + let wifi_img = + gtk4::Image::from_paintable(Some(&svg_texture(asset!("WiFi Connecting.svg")))); + wifi_img.add_css_class("stat-icon"); // Content pane only — this becomes a tab inside the merged @@ -287,18 +239,10 @@ 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_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_indicator = gtk4::Label::new(Some("▶")); + media_indicator.add_css_class("media-indicator"); let media_track_lbl = gtk4::Label::new(None); media_track_lbl.add_css_class("media-track-lbl"); @@ -306,7 +250,7 @@ impl SimpleComponent for App { media_track_lbl.set_max_width_chars(42); media_track_lbl.set_xalign(0.0); - media_widget.append(&media_eq); + media_widget.append(&media_indicator); media_widget.append(&media_track_lbl); // Media controls popover @@ -318,12 +262,14 @@ impl SimpleComponent for App { media_controls_box.set_margin_end(4); let prev_btn = gtk4::Button::new(); - prev_btn.set_child(Some(&svg_image(asset!("Previous.svg")))); + prev_btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture( + 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 = svg_image(asset!("Pause.svg")); + let media_play_icon = gtk4::Image::from_paintable(Some(&svg_texture(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"); @@ -332,7 +278,9 @@ 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(&svg_image(asset!("Next.svg")))); + next_btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture( + 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")); @@ -341,86 +289,61 @@ impl SimpleComponent for App { media_controls_box.append(&media_play_btn); media_controls_box.append(&next_btn); - // 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); + 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"); // Center area: [media_widget · widgets · clock · widgets] - let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); 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(&clock_lbl); center_area.append(&widget_right_of_clock); // ── Stats box (right side) ─────────────────────────────────────── - // Demo order: [vol 64] [wifi] [bat 83] [☰] - let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); + let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); 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 stay built (control panel + screenshots still - // read the labels) but never mount on the island — the demo bar - // does not show them. + // CPU/RAM/power draw: hidden by default (see StatsUpdate), so this + // whole sub-group — plus its separator — collapses away when quiet. 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 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_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); + 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); - - 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); + stats_box.append(&system_sep); 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"); @@ -439,38 +362,41 @@ impl SimpleComponent for App { // Boxes with manual visibility toggling, which left stale width // behind on reopen. // - // 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. + // 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. let content_stack = gtk4::Stack::new(); content_stack.set_hhomogeneous(true); - content_stack.set_vhomogeneous(false); - content_stack.set_transition_type(gtk4::StackTransitionType::Crossfade); - content_stack.set_transition_duration(220); + 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.add_named(&wifi_pane, Some("wifi")); content_stack.add_named(&bt_pane, Some("bluetooth")); content_stack.set_visible_child_name("wifi"); - // 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"); + let wifi_tab_btn = gtk4::ToggleButton::with_label("Wi-Fi"); + wifi_tab_btn.add_css_class("popover-tab"); wifi_tab_btn.set_active(true); - let bt_tab_btn = popover_tab("Bluetooth"); + let bt_tab_btn = gtk4::ToggleButton::with_label("Bluetooth"); + bt_tab_btn.add_css_class("popover-tab"); bt_tab_btn.set_group(Some(&wifi_tab_btn)); - let tab_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + let tab_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); 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| { @@ -488,72 +414,170 @@ 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(&wifi_caret); - connectivity_inner.append(&content_scroll); + 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)); 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); + connectivity_pair.append(&bt_img); 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); - stats_box.append(&bat_box); + + 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()); + }); // ── 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"); - 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); + // Volume row + let vol_row = build_slider_row(bar::stats::ICON_VOLUME, 0.0, 1.5, 0.02); 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); + // Brightness row + let bright_row = build_slider_row(bar::stats::ICON_BRIGHTNESS, 0.0, 1.0, 0.02); 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); + panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + // 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); power_row.add_css_class("power-row"); - power_row.set_halign(gtk4::Align::Center); - for (label, cmd) in [ + for (icon_svg, 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. - ("lock", vec!["breadlock"]), - ("sleep", vec!["systemctl", "suspend"]), - ("off", vec!["systemctl", "poweroff"]), + (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"]), ] { - let btn = gtk4::Button::with_label(label); + let btn = gtk4::Button::new(); + btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture(icon_svg))))); btn.add_css_class("flat"); btn.add_css_class("power-btn"); btn.connect_clicked(move |_| { @@ -566,121 +590,49 @@ impl SimpleComponent for App { }); power_row.append(&btn); } - panel_inner.append(&power_row); + power_section.append(&power_row); + panel_inner.append(&power_section); - // 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); + let control_popover = gtk4::Popover::new(); + control_popover.add_css_class("control-panel"); + control_popover.set_child(Some(&panel_inner)); - 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. + // Hamburger button let hamburger_btn = gtk4::Button::with_label("☰"); hamburger_btn.add_css_class("flat"); hamburger_btn.add_css_class("control-panel-btn"); - hamburger_btn.add_css_class("stat-pair"); - hamburger_btn.add_css_class("icon-only"); - bar_chip(&hamburger_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()); + }); // 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| { - vol_lbl_live.set_label(&format!("{:.0}", s.value() * 100.0)); - if loading_v.get() { - return; - } + 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), @@ -696,48 +648,29 @@ 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_panel_for_screenshot = panels.control.clone(); - let connectivity_panel_for_screenshot = panels.connectivity.clone(); + let control_popover_for_screenshot = control_popover.clone(); + let connectivity_popover_for_screenshot = connectivity_popover.clone(); let wifi_tab_btn_for_screenshot = wifi_tab_btn.clone(); let bt_tab_btn_for_screenshot = bt_tab_btn.clone(); - let media_panel_for_screenshot = panels.media.clone(); + let media_popover_for_screenshot = media_popover.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_digits, - date_lbl, + clock_lbl, system_stats_box, system_sep, cpu_pair, mem_pair, pwr_pair, - gpu_pair, cpu_lbl, mem_lbl, pwr_lbl, - gpu_lbl, - vol_lbl, bat_lbl, bat_img, bat_textures, @@ -746,6 +679,7 @@ impl SimpleComponent for App { bt_textures, wifi_lbl, wifi_img, + wifi_textures, wifi_pane, crumbs_status: None, wifi_popover_data: None, @@ -758,11 +692,19 @@ impl SimpleComponent for App { media_play_icon, media_last: None, media_paused_at: None, + control_popover, panel_vol_slider, panel_bright_slider, panel_loading, - sink_box, - sink_section, + 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, @@ -770,20 +712,16 @@ 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()); - if init.primary { - bar::tray::spawn_watcher(sender.clone()); - widgets::client::spawn(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 @@ -793,37 +731,28 @@ impl SimpleComponent for App { "notification-critical" => Some(notifications::SampleKind::Critical), _ => None, }); - let notification_window = if init.primary { - Some(notifications::spawn(notif_sample)) - } else { - None - }; + let notification_window = notifications::spawn(notif_sample); 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 = if init.primary { - Some(osd::spawn(osd_sample)) - } else { - None - }; + let osd_window = osd::spawn(osd_sample); if let Some(req) = screenshot_req { - 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")); + 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); screenshot::dispatch( &root, req, screenshot::Handles { - control_panel: control_panel_for_screenshot, - connectivity_panel: connectivity_panel_for_screenshot, + control_popover: control_popover_for_screenshot, + connectivity_popover: connectivity_popover_for_screenshot, wifi_tab_btn: wifi_tab_btn_for_screenshot, bt_tab_btn: bt_tab_btn_for_screenshot, - media_panel: media_panel_for_screenshot, + media_popover: media_popover_for_screenshot, media_widget: media_widget_for_screenshot, media_track_lbl: media_track_lbl_for_screenshot, notification_window, @@ -837,95 +766,48 @@ impl SimpleComponent for App { fn update(&mut self, msg: Self::Input, sender: ComponentSender) { match msg { - AppInput::WorkspaceSync { - workspaces, - actives, - } => { - let mut sorted = workspaces; + AppInput::WorkspaceList(list) => { + let mut sorted = list; 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; - 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); - } - } + self.rebuild_buttons(); } - AppInput::MonitorAdded(name) => { - if !self.primary || name == self.monitor { - return; + AppInput::ActiveWorkspace(id) => { + if let Some(old) = self.button_map.get(&self.active_ws) { + old.remove_css_class("active"); } - if self.satellites.iter().any(|(n, _)| n == &name) { - return; + self.active_ws = id; + if let Some(btn) = self.button_map.get(&self.active_ws) { + btn.add_css_class("active"); } - 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(); - flip_clock_digits(&self.clock_digits, &bar::clock::time()); - self.date_lbl.set_label(&bar::clock::date()); + self.clock_lbl.set_label(&self.time_str); } AppInput::StatsUpdate(stats) => { - let cpu = match stats.cpu_temp { - Some(t) => format!("{} · {:.0}°", stats.cpu, t), - None => stats.cpu, - }; - self.cpu_lbl.set_label(&cpu); + self.cpu_lbl.set_label(&stats.cpu); self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); - 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()); - self.vol_lbl.set_tooltip_text(Some(&format!("volume {}%", stats.volume_pct))); - tick_label(&self.bat_lbl, &stats.bat); + // 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); + + self.bat_lbl.set_label(&stats.bat); if let Some(tex) = self.bat_textures.get(&(stats.bat_icon.as_ptr() as usize)) { self.bat_img.set_paintable(Some(tex)); } - 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); + self.ac_img.set_visible(stats.ac_connected); if let Some(tex) = self.bt_textures.get(&(stats.bt_icon.as_ptr() as usize)) { self.bt_img.set_paintable(Some(tex)); } @@ -940,12 +822,41 @@ 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_ICON_OFF + bar::stats::WIFI_OFF } else { stats.wifi_icon }; - self.wifi_img.set_icon_name(Some(icon)); + if let Some(tex) = self.wifi_textures.get(&(icon.as_ptr() as usize)) { + self.wifi_img.set_paintable(Some(tex)); + } + // 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) { @@ -1006,14 +917,7 @@ impl SimpleComponent for App { } else { asset!("Play.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"); - } + self.media_play_icon.set_paintable(Some(&svg_texture(icon_svg))); if state.playing { self.media_paused_at = None; @@ -1023,22 +927,21 @@ impl SimpleComponent for App { let within_linger = self .media_paused_at - .is_none_or(|t| t.elapsed().as_secs() < 30 * 60); + .map_or(true, |t| t.elapsed().as_secs() < 30 * 60); self.media_last = Some(state); - reveal_media(&self.media_widget, within_linger); + self.media_widget.set_visible(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 { - reveal_media(&self.media_widget, true); + self.media_widget.set_visible(true); } else { - reveal_media(&self.media_widget, false); + self.media_widget.set_visible(false); self.media_last = None; self.media_paused_at = None; } } else { - reveal_media(&self.media_widget, false); + self.media_widget.set_visible(false); self.media_last = None; } } @@ -1049,19 +952,36 @@ 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); + + // 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(); - } } } } @@ -1110,118 +1030,34 @@ 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 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), None => self.current_ssid.clone(), }; self.wifi_lbl.set_label(&label); - self.wifi_img.set_tooltip_text(Some(&label)); } - fn rebuild_buttons(&mut self, animate: bool) { - self.workspace_trail.cancel(); - let prev: std::collections::HashSet = - self.button_map.keys().copied().collect(); + fn rebuild_buttons(&mut self) { while let Some(child) = self.workspace_box.first_child() { self.workspace_box.remove(&child); } self.button_map.clear(); 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 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); - } + let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws); 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); } @@ -1253,11 +1089,7 @@ 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"); @@ -1269,36 +1101,59 @@ impl App { .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); } - 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 Some(data) = &self.wifi_popover_data else { let lbl = gtk4::Label::new(Some("Scanning…")); lbl.add_css_class("wifi-popover-loading"); - lbl.set_xalign(0.0); 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"); + 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); - self.wifi_pane.append(&lbl); - } else { - for (i, entry) in scan.iter().enumerate() { + 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 { 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"); } @@ -1307,61 +1162,31 @@ impl App { row.add_css_class("wifi-popover-row-active"); } - 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)); + 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, + ))); 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(), |_| {}); } - 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(); + close_parent_popover(btn); }); self.wifi_pane.append(&row); } @@ -1388,7 +1213,6 @@ 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| { @@ -1410,24 +1234,26 @@ 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(4); + dh.set_margin_bottom(2); self.bt_pane.append(&dh); - for (i, dev) in data.devices.iter().enumerate() { + for dev in &data.devices { 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(&dev.name)); + let lbl = gtk4::Label::new(Some(&format!( + "{}{}", + if dev.connected { "● " } else { " " }, + dev.name, + ))); lbl.set_xalign(0.0); - lbl.set_valign(gtk4::Align::Center); row.set_child(Some(&lbl)); let address = dev.address.clone(); @@ -1449,7 +1275,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 = svg_image(bar::stats::ICON_BT_SETTINGS); + let settings_icon = gtk4::Image::from_paintable(Some(&svg_texture(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"))); @@ -1465,60 +1291,65 @@ impl App { // ── Helpers ─────────────────────────────────────────────────────────────────── -fn build_slider_row(label: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { +fn build_slider_row(icon_svg: &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); - 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); + // 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 slider = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, min, max, step); slider.set_draw_value(false); slider.set_hexpand(true); - slider.set_width_request(160); + slider.set_width_request(180); slider.add_css_class("control-panel-slider"); - row.append(&lbl); + row.append(&icon); row.append(&slider); (row, slider) } -fn wifi_icon_for_signal(pct: u8) -> &'static str { - use bar::stats::{ - WIFI_ICON_EXCELLENT, WIFI_ICON_GOOD, WIFI_ICON_OFF, WIFI_ICON_OK, WIFI_ICON_WEAK, - }; - match pct { - 75..=100 => WIFI_ICON_EXCELLENT, - 50..=74 => WIFI_ICON_GOOD, - 25..=49 => WIFI_ICON_OK, - 1..=24 => WIFI_ICON_WEAK, - _ => WIFI_ICON_OFF, +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}; + match pct { + 75..=100 => WIFI_STRONG, + 50..=74 => WIFI_MEDIUM, + 25..=49 => WIFI_WEAK, + _ => WIFI_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 @@ -1602,180 +1433,33 @@ fn show_add_network_dialog( 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"); - bar_chip(&pair); - let img = svg_image(icon_svg); + let img = gtk4::Image::from_paintable(Some(&svg_texture(icon_svg))); img.add_css_class("stat-icon"); pair.append(&img); pair.append(label); pair } -/// 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() -} - -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) + svg_texture_sized(svg_src, 16) } -/// Rasterise at 2× the display size so Lucide strokes stay sharp when GTK -/// displays the texture at `px` via `Image::set_pixel_size`. +/// 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. 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="{raster}" height="{raster}""#); + let dim = format!(r#"width="{px}" height="{px}""#); 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, @@ -1787,8 +1471,6 @@ 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"); @@ -1833,138 +1515,5 @@ fn main() { if screenshot_req.is_some() { app.allow_multiple_instances(true); } - 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 - } - }); + app.run::(screenshot_req); } diff --git a/src/notifications/history.rs b/src/notifications/history.rs index 61adc0f..0e158cc 100644 --- a/src/notifications/history.rs +++ b/src/notifications/history.rs @@ -175,15 +175,13 @@ 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, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8); - window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES); + window.set_margin(Edge::Top, 48); + window.set_margin(Edge::Right, 20); 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); @@ -286,8 +284,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 9c767b6..f221524 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -170,24 +170,15 @@ 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, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8); - window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES); + window.set_margin(Edge::Top, 20); + window.set_margin(Edge::Right, 20); 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::theme::bind_auto(&window); + // 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); window } diff --git a/src/osd.rs b/src/osd.rs index d438292..5deb1d4 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -163,7 +163,9 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver) { container.set_margin_end(14); window.set_child(Some(&container)); - let icon = crate::svg_image(crate::bar::stats::ICON_VOLUME); + let icon = gtk4::Image::from_paintable(Some(&crate::svg_texture( + crate::bar::stats::ICON_VOLUME, + ))); icon.add_css_class("osd-icon"); container.append(&icon); @@ -182,7 +184,6 @@ 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 { @@ -208,11 +209,9 @@ 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/panel.rs b/src/panel.rs deleted file mode 100644 index c1bcd75..0000000 --- a/src/panel.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! 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/screenshot.rs b/src/screenshot.rs index 05e9af6..d3848eb 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -20,9 +20,11 @@ use gtk4::prelude::*; use std::path::PathBuf; use std::time::Duration; -/// 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. +/// 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. const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200); /// Delay between the bar's own `map` and calling `popover.popup()`. Calling @@ -105,11 +107,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_panel: gtk4::Window, - pub connectivity_panel: gtk4::Window, + pub control_popover: gtk4::Popover, + pub connectivity_popover: gtk4::Popover, pub wifi_tab_btn: gtk4::ToggleButton, pub bt_tab_btn: gtk4::ToggleButton, - pub media_panel: gtk4::Window, + pub media_popover: gtk4::Popover, pub media_widget: gtk4::Box, pub media_track_lbl: gtk4::Label, /// Already built and primed with sample content by `main.rs` (via @@ -120,10 +122,10 @@ pub struct Handles { pub osd_window: Option, } -/// 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; +/// 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; pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) { let output = req.output; @@ -139,15 +141,15 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: }); } "control-panel" => { - open_panel_on_root_map(root, handles.control_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_popover_on_root_map(root, handles.control_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); } "connectivity-wifi" => { handles.wifi_tab_btn.set_active(true); - open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); } "connectivity-bluetooth" => { handles.bt_tab_btn.set_active(true); - open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); } "media-popover" => { // Real media state only shows the widget/text when something's @@ -155,9 +157,8 @@ 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_panel_on_root_map(root, handles.media_panel, SETTLE_DELAY, output, width, height); + open_popover_on_root_map(root, handles.media_popover, SETTLE_DELAY, output, width, height); } "notification" | "notification-critical" => { let Some(window) = handles.notification_window else { @@ -195,25 +196,27 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: } } -/// 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( +/// 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( root: >k4::ApplicationWindow, - panel: gtk4::Window, + popover: gtk4::Popover, settle: Duration, output: PathBuf, width: i32, height: i32, ) { - let panel_to_open = panel.clone(); + let popover_to_open = popover.clone(); root.connect_map(move |_| { - let panel_to_open = panel_to_open.clone(); + popover_to_open.set_autohide(false); + let popover_to_open = popover_to_open.clone(); gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { - panel_to_open.set_visible(true); - panel_to_open.present(); + popover_to_open.popup(); }); }); - panel.connect_map(move |_| { + popover.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 f11b8d2..11820bf 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,5 +1,4 @@ -use bread_theme::{gtk as bgtk, ink_on, load_palette, load_palette_for, Palette}; -use gtk4::prelude::IsA; +use bread_theme::{gtk as bgtk, hex_to_rgba, ink_on, load_palette}; use gtk4::CssProvider; use std::cell::RefCell; @@ -8,6 +7,7 @@ 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,79 +15,37 @@ 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. // - // 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"; + // 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"; format!( - "@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: 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 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: 4px; border-radius: 999px;\ - min-width: 32px; min-height: 32px; }}\ - .stat-icon {{ margin-right: 6px; }}\ - .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); }}\ - 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; }}\ + "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}; }}\ .notification-summary {{ font-weight: bold; }}\ - .notification-app {{ opacity: 0.55; font-size: 11px; letter-spacing: 0.04em; }}\ + .notification-app {{ opacity: 0.6; }}\ .notification-actions {{ margin-top: 6px; }}\ - .notification-action {{ padding: 2px 8px; font-size: 11px; border-radius: {radius_sm}; }}\ + .notification-action {{ padding: 2px 8px; font-size: 11px; }}\ .notification-reply {{ margin-top: 6px; }}\ .notification-reply-entry {{ min-width: 0; }}\ .history-title {{ font-weight: bold; font-size: 13px; }}\ @@ -96,130 +54,59 @@ 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, 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; }}\ + window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: {radius_pill}; }}\ .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: 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; }}\ + 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; }}\ .popover-tab:hover {{ opacity: 0.8; }}\ - .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; }}\ + .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; }}\ .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); }}\ - .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; }}\ + border-radius: {radius_sm}; padding: 4px 6px; }}\ + .wifi-popover-row:hover {{ background: alpha({on_bg}, 0.08); }}\ + .wifi-popover-row-active {{ color: {accent}; }}\ .wifi-popover-row-unsaved {{ opacity: 0.4; }}\ .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ - 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;\ + 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};\ border-top-left-radius: {radius}; border-top-right-radius: {radius};\ - border-bottom: 1px solid alpha(@on-bg, 0.10); box-shadow: none; }}\ + border-bottom: 1px solid alpha({on_bg}, 0.08); 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); }}\ - .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); }}\ + .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}; }}\ .control-panel {{ }}\ - .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; }}\ - .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; }}\ - .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;\ - 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); }}\ + .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; }}\ /* 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\ @@ -270,11 +157,14 @@ fn load_css() -> String { .bread-padding-xs {{ padding: 4px; }}\ .bread-padding-sm {{ padding: 8px; }}\ .bread-padding-md {{ padding: 12px; }}", - radius = radius, - radius_bar = radius_bar, - radius_sm = radius_sm, - radius_pill = radius_pill, - pad = pad, + 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), ) } @@ -285,31 +175,6 @@ 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 785470b..53de47b 100644 --- a/src/widgets/render.rs +++ b/src/widgets/render.rs @@ -24,8 +24,7 @@ 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_RESTART, ICON_SHUTDOWN, ICON_SLEEP, ICON_VOLUME, WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG, - WIFI_WEAK, + ICON_VOLUME, WIFI_OFF, WIFI_STRONG, }; Some(match name { "ac-power" => AC_POWER, @@ -35,13 +34,8 @@ 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, @@ -177,7 +171,6 @@ 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, .. } => {