diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 65b0c30..7814150 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -17,7 +17,16 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && bash ci/build.sh cargo build --release --locked + run: | + set -euo pipefail + if [ ! -f src/ci/build.sh ]; then + echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper" + exit 1 + fi + cd src && bash ci/build.sh cargo build --release --locked || { + echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked." + exit 1 + } - name: prepare artifacts run: | @@ -34,8 +43,14 @@ jobs: ln -sfn "${VERSION}" "/srv/breadway-dl/breadbar/latest" - name: regenerate index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} run: | set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)" + exit 1 + fi rm -rf /tmp/bread-ecosystem-ci git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh diff --git a/.gitignore b/.gitignore index 816e2ad..af5d71a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ logs/ # Internal design documents (not for distribution) aster-brief.md + +# graphify knowledge-graph output (local tool cache, not for commit) +graphify-out/ + +# Local-only source overrides (see .cargo/config.toml). +.cargo/ diff --git a/Cargo.lock b/Cargo.lock index 1a1122a..23b75b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,13 +123,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -150,31 +150,30 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bread-launcher" +version = "0.7.5" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8" +dependencies = [ + "bread-utils", + "gtk4", + "serde_json", +] + [[package]] name = "bread-screenshots" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.5" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8" dependencies = [ "anyhow", "bread-utils", "tracing", ] -[[package]] -name = "bread-shared" -version = "0.7.0" -source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" -dependencies = [ - "dirs 5.0.1", - "serde", - "serde_json", - "toml 0.8.23", -] - [[package]] name = "bread-shared" version = "0.8.0" -source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0-rc.1#2485e1af1f941c724461c0d59416c829ded638fe" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0#cdd5de8f58e437b3fc6d9b9087eb7b3d0fd09704" dependencies = [ "dirs 6.0.0", "serde", @@ -185,21 +184,24 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.5" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8" dependencies = [ + "anyhow", "dirs 5.0.1", "gtk4", "serde", "serde_json", + "toml 0.8.23", + "tracing", ] [[package]] name = "bread-utils" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.5" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8" dependencies = [ - "bread-shared 0.7.0", + "bread-shared", "dirs 5.0.1", "serde", "serde_json", @@ -207,11 +209,12 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.3.1" +version = "0.3.3" dependencies = [ "anyhow", + "bread-launcher", "bread-screenshots", - "bread-shared 0.8.0", + "bread-shared", "bread-theme", "bread-utils", "clap", @@ -247,9 +250,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cairo-rs" -version = "0.22.0" +version = "0.22.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" +checksum = "df683f1d30b457964673a5541a4603208ef95ab6873d2a55871895cf310f3b56" dependencies = [ "bitflags", "cairo-sys-rs", @@ -259,20 +262,20 @@ dependencies = [ [[package]] name = "cairo-sys-rs" -version = "0.22.0" +version = "0.22.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" +checksum = "ee548131103ad8f698c6725669647b9c757b3b66992dd6a026037596417bee21" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 9.0.0", ] [[package]] name = "cfg-expr" -version = "0.20.8" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" dependencies = [ "smallvec", "target-lexicon", @@ -286,9 +289,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.4" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -296,9 +299,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -315,7 +318,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -341,9 +344,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -421,9 +424,9 @@ dependencies = [ [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "endi" @@ -527,12 +530,13 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -564,9 +568,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -579,9 +583,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -589,15 +593,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -606,9 +610,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -625,32 +629,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -677,15 +681,15 @@ dependencies = [ [[package]] name = "gdk-pixbuf-sys" -version = "0.22.0" +version = "0.22.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" +checksum = "9d5209294ba3775f9b650bf78bfadeba4332089f2329b3e2f6ce0a4957a45bff" dependencies = [ "gio-sys", "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 9.0.0", ] [[package]] @@ -718,7 +722,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -747,9 +751,9 @@ dependencies = [ [[package]] name = "gio" -version = "0.22.8" +version = "0.22.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded" +checksum = "b399f4650bb52c051f3fdf49a234e8a27ab5725c8cd774556c55eee64b2c0350" dependencies = [ "futures-channel", "futures-core", @@ -764,14 +768,14 @@ dependencies = [ [[package]] name = "gio-sys" -version = "0.22.8" +version = "0.22.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5" +checksum = "6c28739f914c15b87a9856000bed9cbd5b3a8afbca5e982d9a299e1601422cb5" dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 9.0.0", "windows-sys 0.61.2", ] @@ -797,9 +801,9 @@ dependencies = [ [[package]] name = "glib" -version = "0.22.8" +version = "0.22.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" +checksum = "18b9b8d350db41f690ec1b87109f202782748bbd8ab2f2ede17cb40d29e2838c" dependencies = [ "bitflags", "futures-channel", @@ -818,35 +822,35 @@ dependencies = [ [[package]] name = "glib-macros" -version = "0.22.6" +version = "0.22.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" +checksum = "c9597c68fa7bdf4154a3079642cd21c4dccac0b0bdd2897d82861523bf91e7b9" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] name = "glib-sys" -version = "0.22.8" +version = "0.22.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" +checksum = "99b38907e67e40dec9b60f858bcada952598719cb512a13eb13d6cdcd16f8295" dependencies = [ "libc", - "system-deps", + "system-deps 9.0.0", ] [[package]] name = "gobject-sys" -version = "0.22.6" +version = "0.22.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" +checksum = "07595c9ba696bd9819cb6a8df39f08078f3dd679508fe052dd558492c2053834" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 9.0.0", ] [[package]] @@ -861,13 +865,13 @@ dependencies = [ [[package]] name = "graphene-sys" -version = "0.22.8" +version = "0.22.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" +checksum = "97842c7543828f98aa5583aa54fcda28aa84fa2b0f8c556142177f7bbb638057" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 9.0.0", ] [[package]] @@ -898,7 +902,7 @@ dependencies = [ "graphene-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -924,9 +928,9 @@ dependencies = [ [[package]] name = "gtk4-layer-shell" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" dependencies = [ "bitflags", "gdk4", @@ -939,15 +943,15 @@ dependencies = [ [[package]] name = "gtk4-layer-shell-sys" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" dependencies = [ "gdk4-sys", "glib-sys", "gtk4-sys", "libc", - "system-deps", + "system-deps 8.0.0", ] [[package]] @@ -978,7 +982,7 @@ dependencies = [ "gsk4-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1036,9 +1040,9 @@ checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", "hashbrown", @@ -1058,9 +1062,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -1093,9 +1097,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" dependencies = [ "libc", ] @@ -1117,9 +1121,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "memchr" @@ -1146,6 +1150,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -1196,9 +1210,9 @@ dependencies = [ [[package]] name = "pango" -version = "0.22.8" +version = "0.22.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c" +checksum = "0b2022dbbd82e1c42bd950a6f1df9b98c796e725dce5ec275e03cbf9772e4efa" dependencies = [ "gio", "glib", @@ -1207,14 +1221,14 @@ dependencies = [ [[package]] name = "pango-sys" -version = "0.22.0" +version = "0.22.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" +checksum = "ca02d64761b74d56cdd4b437db6d73dc95e6c0d40f27e1c707952c0f09052948" dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 9.0.0", ] [[package]] @@ -1243,9 +1257,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "png" @@ -1257,7 +1271,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -1321,7 +1335,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1457,7 +1471,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1481,7 +1495,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1602,9 +1616,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -1624,6 +1638,32 @@ 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 = "system-deps" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0dae2cbca1f0ff93795713cfe0a4c02f760e3c0b8ae2ab4ec4045808ff429f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 1.1.4+spec-1.1.0", + "version-compare", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -1654,11 +1694,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -1674,13 +1714,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1728,13 +1768,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -1919,9 +1959,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -1943,9 +1983,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -1956,9 +1996,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1966,9 +2006,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -1979,9 +2019,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -2087,9 +2127,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xmlwriter" @@ -2099,9 +2139,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "zbus" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-recursion", @@ -2129,14 +2169,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", "zbus_names", "zvariant", "zvariant_utils", @@ -2153,6 +2193,21 @@ 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 = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" @@ -2161,40 +2216,41 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.13.1" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.13.1" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.5.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.119", + "syn 3.0.4", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index 1648851..21f8fa6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.3.1" +version = "0.3.3" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" @@ -10,17 +10,22 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", 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.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" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["bread-client"] } +# bread_shared::widget wire types; match the bread-shared tag bread-utils +# pins (v0.8.0) so only one copy of bread-shared is linked. +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0" } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5" } +# Headless app-launcher core + GTK4 results-list widget (plan §3/§7): theme +# 04/spotlight embeds the SAME `ResultsList` breadbox's overlay wraps, via +# `bar::launcher_results` — one implementation, two hosts. `gtk` feature for +# the results widget itself. +bread-launcher = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } diff --git a/README.md b/README.md index 0047613..3addfa1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ A single Rust binary that provides a full-width top bar, a D-Bus notification da - Implements `org.freedesktop.Notifications` (D-Bus) — works with any standard sender (`notify-send`, etc.) - Popups appear top-right, stack vertically, auto-dismiss after the sender-specified timeout (default 5 s) - Supports `CloseNotification` and `replaces_id` -- In-memory history of the last 50 notifications (app, summary, truncated body, time). Toggle with `breadbar --history` (Hyprland: `bind = SUPER, N, exec, breadbar --history`) or D-Bus `dev.breadway.Bar.ToggleHistory` on `org.freedesktop.Notifications` at `/dev/breadway/Bar`. Not persisted. +- History of the last 50 notifications (app, summary, truncated body, time). Loaded from and saved to `$XDG_STATE_HOME/breadbar/history.json` (typically `~/.local/state/breadbar/history.json`). Toggle with `breadbar --history` (Hyprland: `bind = SUPER, N, exec, breadbar --history`) or D-Bus `dev.breadway.Bar.ToggleHistory` on `org.freedesktop.Notifications` at `/dev/breadway/Bar`. **Volume/brightness OSD**: @@ -141,7 +141,7 @@ Example — change the font size: | `src/bar/tray.rs` | `org.kde.StatusNotifierWatcher` D-Bus service, SNI item rendering | | `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service + `dev.breadway.Bar` history IPC | | `src/notifications/popup.rs` | Layer-shell popup window and card stack | -| `src/notifications/history.rs` | Bounded in-memory history and layer-shell history window | +| `src/notifications/history.rs` | Bounded history (last 50, persisted under XDG state) and layer-shell history window | | `src/osd.rs` | Volume/brightness on-screen display | | `src/widgets/` | Live Lua widgets from breadd (`BreadClient` + `WidgetSpec`) | | `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection | diff --git a/assets/GPU.svg b/assets/GPU.svg new file mode 100644 index 0000000..03c7f1c --- /dev/null +++ b/assets/GPU.svg @@ -0,0 +1 @@ + diff --git a/src/bar/clock.rs b/src/bar/clock.rs index 4501fde..f7d897b 100644 --- a/src/bar/clock.rs +++ b/src/bar/clock.rs @@ -1,11 +1,34 @@ use crate::{App, AppInput}; use relm4::ComponentSender; +pub fn now() -> gtk4::glib::DateTime { + gtk4::glib::DateTime::now_local().expect("local time") +} + +pub fn time() -> String { + let dt = now(); + format!("{:02}:{:02}", dt.hour(), dt.minute()) +} + +pub fn date() -> String { + now().format("%a %d/%m").expect("date format").to_string() +} + pub fn current() -> String { - let dt = gtk4::glib::DateTime::now_local().expect("local time"); - let date = dt.format("%a %d/%m").expect("date format"); - let time = format!("{:02}:{:02}", dt.hour(), dt.minute()); - format!("{} {}", date, time) + format!("{} {}", date(), time()) +} + +/// `modules.clock.format` rendered against GLib's own `DateTime::format` +/// (a strftime subset — `%H`, `%M`, `%a`, `%d`, `%m`, ... all work). Falls +/// back to [`time`]'s hardcoded "HH:MM" on a malformed format string rather +/// than propagating an error — a broken theme's clock format must degrade, +/// not crash the bar, same as every other "malformed theme" fallback in +/// this system. +pub fn formatted(format: &str) -> String { + now() + .format(format) + .map(|s| s.to_string()) + .unwrap_or_else(|_| time()) } pub fn spawn_ticker(sender: ComponentSender) { diff --git a/src/bar/control.rs b/src/bar/control.rs index ed99568..43cb6c3 100644 --- a/src/bar/control.rs +++ b/src/bar/control.rs @@ -121,11 +121,29 @@ pub fn spawn_set_brightness(v: f64) { }); } -pub fn spawn_set_sink(name: String) { +pub fn spawn_set_sink(name: String, sender: ComponentSender) { relm4::spawn(async move { let _ = tokio::process::Command::new("pactl") .args(["set-default-sink", &name]) .output() .await; + // Default sink alone leaves already-playing streams on the old + // device — move them too so the switch is audible immediately. + if let Ok(o) = tokio::process::Command::new("pactl") + .args(["list", "short", "sink-inputs"]) + .output() + .await + { + for line in String::from_utf8_lossy(&o.stdout).lines() { + let Some(id) = line.split_whitespace().next() else { + continue; + }; + let _ = tokio::process::Command::new("pactl") + .args(["move-sink-input", id, &name]) + .output() + .await; + } + } + spawn_load(sender); }); } diff --git a/src/bar/mod.rs b/src/bar/mod.rs index e006a63..0d20edc 100644 --- a/src/bar/mod.rs +++ b/src/bar/mod.rs @@ -2,6 +2,7 @@ pub mod bluetooth; pub mod clock; pub mod control; pub mod media; +pub mod slots; pub mod stats; pub mod tray; pub mod wifi; diff --git a/src/bar/slots.rs b/src/bar/slots.rs new file mode 100644 index 0000000..87bf915 --- /dev/null +++ b/src/bar/slots.rs @@ -0,0 +1,86 @@ +//! Module registry for the theme manifest's `[bar.slots]` (plan Phase 3a), +//! extended in Phase 3b to also route `widget:` entries. +//! +//! Each bar module (`workspaces`, `media`, `clock`, `volume`, `wifi`, +//! `battery`, `control`, …) is still built exactly where it always was in +//! `main.rs` — this registry only decouples the ORDER in which those +//! already-constructed widgets get appended into the left/centre/right +//! containers from the fixed source-code order they were built in. main.rs +//! registers each widget by its manifest module name once construction is +//! done, then walks `ShellTheme::slots()` to append them in the theme's +//! order instead of a hardcoded sequence. +//! +//! A slot entry may also be `widget:`, where `` is either a +//! `WidgetPlacement` alias (`right_of_workspaces`, `left_of_clock`, +//! `right_of_clock`, `left_of_stats`, `tray`) or a Lua module name (see +//! `bread_shared::widget::WidgetSpec::module`) — these route through +//! `for_each_in_slot`'s `on_widget` callback rather than this registry, +//! since their containers are Lua-widget slots created on demand by the +//! caller, not modules registered here. `WidgetPlacement` itself is a wire +//! type from `bread-shared` and is never referenced in this file. + +use gtk4::prelude::*; +use std::collections::HashMap; + +/// Maps a `[bar.slots]` module name to its already-built widget. +#[derive(Default)] +pub struct ModuleRegistry(HashMap<&'static str, gtk4::Widget>); + +impl ModuleRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Registers `widget` under `name` (a `[bar.slots]` module name, e.g. + /// `"workspaces"` or `"clock"`). + pub fn register(&mut self, name: &'static str, widget: &impl IsA) { + self.0.insert(name, widget.clone().upcast()); + } + + /// Walks every entry named in `names` (a manifest slot list, in theme + /// order). A `widget:` entry calls `on_widget(key)`, letting the + /// caller create-or-fetch that Lua widget container and append it at + /// this exact position. Anything else is looked up as a registered + /// module name and passed to `on_module`; a name with no registered + /// widget is logged and skipped — an unrecognized or unmapped module in + /// a theme manifest must never crash the bar. + pub fn for_each_in_slot( + &self, + names: &[String], + mut on_module: impl FnMut(&str, >k4::Widget), + mut on_widget: impl FnMut(&str), + ) { + for name in names { + if let Some(key) = name.strip_prefix("widget:") { + on_widget(key); + continue; + } + match self.0.get(name.as_str()) { + Some(widget) => on_module(name, widget), + None => eprintln!("breadbar: [bar.slots] names unknown module '{name}' — skipping"), + } + } + } +} + +/// Returns the widget container keyed `key` in `containers`, creating it +/// (a plain horizontal box, styled like every other Lua widget slot) on +/// first use. Called from `for_each_in_slot`'s `on_widget` callback so a +/// `widget:` slot entry gets a container the first time a theme +/// places one there, regardless of whether `key` is a `WidgetPlacement` +/// alias or a Lua module name — `reconcile_widgets` (main.rs) is what +/// gives that distinction meaning when it routes specs into these +/// containers. +pub fn widget_slot_container( + containers: &mut HashMap, + key: &str, +) -> gtk4::Box { + containers + .entry(key.to_string()) + .or_insert_with(|| { + let b = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + b.add_css_class("bread-widget-slot"); + b + }) + .clone() +} diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 764d758..26358e0 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -25,6 +25,14 @@ pub const WIFI_MEDIUM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), " pub const WIFI_WEAK: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Weak.svg")); pub const WIFI_OFF: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Disconnect.svg")); +/// Adwaita symbolic names — these are drawn for 16px status bars, not our +/// hand-cropped Lucide arcs. +pub const WIFI_ICON_EXCELLENT: &str = "network-wireless-signal-excellent-symbolic"; +pub const WIFI_ICON_GOOD: &str = "network-wireless-signal-good-symbolic"; +pub const WIFI_ICON_OK: &str = "network-wireless-signal-ok-symbolic"; +pub const WIFI_ICON_WEAK: &str = "network-wireless-signal-weak-symbolic"; +pub const WIFI_ICON_OFF: &str = "network-wireless-offline-symbolic"; + pub const BAT_HIGH: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 3 Bars.svg")); pub const BAT_MID: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 2 Bars.svg")); pub const BAT_LOW: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 1 Bar.svg")); @@ -65,6 +73,7 @@ pub struct Stats { pub gpu_temp: Option, pub net_rx_kbs: f32, pub net_tx_kbs: f32, + pub volume_pct: u8, } struct CpuSnapshot { @@ -76,7 +85,7 @@ static PREV_CPU: OnceLock> = OnceLock::new(); static BAT_PATH: OnceLock> = OnceLock::new(); static AC_PATH: OnceLock> = OnceLock::new(); static WIFI_CACHE: LazyLock> = - LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_OFF))); + LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_ICON_OFF))); static WIFI_TICK: AtomicU8 = AtomicU8::new(0); fn read_cpu() -> f32 { @@ -271,7 +280,7 @@ fn wifi_iface() -> Option<&'static str> { async fn read_wifi() -> (String, &'static str) { let Some(iface) = wifi_iface() else { - return ("—".into(), WIFI_OFF); + return ("—".into(), WIFI_ICON_OFF); }; let link_out = tokio::process::Command::new("iw") @@ -281,7 +290,7 @@ async fn read_wifi() -> (String, &'static str) { .ok(); let link_stdout = match link_out { Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(), - _ => return ("—".into(), WIFI_OFF), + _ => return ("—".into(), WIFI_ICON_OFF), }; let mut ssid = None; @@ -296,13 +305,14 @@ async fn read_wifi() -> (String, &'static str) { } let Some(ssid) = ssid else { - return ("—".into(), WIFI_OFF); + return ("—".into(), WIFI_ICON_OFF); }; let icon = match rssi { - Some(r) if r >= -55 => WIFI_STRONG, - Some(r) if r >= -70 => WIFI_MEDIUM, - _ => WIFI_WEAK, + Some(r) if r >= -55 => WIFI_ICON_EXCELLENT, + Some(r) if r >= -70 => WIFI_ICON_GOOD, + Some(r) if r >= -80 => WIFI_ICON_OK, + _ => WIFI_ICON_WEAK, }; (ssid, icon) @@ -392,7 +402,7 @@ fn read_crumbs_profile() -> Option { for line in text.lines() { if let Some(rest) = line.trim().strip_prefix("profile") { let val = rest - .trim_start_matches(|c: char| c == ' ' || c == '=') + .trim_start_matches([' ', '=']) .trim_matches('"'); if !val.is_empty() { return Some(val.to_string()); @@ -413,7 +423,8 @@ pub async fn poll() -> Stats { let power_watts = read_power(); let power = power_watts.map_or_else(|| "—W".into(), |w| format!("{w:.1}W")); let pct = read_battery(); - let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}%")); + // Demo bar prints the bare number ("83"), not "83%". + let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}")); let bat_icon = pct.map_or(BAT_MID, bat_level_icon); let ac_connected = read_ac(); // BT and WiFi both refresh every 8 cycles (~16 s); cache in between. @@ -442,6 +453,7 @@ pub async fn poll() -> Stats { let gpu_usage = read_gpu_usage(); let gpu_temp = read_gpu_temp(); let (net_rx_kbs, net_tx_kbs) = read_net_throughput(); + let volume_pct = read_volume_pct(); Stats { cpu: format!("{cpu:.0}%"), cpu_pct: cpu, @@ -465,9 +477,30 @@ pub async fn poll() -> Stats { gpu_temp, net_rx_kbs, net_tx_kbs, + volume_pct, } } +/// `wpctl get-volume` prints `Volume: 0.44 [MUTED]`. Scale to a 0–150 percent +/// for the bar chip. Missing pipewire / wpctl degrades to 0 rather than +/// blocking the rest of the poll. +fn read_volume_pct() -> u8 { + let out = std::process::Command::new("wpctl") + .args(["get-volume", "@DEFAULT_AUDIO_SINK@"]) + .output() + .ok(); + let Some(o) = out.filter(|o| o.status.success()) else { + return 0; + }; + String::from_utf8_lossy(&o.stdout) + .trim() + .strip_prefix("Volume:") + .and_then(|s| s.split_whitespace().next()) + .and_then(|s| s.parse::().ok()) + .map(|v| (v * 100.0).round().clamp(0.0, 150.0) as u8) + .unwrap_or(0) +} + pub fn spawn_poller(sender: ComponentSender) { relm4::spawn(async move { loop { diff --git a/src/bar/wifi.rs b/src/bar/wifi.rs index 6531ece..cf5e272 100644 --- a/src/bar/wifi.rs +++ b/src/bar/wifi.rs @@ -24,6 +24,8 @@ pub struct ScanEntry { pub struct WifiPopoverData { pub profiles: Vec<(String, bool)>, // (name, is_active) pub scan: Vec, + /// False while nmcli is still listing APs — profiles must still be usable. + pub scan_ready: bool, } async fn fetch_status() -> Option { @@ -73,31 +75,57 @@ async fn fetch_profile_list() -> Vec<(String, bool)> { .collect() } +async fn saved_ssids() -> std::collections::HashSet { + let out = tokio::process::Command::new("nmcli") + .args(["-t", "-f", "NAME,TYPE", "connection", "show"]) + .output() + .await; + let Ok(o) = out else { + return std::collections::HashSet::new(); + }; + String::from_utf8_lossy(&o.stdout) + .lines() + .filter_map(|line| { + let (name, ty) = line.rsplit_once(':')?; + if ty == "802-11-wireless" || ty == "wifi" { + Some(name.to_string()) + } else { + None + } + }) + .collect() +} + +/// Cached AP list (no rescan). Fast enough to paint next to profiles. async fn fetch_scan() -> Vec { - let Ok(Ok(out)) = tokio::time::timeout( - Duration::from_secs(10), - tokio::process::Command::new("breadcrumbs") - .args(["scan-list", "--json"]) + let out = tokio::time::timeout( + Duration::from_secs(4), + tokio::process::Command::new("nmcli") + .args(["-t", "-f", "SSID,SIGNAL,IN-USE", "device", "wifi", "list"]) .output(), ) - .await - else { + .await; + let Ok(Ok(o)) = out else { return vec![]; }; - let arr: Vec = - serde_json::from_slice(&out.stdout).unwrap_or_default(); - arr.into_iter() - .filter_map(|v| { - let ssid = v["ssid"].as_str()?.to_string(); - if ssid.is_empty() { + let saved = saved_ssids().await; + let mut seen = std::collections::HashSet::new(); + String::from_utf8_lossy(&o.stdout) + .lines() + .filter_map(|line| { + let mut parts = line.rsplitn(3, ':'); + let _in_use = parts.next()?; + let signal = parts.next()?.parse::().ok().unwrap_or(0); + let ssid = parts.next()?.replace("\\:", ":"); + if ssid.is_empty() || ssid == "--" || !seen.insert(ssid.clone()) { return None; } - let signal = v["signal"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - let saved = v["saved"].as_bool().unwrap_or(false); - Some(ScanEntry { ssid, signal, saved }) + let saved = saved.contains(&ssid); + Some(ScanEntry { + ssid, + signal, + saved, + }) }) .collect() } @@ -114,11 +142,32 @@ pub fn spawn_status_poller(sender: ComponentSender) { }); } -/// Called when the popover opens — loads profiles + scan in parallel. +/// Profiles first (so you can switch Home/Away immediately), then the +/// cached AP list. A background rescan refreshes the list if it finds more. pub fn spawn_popover_load(sender: ComponentSender) { relm4::spawn(async move { - let (profiles, scan) = tokio::join!(fetch_profile_list(), fetch_scan()); - sender.input(AppInput::WifiPopoverData(WifiPopoverData { profiles, scan })); + let profiles = fetch_profile_list().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles: profiles.clone(), + scan: vec![], + scan_ready: false, + })); + let scan = fetch_scan().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles: profiles.clone(), + scan: scan.clone(), + scan_ready: true, + })); + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "rescan"]) + .output() + .await; + let scan = fetch_scan().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles, + scan, + scan_ready: true, + })); }); } @@ -132,29 +181,27 @@ pub fn spawn_profile_set(name: String) { }); } -/// Fire-and-forget: connect to a specific saved SSID via `breadcrumbs join`. +/// Fire-and-forget: connect to a known SSID via NetworkManager. pub fn spawn_join(ssid: String) { relm4::spawn(async move { - let _ = tokio::process::Command::new("breadcrumbs") - .args(["join", &ssid]) + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "connect", &ssid]) .output() .await; }); } -/// Fire-and-forget: save a new network with its password, then join it. +/// Save in breadcrumbs (if the CLI still accepts `add`) and connect with nmcli. pub fn spawn_add_and_join(ssid: String, password: String) { relm4::spawn(async move { - let added = tokio::process::Command::new("breadcrumbs") + let _ = tokio::process::Command::new("breadcrumbs") .args(["add", &ssid, &password]) .output() .await; - if matches!(added, Ok(o) if o.status.success()) { - let _ = tokio::process::Command::new("breadcrumbs") - .args(["join", &ssid]) - .output() - .await; - } + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "connect", &ssid, "password", &password]) + .output() + .await; }); } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 67942f8..26e4cdc 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -1,7 +1,12 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Instant; + use futures_lite::StreamExt; +use gtk4::glib::ControlFlow; use gtk4::prelude::*; use hyprland::{ - data::{Workspace, Workspaces}, + data::{Monitors, Workspaces}, event_listener::{Event, EventStream}, prelude::*, shared::WorkspaceId, @@ -10,16 +15,62 @@ use relm4::ComponentSender; use crate::AppInput; -/// Fetches the current workspace list + active workspace and pushes both to -/// the app — used both for the initial state and to re-sync after the event -/// stream reconnects (state may have changed while we were disconnected). +/// Stock Hyprland accepts `hyprctl dispatch workspace N`. Lua-config +/// Hyprland (BOS) rewrites that as `hl.dispatch(workspace N)`, which is +/// a syntax error — the working form is `hl.dsp.focus({workspace=N})`. +async fn switch_workspace(id: hyprland::shared::WorkspaceId) { + let arg = id.to_string(); + let stock = tokio::process::Command::new("hyprctl") + .args(["dispatch", "workspace", &arg]) + .output() + .await; + if let Ok(o) = &stock { + let err = String::from_utf8_lossy(&o.stderr); + let out = String::from_utf8_lossy(&o.stdout); + if o.status.success() && !err.contains("hl.dispatch") && !out.contains("hl.dispatch") { + return; + } + } + let expr = format!("hl.dispatch(hl.dsp.focus({{workspace={arg}}}))"); + let lua = tokio::process::Command::new("hyprctl") + .args(["eval", &expr]) + .output() + .await; + match lua { + Ok(o) if o.status.success() => {} + Ok(o) => eprintln!( + "breadbar: workspace {arg}: {}", + String::from_utf8_lossy(&o.stderr) + ), + Err(e) => eprintln!("breadbar: workspace {arg}: {e}"), + } +} + +/// Stretch to the old→new span, then snap onto the destination — CSS +/// transitions cannot widen a pill across two buttons, so the trail's +/// Fixed allocation is interpolated on the frame clock instead. +const STRETCH_MS: f64 = 220.0; +const SNAP_MS: f64 = 380.0; + +/// Full workspace + per-monitor active snapshot. Each bar filters this to +/// its own output so a second display does not inherit the laptop's set. async fn sync_state(sender: &ComponentSender) { - if let Ok(ws) = Workspaces::get_async().await { - sender.input(AppInput::WorkspaceList(ws.to_vec())); - } - if let Ok(active) = Workspace::get_active_async().await { - sender.input(AppInput::ActiveWorkspace(active.id)); + let workspaces = Workspaces::get_async() + .await + .map(|w| w.to_vec()) + .unwrap_or_default(); + let mut actives = std::collections::HashMap::new(); + if let Ok(mons) = Monitors::get_async().await { + for m in mons { + if !m.disabled { + actives.insert(m.name, m.active_workspace.id); + } + } } + sender.input(AppInput::WorkspaceSync { + workspaces, + actives, + }); } pub fn spawn_watcher(sender: ComponentSender) { @@ -41,13 +92,21 @@ pub fn spawn_watcher(sender: ComponentSender) { while let Some(Ok(event)) = stream.next().await { backoff = std::time::Duration::from_millis(500); match event { - Event::WorkspaceChanged(data) => { - sender.input(AppInput::ActiveWorkspace(data.id)); + Event::WorkspaceChanged(_) + | Event::WorkspaceAdded(_) + | Event::WorkspaceDeleted(_) => { + sync_state(&sender).await; } - Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => { - if let Ok(ws) = Workspaces::get_async().await { - sender.input(AppInput::WorkspaceList(ws.to_vec())); - } + Event::MonitorAdded(data) => { + sender.input(AppInput::MonitorAdded(data.name)); + sync_state(&sender).await; + } + Event::MonitorRemoved(name) => { + sender.input(AppInput::MonitorRemoved(name)); + sync_state(&sender).await; + } + Event::ActiveWindowChanged(_) => { + sender.input(AppInput::DismissPanels); } _ => {} } @@ -65,17 +124,556 @@ pub fn spawn_watcher(sender: ComponentSender) { }); } -pub fn make_button(id: WorkspaceId, name: &str, active: WorkspaceId) -> gtk4::Button { +pub fn make_button( + id: WorkspaceId, + name: &str, + active: WorkspaceId, + occupied: bool, +) -> gtk4::Button { let btn = gtk4::Button::with_label(name); btn.add_css_class("workspace-btn"); + if occupied { + btn.add_css_class("occupied"); + } if id == active { btn.add_css_class("active"); } + btn.set_valign(gtk4::Align::Center); + btn.set_halign(gtk4::Align::Center); + btn.set_vexpand(false); + btn.set_hexpand(false); + // `crate::theme::approved_chip_height`, not `tokens().chip_height()`: + // the latter is the stale pre-demo `breadbar::CHIP_HEIGHT` token (32 + // for this Trail/Pill style's theme) and, as a hard `set_size_request` + // minimum, would out-rank the CSS `min-height` the demo actually wants + // (26px Trail / 22px Pill) — see that function's doc comment. + let style = crate::theme::shell_theme().modules().workspaces.style; + btn.set_size_request(-1, crate::theme::approved_chip_height(style) as i32); + if let Some(child) = btn.child() { + child.set_halign(gtk4::Align::Center); + child.set_valign(gtk4::Align::Center); + } btn.connect_clicked(move |_| { - use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial}; - let _ = Dispatch::call(DispatchType::Workspace(WorkspaceIdentifierWithSpecial::Id( - id, - ))); + relm4::spawn(async move { + switch_workspace(id).await; + }); }); btn } + +/// `style = "dots"` (theme 04/spotlight): a label-less pill whose WIDTH +/// encodes `windows` (0/1/2/3-or-more open). Distinct from [`make_button`] +/// (Trail/Pill) rather than a variant of it because dots carry no text at +/// all (`04-spotlight.html`'s `.dots button` has no label); reusing +/// `Button::with_label("")` would still measure/lay out an empty label box +/// that a genuinely childless button doesn't. Width is a hard +/// `set_size_request` snap, not animated — GTK CSS min-width transitions +/// don't participate in a directly-set size request the way an opacity/ +/// background-color transition does, and the plan only calls out the +/// capsule's own expand/collapse as worth the `anim::spring_to` treatment. +/// +/// `_dot_widths` (the manifest's `modules.workspaces.dot_widths`) is +/// accepted but deliberately unused — see `APPROVED_DOT_WIDTHS` below, +/// which overrides it with the approved Option B numbers the manifest's +/// own value predates. Kept in the signature rather than dropped so the +/// call site still documents where a real per-theme width would flow from +/// once `theme.toml` catches up. +pub fn make_dot_button( + id: WorkspaceId, + active: WorkspaceId, + windows: i32, + _dot_widths: bread_theme::shell::DotWidths, +) -> gtk4::Button { + let btn = gtk4::Button::new(); + btn.add_css_class("workspace-dot"); + if windows > 0 { + btn.add_css_class("occupied"); + } + if id == active { + btn.add_css_class("active"); + } + btn.set_valign(gtk4::Align::Center); + btn.set_halign(gtk4::Align::Center); + btn.set_vexpand(false); + btn.set_hexpand(false); + // Height is a deliberate departure from `04-spotlight.html`'s own 6px + // (see the demo's `.dots button { height: 6px }`): reported as "too + // small and hard to click", and also genuinely hard to *see* on a real + // display, not just hard to hit. Option B (approved): 10px tall — up + // from an earlier 9px pass that undershot the approved number by 1px. + // Keeps the dots reading as slim pills rather than growing into little + // chips (which would fight the capsule's minimal, text-first look), + // while being clearly perceptible against the 36px-tall bar. + const DOT_HEIGHT: i32 = 10; + // Option B widths (approved): 8/13/17/22px for 0/1/2/3-or-more open + // windows — `[8, 13, 17, 22]`, not the `dot_widths` parameter's own + // manifest value. `theme.toml`'s `modules.workspaces.dot_widths = + // [6, 10, 14, 18]` predates this pass and was never updated to match; + // hardcoded here (ignoring the passed-in `dot_widths`) rather than + // edited upstream, since bread-ecosystem is a sibling agent's repo + // this pass. Flagged in the task report — `dot_widths` should become + // `[8, 13, 17, 22]` in `assets/shell/spotlight/theme.toml`. + const APPROVED_DOT_WIDTHS: bread_theme::shell::DotWidths = [8, 13, 17, 22]; + btn.set_size_request( + APPROVED_DOT_WIDTHS[dot_width_index(windows)], + DOT_HEIGHT, + ); + btn.connect_clicked(move |_| { + relm4::spawn(async move { + switch_workspace(id).await; + }); + }); + btn +} + +/// Maps an open-window count to a [`bread_theme::shell::DotWidths`] index: +/// 0/1/2 pass through, 3-or-more all collapse onto index 3 (the demo's own +/// `.dots button[data-n="3"]` never has a "4" variant). Pulled out of +/// [`make_dot_button`] as its own pure function purely so it's testable +/// without a GTK display — the isolated screenshot harness +/// (`bread-capture`) has no Hyprland IPC, so it can never exercise a +/// nonzero window count, and this is what stands in for that visual proof +/// (see the task notes on that gap). +fn dot_width_index(windows: i32) -> usize { + (windows.max(0) as usize).min(3) +} + +#[cfg(test)] +mod dot_width_tests { + use super::dot_width_index; + + #[test] + fn zero_and_one_and_two_pass_through() { + assert_eq!(dot_width_index(0), 0); + assert_eq!(dot_width_index(1), 1); + assert_eq!(dot_width_index(2), 2); + } + + #[test] + fn three_or_more_all_collapse_onto_index_three() { + assert_eq!(dot_width_index(3), 3); + assert_eq!(dot_width_index(4), 3); + assert_eq!(dot_width_index(50), 3); + } + + #[test] + fn negative_windows_clamps_to_zero_rather_than_panicking() { + // Hyprland's `windows` count is unsigned (u16) in practice, but + // `make_dot_button` takes a plain i32 — a negative value must + // never underflow the `dot_widths` index and panic. + assert_eq!(dot_width_index(-1), 0); + assert_eq!(dot_width_index(i32::MIN), 0); + } +} + +#[derive(Clone, Copy)] +struct Geom { + x: f64, + y: f64, + w: f64, + h: f64, +} + +struct TrailInner { + tick: Option, + geom: Geom, + /// Last width/height measured from a button that was actually allocated. + /// A row rebuild (switching to an empty workspace makes Hyprland create + /// and destroy it) can leave every button unallocated for a frame, and + /// without a remembered size the trail had nothing safe to animate from + /// and fell back to an instant `place()` — which is what made a switch + /// snap instead of move. + natural: Option<(f64, f64)>, +} + +/// 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, + natural: 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, to) 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) + }; + // Squash-and-stretch (ANIMATION WORK #1): compress the pill's + // height a few px while it's mid-flight and let it spring back + // — slightly taller than normal, then settling — as it lands, + // so the trail reads as having weight instead of sliding like a + // rigid box. A pure post-process on top of the already-correct + // x/w/y trajectory above, applied only to the frame actually + // painted (`g`) — never fed back into `TrailInner::natural` + // (`from_geom`'s own width/height memory for the *next* + // animation), which stays driven solely by real button geometry + // as before. Skipped on the final `done` frame so the resting + // geometry is exactly `to_g`, unperturbed. + let g = if done { g } else { squash_geom(g, squash_factor(elapsed)) }; + 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); + } + + // `from` is a noun here (the source button we animate away from), not a + // conversion — `&self` is correct. + #[allow(clippy::wrong_self_convention)] + fn from_geom(&self, from: Option<>k4::Button>, to: >k4::Button) -> Option { + let st = self.inner.borrow(); + let live = if self.pill.is_visible() && st.geom.w > 0.5 { + Some(st.geom) + } else { + None + }; + let cached = st.natural; + drop(st); + + // Width must always come from a button that is CURRENTLY IN THE ROW. + // Switching to an empty workspace makes Hyprland create and destroy it, + // which rebuilds the button row mid-animation and can leave `from` + // detached — `button_geom` then returns None. Falling back to the live + // geometry there handed the wide mid-stretch span straight back in, + // which is exactly the accumulation this function exists to prevent + // (reproduced by spamming between workspace 1 and an empty 6). The + // destination button is always live, so it is the correct fallback. + let natural = from + .and_then(|b| button_geom(b, &self.host).map(inset_pill)) + .or_else(|| button_geom(to, &self.host).map(inset_pill)); + + // Continuity without accumulation. + // + // Interrupting an in-flight stretch should carry the pill's CURRENT + // POSITION into the next animation, so a rapid sequence of switches + // reads as one continuous movement. It must not carry the current + // WIDTH: mid-stretch the pill deliberately spans both the old and new + // buttons, and `ease_overshoot` (c = 1.4) pushes it wider still past + // the target. Feeding that span back in as the next `from` made each + // interrupted switch start wider than the last, so spamming workspace + // switches grew the pill until it hit MAX_CHIP_W — which only capped + // the runaway, it never stopped the compounding. + // + // Taking position from the live geometry and width from the source + // button's natural size keeps the motion continuous while making width + // a pure function of which button we started from. + if let Some(n) = natural { + self.inner.borrow_mut().natural = Some((n.w, n.h)); + } + // Only x comes from the live geometry. That is what makes an + // interrupted switch continue from where the pill currently is instead + // of jumping back. y/w/h always come from a real button: taking y from + // a mid-animation or post-rebuild geometry is what made the pill sit + // low, and taking w from it is what let the width compound. + let size = natural.map(|n| (n.w, n.h)).or(cached); + match (live, natural, size) { + (Some(live), _, Some((w, h))) => Some(Geom { + x: live.x, + y: natural.map(|n| n.y).unwrap_or(live.y), + w, + h, + }), + (None, Some(natural), _) => Some(natural), + _ => None, + } + } +} + +/// 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) +} + +/// Lowest fraction of the resting height the pill compresses to, at the +/// peak of the stretch phase (fastest travel). +const SQUASH_MIN: f64 = 0.80; + +/// The squash-and-stretch height multiplier for a given point in +/// `stretch`'s own STRETCH_MS-then-SNAP_MS timeline: eases DOWN to +/// `SQUASH_MIN` across the stretch phase (using the same `ease` curve the +/// width stretch already uses), then eases back UP to 1.0 across the snap +/// phase using `ease_overshoot` — which legitimately overshoots past 1.0 +/// partway through, so the pill also plumps up slightly taller than its +/// resting height right before settling, the same "spring" read the width +/// snap already has. Past both phases (a caller-side `elapsed` this large +/// only happens if something calls this after `stretch`'s own `done` cutoff) +/// this is exactly 1.0, i.e. a no-op. +fn squash_factor(elapsed: f64) -> f64 { + if elapsed < STRETCH_MS { + lerp(1.0, SQUASH_MIN, ease(elapsed / STRETCH_MS)) + } else if elapsed < STRETCH_MS + SNAP_MS { + lerp(SQUASH_MIN, 1.0, ease_overshoot((elapsed - STRETCH_MS) / SNAP_MS)) + } else { + 1.0 + } +} + +/// Applies a height multiplier to `g`, keeping it vertically centred on +/// `g`'s own centre (so the squash reads as compression, not a pill that +/// sinks or rises) and leaving x/w untouched. +fn squash_geom(g: Geom, factor: f64) -> Geom { + let h = g.h * factor; + Geom { + x: g.x, + y: g.y + (g.h - h) * 0.5, + w: g.w, + h, + } +} + +#[cfg(test)] +mod squash_tests { + use super::*; + + #[test] + fn factor_starts_and_ends_at_one() { + assert!((squash_factor(0.0) - 1.0).abs() < 1e-9); + assert!((squash_factor(STRETCH_MS + SNAP_MS) - 1.0).abs() < 1e-9); + assert_eq!(squash_factor(STRETCH_MS + SNAP_MS + 500.0), 1.0); + } + + #[test] + fn factor_dips_below_one_mid_stretch() { + let mid_stretch = STRETCH_MS * 0.5; + assert!(squash_factor(mid_stretch) < 1.0); + assert!(squash_factor(mid_stretch) >= SQUASH_MIN); + } + + #[test] + fn squash_geom_keeps_the_vertical_centre_fixed() { + let g = Geom { + x: 10.0, + y: 20.0, + w: 30.0, + h: 26.0, + }; + let centre = g.y + g.h * 0.5; + let squashed = squash_geom(g, 0.8); + assert!((squashed.h - 20.8).abs() < 1e-9); + assert!((squashed.y + squashed.h * 0.5 - centre).abs() < 1e-9); + assert_eq!(squashed.x, g.x); + assert_eq!(squashed.w, g.w); + } + + #[test] + fn squash_geom_factor_one_is_identity() { + let g = Geom { + x: 1.0, + y: 2.0, + w: 3.0, + h: 4.0, + }; + let out = squash_geom(g, 1.0); + assert_eq!(out.y, g.y); + assert_eq!(out.h, g.h); + } +} diff --git a/src/launcher_command.rs b/src/launcher_command.rs new file mode 100644 index 0000000..798f5ec --- /dev/null +++ b/src/launcher_command.rs @@ -0,0 +1,53 @@ +//! Subscribes to `bread.command.box.open` and focuses/opens the capsule — +//! only under `[launcher] mode = "embedded"` (spotlight, THEME_SYSTEM_PLAN.md +//! §7 phase 6c). See `breadbox/EVENTS.md` for the command's existing +//! contract: it's honored today only while `breadbox listen` is running, and +//! is a silent no-op at the bus with no subscriber. breadbar becomes a +//! SECOND subscriber of the exact same verb here — under an embedded theme, +//! breadbox's own `main` (see its doc comment on `dispatch_embedded_open`) +//! redirects a direct launch to this same event instead of mapping its own +//! overlay window, specifically so this module can pick it up. `breadbox +//! listen`'s own handling of the same event is separately made a no-op +//! under an embedded theme (see its `handle_open`) — there is exactly one +//! real handler for this event at a time, whichever theme is active. +//! +//! Same connection pattern as `widgets::client` (this crate's other +//! `BreadClient::subscribe` user): a fire-and-forget connect, a background +//! subscription thread with its own reconnect/backoff, and the handle is +//! leaked rather than threaded through `App` — there's no natural point to +//! stop it before the process exits. + +use crate::{App, AppInput}; +use bread_theme::shell::LauncherMode; +use bread_utils::bread_client::BreadClient; +use relm4::ComponentSender; + +/// Starts the subscription iff the active shell theme's launcher is +/// `Embedded`. A no-op call under every other theme — never connects to +/// breadd at all, matching the "effectively a no-op under every other +/// theme" pattern the rest of the capsule wiring already follows (main.rs's +/// `open_fn`/`close_fn` doc comment). +pub fn spawn(sender: ComponentSender) { + if crate::theme::shell_theme().launcher().mode != LauncherMode::Embedded { + return; + } + let client = BreadClient::connect(crate::widgets::client::APP_ID); + // Two verbs, deliberately. + // + // `bread.box.open_requested` is what breadbox actually emits when the + // active theme is embedded: an app may only publish inside its own + // `bread..*` namespace, so breadbox (app id `box`) cannot emit a + // `bread.command.*` event at all — bread-client refuses it outright. + // + // `bread.command.box.open` is kept because it is the addressed-TO-an-app + // command form, which is what an external trigger (the `bread` CLI, a + // keybind, another app) would legitimately send. Honouring both means the + // capsule opens whether it was asked directly or told by breadbox. + for verb in ["bread.box.open_requested", "bread.command.box.open"] { + let sender = sender.clone(); + let subscription = client.subscribe(verb, move |_event| { + sender.input(AppInput::OpenLauncher); + }); + std::mem::forget(subscription); + } +} diff --git a/src/main.rs b/src/main.rs index 0ce99a2..fe1ae21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,50 +5,255 @@ macro_rules! asset { } mod bar; +mod launcher_command; mod notifications; mod osd; +mod panel; mod screenshot; +mod surface; mod theme; mod widgets; -/// Thresholds above which the bar's CPU/RAM/power-draw readouts appear at -/// all — see `AppInput::StatsUpdate`. Below these, the bar stays quiet. -const CPU_ATTENTION_THRESHOLD: f32 = 70.0; -const MEM_ATTENTION_THRESHOLD: f32 = 80.0; -const POWER_ATTENTION_THRESHOLD: f32 = 30.0; - +use bread_launcher::gtk::ResultsList; +use bread_theme::shell::{ClockStyle, Exclusive, Keyboard, Width, WorkspaceStyle}; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, Layer, LayerShell}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use hyprland::data::Workspace; use hyprland::shared::WorkspaceId; use relm4::prelude::*; +use relm4::{Component, ComponentController, Controller}; use std::cell::Cell; +use std::cell::RefCell; use std::rc::Rc; +/// The launched-app event's publisher id and event name — deliberately +/// matching breadbox's OWN constants (`breadbox/src/main.rs`: `APP_ID = +/// "box"`, `LAUNCHED_EVENT = "bread.box.launched"`), NOT breadbar's own +/// `widgets::client::APP_ID` ("bar"). Theme 04/spotlight's capsule IS the +/// launcher wearing a different shell (plan §7), sharing breadbox's cache +/// and history via `bread_launcher::LAUNCHER_APP` — it publishes under that +/// same launcher identity too, so anything downstream listening for "an app +/// was launched via the launcher" sees one event stream regardless of which +/// surface launched it. +const LAUNCHER_APP_ID: &str = "box"; +const LAUNCHER_LAUNCHED_EVENT: &str = "bread.box.launched"; + +/// Diagnostic trace for the capsule's open/close/focus wiring, gated +/// behind an env var so it costs nothing by default. Three previous fixes +/// for the "opens itself at startup with no focus" bug all failed because +/// nothing could observe which path actually fired on a real desktop — +/// this makes that observable: `BREADBAR_CAPSULE_DEBUG=1 breadbar` prints +/// every call to `open_fn`/`close_fn` and every signal that could lead to +/// one, with enough context (source, open state) to tell a legitimate +/// user-driven open from an accidental one after the fact. +fn capsule_debug_enabled() -> bool { + std::env::var_os("BREADBAR_CAPSULE_DEBUG").is_some() +} + +macro_rules! capsule_trace { + ($($arg:tt)*) => { + if capsule_debug_enabled() { + eprintln!("[capsule] {}", format!($($arg)*)); + } + }; +} + +/// The drawer's own content-height ceiling (`04-spotlight.html`: `.searching +/// .results { max-height: 420px }`) — see [`drawer_target_height`]. Also +/// used to size the click-away scrim's dead zone (see `open_fn` in `init`): +/// the scrim's clickable region never reaches higher than the capsule row +/// plus this much, so it can never overlap a real result row regardless of +/// how tall the drawer currently is. +const DRAWER_MAX_HEIGHT_PX: i32 = 420; + +/// `bar.window.exclusive` resolved to a layer-shell exclusive-zone pixel +/// value (axis 2, daylight). `Exclusive::Auto` reserves `height` plus the +/// margin on whichever edge the bar is actually anchored to — `margin.top` +/// for every theme through spotlight (all top-anchored), `margin.bottom` +/// for daylight's bottom-anchored dock. Before daylight this always read +/// `margin.top` unconditionally, which happened to be correct for every +/// existing theme (a bottom-anchored bar had never shipped, so +/// `margin.bottom` was always 0 and never observed) but reserved 12px too +/// little for daylight (`height` alone, not `height + margin.bottom`) — +/// tiled windows would have run up under the last 12px of the floating +/// dock. Extracted to a pure function so this is unit-testable without a +/// live layer-shell surface. +fn exclusive_zone_for(spec: &bread_theme::shell::WindowSpec) -> i32 { + let anchor_margin = if spec.anchors.iter().any(|a| a == "bottom") { + spec.margin.bottom + } else { + spec.margin.top + }; + match spec.exclusive { + Exclusive::Auto => spec.height + anchor_margin, + Exclusive::None => -1, + Exclusive::Px(px) => px, + } +} + +#[cfg(test)] +mod exclusive_zone_tests { + use super::exclusive_zone_for; + use bread_theme::shell::{Exclusive, Margin, Width, WindowSpec}; + + fn spec(anchors: &[&str], height: i32, margin: Margin, exclusive: Exclusive) -> WindowSpec { + WindowSpec { + anchors: anchors.iter().map(|s| s.to_string()).collect(), + width: Width::Fill, + height, + margin, + exclusive, + keyboard: bread_theme::shell::Keyboard::None, + layer: "top".to_string(), + } + } + + #[test] + fn top_anchored_auto_reserves_height_plus_top_margin() { + let s = spec( + &["top", "left", "right"], + 44, + Margin { + top: 12, + left: 16, + right: 16, + bottom: 0, + }, + Exclusive::Auto, + ); + assert_eq!(exclusive_zone_for(&s), 56); + } + + #[test] + fn bottom_anchored_auto_reserves_height_plus_bottom_margin_not_top() { + // The daylight case: margin.top is 0 (nothing reserved there), the + // real floating gap is margin.bottom. + let s = spec( + &["bottom", "left", "right"], + 40, + Margin { + top: 0, + left: 0, + right: 0, + bottom: 12, + }, + Exclusive::Auto, + ); + assert_eq!(exclusive_zone_for(&s), 52); + } + + #[test] + fn none_is_no_reservation_regardless_of_anchor() { + let s = spec( + &["bottom"], + 40, + Margin::default(), + Exclusive::None, + ); + assert_eq!(exclusive_zone_for(&s), -1); + } + + #[test] + fn explicit_px_wins_over_computed_auto_value() { + let s = spec( + &["bottom", "left", "right"], + 40, + Margin { + bottom: 12, + ..Margin::default() + }, + Exclusive::Px(99), + ); + assert_eq!(exclusive_zone_for(&s), 99); + } +} + +pub struct BarInit { + pub screenshot: Option, + pub monitor: Option, + pub primary: bool, +} + pub struct App { + monitor: String, + primary: bool, + satellites: Vec<(String, Controller)>, + // ── Workspaces ──────────────────────────────────────────────────────── workspaces: Vec, active_ws: WorkspaceId, workspace_box: gtk4::Box, + workspace_trail: bar::workspaces::WorkspaceTrail, button_map: std::collections::HashMap, // ── Clock ───────────────────────────────────────────────────────────── time_str: String, - clock_lbl: gtk4::Label, + clock_digits: Vec, + date_lbl: gtk4::Label, + // `modules.clock.style = "plain"` (glass-workbench, Phase 5): a plain + // "HH:MM" label with no per-digit flip. Built alongside `clock_digits` + // regardless of the active theme's style so switching styles needs no + // recompile; only one of the two ever lands in a `[bar.slots]` module + // registration (see the "Assemble" section). + clock_plain_lbl: gtk4::Label, + // `modules.clock.placeholder_clock` (spotlight, theme 04): when set, + // `AppInput::ClockTick` writes the time into this entry's placeholder + // text instead of (or alongside) any clock label — see that handler. + launcher_entry: gtk4::Entry, + // Whether the capsule's drawer is currently expanded — read by + // `ClockTick` so a live search in progress never has its placeholder + // text stomped (it wouldn't be visible anyway once there's real text, + // but matches the demo's own `if (!open) q.placeholder = t;` guard). + launcher_open: Rc>, + // `AppInput::OpenLauncher`'s local-route arm needs to actually open the + // capsule, not just move keyboard focus onto `launcher_entry` — focus + // alone stopped opening it when `connect_enter`'s `open_fn()` call was + // removed (see that handler's own comment for why: it was the + // mechanism behind the startup-open bug). This is `init`'s `open_fn`, + // stored so the hotkey/command path can call it directly, the same way + // the click gesture and `connect_changed` do at their own call sites. + launcher_open_fn: Rc, // ── Stats bar ───────────────────────────────────────────────────────── - // The system-stats trio (CPU/RAM/power draw) only shows up when a value - // crosses a "you probably want to know about this" threshold — otherwise - // the bar stays quiet. See AppInput::StatsUpdate. + // Island chrome matches the Liquid Motion demo: volume / wifi / battery + // / hamburger. CPU/RAM/power live in the control panel, not on the bar. + // Held to keep the control-panel sys-grid widgets alive for the App's + // lifetime; their parent boxes own them, so they're never read back. + #[allow(dead_code)] system_stats_box: gtk4::Box, system_sep: gtk4::Separator, + #[allow(dead_code)] cpu_pair: gtk4::Box, + #[allow(dead_code)] mem_pair: gtk4::Box, + #[allow(dead_code)] pwr_pair: gtk4::Box, + gpu_pair: gtk4::Box, cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, - bat_lbl: gtk4::Label, + // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench, Phase + // 5): separate instances from `cpu_pair`/`mem_pair` above, which stay + // parented in the control panel's sys-grid — a GTK widget can only have + // one parent, so reusing those here would mean reparenting them out of + // the panel, changing panel behaviour no theme asked to change. Fed by + // the same `AppInput::StatsUpdate` data. + bar_cpu_lbl: gtk4::Label, + bar_ram_lbl: gtk4::Label, + gpu_lbl: gtk4::Label, + // Odometer-style digit chips (plan: "ODOMETER DIGITS FOR NUMERIC + // CHIPS") — reuses the clock's `make_clock_digits`/`flip_clock_digits` + // machinery (see `make_digit_chip`/`flip_digit_chip` below), so the + // volume/battery numbers roll per-digit instead of snapping. `Rc>`, + // not a plain `Vec`, because the label set is also mutated from the + // control panel's volume-slider `connect_value_changed` closure (set up + // in `init`, before `self` exists) as well as from `update`'s + // `StatsUpdate` handler — both need to see and replace the same digit + // set. + vol_lbl: gtk4::Box, + vol_digits: Rc>>, + bat_lbl: gtk4::Box, + bat_digits: Rc>>, bat_img: gtk4::Image, bat_textures: std::collections::HashMap, ac_img: gtk4::Image, @@ -56,7 +261,6 @@ pub struct App { bt_textures: std::collections::HashMap, wifi_lbl: gtk4::Label, wifi_img: gtk4::Image, - wifi_textures: std::collections::HashMap, // ── WiFi popover ────────────────────────────────────────────────────── wifi_pane: gtk4::Box, @@ -77,19 +281,11 @@ pub struct App { media_paused_at: Option, // ── Control panel ───────────────────────────────────────────────────── - control_popover: gtk4::Popover, panel_vol_slider: gtk4::Scale, panel_bright_slider: gtk4::Scale, panel_loading: Rc>, - panel_sink_store: gtk4::StringList, - panel_sink_dropdown: gtk4::DropDown, - panel_sink_signal: Option, - panel_sinks: Vec, - panel_cpu_lbl: gtk4::Label, - panel_mem_lbl: gtk4::Label, - panel_pwr_lbl: gtk4::Label, - panel_gpu_lbl: gtk4::Label, - panel_net_lbl: gtk4::Label, + sink_box: gtk4::Box, + sink_section: gtk4::Box, // ── Tray ────────────────────────────────────────────────────────────── tray_section: gtk4::Box, @@ -98,19 +294,33 @@ pub struct App { tray_items: std::collections::HashMap, // ── Lua-declared widgets ───────────────────────────────────────────── - // One container per WidgetPlacement (see bread_shared::widget), fully - // rebuilt on every AppInput::WidgetsUpdate — see widgets::client's + // One container per `widget:` slot entry (Phase 3b — see + // bar::slots::ModuleRegistry and reconcile_widgets' routing below), + // fully rebuilt on every AppInput::WidgetsUpdate — see widgets::client's // module doc for why that's simpler than incremental patching here. - widget_containers: - std::collections::HashMap, + // Keyed by the slot entry's key: either a WidgetPlacement alias + // (`right_of_workspaces`, `left_of_clock`, `right_of_clock`, + // `left_of_stats`, `tray`) or a Lua module name. `bread_shared::widget`'s + // `WidgetPlacement` itself never appears here — it's a wire type from + // the bread daemon API and stays untouched. + widget_containers: std::collections::HashMap, + /// Widget ids already reported as undeliverable by `reconcile_widgets`, so + /// the warning fires once per widget instead of once per reconcile. + dropped_widget_warned: std::collections::HashSet, widget_tray_section: gtk4::Box, widget_tray_sep: gtk4::Separator, + + panels: panel::PanelSet, } #[derive(Debug)] pub enum AppInput { - WorkspaceList(Vec), - ActiveWorkspace(WorkspaceId), + WorkspaceSync { + workspaces: Vec, + actives: std::collections::HashMap, + }, + MonitorAdded(String), + MonitorRemoved(String), ClockTick, StatsUpdate(bar::stats::Stats), TrayUpdate(bar::tray::TrayUpdate), @@ -121,11 +331,25 @@ pub enum AppInput { MediaUpdate(bar::media::MediaState), ControlPanelData(bar::control::ControlPanelData), WidgetsUpdate(Vec), + ReconcileMonitors, + DismissPanels, + // `bread.command.box.open` (plan §7 phase 6c, `launcher_command` + // module): only ever dispatched when the active theme's launcher is + // `Embedded` — `launcher_command::spawn` never subscribes otherwise. + // Focuses `launcher_entry` AND calls `self.launcher_open_fn()` + // directly (its handler below) — the hotkey/command itself is the + // real-user-input signal, the same status a mouse click into the + // entry has at its own call site. Deliberately does NOT rely on + // `grab_focus()` triggering `EventControllerFocus::connect_enter` to + // open it as a side effect any more; that indirection was the actual + // mechanism behind the startup-open bug (see `connect_enter`'s own + // comment in `init`). + OpenLauncher, } #[relm4::component(pub)] impl SimpleComponent for App { - type Init = Option; + type Init = BarInit; type Input = AppInput; type Output = (); @@ -133,57 +357,230 @@ impl SimpleComponent for App { gtk::ApplicationWindow { add_css_class: "breadbar", set_title: Some("breadbar"), - set_default_height: 32, + set_default_height: bar_height, - #[name = "center_box"] - gtk::CenterBox { + // Root is a vbox (bar row + drawer), not a bare CenterBox, per + // plan §2/§11: `drawer` is the only structural thing Capsule/ + // theme-04 adds over Island/Edge, and it's a slot below the bar + // row, not a separate layout code path. `drawer_box` starts + // empty and zero-height for every theme that never names a + // module in `[bar.slots].drawer` (liquid-motion, glass- + // workbench) — see main.rs's "Assemble" section and + // `theme.rs`'s `window.breadbar > box > centerbox` selector + // update for why this is a no-op for both. + #[name = "root_vbox"] + gtk::Box { + set_orientation: gtk4::Orientation::Vertical, + + #[name = "center_box"] + gtk::CenterBox { + // Fill the bar's height. Without this the CenterBox takes + // only its natural height and sits at the TOP of the vbox, + // leaving the remainder as dead space along the bottom + // edge — so every valign:Center child centred within a + // short box rather than within the bar, and the whole row + // rode high. Regression from wrapping the bar row in a + // vbox to gain the drawer slot. + set_vexpand: true, + }, + + #[name = "drawer_box"] + gtk::Box { + set_orientation: gtk4::Orientation::Vertical, + }, } } } fn init( - screenshot_req: Self::Init, + init: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { + let screenshot_req = init.screenshot; + let monitor_name = init + .monitor + .clone() + .or_else(primary_hypr_monitor) + .unwrap_or_else(|| "eDP-1".into()); + + // `bar.window` (plan §2/§6) — window shape is data, not a closed + // layout enum. Read once and reused below for the layer-shell setup + // and (via `bar_height`, captured for the view! macro above) the + // root window's initial GTK height. + let window_spec = theme::shell_theme().window().clone(); + let bar_height = window_spec.height; + root.init_layer_shell(); root.set_namespace(Some("breadbar")); - root.set_layer(Layer::Top); - root.set_anchor(Edge::Top, true); - root.set_anchor(Edge::Left, true); - root.set_anchor(Edge::Right, true); - root.set_exclusive_zone(32); + root.set_layer(if window_spec.layer == "overlay" { + Layer::Overlay + } else { + Layer::Top + }); + for anchor in &window_spec.anchors { + match anchor.as_str() { + "top" => root.set_anchor(Edge::Top, true), + "bottom" => root.set_anchor(Edge::Bottom, true), + "left" => root.set_anchor(Edge::Left, true), + "right" => root.set_anchor(Edge::Right, true), + other => eprintln!( + "breadbar: bar.window.anchors entry \"{other}\" is not top|bottom|left|right, ignoring" + ), + } + } + root.set_margin(Edge::Top, window_spec.margin.top); + root.set_margin(Edge::Left, window_spec.margin.left); + root.set_margin(Edge::Right, window_spec.margin.right); + // `Margin::bottom` (axis 2, daylight): every builtin before daylight + // anchored top and left this at its struct default of 0, which is + // exactly why this call was missing entirely — see that field's own + // doc comment (bread-theme) for the gap this closes. A bottom- + // anchored theme's floating gap off the screen edge IS this margin, + // the same way a top-anchored theme's is `margin.top`. + root.set_margin(Edge::Bottom, window_spec.margin.bottom); + // `Width::Fill` (Island/Edge): unset, exactly as before this + // change — the surface stretches to the anchored left/right edges + // on its own, with no explicit width request needed. `Width::Px` + // (the capsule, anchored top-only): gtk4-layer-shell has nothing to + // stretch it TO, so without this it would size to its natural + // content width instead of the theme's requested 480px — this was + // a schema key declared but never consumed before theme 04 needed + // a real value out of it. + if let Width::Px(px) = window_spec.width { + root.set_default_width(px); + // set_default_width alone is only a preference — a wide child (the + // results list, whose natural width is its longest app name plus + // icon and wm-class) overrides it, so the capsule rendered far + // wider than the theme's 480px and stopped reading as a pill. + // Pinning the request keeps the surface at the theme's width + // regardless of what the app catalog contains. + root.set_size_request(px, -1); + } + // "auto" reserves height + the margin on the ANCHORED edge, so + // tiled clients sit clear of the gap — see WindowSpec::exclusive's + // doc comment (bread-theme). Every theme before daylight anchored + // top, so `margin.top` was the only edge that ever mattered here; + // reading it unconditionally under a bottom-anchored bar reserved + // only `height` (40px), 12px short of the dock's real footprint + // (`height` + `margin.bottom`) — tiled windows would have run up + // into the last 12px of the floating dock. `margin_for_exclusive` + // picks whichever edge's margin actually borders the reserved + // strip, same reasoning `exclusive_zone_for`'s own doc comment + // (tested standalone) spells out. + let exclusive_zone = exclusive_zone_for(&window_spec); + root.set_exclusive_zone(exclusive_zone); + // ANIMATION WORK #3: bar entrance on first map, Liquid Motion + // only. The flush glass-workbench bar has no floating margin to + // slide from (it sits edge-to-edge against the screen), so it + // gets nothing; spotlight's capsule already has its own width/ + // drawer motion in flight during a real search, so stacking a + // margin spring underneath that felt like fighting it rather than + // adding to it — it gets a plain opacity fade instead (the + // `bar-entrance` CSS class, added unconditionally by both arms + // below; only liquid-motion also gets the margin spring). + // + // This animates the SURFACE's own layer-shell top margin via + // `anim::spring_to`, not a CSS property, deliberately: `theme.rs` + // has no `margin-top` transition/keyframe reachable from here + // that wouldn't also require moving `root_vbox`'s or + // `center_box`'s own CSS margin, which — unlike an outer + // layer-shell margin, a compositor-level surface placement, not a + // GTK box-model property at all — WOULD perturb every descendant + // widget's own measured size, including the workspace row's. + // `WorkspaceTrail::place()`'s single-shot initial geometry sample + // is a documented previous crash site for exactly that: `ws-in`'s + // margin-top keyframe on a first-build button raced that sample + // and froze the trail pill low (see the `!is_first_build` guard + // and its comment, a few hundred lines below, for the full + // story). A layer-shell margin change repositions the whole + // surface on screen without touching any widget's own box model, + // so `compute_bounds(host)`'s host-local coordinates — the trail's + // whole coordinate system — never see it move at all, by + // construction rather than by luck. + let theme_id = theme::shell_theme().id().to_string(); + if theme_id == "liquid-motion" { + const BAR_ENTRANCE_DROP: i32 = 18; + let target_top = window_spec.margin.top; + let start_top = (target_top - BAR_ENTRANCE_DROP).max(0); + root.set_margin(Edge::Top, start_top); + root.add_css_class("bar-entrance"); + let played = Rc::new(Cell::new(false)); + root.connect_map(move |win| { + // Guard against replaying on a later remap (e.g. a + // satellite hidden/reshown — see `root.set_visible(false)` + // a few lines below for the unbound-satellite case): this + // is a first-paint entrance, not a reveal animation. + if played.replace(true) { + return; + } + let target = win.clone(); + bread_theme::anim::spring_to(win, start_top, target_top, 420.0, move |v| { + target.set_margin(Edge::Top, v); + }); + }); + } else if theme_id == "spotlight" { + root.add_css_class("bar-entrance"); + } + // breadbar never called `set_keyboard_mode` before Phase 2 — it + // relied on gtk4-layer-shell's own default (`KeyboardMode::None`), + // which is exactly what the builtin manifest's `keyboard = "none"` + // resolves to. Same behaviour, no longer implicit. + root.set_keyboard_mode(match window_spec.keyboard { + Keyboard::None => KeyboardMode::None, + Keyboard::OnDemand => KeyboardMode::OnDemand, + Keyboard::Exclusive => KeyboardMode::Exclusive, + }); + eprintln!( + "breadbar: init monitor={monitor_name} primary={}", + init.primary + ); + if screenshot_req.is_none() && !bind_layer_monitor(&root, &monitor_name) { + // Unbound satellites must not map on the compositor default + // (that stacks a second exclusive-zone bar on the laptop). + root.set_exclusive_zone(-1); + if !init.primary { + root.set_visible(false); + } + } // ── Workspace row (left) ──────────────────────────────────────── // Built imperatively (not via the view! macro) so a widget - // container can sit as a plain sibling of workspace_box — see - // WidgetPlacement::RightOfWorkspaces below. - let workspace_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + // container can sit as a plain sibling of workspace_box — see the + // `widget:*` slot-entry handling in "Assemble" below. The Overlay + // trail lives behind the buttons; rebuild_buttons only touches the + // button box, never the trail host. + let workspace_trail = bar::workspaces::WorkspaceTrail::new(); + let workspace_box = workspace_trail.buttons.clone(); let workspace_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - workspace_row.append(&workspace_box); + // `.bar-segment` (axis 3, daylight): a no-op class under every other + // theme (theme.rs only ever emits a `.bar-segment` CSS rule when + // `bar_border == "segmented"` — see its `segment_css` local) — + // added unconditionally here, same as `center_area`/`stats_box` + // below, so the Rust side never has to branch on the active theme + // id to know which boxes are "the three segments". + workspace_row.add_css_class("bar-segment"); + workspace_row.set_margin_start(8); + workspace_row.set_valign(gtk4::Align::Center); + workspace_row.set_vexpand(false); + // `workspace_trail.overlay` is appended in the "Assemble" section + // below, in the order `[bar.slots].left` names it — not here. // ── Lua-declared widget containers ────────────────────────────── - // One per WidgetPlacement; positioned into the layout below as each - // surrounding section (workspace row / center area / stats box / - // control popover) is built. Populated by widgets::client's - // events.subscribe-driven refresh loop, started at the end of init. - use bread_shared::widget::WidgetPlacement; - let widget_right_of_workspaces = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_right_of_workspaces.add_css_class("bread-widget-slot"); - workspace_row.append(&widget_right_of_workspaces); + // Phase 3b: a container per `widget:` slot entry is created + // on demand while walking `[bar.slots]` in the "Assemble" section + // below (see `bar::slots::widget_slot_container`), so ANY slot can + // host a Lua widget — not just the four fixed positions Phase 3a + // shipped with. Populated by widgets::client's events.subscribe- + // driven refresh loop, started at the end of init. - let widget_left_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_left_of_clock.add_css_class("bread-widget-slot"); - let widget_right_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_right_of_clock.add_css_class("bread-widget-slot"); - - let widget_left_of_stats = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_left_of_stats.add_css_class("bread-widget-slot"); + // `tokens.icon_px` (plan §4) — bar-chrome icon pixel size; reused + // below for every `prepare_icon` call in this function. + let icon_px = theme::shell_theme().tokens().icon_px() as i32; // ── SVG icon sets ──────────────────────────────────────────────── use bar::stats::{ - AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, WIFI_MEDIUM, - WIFI_OFF, WIFI_STRONG, WIFI_WEAK, + AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, ICON_VOLUME, }; let bat_textures: std::collections::HashMap = [BAT_HIGH, BAT_MID, BAT_LOW] @@ -195,26 +592,29 @@ 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 bat_lbl = stat_label(); + let gpu_lbl = stat_label(); + let vol_lbl = digit_chip_box(); + let vol_digits: Rc>> = Rc::new(RefCell::new(Vec::new())); + let bat_lbl = digit_chip_box(); + let bat_digits: Rc>> = Rc::new(RefCell::new(Vec::new())); + let vol_img = svg_image(ICON_VOLUME); + vol_img.add_css_class("stat-icon"); let bat_img = gtk4::Image::from_paintable(Some( bat_textures.get(&(BAT_MID.as_ptr() as usize)).unwrap(), )); - let ac_img = gtk4::Image::from_paintable(Some(&svg_texture(AC_POWER))); + prepare_icon(&bat_img, icon_px); + let ac_img = svg_image(AC_POWER); ac_img.set_visible(false); let bt_img = gtk4::Image::from_paintable(Some( bt_textures.get(&(BT_OFF.as_ptr() as usize)).unwrap(), )); + prepare_icon(&bt_img, icon_px); + bt_img.set_visible(false); // ── WiFi pair + popover ────────────────────────────────────────── let wifi_lbl = gtk4::Label::new(None); @@ -223,9 +623,11 @@ impl SimpleComponent for App { wifi_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); wifi_lbl.set_max_width_chars(28); wifi_lbl.set_xalign(0.0); - let wifi_img = - gtk4::Image::from_paintable(Some(&svg_texture(asset!("WiFi Connecting.svg")))); - + // SSID lives in the popover + tooltip — a 20-char network name + // crowding the tray is the opposite of a glass workbench bar. + wifi_lbl.set_visible(false); + let wifi_img = gtk4::Image::from_icon_name(bar::stats::WIFI_ICON_EXCELLENT); + prepare_icon(&wifi_img, icon_px); wifi_img.add_css_class("stat-icon"); // Content pane only — this becomes a tab inside the merged @@ -239,10 +641,18 @@ impl SimpleComponent for App { // ── Media widget (center) ──────────────────────────────────────── let media_widget = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); media_widget.add_css_class("media-widget"); + bar_chip(&media_widget); media_widget.set_visible(false); - let media_indicator = gtk4::Label::new(Some("▶")); - media_indicator.add_css_class("media-indicator"); + let media_eq = gtk4::Box::new(gtk4::Orientation::Horizontal, 3); + media_eq.add_css_class("media-eq"); + media_eq.set_valign(gtk4::Align::Center); + for _ in 0..4 { + let bar = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + bar.add_css_class("media-eq-bar"); + bar.set_valign(gtk4::Align::End); + media_eq.append(&bar); + } let media_track_lbl = gtk4::Label::new(None); media_track_lbl.add_css_class("media-track-lbl"); @@ -250,7 +660,7 @@ impl SimpleComponent for App { media_track_lbl.set_max_width_chars(42); media_track_lbl.set_xalign(0.0); - media_widget.append(&media_indicator); + media_widget.append(&media_eq); media_widget.append(&media_track_lbl); // Media controls popover @@ -262,14 +672,12 @@ impl SimpleComponent for App { media_controls_box.set_margin_end(4); let prev_btn = gtk4::Button::new(); - prev_btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture( - asset!("Previous.svg"), - ))))); + prev_btn.set_child(Some(&svg_image(asset!("Previous.svg")))); prev_btn.add_css_class("flat"); prev_btn.add_css_class("media-btn"); prev_btn.connect_clicked(|_| bar::media::spawn_cmd("previous")); - let media_play_icon = gtk4::Image::from_paintable(Some(&svg_texture(asset!("Pause.svg")))); + let media_play_icon = svg_image(asset!("Pause.svg")); let media_play_btn = gtk4::Button::new(); media_play_btn.set_child(Some(&media_play_icon)); media_play_btn.add_css_class("flat"); @@ -278,9 +686,7 @@ impl SimpleComponent for App { media_play_btn.connect_clicked(|_| bar::media::spawn_cmd("play-pause")); let next_btn = gtk4::Button::new(); - next_btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture( - asset!("Next.svg"), - ))))); + next_btn.set_child(Some(&svg_image(asset!("Next.svg")))); next_btn.add_css_class("flat"); next_btn.add_css_class("media-btn"); next_btn.connect_clicked(|_| bar::media::spawn_cmd("next")); @@ -289,61 +695,196 @@ impl SimpleComponent for App { media_controls_box.append(&media_play_btn); media_controls_box.append(&next_btn); - let media_popover = gtk4::Popover::new(); - media_popover.add_css_class("media-popover"); - media_popover.set_child(Some(&media_controls_box)); - media_popover.set_parent(&media_widget); + // 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. + // No compensating margin: this +3 existed to nudge the clock down + // against the mis-centred CenterBox above. With the row now filling + // the bar height and centring properly, the same offset would push + // the clock 3px BELOW everything else. + clock_box.set_margin_top(0); + 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 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); + // `modules.clock.style = "plain"` (glass-workbench): date_lbl above + // plus one plain "HH:MM" label, no per-digit flip markup at all. + // Built unconditionally alongside the flip clock so a theme's style + // choice is just which of the two gets registered into the "clock" + // slot below — see "Assemble". + let clock_plain_lbl = gtk4::Label::new(Some(&bar::clock::time())); + clock_plain_lbl.add_css_class("clock-plain-time"); + clock_plain_lbl.set_valign(gtk4::Align::Center); + clock_plain_lbl.set_vexpand(false); + let clock_plain_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + clock_plain_box.add_css_class("clock-plain"); + clock_plain_box.set_valign(gtk4::Align::Center); + clock_plain_box.set_vexpand(false); + clock_plain_box.append(&date_lbl); + clock_plain_box.append(&clock_plain_lbl); - // Clock label - let clock_lbl = gtk4::Label::new(Some(&bar::clock::current())); - clock_lbl.add_css_class("clock-label"); + // ── Launcher entry + results (theme 04/spotlight, plan §7) ─────── + // Built unconditionally, exactly like `clock_plain_box` above — + // placed in a slot only by a theme that names "launcher_entry"/ + // "launcher_results" (spotlight today; see "Assemble" below). + // `bread_launcher::LAUNCHER_APP` ("breadbox") is the launcher's + // shared identity: this reads/writes the SAME icon cache and + // launch history breadbox's own overlay window does, so the + // capsule and breadbox rank a user's apps identically instead of + // forking into two histories just because a different theme + // happens to be active (see that constant's own doc comment). + let launcher_cfg = theme::shell_theme().launcher().clone(); + let launcher_manifest: std::collections::HashMap = + std::fs::read_to_string(bread_launcher::IconCache::manifest_path( + bread_launcher::LAUNCHER_APP, + )) + .ok() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, std::path::PathBuf::from(v))) + .collect(); + let launcher_history = Rc::new(RefCell::new(bread_launcher::LaunchHistory::load( + bread_launcher::LAUNCHER_APP, + ))); + // No per-workspace priority context here — that's breadbox's own + // `Config`/`Context` format (breadbox-shared), not launcher + // substance, and breadbar has no equivalent concept. The capsule + // sorts by launch history then alphabetically, same fallback + // ordering breadbox itself uses once a workspace has no configured + // priority list at all. + let launcher_entries = bread_launcher::load_sorted_entries( + &launcher_manifest, + &[], + &launcher_history.borrow(), + ); + let launcher_results = ResultsList::new( + &launcher_entries, + launcher_cfg.icon_px, + Rc::clone(&launcher_history), + launcher_cfg.sections, + ); + launcher_results.scroller.add_css_class("bread-drawer-scroller"); + + let launcher_entry = gtk4::Entry::new(); + launcher_entry.add_css_class("launcher-entry"); + launcher_entry.set_has_frame(false); + launcher_entry.set_hexpand(true); + // Starts non-focusable ("the spotlight theme starts with the + // search open"). GTK4 auto-assigns keyboard focus to the first + // can-focus widget in a window as it's first mapped/shown — with + // nothing else focusable in the bar, that was always + // `launcher_entry`, and `focus_ctrl`'s `connect_enter` below + // (added unconditionally) turns "the entry gained focus" straight + // into `open_fn()`, so the capsule opened itself at startup. A + // widget that can't focus is skipped by BOTH that automatic + // selection and an explicit `grab_focus()` call — see GTK's own + // docs for `Widget::grab_focus`: "if widget is not focusable... + // this function does nothing." Every place that later wants real + // focus (the click gesture below, and `AppInput::OpenLauncher`'s + // handler) flips this back to `true` immediately before grabbing. + launcher_entry.set_can_focus(false); + gtk4::prelude::EntryExt::set_alignment(&launcher_entry, 0.5); + // `modules.clock.placeholder_clock` (spotlight): the entry's idle + // placeholder IS the clock — no separate clock module renders at + // all under `style = "none"`. Any other theme gets a plain "Search" + // placeholder (never shown today: no other builtin slots + // "launcher_entry" anywhere), so this still degrades sanely if a + // future/user theme places it without also setting the flag. + let placeholder_clock = theme::shell_theme().modules().clock.placeholder_clock; + launcher_entry.set_placeholder_text(Some(if placeholder_clock { + bar::clock::time() + } else { + "Search".to_string() + }.as_str())); // Center area: [media_widget · widgets · clock · widgets] - let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); center_area.add_css_class("center-area"); - center_area.append(&media_widget); - center_area.append(&widget_left_of_clock); - center_area.append(&clock_lbl); - center_area.append(&widget_right_of_clock); + center_area.add_css_class("bar-segment"); + center_area.set_valign(gtk4::Align::Center); + center_area.set_vexpand(false); + // `media_widget`/`clock_box` and any `widget:*` entries interleaved + // around them are all appended in "Assemble" below, in the exact + // order `[bar.slots].centre` names them. // ── Stats box (right side) ─────────────────────────────────────── - let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + // Demo order: [vol 64] [wifi] [bat 83] [☰] + let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); stats_box.add_css_class("stats-box"); - stats_box.append(&widget_left_of_stats); + stats_box.add_css_class("bar-segment"); + stats_box.set_margin_end(2); + stats_box.set_valign(gtk4::Align::Center); + stats_box.set_vexpand(false); + // Also appended in "Assemble" — before the right slot's modules, + // preserving its fixed position (left of the stats modules) + // whatever `[bar.slots].right` contains. - // CPU/RAM/power draw: hidden by default (see StatsUpdate), so this - // whole sub-group — plus its separator — collapses away when quiet. + // CPU/RAM/power draw stay built (control panel + screenshots still + // read the labels) but never mount on the island — the demo bar + // does not show them. let cpu_pair = stat_pair(asset!("CPU.svg"), &cpu_lbl); let mem_pair = stat_pair(asset!("RAM Usage.svg"), &mem_lbl); let pwr_pair = stat_pair(asset!("Power Draw.svg"), &pwr_lbl); - 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 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); + + // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench, + // Phase 5): separate chip instances from `cpu_pair`/`mem_pair` + // above, which stay parented in the control panel's sys-grid below + // — reusing them here would mean reparenting them out of the panel. + // Same icons, same `.stat-pair` chip styling every other bar chip + // (volume/battery) already uses; fed by the same `StatsUpdate` data. + let bar_cpu_lbl = stat_label(); + let bar_ram_lbl = stat_label(); + let bar_cpu_pair = stat_pair(asset!("CPU.svg"), &bar_cpu_lbl); + let bar_ram_pair = stat_pair(asset!("RAM Usage.svg"), &bar_ram_lbl); + + let system_stats_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + system_stats_box.add_css_class("sys-grid"); + let sys_row1 = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + sys_row1.append(&cpu_pair); + sys_row1.append(&mem_pair); + let sys_row2 = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + sys_row2.append(&gpu_pair); + sys_row2.append(&pwr_pair); + system_stats_box.append(&sys_row1); + system_stats_box.append(&sys_row2); let system_sep = gtk4::Separator::new(gtk4::Orientation::Vertical); system_sep.add_css_class("bar-sep"); system_sep.set_visible(false); - stats_box.append(&system_sep); + + let vol_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + vol_box.add_css_class("stat-pair"); + bar_chip(&vol_box); + bar_chip(&vol_lbl); + vol_box.append(&vol_img); + vol_box.append(&vol_lbl); + // Appended in "Assemble" below, per `[bar.slots].right`. let bat_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); bat_box.add_css_class("stat-pair"); + bar_chip(&bat_box); bat_img.add_css_class("stat-icon"); - bat_lbl.add_css_class("stat-label"); + bar_chip(&bat_lbl); ac_img.add_css_class("stat-icon"); - ac_img.set_margin_start(6); bat_box.append(&bat_img); bat_box.append(&bat_lbl); - bat_box.append(&ac_img); - stats_box.append(&bat_box); bt_img.add_css_class("bt-icon"); @@ -362,41 +903,38 @@ impl SimpleComponent for App { // Boxes with manual visibility toggling, which left stale width // behind on reopen. // - // hhomogeneous/vhomogeneous are ON (GTK's default), even though that - // means the popover is always sized to the *larger* of the two panes - // (visible empty space under the shorter one) — the alternative, - // sizing to just the active pane, means the popup has to resize - // itself in place when you switch tabs while it's open. Under - // gtk4-layer-shell that in-place resize doesn't reliably reach the - // compositor as a proper xdg_popup reposition; switching from the - // shorter tab to the taller one after a close/reopen cycle made the - // whole popover silently vanish instead of growing. A constant - // footprint sidesteps the resize entirely. + // Scrollport is a fixed 300px so tab-switch / first scan cannot + // resize the xdg_popup (that vanish-on-grow bug). Nearby networks + // live in the scroll, not a clipped 240px well. let content_stack = gtk4::Stack::new(); content_stack.set_hhomogeneous(true); - content_stack.set_vhomogeneous(true); - // Reserve enough room up front for the tallest realistic content - // (WiFi tab with a handful of nearby networks). Homogeneous sizing - // alone still lets the *first* real data load (Scanning… → populated - // list) trigger a live resize while the popup is mapped, which hits - // the same reposition fragility as the tab-switch case — claiming - // the space before anything is shown avoids that resize too. - content_stack.set_size_request(220, 420); + content_stack.set_vhomogeneous(false); + content_stack.set_transition_type(gtk4::StackTransitionType::Crossfade); + content_stack.set_transition_duration(220); content_stack.add_named(&wifi_pane, Some("wifi")); content_stack.add_named(&bt_pane, Some("bluetooth")); content_stack.set_visible_child_name("wifi"); - let wifi_tab_btn = gtk4::ToggleButton::with_label("Wi-Fi"); - wifi_tab_btn.add_css_class("popover-tab"); + // Fixed scrollport so nearby networks stay reachable without + // resizing the xdg_popup (that vanish-on-grow bug). + let content_scroll = gtk4::ScrolledWindow::new(); + content_scroll.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); + content_scroll.set_propagate_natural_width(true); + content_scroll.set_min_content_height(300); + content_scroll.set_max_content_height(300); + content_scroll.set_child(Some(&content_stack)); + + let wifi_tab_btn = popover_tab("Wi-Fi"); wifi_tab_btn.set_active(true); - let bt_tab_btn = gtk4::ToggleButton::with_label("Bluetooth"); - bt_tab_btn.add_css_class("popover-tab"); + let bt_tab_btn = popover_tab("Bluetooth"); bt_tab_btn.set_group(Some(&wifi_tab_btn)); - let tab_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + let tab_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); tab_row.add_css_class("popover-tab-row"); + tab_row.set_homogeneous(true); tab_row.append(&wifi_tab_btn); tab_row.append(&bt_tab_btn); + let wifi_caret = popover_caret(); let stack_for_wifi = content_stack.clone(); wifi_tab_btn.connect_toggled(move |btn| { @@ -414,170 +952,99 @@ impl SimpleComponent for App { let connectivity_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); connectivity_inner.add_css_class("wifi-popover-inner"); connectivity_inner.append(&tab_row); - connectivity_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - connectivity_inner.append(&content_stack); - - let connectivity_popover = gtk4::Popover::new(); - connectivity_popover.add_css_class("wifi-popover"); - connectivity_popover.set_child(Some(&connectivity_inner)); + connectivity_inner.append(&wifi_caret); + connectivity_inner.append(&content_scroll); let connectivity_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); connectivity_pair.add_css_class("stat-pair"); - connectivity_pair.add_css_class("wifi-pair"); - connectivity_pair.append(&bt_img); + connectivity_pair.add_css_class("icon-only"); + bar_chip(&connectivity_pair); + wifi_img.set_halign(gtk4::Align::Center); + // `.stat-pair.icon-only` forces a 32px min-width on this box, wider + // than the 24px icon's natural size. Without hexpand, a `gtk4::Box` + // packs a non-expanding child at its natural size flush against the + // start edge and leaves the leftover width trailing after it — so + // `halign: Center` had nothing to center within and the glyph sat a + // few pixels left of true center (reported: wifi icon not centered + // on glass-workbench/liquid-motion). `bat_box` never showed this + // because it isn't `icon-only` — no forced min-width wider than its + // (icon + label) content, so there's no leftover space to + // mis-place. hexpand(true) gives the icon a fillable cell spanning + // the full 32px box, which `halign: Center` then centers within, + // matching `bat_box`'s already-centered result. + // + // A bare `set_hexpand(true)` on the image is not enough on its + // own: GTK4 computes a container's *effective* expand by OR-ing in + // its children's hexpand whenever the container's own hexpand + // hasn't been explicitly set, so the flag silently bubbles up + // through `connectivity_pair` into the shared right-hand stats box + // and from there into the centerbox's end slot — which then hands + // that slot most of the bar's remaining width instead of its + // normal packed size. The visible symptom was dramatic, not + // subtle: the whole vol/wifi cluster jumped left to sit against + // the clock, with a huge dead gap before battery/hamburger, in a + // `--screenshot bar` capture. `connectivity_pair.set_hexpand(false)` + // pins this box's own expand explicitly, which stops the + // computation from climbing any further — the child can still + // fill and center within this one box's fixed 32px cell. + wifi_img.set_hexpand(true); + connectivity_pair.set_hexpand(false); connectivity_pair.append(&wifi_img); - connectivity_pair.append(&wifi_lbl); - // Anchored to wifi_lbl specifically, not the connectivity_pair row — - // a Popover parented to a multi-child Box via set_parent() balloons - // that Box's own allocation after a popup→popdown→popup cycle (its - // width roughly quadrupled in testing, shoving every bar item to its - // left further left). Anchoring to a single leaf widget instead - // sidesteps whatever GTK4/gtk4-layer-shell interaction causes that; - // the click target below still covers the whole row regardless. - connectivity_popover.set_parent(&wifi_lbl); - stats_box.append(&connectivity_pair); - - let cpop = connectivity_popover.clone(); - let gesture = gtk4::GestureClick::new(); - gesture.connect_released(move |_, _, _, _| { - if cpop.is_visible() { cpop.popdown(); } else { cpop.popup(); } - }); - connectivity_pair.add_controller(gesture); - - let sender_conn = sender.clone(); - connectivity_popover.connect_show(move |_| { - bar::wifi::spawn_popover_load(sender_conn.clone()); - bar::bluetooth::spawn_popover_load(sender_conn.clone()); - }); + // `connectivity_pair` and `bat_box` are appended in "Assemble" + // below, per `[bar.slots].right`. // ── Control panel popover ──────────────────────────────────────── + // Liquid Motion chrome: CONTROL / vol / bl / lock·sleep·off. + // SNI tray + Lua tray widgets still mount here, but only as a + // headerless icon row when something actually registers. let panel_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); panel_inner.add_css_class("control-panel-inner"); - // Volume row - let vol_row = build_slider_row(bar::stats::ICON_VOLUME, 0.0, 1.5, 0.02); + let panel_header = gtk4::Label::new(Some("CONTROL")); + panel_header.add_css_class("control-panel-header"); + panel_header.set_xalign(0.0); + panel_inner.append(&panel_header); + panel_inner.append(&popover_caret()); + + let vol_row = build_slider_row("vol", 0.0, 1.5, 0.02); let panel_vol_slider = vol_row.1.clone(); panel_inner.append(&vol_row.0); - // Brightness row - let bright_row = build_slider_row(bar::stats::ICON_BRIGHTNESS, 0.0, 1.0, 0.02); + let sink_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + sink_section.add_css_class("control-panel-section"); + let sink_header = gtk4::Label::new(Some("OUTPUT")); + sink_header.add_css_class("control-panel-header"); + sink_header.set_xalign(0.0); + sink_header.set_margin_top(6); + let sink_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + sink_section.append(&sink_header); + sink_section.append(&sink_box); + sink_section.set_visible(false); + panel_inner.append(&sink_section); + + let bright_row = build_slider_row("bl", 0.0, 1.0, 0.02); let panel_bright_slider = bright_row.1.clone(); panel_inner.append(&bright_row.0); - panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + 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); - // Stats section - let stats_section = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - stats_section.add_css_class("control-panel-stats"); - - let panel_cpu_lbl = gtk4::Label::new(Some("CPU —")); - panel_cpu_lbl.add_css_class("control-panel-stat"); - panel_cpu_lbl.set_xalign(0.0); - - let panel_mem_lbl = gtk4::Label::new(Some("RAM —")); - panel_mem_lbl.add_css_class("control-panel-stat"); - panel_mem_lbl.set_xalign(0.0); - - let panel_pwr_lbl = gtk4::Label::new(Some("PWR —")); - panel_pwr_lbl.add_css_class("control-panel-stat"); - panel_pwr_lbl.set_xalign(0.0); - - let panel_gpu_lbl = gtk4::Label::new(Some("GPU —")); - panel_gpu_lbl.add_css_class("control-panel-stat"); - panel_gpu_lbl.set_xalign(0.0); - - let panel_net_lbl = gtk4::Label::new(Some("↓ — ↑ —")); - panel_net_lbl.add_css_class("control-panel-stat"); - panel_net_lbl.set_xalign(0.0); - - stats_section.append(&panel_cpu_lbl); - stats_section.append(&panel_mem_lbl); - stats_section.append(&panel_pwr_lbl); - stats_section.append(&panel_gpu_lbl); - stats_section.append(&panel_net_lbl); - panel_inner.append(&stats_section); - - panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - - // Audio output section - let sink_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - sink_section.add_css_class("control-panel-section"); - let sink_header = gtk4::Label::new(Some("Audio Output")); - sink_header.add_css_class("control-panel-section-header"); - sink_header.set_xalign(0.0); - - let panel_sink_store = gtk4::StringList::new(&[]); - let panel_sink_dropdown = gtk4::DropDown::new( - Some(panel_sink_store.clone().upcast::()), - Option::::None, - ); - panel_sink_dropdown.add_css_class("control-panel-sink-dropdown"); - panel_sink_dropdown.set_hexpand(true); - - sink_section.append(&sink_header); - sink_section.append(&panel_sink_dropdown); - panel_inner.append(&sink_section); - - panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - - // Tray section - let tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - tray_section.add_css_class("control-panel-section"); - let tray_header = gtk4::Label::new(Some("Apps")); - tray_header.add_css_class("control-panel-section-header"); - tray_header.set_xalign(0.0); - let tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - tray_box.add_css_class("tray-box"); - tray_section.append(&tray_header); - tray_section.append(&tray_box); - // Collapsed (along with its separator) until an SNI app actually - // registers — an empty "Apps" section heading is dead weight. - tray_section.set_visible(false); - panel_inner.append(&tray_section); - - let tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); - tray_sep.set_visible(false); - panel_inner.append(&tray_sep); - - // Widgets section — Lua-declared widgets with placement = "tray". - // Same collapse-when-empty idiom as the Apps section above. - let widget_tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - widget_tray_section.add_css_class("control-panel-section"); - let widget_tray_header = gtk4::Label::new(Some("Widgets")); - widget_tray_header.add_css_class("control-panel-section-header"); - widget_tray_header.set_xalign(0.0); - let widget_tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_tray_box.add_css_class("tray-box"); - widget_tray_section.append(&widget_tray_header); - widget_tray_section.append(&widget_tray_box); - widget_tray_section.set_visible(false); - panel_inner.append(&widget_tray_section); - - let widget_tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); - widget_tray_sep.set_visible(false); - panel_inner.append(&widget_tray_sep); - - // Power section - let power_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - power_section.add_css_class("control-panel-section"); - let power_header = gtk4::Label::new(Some("Power")); - power_header.add_css_class("control-panel-section-header"); - power_header.set_xalign(0.0); - power_section.append(&power_header); - - let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); power_row.add_css_class("power-row"); - for (icon_svg, cmd) in [ + power_row.set_halign(gtk4::Align::Center); + for (label, cmd) in [ // breadlock is the ecosystem's own screen locker — hyprlock is // the thing it was built to replace; the bar shouldn't still // be pointing at it. - (bar::stats::ICON_LOCK, vec!["breadlock"]), - (bar::stats::ICON_SLEEP, vec!["systemctl", "suspend"]), - (bar::stats::ICON_RESTART, vec!["systemctl", "reboot"]), - (bar::stats::ICON_SHUTDOWN, vec!["systemctl", "poweroff"]), + ("lock", vec!["breadlock"]), + ("sleep", vec!["systemctl", "suspend"]), + ("off", vec!["systemctl", "poweroff"]), ] { - let btn = gtk4::Button::new(); - btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture(icon_svg))))); + let btn = gtk4::Button::with_label(label); btn.add_css_class("flat"); btn.add_css_class("power-btn"); btn.connect_clicked(move |_| { @@ -590,54 +1057,198 @@ impl SimpleComponent for App { }); power_row.append(&btn); } - power_section.append(&power_row); - panel_inner.append(&power_section); + panel_inner.append(&power_row); - let control_popover = gtk4::Popover::new(); - control_popover.add_css_class("control-panel"); - control_popover.set_child(Some(&panel_inner)); + // SNI / Lua tray sit under the demo chrome so a single icon cannot + // split the sliders from the power chips. + let tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + tray_section.add_css_class("control-panel-section"); + let tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + tray_box.add_css_class("tray-box"); + tray_box.set_halign(gtk4::Align::Center); + tray_section.append(&tray_box); + tray_section.set_visible(false); + panel_inner.append(&tray_section); + let tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); + tray_sep.set_visible(false); - // Hamburger button + let widget_tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + widget_tray_section.add_css_class("control-panel-section"); + let widget_tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_tray_box.add_css_class("tray-box"); + widget_tray_box.set_halign(gtk4::Align::Center); + widget_tray_section.append(&widget_tray_box); + widget_tray_section.set_visible(false); + panel_inner.append(&widget_tray_section); + let widget_tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); + widget_tray_sep.set_visible(false); + + // Hamburger button — same chip chrome as volume / wifi / battery. let hamburger_btn = gtk4::Button::with_label("☰"); hamburger_btn.add_css_class("flat"); hamburger_btn.add_css_class("control-panel-btn"); - - control_popover.set_parent(&hamburger_btn); - - let cpop = control_popover.clone(); - hamburger_btn.connect_clicked(move |_| { - if cpop.is_visible() { cpop.popdown(); } else { cpop.popup(); } - }); - - let sender_cp = sender.clone(); - control_popover.connect_show(move |_| { - bar::control::spawn_load(sender_cp.clone()); - }); + hamburger_btn.add_css_class("stat-pair"); + hamburger_btn.add_css_class("icon-only"); + bar_chip(&hamburger_btn); // Slider signals — use Rc> to suppress feedback during data load let panel_loading = Rc::new(Cell::new(false)); let loading_v = panel_loading.clone(); + let vol_lbl_live = vol_lbl.clone(); + let vol_digits_live = vol_digits.clone(); panel_vol_slider.connect_value_changed(move |s| { - if loading_v.get() { return; } + // No flip animation here, deliberately: a slider drag fires + // this on every pointer-move tick, and re-playing the + // digit-flip keyframe that fast would read as a flicker, not a + // roll. `set_digit_chip` just swaps the label text/count in + // place; `flip_digit_chip` (used by the `StatsUpdate` poll + // below) is the one that actually animates. + set_digit_chip( + &vol_lbl_live, + &mut vol_digits_live.borrow_mut(), + &format!("{:.0}", s.value() * 100.0), + ); + if loading_v.get() { + return; + } bar::control::spawn_set_volume(s.value()); }); let loading_b = panel_loading.clone(); panel_bright_slider.connect_value_changed(move |s| { - if loading_b.get() { return; } + if loading_b.get() { + return; + } bar::control::spawn_set_brightness(s.value()); }); - stats_box.append(&hamburger_btn); + // `hamburger_btn` is appended in "Assemble" below, per + // `[bar.slots].right`. - let widget_containers = std::collections::HashMap::from([ - (WidgetPlacement::RightOfWorkspaces, widget_right_of_workspaces), - (WidgetPlacement::LeftOfClock, widget_left_of_clock), - (WidgetPlacement::RightOfClock, widget_right_of_clock), - (WidgetPlacement::LeftOfStats, widget_left_of_stats), - (WidgetPlacement::Tray, widget_tray_box), - ]); + // 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); + } + + // ── Assemble: slot-driven module + widget order (plan §11 Phase 3b) ── + // Every module widget above is already fully built; only the ORDER + // it lands in its container, and which of left/centre/right it + // lands in, comes from the theme manifest's `[bar.slots]` now. A + // `widget:` slot entry gets (or creates) a Lua widget + // container at that exact position — `` is either a + // `WidgetPlacement` alias or a Lua module name; see + // `bar::slots::widget_slot_container` and `reconcile_widgets`' + // routing below. This is how a Lua widget can land in ANY slot, + // not just the four fixed positions Phase 3a shipped with. + let bar_shell_theme = theme::shell_theme(); + let bar_slots = bar_shell_theme.slots(); + let mut bar_modules = bar::slots::ModuleRegistry::new(); + bar_modules.register("workspaces", &workspace_trail.overlay); + bar_modules.register("media", &media_widget); + // `modules.clock.style`: "flip" (default, liquid-motion) registers + // the existing per-digit clock_box unchanged; "plain" (glass- + // workbench) registers clock_plain_box instead and reveals date_lbl + // per `show_date` — clock_box/clock_digits are still fully built in + // that case, just never placed in any slot. "none" (Phase 6+, + // unused today) registers neither. + match bar_shell_theme.modules().clock.style { + ClockStyle::Plain => { + date_lbl.set_visible(bar_shell_theme.modules().clock.show_date); + bar_modules.register("clock", &clock_plain_box); + } + ClockStyle::Flip => bar_modules.register("clock", &clock_box), + ClockStyle::None => {} + } + bar_modules.register("volume", &vol_box); + bar_modules.register("wifi", &connectivity_pair); + bar_modules.register("battery", &bat_box); + bar_modules.register("control", &hamburger_btn); + // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench): + // registered unconditionally, same as every other module — a theme + // that never names "cpu"/"ram" in a slot (liquid-motion) just never + // walks these entries in `for_each_in_slot` below, so they stay + // built but unparented, exactly like `media_widget` does for + // glass-workbench (which omits "media" entirely). + bar_modules.register("cpu", &bar_cpu_pair); + bar_modules.register("ram", &bar_ram_pair); + // Theme 04/spotlight (plan §7): unconditional, same reasoning — + // liquid-motion/glass-workbench never name either in a slot, so + // both stay built but unparented for them. + bar_modules.register("launcher_entry", &launcher_entry); + bar_modules.register("launcher_results", &launcher_results.scroller); + + // `tray` never appears in a bar slot — it stays inside the + // control-panel popover (built above, next to the SNI tray) — but + // it's keyed here so `reconcile_widgets`' routing finds it the same + // way as any slot-driven widget container. + let mut widget_containers: std::collections::HashMap = + std::collections::HashMap::new(); + widget_containers.insert("tray".to_string(), widget_tray_box); + + bar_modules.for_each_in_slot( + &bar_slots.left, + |_, widget| workspace_row.append(widget), + |key| workspace_row.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), + ); + bar_modules.for_each_in_slot( + &bar_slots.centre, + |_, widget| center_area.append(widget), + |key| center_area.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), + ); + bar_modules.for_each_in_slot( + &bar_slots.right, + |_, widget| stats_box.append(widget), + |key| stats_box.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), + ); // ── Assemble ───────────────────────────────────────────────────── let widgets = view_output!(); @@ -645,33 +1256,476 @@ impl SimpleComponent for App { widgets.center_box.set_center_widget(Some(¢er_area)); widgets.center_box.set_end_widget(Some(&stats_box)); + // `drawer` slot (plan §2/§7/§11 Phase 6): the only slot list that + // isn't left/centre/right of the CenterBox — appended into the vbox + // row below it instead. Empty for every theme but spotlight, so + // `drawer_box` stays a childless, zero-height box for them (see the + // `window.breadbar > box > centerbox` selector note in theme.rs for + // why the vbox wrapper itself is safe for those two themes too). + widgets.drawer_box.add_css_class("bread-drawer"); + bar_modules.for_each_in_slot( + &bar_slots.drawer, + |_, widget| widgets.drawer_box.append(widget), + |key| { + widgets + .drawer_box + .append(&bar::slots::widget_slot_container(&mut widget_containers, key)) + }, + ); + // Collapsed by default; `Overflow::Hidden` clips the results list + // while its allocated height is below its natural content height, + // same as the demo's `.results { max-height: 0; overflow: hidden }`. + widgets.drawer_box.set_overflow(gtk4::Overflow::Hidden); + widgets.drawer_box.set_size_request(-1, 0); + // set_size_request is a MINIMUM, not a maximum: GTK still allocates a + // visible box its natural height, and this layer-shell surface has no + // fixed height, so the window grew to fit the whole results list and + // the capsule sat open-at-idle showing a stray row. A hidden widget + // requests no size at all, which is what "collapsed" actually needs. + // open_fn/close_fn toggle this back on/off around the height animation. + widgets.drawer_box.set_visible(false); + + // ── Query-mode results (plan §7 phase 6c: `=` calc, `>` cmd, `.` + // url) ─────────────────────────────────────────────────────────── + // A second, small list living alongside `launcher_results.scroller` + // in the same drawer — only one of the two is ever visible at a + // time (see `connect_changed` below). Built unconditionally, same + // as `launcher_results` itself, but only ever APPENDED into + // `drawer_box` for an embedded launcher: every other theme must + // keep `drawer_box` exactly as childless as it already is (see the + // comment above `bar_modules.for_each_in_slot(&bar_slots.drawer, ..)`). + let mode_list = gtk4::ListBox::new(); + mode_list.set_selection_mode(gtk4::SelectionMode::Browse); + mode_list.set_visible(false); + if launcher_cfg.mode == bread_theme::shell::LauncherMode::Embedded { + widgets.drawer_box.append(&mode_list); + } + + // ── Capsule expand/collapse + search wiring (theme 04/spotlight) ── + // Effectively a no-op under every other theme: `launcher_entry` + // never receives focus if it's never in a slot, so `open_fn` is + // simply never invoked. `results.set_query`/select_next`/`select_prev` + // and launching all come straight from `bread-launcher`; only the + // capsule shell (drawer height, entry placeholder/alignment, + // keyboard focus) is this file's own. + let anim: Rc>> = Rc::new(RefCell::new(None)); + // Search-state width (plan §7 phase 6c: `[launcher].search_width`, + // `04-spotlight.html`'s `.searching .capsule { width: 520px }`) — + // a separate animation from the drawer's own height, both driven + // by `bread_theme::anim::spring_to` independently. + let anim_width: Rc>> = Rc::new(RefCell::new(None)); + let launcher_open: Rc> = Rc::new(Cell::new(false)); + // Set around every PROGRAMMATIC `launcher_entry.set_text(...)` call + // (close_fn's reset-to-empty today; any future one belongs here + // too) so `connect_changed` below can tell "the user typed a + // character" from "code changed the buffer" and only ever opens + // the capsule for the former — see that handler's own comment. + // Gates the SIGNAL itself rather than hunting every caller that + // could fire it, so no future programmatic `set_text` can reopen + // the capsule by accident, whether or not one does today. + let programmatic_text_change: Rc> = Rc::new(Cell::new(false)); + // The click-away scrim's dead zone (item B, see `DRAWER_MAX_HEIGHT_PX`'s + // own doc comment) — the capsule row's own theme-default dismiss + // offset (52px: `bar.window.height` + `bar.window.margin.top`) plus + // the drawer's own max content height, so the scrim's clickable + // region can never reach up into a rendered result row. + let capsule_dismiss_margin: i32 = theme::shell_theme() + .surfaces() + .get("breadbar-dismiss") + .map(|s| s.offset.first().copied().unwrap_or(0.0) as i32) + .unwrap_or(52) + + DRAWER_MAX_HEIGHT_PX; + + let open_fn: Rc = Rc::new({ + let drawer_box = widgets.drawer_box.clone(); + let anim = Rc::clone(&anim); + let anim_width = Rc::clone(&anim_width); + let launcher_open = Rc::clone(&launcher_open); + let entry = launcher_entry.clone(); + let root = root.clone(); + let panels = panels.clone(); + let idle_width = launcher_cfg.width; + let search_width = launcher_cfg.search_width; + let monitor_name_for_dismiss = monitor_name.clone(); + move || { + capsule_trace!("open_fn() called, was_open={}", launcher_open.get()); + if !launcher_open.get() { + launcher_open.set(true); + // Reveal before measuring/animating: while hidden the box + // reports no natural height, so the open animation would + // target 0 and nothing would appear. + drawer_box.set_visible(true); + entry.add_css_class("searching"); + gtk4::prelude::EntryExt::set_alignment(&entry, 0.0); + // `.searching` on the root itself (plan §7 phase 6c): + // toggles `[launcher].search_radius` via CSS (see + // theme.rs's `window.breadbar.searching` rule) and + // marks the width animation's starting point below. + root.add_css_class("searching"); + let current_width = root.width(); + let from = if current_width > 0 { current_width } else { idle_width }; + animate_capsule_width(&root, &anim_width, from, search_width); + // Item B: click-away scrim. Shown at a fixed dead-zone + // offset (`capsule_dismiss_margin`), never the live + // drawer height — see that constant's own doc comment + // for why a live-tracking offset would risk swallowing + // clicks meant for a result row. `hole` scopes that + // dead zone to the capsule's own column instead of the + // full screen width ("it only sometimes is dismissed + // when you click somewhere else") — see + // `capsule_dismiss_hole`'s doc comment. Falls back to + // the old full-width dead zone if the live geometry + // query fails for any reason. + let hole = hypr_capsule_center_x(&monitor_name_for_dismiss).and_then( + |center_x| { + let (origin_x, _) = hypr_monitor_origin(&monitor_name_for_dismiss)?; + Some(capsule_dismiss_hole(center_x, origin_x, search_width)) + }, + ); + panels.show_capsule_dismiss(capsule_dismiss_margin, hole); + } + let target = drawer_target_height(&drawer_box); + let current = drawer_box.size_request().1; + animate_drawer_height(&drawer_box, &anim, current, target); + } + }); + let close_fn: Rc = Rc::new({ + let drawer_box = widgets.drawer_box.clone(); + let anim = Rc::clone(&anim); + let anim_width = Rc::clone(&anim_width); + let launcher_open = Rc::clone(&launcher_open); + let entry = launcher_entry.clone(); + let root_for_focus = root.clone(); + let root_for_width = root.clone(); + let panels = panels.clone(); + let idle_width = launcher_cfg.width; + let programmatic_text_change = Rc::clone(&programmatic_text_change); + move || { + capsule_trace!("close_fn() called, was_open={}", launcher_open.get()); + if !launcher_open.get() { + return; + } + launcher_open.set(false); + entry.remove_css_class("searching"); + gtk4::prelude::EntryExt::set_alignment(&entry, 0.5); + // Resetting the buffer here is a PROGRAMMATIC change, not a + // user keystroke — flagged so `connect_changed` below + // doesn't treat "close_fn cleared the query" as "the user + // typed", which would immediately reopen what this + // function is in the middle of closing. + programmatic_text_change.set(true); + entry.set_text(""); + programmatic_text_change.set(false); + if placeholder_clock { + entry.set_placeholder_text(Some(&bar::clock::time())); + } + root_for_width.remove_css_class("searching"); + let current_width = root_for_width.width(); + animate_capsule_width(&root_for_width, &anim_width, current_width, idle_width); + panels.hide_dismiss(); + let current = drawer_box.size_request().1; + animate_drawer_height(&drawer_box, &anim, current, 0); + // `keyboard = "on_demand"` (plan §7) ties the layer-shell + // surface's keyboard grab to GTK's own focus-widget state — + // releasing focus here is what hands the keyboard back. + gtk4::prelude::GtkWindowExt::set_focus(&root_for_focus, None::<>k4::Widget>); + } + }); + + panels.set_on_dismiss({ + let close_fn = Rc::clone(&close_fn); + move || close_fn() + }); + + // Which of the four query modes is currently driving `mode_list` + // (plan §7 phase 6c) — read by `key_ctrl` below to route Up/Down/ + // Return at the right list, and whether that mode's row is even + // selectable (an info-only row, e.g. an empty calc expression, + // never is). `launcher_cfg.modes` gates which prefixes actually + // switch mode: a prefix this theme doesn't list in `modes` falls + // through to a literal Apps query, prefix character and all. + let active_mode: Rc> = + Rc::new(Cell::new(bread_launcher::QueryKind::Apps)); + let mode_selectable: Rc> = Rc::new(Cell::new(false)); + let modes = launcher_cfg.modes.clone(); + + { + let results = launcher_results.clone(); + let mode_list = mode_list.clone(); + let open_fn = Rc::clone(&open_fn); + let active_mode = Rc::clone(&active_mode); + let mode_selectable = Rc::clone(&mode_selectable); + let programmatic_text_change = Rc::clone(&programmatic_text_change); + launcher_entry.connect_changed(move |entry| { + // `changed` fires for EVERY buffer mutation, programmatic + // ones included — `close_fn`'s own `entry.set_text("")` + // among them. Without this guard that reset re-enters this + // handler and its unconditional `open_fn()` below reopens + // the capsule close_fn is still in the middle of closing. + // This is the fix for "changed a moment ago". Real typing + // never sets the flag, so it always reaches `open_fn()`. + if programmatic_text_change.get() { + capsule_trace!("connect_changed: skipped (programmatic set_text)"); + return; + } + let text = entry.text(); + capsule_trace!("connect_changed: user input, text={text:?}"); + let parsed = bread_launcher::parse_query(&text); + let mode_name = match parsed.kind { + bread_launcher::QueryKind::Calc => "calc", + bread_launcher::QueryKind::Cmd => "cmd", + bread_launcher::QueryKind::Url => "url", + bread_launcher::QueryKind::Apps => "apps", + }; + let kind = if modes.iter().any(|m| m == mode_name) { + parsed.kind + } else { + bread_launcher::QueryKind::Apps + }; + active_mode.set(kind); + if kind == bread_launcher::QueryKind::Apps { + mode_list.set_visible(false); + results.scroller.set_visible(true); + mode_selectable.set(false); + results.set_query(text.as_str()); + } else { + results.scroller.set_visible(false); + let selectable = populate_mode_list(&mode_list, &parsed); + mode_selectable.set(selectable); + mode_list.set_visible(true); + } + // Reached only for a real keystroke (the guard above + // already returned for a programmatic reset) — matches the + // reference model's `q.addEventListener("input", () => { + // if (!open) setOpen(true); ... })`: typing is itself a + // real-user-input signal that opens the capsule. + open_fn(); + }); + } + { + // Deliberately does NOT call `open_fn()`. It used to — that + // was the actual mechanism behind "opens itself with no + // focus": ANYTHING that moved keyboard focus onto + // `launcher_entry`, including GTK's own auto-focus-on-map of + // the first focusable widget in a window, opened the capsule + // as a side effect. `set_can_focus(false)` (constructor, + // above) already closes that specific hole, but leaving this + // handler wired to `open_fn` kept the same footgun loaded for + // the next path that calls `grab_focus()` on this entry for + // any reason. Both real-input paths that DO want to open now + // call `open_fn()` themselves at their own call site (the + // click gesture below, `AppInput::OpenLauncher`'s handler) — + // opening is a direct consequence of the user's action, not a + // side effect of a focus-change event that could have come + // from anywhere. What's left here is pure diagnostics: under + // `BREADBAR_CAPSULE_DEBUG=1`, seeing "focus entered" with no + // preceding "open_fn() called" trace is exactly the signature + // a future accidental-open regression would leave. + let focus_ctrl = gtk4::EventControllerFocus::new(); + focus_ctrl.connect_enter(move |_| { + capsule_trace!("focus_ctrl: entry gained keyboard focus"); + }); + launcher_entry.add_controller(focus_ctrl); + } + // "you can't close it using escape unless you are focused on the + // UI" / "it doesn't grab keyboard for typing": both are the same + // root cause as the startup-open bug above, from the other side. + // `launcher_entry.set_can_focus(false)` means nothing (startup or + // otherwise) can silently steal GTK's own notion of focus any + // more — so it now needs a real, explicit grab. This click gesture + // is that grab for the mouse path: a `GestureClick` press is a + // genuine user-originated pointer event delivered through the + // compositor, which is also exactly the kind of interaction + // `KeyboardMode::OnDemand` (gtk4-layer-shell/wlr-layer-shell) is + // documented to react to — the protocol spec (wlr-layer-shell- + // unstable-v1.xml) leaves *when* an on-demand surface gets the + // compositor's keyboard focus as "implementation-defined", but a + // literal click on the surface is the universal, minimum-common- + // denominator trigger every compositor implementation reacts to + // (it's the same interaction wofi/fuzzel/rofi-wayland rely on). + // Flipping `can_focus` back on right before `grab_focus()` mirrors + // `AppInput::OpenLauncher`'s handler below, which needs the exact + // same two-liner for the hotkey/command path. + { + let entry_for_click = launcher_entry.clone(); + let open_fn = Rc::clone(&open_fn); + let click = gtk4::GestureClick::new(); + click.connect_pressed(move |_, _, _, _| { + capsule_trace!("click gesture: pressed"); + entry_for_click.set_can_focus(true); + entry_for_click.grab_focus(); + // Called directly here rather than left to fire as a side + // effect of `grab_focus()` moving keyboard focus (that was + // `focus_ctrl.connect_enter`'s job until this same pass + // removed it) — a real click is itself the user-input + // signal that should open the capsule, not merely a way to + // produce a focus-change event that then does. + open_fn(); + }); + launcher_entry.add_controller(click); + } + { + let results = launcher_results.clone(); + let mode_list = mode_list.clone(); + let close_fn = Rc::clone(&close_fn); + let active_mode = Rc::clone(&active_mode); + let mode_selectable = Rc::clone(&mode_selectable); + let key_ctrl = gtk4::EventControllerKey::new(); + // CAPTURE, not the default BUBBLE. A GtkEntry handles Return in the + // target phase itself — it emits `activate` and returns TRUE, which + // stops propagation before a bubble-phase controller ever runs, so + // Enter silently did nothing and the selected app never launched. + // Capturing puts this controller ahead of the entry's own handling + // for every key it cares about (Return/Up/Down/Escape), and keys it + // doesn't claim still Proceed to the entry for normal text input. + key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); + key_ctrl.connect_key_pressed(move |_, key, _, _| { + use gtk4::gdk::Key; + if active_mode.get() != bread_launcher::QueryKind::Apps { + // Calc/Cmd/Url (plan §7 phase 6c): Up/Down move the + // (possibly single-row) `mode_list` selection; Return + // runs whatever `mode_row_action` finds on the + // selected row — nothing, for a calc result or an + // empty prompt, since those rows carry none. + return match key { + Key::Escape => { + close_fn(); + gtk4::glib::Propagation::Stop + } + Key::Down if mode_selectable.get() => { + listbox_select_next(&mode_list); + gtk4::glib::Propagation::Stop + } + Key::Up if mode_selectable.get() => { + listbox_select_prev(&mode_list); + gtk4::glib::Propagation::Stop + } + Key::Return | Key::KP_Enter => { + if let Some(row) = mode_list.selected_row() { + if let Some(action) = mode_row_action(&row) { + run_mode_action(&action); + close_fn(); + } + } + gtk4::glib::Propagation::Stop + } + _ => gtk4::glib::Propagation::Proceed, + }; + } + match key { + Key::Escape => { + close_fn(); + gtk4::glib::Propagation::Stop + } + Key::Down => { + results.select_next(); + gtk4::glib::Propagation::Stop + } + Key::Up => { + results.select_prev(); + gtk4::glib::Propagation::Stop + } + Key::Return | Key::KP_Enter => { + if let Some(entry) = results.selected_entry() { + results.record_launch(&entry); + bread_launcher::do_launch( + &entry, + LAUNCHER_APP_ID, + LAUNCHER_LAUNCHED_EVENT, + ); + } + close_fn(); + gtk4::glib::Propagation::Stop + } + _ => gtk4::glib::Propagation::Proceed, + } + }); + launcher_entry.add_controller(key_ctrl); + } + // Click on a mode_list row (a `>`-mode command or the `.`-mode + // "open this URL" prompt) acts too, same as a result row's click. + { + let close_fn = Rc::clone(&close_fn); + mode_list.connect_row_activated(move |_, row| { + if let Some(action) = mode_row_action(row) { + run_mode_action(&action); + close_fn(); + } + }); + } + // Row click launches too, same as breadbox's own overlay. + { + let results = launcher_results.clone(); + let close_fn = Rc::clone(&close_fn); + launcher_results.list.connect_row_activated(move |_, row| { + if let Some(entry) = bread_launcher::gtk::row_entry(row) { + results.record_launch(&entry); + bread_launcher::do_launch(&entry, LAUNCHER_APP_ID, LAUNCHER_LAUNCHED_EVENT); + } + close_fn(); + }); + } + // Captured before these move into `model` (or are otherwise dropped // as bare locals, never stored on `App` at all) — needed by the // screenshot dispatch just before this function returns. - let control_popover_for_screenshot = control_popover.clone(); - let connectivity_popover_for_screenshot = connectivity_popover.clone(); + let control_panel_for_screenshot = panels.control.clone(); + let connectivity_panel_for_screenshot = panels.connectivity.clone(); let wifi_tab_btn_for_screenshot = wifi_tab_btn.clone(); let bt_tab_btn_for_screenshot = bt_tab_btn.clone(); - let media_popover_for_screenshot = media_popover.clone(); + let media_panel_for_screenshot = panels.media.clone(); let media_widget_for_screenshot = media_widget.clone(); let media_track_lbl_for_screenshot = media_track_lbl.clone(); + // Theme 04/spotlight's capsule (plan §6b): `launcher_entry` moves + // into `model` below, `drawer_box` lives only in `widgets` — both + // need a clone out here for the same reason every other + // `_for_screenshot` handle above does. + let launcher_entry_for_screenshot = launcher_entry.clone(); + let drawer_box_for_screenshot = widgets.drawer_box.clone(); + + // Never launch sibling App windows from inside this init — RelmApp + // is still in GApplication activate, and a same-type launch here + // creates a second primary on the laptop. Idle reconcile after. + let satellites = Vec::new(); + if init.primary && screenshot_req.is_none() { + let later = sender.clone(); + gtk4::glib::idle_add_local_once(move || { + later.input(AppInput::ReconcileMonitors); + }); + } let model = App { + monitor: monitor_name, + primary: init.primary, + satellites, workspaces: vec![], active_ws: 1, workspace_box, + workspace_trail, button_map: std::collections::HashMap::new(), time_str: bar::clock::current(), - clock_lbl, + clock_digits, + date_lbl, + clock_plain_lbl, + launcher_entry, + launcher_open, + launcher_open_fn: open_fn, system_stats_box, system_sep, cpu_pair, mem_pair, pwr_pair, + gpu_pair, cpu_lbl, mem_lbl, pwr_lbl, + bar_cpu_lbl, + bar_ram_lbl, + gpu_lbl, + vol_lbl, + vol_digits, bat_lbl, + bat_digits, bat_img, bat_textures, ac_img, @@ -679,7 +1733,6 @@ impl SimpleComponent for App { bt_textures, wifi_lbl, wifi_img, - wifi_textures, wifi_pane, crumbs_status: None, wifi_popover_data: None, @@ -692,36 +1745,44 @@ impl SimpleComponent for App { media_play_icon, media_last: None, media_paused_at: None, - control_popover, panel_vol_slider, panel_bright_slider, panel_loading, - panel_sink_store, - panel_sink_dropdown, - panel_sink_signal: None, - panel_sinks: vec![], - panel_cpu_lbl, - panel_mem_lbl, - panel_pwr_lbl, - panel_gpu_lbl, - panel_net_lbl, + sink_box, + sink_section, tray_section, tray_sep, tray_box, tray_items: std::collections::HashMap::new(), widget_containers, + dropped_widget_warned: std::collections::HashSet::new(), widget_tray_section, widget_tray_sep, + panels, }; theme::apply(); + theme::bind_output(&root, &model.monitor); bar::workspaces::spawn_watcher(sender.clone()); bar::clock::spawn_ticker(sender.clone()); bar::stats::spawn_poller(sender.clone()); - bar::tray::spawn_watcher(sender.clone()); bar::wifi::spawn_status_poller(sender.clone()); bar::media::spawn_poller(sender.clone()); - widgets::client::spawn(sender.clone()); + if init.primary { + bar::tray::spawn_watcher(sender.clone()); + widgets::client::spawn(sender.clone()); + // `bread.command.box.open` (plan §7 phase 6c): one subscriber, + // same reasoning as `widgets::client::spawn` above — a keybind + // should focus ONE capsule, not every satellite monitor's. + // A no-op call under every theme but spotlight (see the + // module's own doc comment). + launcher_command::spawn(sender.clone()); + // Optional (plan §10, Phase 2 item 6): live theme.toml/extra.css + // token reload, the same way a pywal palette change already + // hot-reloads via `apply_app_css`. One watch per process, so + // only the primary instance arms it. + theme::watch_hot_reload(); + } // Screenshot mode primes these with sample content instead of the // real D-Bus/pactl/backlight sources — see notifications::SampleKind @@ -731,32 +1792,43 @@ impl SimpleComponent for App { "notification-critical" => Some(notifications::SampleKind::Critical), _ => None, }); - let notification_window = notifications::spawn(notif_sample); + let notification_window = if init.primary { + Some(notifications::spawn(notif_sample)) + } else { + None + }; let osd_sample = screenshot_req.as_ref().and_then(|r| match r.view.as_str() { "osd-volume" => Some(osd::SampleKind::Volume), "osd-brightness" => Some(osd::SampleKind::Brightness), _ => None, }); - let osd_window = osd::spawn(osd_sample); + let osd_window = if init.primary { + Some(osd::spawn(osd_sample)) + } else { + None + }; if let Some(req) = screenshot_req { - let notification_window = matches!(req.view.as_str(), "notification" | "notification-critical") - .then_some(notification_window); - let osd_window = matches!(req.view.as_str(), "osd-volume" | "osd-brightness") - .then_some(osd_window); + let notification_window = notification_window.filter(|_| { + matches!(req.view.as_str(), "notification" | "notification-critical") + }); + let osd_window = + osd_window.filter(|_| matches!(req.view.as_str(), "osd-volume" | "osd-brightness")); screenshot::dispatch( &root, req, screenshot::Handles { - control_popover: control_popover_for_screenshot, - connectivity_popover: connectivity_popover_for_screenshot, + control_panel: control_panel_for_screenshot, + connectivity_panel: connectivity_panel_for_screenshot, wifi_tab_btn: wifi_tab_btn_for_screenshot, bt_tab_btn: bt_tab_btn_for_screenshot, - media_popover: media_popover_for_screenshot, + media_panel: media_panel_for_screenshot, media_widget: media_widget_for_screenshot, media_track_lbl: media_track_lbl_for_screenshot, notification_window, osd_window, + launcher_entry: launcher_entry_for_screenshot, + drawer_box: drawer_box_for_screenshot, }, ); } @@ -766,48 +1838,137 @@ impl SimpleComponent for App { fn update(&mut self, msg: Self::Input, sender: ComponentSender) { match msg { - AppInput::WorkspaceList(list) => { - let mut sorted = list; + AppInput::WorkspaceSync { + workspaces, + actives, + } => { + let mut sorted = workspaces; sorted.sort_by_key(|w| w.id); + let new_active = actives + .get(&self.monitor) + .copied() + .unwrap_or(self.active_ws); + // Workspace also carries last-window title/address, which + // change constantly. Only the visible row (id/name/monitor/ + // occupied) should rebuild the pills — otherwise a title + // flicker on switch cancels the trail mid-stretch. + let rows_changed = visible_ws_rows(&sorted, &self.monitor, new_active) + != visible_ws_rows(&self.workspaces, &self.monitor, self.active_ws); + let active_changed = new_active != self.active_ws; self.workspaces = sorted; - self.rebuild_buttons(); + if self.primary { + self.reconcile_satellites(); + } + if rows_changed { + self.active_ws = new_active; + self.rebuild_buttons(active_changed); + } else if active_changed { + let from = self.button_map.get(&self.active_ws).cloned(); + if let Some(old) = &from { + old.remove_css_class("active"); + } + self.active_ws = new_active; + if let Some(btn) = self.button_map.get(&self.active_ws).cloned() { + btn.add_css_class("active"); + // Trail style only: pill/dots never call place()/ + // stretch() at all — the "active" CSS class above is + // the whole of their active-workspace treatment + // (solid accent fill, no trail overlay). + if theme::shell_theme().modules().workspaces.style == WorkspaceStyle::Trail + { + self.workspace_trail.stretch(from.as_ref(), &btn); + } + } + } } - AppInput::ActiveWorkspace(id) => { - if let Some(old) = self.button_map.get(&self.active_ws) { - old.remove_css_class("active"); + AppInput::MonitorAdded(name) => { + if !self.primary || name == self.monitor { + return; } - self.active_ws = id; - if let Some(btn) = self.button_map.get(&self.active_ws) { - btn.add_css_class("active"); + if self.satellites.iter().any(|(n, _)| n == &name) { + return; } + if let Some(ctrl) = spawn_satellite(&name) { + self.satellites.push((name, ctrl)); + } + } + AppInput::MonitorRemoved(name) => { + drop_satellite(&mut self.satellites, &name); } AppInput::ClockTick => { self.time_str = bar::clock::current(); - self.clock_lbl.set_label(&self.time_str); + self.date_lbl.set_label(&bar::clock::date()); + let clock_module = theme::shell_theme().modules().clock.clone(); + match clock_module.style { + // Plain (glass-workbench): one label, no flip animation + // — `flip_clock_digits` would just be wasted work (and + // a pointless 450ms `play_once` timer) on digits that + // are never on screen. + ClockStyle::Plain => { + self.clock_plain_lbl + .set_label(&bar::clock::formatted(&clock_module.format)); + } + // Flip (default, liquid-motion) and None both keep + // exactly today's per-digit-flip update — None has no + // module in a slot to display it, but there's no reason + // to special-case skipping the (cheap, idempotent) work. + ClockStyle::Flip | ClockStyle::None => { + flip_clock_digits(&self.clock_digits, &bar::clock::time()); + } + } + // `modules.clock.placeholder_clock` (spotlight): the + // capsule's entry IS the clock until focused — matches the + // demo's own `if (!open) q.placeholder = t;` guard so a + // live search in progress never has its placeholder text + // (invisibly, since real text covers it) stomped mid-type. + if clock_module.placeholder_clock && !self.launcher_open.get() { + self.launcher_entry + .set_placeholder_text(Some(&bar::clock::time())); + } } AppInput::StatsUpdate(stats) => { - self.cpu_lbl.set_label(&stats.cpu); + let cpu = match stats.cpu_temp { + Some(t) => format!("{} · {:.0}°", stats.cpu, t), + None => stats.cpu, + }; + self.cpu_lbl.set_label(&cpu); self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); + // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass- + // workbench): same formatted text, separate chip instances + // (see the App struct field docs for why). + self.bar_cpu_lbl.set_label(&cpu); + self.bar_ram_lbl.set_label(&stats.mem); + match stats.gpu_usage { + Some(g) => { + let gpu = match stats.gpu_temp { + 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); - // 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); + flip_digit_chip( + &self.vol_lbl, + &mut self.vol_digits.borrow_mut(), + &stats.volume_pct.to_string(), + ); + self.vol_lbl.set_tooltip_text(Some(&format!("volume {}%", stats.volume_pct))); + flip_digit_chip(&self.bat_lbl, &mut self.bat_digits.borrow_mut(), &stats.bat); if let Some(tex) = self.bat_textures.get(&(stats.bat_icon.as_ptr() as usize)) { self.bat_img.set_paintable(Some(tex)); } - self.ac_img.set_visible(stats.ac_connected); + let bat_tip = if stats.ac_connected { + format!("{}% · charging", stats.bat) + } else { + format!("{}%", stats.bat) + }; + self.bat_img.set_tooltip_text(Some(&bat_tip)); + self.ac_img.set_visible(false); if let Some(tex) = self.bt_textures.get(&(stats.bt_icon.as_ptr() as usize)) { self.bt_img.set_paintable(Some(tex)); } @@ -822,41 +1983,12 @@ impl SimpleComponent for App { .map(|s| s.internet && !s.captive_portal) .unwrap_or(true); let icon = if !internet_ok && stats.wifi_ssid != "—" { - bar::stats::WIFI_OFF + bar::stats::WIFI_ICON_OFF } else { stats.wifi_icon }; - if let Some(tex) = self.wifi_textures.get(&(icon.as_ptr() as usize)) { - self.wifi_img.set_paintable(Some(tex)); - } + self.wifi_img.set_icon_name(Some(icon)); - // Live-update control panel stats while open - if self.control_popover.is_visible() { - let cpu_str = match (stats.cpu_temp, stats.cpu.as_str()) { - (Some(t), pct) => format!("CPU {pct} {t:.0}°C"), - (None, pct) => format!("CPU {pct}"), - }; - self.panel_cpu_lbl.set_label(&cpu_str); - - self.panel_mem_lbl - .set_label(&format!("RAM {:.0}% {}", stats.mem_pct, stats.mem)); - self.panel_pwr_lbl - .set_label(&format!("PWR {}", stats.power)); - - let gpu_str = match (stats.gpu_usage, stats.gpu_temp) { - (Some(u), Some(t)) => format!("GPU {u}% {t:.0}°C"), - (Some(u), None) => format!("GPU {u}%"), - (None, Some(t)) => format!("GPU {t:.0}°C"), - (None, None) => "GPU —".to_string(), - }; - self.panel_gpu_lbl.set_label(&gpu_str); - - self.panel_net_lbl.set_label(&format!( - "↓ {} ↑ {}", - fmt_speed(stats.net_rx_kbs), - fmt_speed(stats.net_tx_kbs), - )); - } } AppInput::TrayUpdate(bar::tray::TrayUpdate::Add { id, icon, title }) => { if self.tray_items.contains_key(&id) { @@ -917,7 +2049,17 @@ impl SimpleComponent for App { } else { asset!("Play.svg") }; - self.media_play_icon.set_paintable(Some(&svg_texture(icon_svg))); + self.media_play_icon + .set_paintable(Some(&svg_texture(icon_svg))); + prepare_icon( + &self.media_play_icon, + theme::shell_theme().tokens().icon_px() as i32, + ); + if state.playing { + self.media_widget.add_css_class("playing"); + } else { + self.media_widget.remove_css_class("playing"); + } if state.playing { self.media_paused_at = None; @@ -927,21 +2069,22 @@ impl SimpleComponent for App { let within_linger = self .media_paused_at - .map_or(true, |t| t.elapsed().as_secs() < 30 * 60); + .is_none_or(|t| t.elapsed().as_secs() < 30 * 60); self.media_last = Some(state); - self.media_widget.set_visible(within_linger); + reveal_media(&self.media_widget, within_linger); } else { // Player gone — honour linger from last pause + self.media_widget.remove_css_class("playing"); if let Some(paused_at) = self.media_paused_at { if paused_at.elapsed().as_secs() < 30 * 60 { - self.media_widget.set_visible(true); + reveal_media(&self.media_widget, true); } else { - self.media_widget.set_visible(false); + reveal_media(&self.media_widget, false); self.media_last = None; self.media_paused_at = None; } } else { - self.media_widget.set_visible(false); + reveal_media(&self.media_widget, false); self.media_last = None; } } @@ -952,40 +2095,79 @@ impl SimpleComponent for App { self.panel_vol_slider.set_value(data.volume); self.panel_bright_slider.set_value(data.brightness); self.panel_loading.set(false); - - // Rebuild sink dropdown — disconnect, repopulate, reconnect - if let Some(id) = self.panel_sink_signal.take() { - self.panel_sink_dropdown.disconnect(id); - } - // Clear store - let n = self.panel_sink_store.n_items(); - for i in (0..n).rev() { - self.panel_sink_store.remove(i); - } - for sink in &data.sinks { - self.panel_sink_store.append(&sink.description); - } - if let Some(idx) = data.sinks.iter().position(|s| s.is_default) { - self.panel_sink_dropdown.set_selected(idx as u32); - } - self.panel_sinks = data.sinks; - - let sinks = self.panel_sinks.clone(); - let id = self.panel_sink_dropdown.connect_selected_notify(move |dd| { - let idx = dd.selected() as usize; - if let Some(sink) = sinks.get(idx) { - bar::control::spawn_set_sink(sink.name.clone()); - } - }); - self.panel_sink_signal = Some(id); + self.rebuild_sinks(&data.sinks, &sender); } AppInput::WidgetsUpdate(specs) => { self.reconcile_widgets(specs); } + AppInput::ReconcileMonitors => { + if self.primary { + self.reconcile_satellites(); + } + } + AppInput::DismissPanels => { + self.panels.hide_all(); + } + AppInput::OpenLauncher => { + // Only the primary instance subscribes to the open command + // (`launcher_command::spawn`), but `self.monitor` here is + // whichever output was focused ONCE, at this instance's own + // `init()` — baked in at process start, not re-resolved on + // every keybind press. If the user has since moved focus to + // a different monitor, blindly grabbing focus on `self` + // would open the capsule on the wrong screen. Re-resolve + // the focused monitor now and route to whichever instance + // actually owns it. + let satellite_names: Vec<&str> = + self.satellites.iter().map(|(n, _)| n.as_str()).collect(); + let focused = primary_hypr_monitor(); + match resolve_launcher_route(focused.as_deref(), &self.monitor, &satellite_names) + { + LauncherRoute::Satellite(name) => { + // resolve_launcher_route only returns a name present in + // satellite_names, so this lookup cannot miss. + if let Some((_, ctrl)) = self.satellites.iter().find(|(n, _)| *n == name) { + ctrl.sender().emit(AppInput::OpenLauncher); + } + } + LauncherRoute::Local => { + // See `launcher_entry.set_can_focus(false)`'s own + // comment above: a hotkey/command-triggered open + // has no pointer click to flip this back on, so + // this path has to do it itself or `grab_focus()` + // below is a silent no-op. + self.launcher_entry.set_can_focus(true); + self.launcher_entry.grab_focus(); + // ...and then actually OPEN it. Focus alone no longer + // opens the capsule: `connect_enter` used to call + // `open_fn()`, which is precisely what made the capsule + // open itself during window construction, so that path + // was deliberately removed. Without this call the + // keybind focuses the entry and leaves the drawer shut. + (self.launcher_open_fn)(); + } + } + } } } } +/// The `widget:` alias `for_each_in_slot` recognizes for each +/// `WidgetPlacement` variant — the fallback a `WidgetSpec` routes through +/// when no `widget:` container claims its module name specifically. +/// Kept in one place since both the builtin manifest's slot lists and +/// `reconcile_widgets`' routing below must agree on these names. +fn placement_alias(placement: bread_shared::widget::WidgetPlacement) -> &'static str { + use bread_shared::widget::WidgetPlacement::*; + match placement { + Tray => "tray", + LeftOfClock => "left_of_clock", + RightOfClock => "right_of_clock", + RightOfWorkspaces => "right_of_workspaces", + LeftOfStats => "left_of_stats", + } +} + impl App { fn reconcile_widgets(&mut self, specs: Vec) { for container in self.widget_containers.values() { @@ -994,23 +2176,55 @@ impl App { } } - let mut by_placement: std::collections::HashMap< - bread_shared::widget::WidgetPlacement, + // Route each spec to a widget_containers entry: a `widget:` + // slot entry (keyed by WidgetSpec::module) takes priority over the + // spec's placement alias, so a theme can retarget one Lua module's + // widgets without moving every widget that shares its placement. + // A spec whose module AND placement alias both lack a container + // (e.g. a theme's slots omit that placement's widget: entry + // entirely) is logged and dropped rather than silently vanishing — + // WidgetPlacement itself never changes; only which container (if + // any) each spec lands in does. + let mut by_container: std::collections::HashMap< + String, Vec<&bread_shared::widget::WidgetSpec>, > = std::collections::HashMap::new(); for spec in &specs { - by_placement.entry(spec.placement).or_default().push(spec); + let key = if self.widget_containers.contains_key(&spec.module) { + spec.module.clone() + } else { + placement_alias(spec.placement).to_string() + }; + if self.widget_containers.contains_key(&key) { + by_container.entry(key).or_default().push(spec); + } else if self.dropped_widget_warned.insert(spec.id.clone()) { + // Warn ONCE per widget id, not once per reconcile: breadd + // re-pushes every spec on each update (a widget on a timer, + // like a git-branch poller, reconciles continuously), which + // turned a legitimate one-off diagnostic into unbounded log + // spam. The set is only added to, so a spec that starts + // resolving again after a theme switch stays quiet — the + // message is about a theme lacking the slot, and repeating it + // every tick tells the reader nothing new. + eprintln!( + "breadbar: widget '{}' (module '{}', placement {:?}) has no matching \ + [bar.slots] widget: container — dropping", + spec.id, spec.module, spec.placement + ); + } } - for (placement, mut group) in by_placement { - let Some(container) = self.widget_containers.get(&placement) else { - continue; - }; + let mut has_tray_widgets = false; + for (key, mut group) in by_container { + let container = &self.widget_containers[&key]; group.sort_by_key(|s| s.order); for spec in group { if !spec.visible { continue; } + if key == "tray" { + has_tray_widgets = true; + } let node = widgets::build_node(&spec.root, &spec.id); if let Some(tooltip) = &spec.tooltip { node.set_tooltip_text(Some(tooltip)); @@ -1019,45 +2233,180 @@ impl App { } } - // The Tray placement has its own section/separator (handled below, - // same as the existing SNI tray items) — an empty inline slot has no - // such wrapper, so it must hide itself to stop contributing to - // center_area's `spacing` gap. - for (placement, container) in &self.widget_containers { - if *placement == bread_shared::widget::WidgetPlacement::Tray { + // The "tray" container has its own section/separator (handled + // below, same as the existing SNI tray items) — an empty inline + // slot has no such wrapper, so it must hide itself to stop + // contributing to its parent box's `spacing` gap. + for (key, container) in &self.widget_containers { + if key == "tray" { continue; } container.set_visible(container.first_child().is_some()); } - let has_tray_widgets = specs.iter().any(|s| { - s.visible && s.placement == bread_shared::widget::WidgetPlacement::Tray - }); self.widget_tray_section.set_visible(has_tray_widgets); self.widget_tray_sep.set_visible(has_tray_widgets); } + 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) { + fn rebuild_buttons(&mut self, animate: bool) { + self.workspace_trail.cancel(); + let prev: std::collections::HashSet = + self.button_map.keys().copied().collect(); + // `button_map` only starts empty once — the very first call this + // App instance ever makes, before any workspace has ever been + // synced from Hyprland. A fully-emptied bar never happens after + // that (the active workspace's own row is always kept), so this + // doubles as a clean "is this the initial paint" signal without a + // dedicated flag — see its one use below. + let is_first_build = prev.is_empty(); while let Some(child) = self.workspace_box.first_child() { self.workspace_box.remove(&child); } self.button_map.clear(); + let modules = theme::shell_theme().modules().clone(); + let ws_style = modules.workspaces.style; + let show_empty = modules.workspaces.show_empty; for ws in &self.workspaces { - let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws); + if ws.monitor != self.monitor { + continue; + } + let empty = ws.windows == 0 && ws.id != self.active_ws; + if empty { + match ws_style { + // Trail (default, liquid-motion): unconditionally off + // the bar, exactly as before this change — regardless + // of `show_empty`, which liquid-motion's own manifest + // declares `true` but this style has never consumed. + // Changing that now would be a real, undesired + // liquid-motion regression, not a Phase 5 fix. + WorkspaceStyle::Trail => continue, + // Pill/Dots: honour `show_empty` for real — demo 02's + // pills render an unoccupied, non-active workspace at + // reduced opacity via the `.workspace-btn:not(.occupied) + // :not(.active)` CSS rule rather than hiding it. + _ => { + if !show_empty { + continue; + } + } + } + } + let btn = match ws_style { + WorkspaceStyle::Dots => bar::workspaces::make_dot_button( + ws.id, + self.active_ws, + ws.windows as i32, + modules.workspaces.dot_widths, + ), + WorkspaceStyle::Trail | WorkspaceStyle::Pill => { + bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0) + } + }; + // Never on the very first build (bug: "the [Trail] row sits + // ~5px low on first paint and only corrects after the first + // switch"). Root cause: `ws-in`'s `row-in` keyframe animates + // `margin-top` 8px → 0 over 320ms; `WorkspaceTrail::place` + // (called once, synchronously-ish, right after this loop for + // the initial row) samples each button's geometry via a + // single-shot tick callback that can fire while that margin + // is still mid-animation, freezing the trail pill a few px + // low until the next `place`/`stretch` call (the first real + // workspace switch) re-samples the by-then-settled layout. + // The demo itself never animates the initial row in at all + // (`OCC.forEach` builds plainly, only `place(0)` runs) — only + // *subsequently added* workspaces should ever play this. + if !is_first_build && !prev.contains(&ws.id) { + play_once(&btn, "ws-in", 360); + } self.workspace_box.append(&btn); self.button_map.insert(ws.id, btn); } + // Trail style only: pill/dots never call place()/stretch()/clear() + // at all — the "active" CSS class `make_button` already applies is + // the whole of their active-workspace treatment. + if ws_style == WorkspaceStyle::Trail { + match self.button_map.get(&self.active_ws).cloned() { + Some(btn) if animate => self.workspace_trail.stretch(None, &btn), + Some(btn) => self.workspace_trail.place(&btn), + None => self.workspace_trail.clear(), + } + } } fn rebuild_wifi_popover(&mut self, sender: &ComponentSender) { + let panels = self.panels.clone(); while let Some(child) = self.wifi_pane.first_child() { self.wifi_pane.remove(&child); } @@ -1089,7 +2438,11 @@ impl App { parts.push("internet ✗"); } if st.tailscale_required { - parts.push(if st.tailscale_ok { "tailscale ✓" } else { "tailscale ✗" }); + parts.push(if st.tailscale_ok { + "tailscale ✓" + } else { + "tailscale ✗" + }); } let status_lbl = gtk4::Label::new(Some(&parts.join(" "))); status_lbl.add_css_class("wifi-popover-status"); @@ -1101,59 +2454,36 @@ impl App { .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); } - let Some(data) = &self.wifi_popover_data else { + let nh = gtk4::Label::new(Some("NETWORKS")); + nh.add_css_class("wifi-popover-section"); + nh.set_xalign(0.0); + nh.set_margin_top(2); + nh.set_margin_bottom(4); + self.wifi_pane.append(&nh); + + let data = self.wifi_popover_data.as_ref(); + let scan_ready = data.map(|d| d.scan_ready).unwrap_or(false); + let scan = data.map(|d| d.scan.as_slice()).unwrap_or(&[]); + let profiles = data + .map(|d| unique_profiles(&d.profiles)) + .unwrap_or_default(); + + if !scan_ready { let lbl = gtk4::Label::new(Some("Scanning…")); lbl.add_css_class("wifi-popover-loading"); - self.wifi_pane.append(&lbl); - return; - }; - - let ph = gtk4::Label::new(Some("Profiles")); - ph.add_css_class("wifi-popover-section"); - ph.set_xalign(0.0); - ph.set_margin_top(6); - ph.set_margin_bottom(2); - self.wifi_pane.append(&ph); - - for (name, active) in &data.profiles { - let row = gtk4::Button::new(); - row.add_css_class("flat"); - row.add_css_class("wifi-popover-row"); - if *active { - row.add_css_class("wifi-popover-row-active"); - } - let lbl = gtk4::Label::new(Some(&format!( - "{}{}", - if *active { "● " } else { " " }, - name - ))); lbl.set_xalign(0.0); - row.set_child(Some(&lbl)); - - let name_clone = name.clone(); - let sender_clone = sender.clone(); - row.connect_clicked(move |btn| { - sender_clone.input(AppInput::SetProfile(name_clone.clone())); - bar::wifi::spawn_profile_set(name_clone.clone()); - close_parent_popover(btn); - }); - self.wifi_pane.append(&row); - } - - if !data.scan.is_empty() { - self.wifi_pane - .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - let nh = gtk4::Label::new(Some("Nearby")); - nh.add_css_class("wifi-popover-section"); - nh.set_xalign(0.0); - nh.set_margin_top(6); - nh.set_margin_bottom(2); - self.wifi_pane.append(&nh); - - for entry in &data.scan { + self.wifi_pane.append(&lbl); + } else if scan.is_empty() { + let lbl = gtk4::Label::new(Some("No networks found")); + lbl.add_css_class("wifi-popover-loading"); + lbl.set_xalign(0.0); + self.wifi_pane.append(&lbl); + } else { + for (i, entry) in scan.iter().enumerate() { let row = gtk4::Button::new(); row.add_css_class("flat"); row.add_css_class("wifi-popover-row"); + stagger_row(&row, i); if !entry.saved { row.add_css_class("wifi-popover-row-unsaved"); } @@ -1162,31 +2492,61 @@ impl App { row.add_css_class("wifi-popover-row-active"); } - let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - let icon_svg = wifi_icon_for_signal(entry.signal); - if let Some(tex) = self.wifi_textures.get(&(icon_svg.as_ptr() as usize)) { - let img = gtk4::Image::from_paintable(Some(tex)); - img.add_css_class("stat-icon"); - row_box.append(&img); - } - let lbl = gtk4::Label::new(Some(&format!( - "{}{}", - if is_current { "● " } else { " " }, - entry.ssid, - ))); + let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let img = gtk4::Image::from_icon_name(wifi_icon_for_signal(entry.signal)); + prepare_icon(&img, 18); + img.add_css_class("stat-icon"); + row_box.append(&img); + let lbl = gtk4::Label::new(Some(&entry.ssid)); lbl.set_xalign(0.0); + lbl.set_hexpand(true); + lbl.set_valign(gtk4::Align::Center); row_box.append(&lbl); row.set_child(Some(&row_box)); let ssid_clone = entry.ssid.clone(); let saved = entry.saved; + let panels = panels.clone(); row.connect_clicked(move |btn| { if saved { bar::wifi::spawn_join(ssid_clone.clone()); } else { show_add_network_dialog(btn, ssid_clone.clone(), |_| {}); } - close_parent_popover(btn); + panels.hide_all(); + }); + self.wifi_pane.append(&row); + } + } + + if !profiles.is_empty() { + let ph = gtk4::Label::new(Some("PROFILES")); + ph.add_css_class("wifi-popover-section"); + ph.set_xalign(0.0); + ph.set_margin_top(10); + ph.set_margin_bottom(4); + self.wifi_pane.append(&ph); + + for (i, (name, active)) in profiles.into_iter().enumerate() { + let row = gtk4::Button::new(); + row.add_css_class("flat"); + row.add_css_class("wifi-popover-row"); + stagger_row(&row, i); + if active { + row.add_css_class("wifi-popover-row-active"); + } + let lbl = gtk4::Label::new(Some(&name)); + lbl.set_xalign(0.0); + lbl.set_valign(gtk4::Align::Center); + row.set_child(Some(&lbl)); + + let name_clone = name.clone(); + let sender_clone = sender.clone(); + let panels = panels.clone(); + row.connect_clicked(move |_| { + sender_clone.input(AppInput::SetProfile(name_clone.clone())); + bar::wifi::spawn_profile_set(name_clone.clone()); + panels.hide_all(); }); self.wifi_pane.append(&row); } @@ -1213,6 +2573,7 @@ impl App { toggle_lbl.set_hexpand(true); toggle_lbl.set_xalign(0.0); let toggle_switch = gtk4::Switch::new(); + toggle_switch.add_css_class("bt-switch"); toggle_switch.set_active(data.powered); toggle_switch.set_valign(gtk4::Align::Center); toggle_switch.connect_state_set(|_, on| { @@ -1234,26 +2595,24 @@ impl App { lbl.add_css_class("wifi-popover-loading"); self.bt_pane.append(&lbl); } else { - let dh = gtk4::Label::new(Some("Paired")); + let dh = gtk4::Label::new(Some("PAIRED")); dh.add_css_class("wifi-popover-section"); dh.set_xalign(0.0); dh.set_margin_top(2); - dh.set_margin_bottom(2); + dh.set_margin_bottom(4); self.bt_pane.append(&dh); - for dev in &data.devices { + for (i, dev) in data.devices.iter().enumerate() { let row = gtk4::Button::new(); row.add_css_class("flat"); row.add_css_class("wifi-popover-row"); + stagger_row(&row, i); if dev.connected { row.add_css_class("wifi-popover-row-active"); } - let lbl = gtk4::Label::new(Some(&format!( - "{}{}", - if dev.connected { "● " } else { " " }, - dev.name, - ))); + let lbl = gtk4::Label::new(Some(&dev.name)); lbl.set_xalign(0.0); + lbl.set_valign(gtk4::Align::Center); row.set_child(Some(&lbl)); let address = dev.address.clone(); @@ -1275,7 +2634,7 @@ impl App { settings_row.add_css_class("flat"); settings_row.add_css_class("wifi-popover-row"); let settings_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - let settings_icon = gtk4::Image::from_paintable(Some(&svg_texture(bar::stats::ICON_BT_SETTINGS))); + let settings_icon = svg_image(bar::stats::ICON_BT_SETTINGS); settings_icon.add_css_class("stat-icon"); settings_box.append(&settings_icon); settings_box.append(>k4::Label::new(Some("Bluetooth settings"))); @@ -1291,65 +2650,60 @@ impl App { // ── Helpers ─────────────────────────────────────────────────────────────────── -fn build_slider_row(icon_svg: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { +fn build_slider_row(label: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); row.add_css_class("control-panel-row"); - row.set_margin_top(2); - row.set_margin_bottom(2); - // Rendered larger than the standard 16px stat/section icons: these are - // the two things in the panel you actually drag, so they should read as - // primary controls rather than blend in with passive readouts. - let icon = gtk4::Image::from_paintable(Some(&svg_texture_sized(icon_svg, 20))); - icon.add_css_class("control-panel-row-icon"); + let lbl = gtk4::Label::new(Some(label)); + lbl.add_css_class("control-panel-row-label"); + lbl.set_xalign(0.0); + lbl.set_width_chars(3); let slider = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, min, max, step); slider.set_draw_value(false); slider.set_hexpand(true); - slider.set_width_request(180); + slider.set_width_request(160); slider.add_css_class("control-panel-slider"); - row.append(&icon); + row.append(&lbl); row.append(&slider); (row, slider) } -fn fmt_speed(kbs: f32) -> String { - if kbs >= 1024.0 { - format!("{:.1} MB/s", kbs / 1024.0) - } else { - format!("{:.0} KB/s", kbs) - } -} - fn wifi_icon_for_signal(pct: u8) -> &'static str { - use bar::stats::{WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG, WIFI_WEAK}; + use bar::stats::{ + WIFI_ICON_EXCELLENT, WIFI_ICON_GOOD, WIFI_ICON_OFF, WIFI_ICON_OK, WIFI_ICON_WEAK, + }; match pct { - 75..=100 => WIFI_STRONG, - 50..=74 => WIFI_MEDIUM, - 25..=49 => WIFI_WEAK, - _ => WIFI_OFF, + 75..=100 => WIFI_ICON_EXCELLENT, + 50..=74 => WIFI_ICON_GOOD, + 25..=49 => WIFI_ICON_OK, + 1..=24 => WIFI_ICON_WEAK, + _ => WIFI_ICON_OFF, } } -fn close_parent_popover(widget: >k4::Button) { - if let Some(w) = widget.ancestor(gtk4::Popover::static_type()) { - if let Ok(p) = w.downcast::() { - p.popdown(); - } - } -} + /// Small modal prompting for a password, then saves + joins the network via /// `breadcrumbs add` + `breadcrumbs join`. `on_build` runs on the freshly /// built dialog *before* it's presented — screenshot mode's only hook point, /// since `connect_map` registered any later would miss a map that already /// happened. The real call site passes a no-op. -fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_build: impl FnOnce(>k4::Window)) { +fn show_add_network_dialog( + anchor: &impl IsA, + ssid: String, + on_build: impl FnOnce(>k4::Window), +) { let dialog = gtk4::Window::new(); dialog.set_title(Some(&format!("Add “{ssid}”"))); dialog.set_resizable(false); dialog.add_css_class("wifi-add-dialog"); + if let Some(output) = bread_theme::gtk::output_for_widget(anchor) { + theme::bind_output(&dialog, &output); + } else { + theme::bind_auto(&dialog); + } // A bare gtk4::Window with no titlebar set falls back to GTK's own // minimal CSD: a flat bar with plain system-font title text and no // rounding — which is what actually made this look like a stray window @@ -1433,33 +2787,513 @@ fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_bui fn stat_pair(icon_svg: &str, label: >k4::Label) -> gtk4::Box { let pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); pair.add_css_class("stat-pair"); - let img = gtk4::Image::from_paintable(Some(&svg_texture(icon_svg))); + bar_chip(&pair); + let img = svg_image(icon_svg); img.add_css_class("stat-icon"); pair.append(&img); pair.append(label); pair } -pub(crate) fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { - svg_texture_sized(svg_src, 16) +/// Identity of the pills this bar actually draws. Ignores last-window +/// title/address so a tab title change cannot rebuild the row. +fn visible_ws_rows( + workspaces: &[Workspace], + monitor: &str, + active: WorkspaceId, +) -> Vec<(WorkspaceId, String, bool)> { + workspaces + .iter() + .filter(|w| w.monitor == monitor && (w.windows > 0 || w.id == active)) + .map(|w| (w.id, w.name.clone(), w.windows > 0)) + .collect() } -/// Same as `svg_texture` but rendered at an explicit pixel size — used to give -/// primary/interactive icons (e.g. sliders you actually drag) more visual -/// weight than passive informational ones, which otherwise all read as the -/// same flat 16px stroke glyph once emoji stopped providing accidental variety. +fn bar_chip(widget: &impl IsA) { + widget.set_valign(gtk4::Align::Center); + widget.set_vexpand(false); +} + +fn popover_caret() -> gtk4::Box { + let caret = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + caret.add_css_class("popover-caret"); + caret.set_hexpand(true); + caret +} + +fn stagger_row(widget: &impl IsA, i: usize) { + widget.add_css_class("row-in"); + widget.add_css_class(&format!("stagger-{}", i.min(11))); +} + +fn play_once(widget: &impl IsA, class: &str, ms: u64) { + widget.remove_css_class(class); + widget.add_css_class(class); + let w = widget.as_ref().clone(); + let class = class.to_string(); + gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(ms), move || { + w.remove_css_class(&class); + }); +} + +fn make_clock_digits(time: &str) -> Vec { + time.chars() + .map(|ch| { + let lbl = gtk4::Label::new(Some(&ch.to_string())); + lbl.add_css_class("clock-digit"); + if ch == ':' { + lbl.add_css_class("clock-colon"); + } + lbl.set_valign(gtk4::Align::Center); + lbl.set_vexpand(false); + lbl.set_yalign(0.5); + lbl + }) + .collect() +} + +fn flip_clock_digits(digits: &[gtk4::Label], time: &str) { + let chars: Vec = time.chars().collect(); + for (i, lbl) in digits.iter().enumerate() { + let next = chars.get(i).copied().unwrap_or(' '); + let next_s = next.to_string(); + if lbl.label().as_str() == next_s { + continue; + } + lbl.set_label(&next_s); + if next != ':' { + play_once(lbl, "flip", 450); + } + } +} + +/// Container for a [`make_digit_chip`]/[`flip_digit_chip`]-driven numeric +/// chip (volume, battery) — a plain horizontal box, same shape as +/// `vol_box`/`bat_box`'s existing icon+label chips, that gets one +/// `.stat-digit` label per character instead of a single `gtk4::Label`. +fn digit_chip_box() -> gtk4::Box { + gtk4::Box::new(gtk4::Orientation::Horizontal, 0) +} + +/// One `.stat-digit` label per character of `text` — the `stat-label` +/// styled counterpart of `make_clock_digits`, reused so volume/battery +/// chips can roll per-digit the same way the clock does (plan: "ODOMETER +/// DIGITS FOR NUMERIC CHIPS"). Not the clock's own `.clock-digit` class: +/// that carries the clock's much larger `font-size`, wrong for a bar chip. +fn make_digit_chip(text: &str) -> Vec { + text.chars() + .map(|ch| { + let lbl = gtk4::Label::new(Some(&ch.to_string())); + lbl.add_css_class("stat-label"); + lbl.add_css_class("stat-digit"); + lbl.set_valign(gtk4::Align::Center); + lbl.set_vexpand(false); + lbl.set_yalign(0.5); + lbl + }) + .collect() +} + +/// Tears down `container`'s current digit labels and rebuilds them for +/// `text` from scratch — the shared fallback [`set_digit_chip`] and +/// [`flip_digit_chip`] both take when the character count changes (`9` -> +/// `10`), including the very first call, when `digits` starts empty. No +/// per-position diff makes sense across different lengths, so this never +/// animates. +fn rebuild_digit_chip(container: >k4::Box, digits: &mut Vec, text: &str) { + for lbl in digits.drain(..) { + container.remove(&lbl); + } + *digits = make_digit_chip(text); + for lbl in digits.iter() { + container.append(lbl); + } +} + +/// Replaces `container`'s digit labels with `text`'s, with NO animation — +/// used for the volume slider's live drag feedback (`connect_value_changed` +/// fires on every pointer-move tick; replaying the flip keyframe that fast +/// would read as a flicker). [`flip_digit_chip`] below is the animated +/// counterpart, driven by the `StatsUpdate` poll instead. +fn set_digit_chip(container: >k4::Box, digits: &mut Vec, text: &str) { + let chars: Vec = text.chars().collect(); + if digits.len() != chars.len() { + rebuild_digit_chip(container, digits, text); + return; + } + for (lbl, ch) in digits.iter().zip(chars.iter()) { + let next = ch.to_string(); + if lbl.label().as_str() != next { + lbl.set_label(&next); + } + } +} + +/// The animated counterpart of [`set_digit_chip`]: same rebuild-on-length- +/// change fallback, but on a same-length update it plays the `flip` +/// keyframe (`digit-flip`, the same one the clock uses) only on the +/// characters that actually changed — same convention as +/// `flip_clock_digits`. +fn flip_digit_chip(container: >k4::Box, digits: &mut Vec, text: &str) { + let chars: Vec = text.chars().collect(); + if digits.len() != chars.len() { + rebuild_digit_chip(container, digits, text); + return; + } + for (lbl, ch) in digits.iter().zip(chars.iter()) { + let next = ch.to_string(); + if lbl.label().as_str() == next { + continue; + } + lbl.set_label(&next); + play_once(lbl, "flip", 350); + } +} + +fn reveal_media(widget: >k4::Box, show: bool) { + if show && !widget.is_visible() { + play_once(widget, "media-in", 420); + } + widget.set_visible(show); +} + +/// Drives `drawer_box`'s height from `from` to `to` over 360ms via +/// `bread_theme::anim::spring_to` (plan §7: GTK4 has no CSS height +/// transition on a widget, so the capsule's `.results { max-height: 0 → +/// 420px }` becomes a `set_size_request` interpolation on the frame clock +/// instead). Cancels any run already in flight first — reopening mid-close +/// (or vice versa) must restart from the CURRENT height, not fight a +/// leftover callback still walking toward the old target. +fn animate_drawer_height( + drawer_box: >k4::Box, + anim: &Rc>>, + from: i32, + to: i32, +) { + if let Some(id) = anim.borrow_mut().take() { + id.remove(); + } + let target = drawer_box.clone(); + let id = bread_theme::anim::spring_to(drawer_box, from, to, 360.0, move |h| { + // `spring_ease` deliberately overshoots past t=1.0 — that bounce is the + // point on expand, but on a collapse (from=content height, to=0) the + // same overshoot carries the interpolated value BELOW zero, and + // `set_size_request` hard-asserts `height >= -1` (GTK-CRITICAL, once + // per frame at 60fps). -1 is GTK's "use natural height" sentinel, not a + // valid animation frame, so clamp to 0 rather than -1: a drawer mid- + // collapse wants zero height, never its natural height. + target.set_size_request(-1, h.max(0)); + // Collapse finished: hide the box so it stops claiming natural height. + // set_size_request is only a minimum, so a visible-but-zero-request + // drawer still gets allocated its children's full height and holds the + // capsule open. Hiding here rather than in close_fn keeps the collapse + // animated instead of snapping shut on the first frame. + if to == 0 && h <= 0 { + target.set_visible(false); + } + }); + *anim.borrow_mut() = Some(id); +} + +/// Drives the capsule's own window width from `from` to `to` over 360ms +/// (plan §7 phase 6c: `[launcher].search_width`, `04-spotlight.html`'s +/// `.searching .capsule { width: 520px }`) — the same `spring_to` + +/// `set_size_request` technique `animate_drawer_height` uses for the +/// drawer's height, applied to the root window itself instead of a child +/// box. Unlike a drawer collapse, width never animates toward a negative +/// target (idle/search widths are both positive theme values), so there is +/// no analogous "clamp to 0" concern here. +fn animate_capsule_width( + root: >k4::ApplicationWindow, + anim: &Rc>>, + from: i32, + to: i32, +) { + if let Some(id) = anim.borrow_mut().take() { + id.remove(); + } + let target = root.clone(); + let id = bread_theme::anim::spring_to(root, from, to, 360.0, move |w| { + target.set_size_request(w.max(0), -1); + }); + *anim.borrow_mut() = Some(id); +} + +/// The drawer's natural content height right now, capped at the demo's own +/// 420px (`04-spotlight.html`: `.searching .results { max-height: 420px }`) +/// — `ResultsList`'s scroller already self-caps at 480px +/// (`max_content_height`), shared with breadbox, so this is a tighter, +/// spotlight-specific ceiling on top of that shared one, not a replacement +/// for it. +fn drawer_target_height(drawer_box: >k4::Box) -> i32 { + // Deliberately never measures `drawer_box` itself. `animate_drawer_height`'s + // tick callback calls `drawer_box.set_size_request(-1, h)` on every + // frame, and GTK clamps a widget's own `measure()` result up to at + // least its own explicit size request — so once an animation has run + // even one frame, `drawer_box.measure()` reports that frame's forced + // height (or the spring's overshoot past it), not whatever its + // children actually need next. This bit spotlight's new query-mode + // rows directly: switching from the (tall) app list to a one-row calc + // result measured "437" instead of "~33", because the PREVIOUS + // animation frame had already forced `drawer_box` to 437px. + // + // Summing each currently-visible child's own natural height instead + // sidesteps this entirely — `launcher_results.scroller` and + // `mode_list` never get an explicit size request of their own, so + // their `measure()` always reflects their actual current content. + let mut total = 0; + let mut child = drawer_box.first_child(); + while let Some(c) = child { + if c.is_visible() { + let (_, natural, _, _) = c.measure(gtk4::Orientation::Vertical, -1); + total += natural; + } + child = c.next_sibling(); + } + total.min(DRAWER_MAX_HEIGHT_PX) +} + +// ── Query-mode rows (plan §7 phase 6c) ────────────────────────────────── +// +// `mode_list`'s rows are NOT `bread_launcher::DesktopEntry`-backed +// (`bread_launcher::gtk::row_entry` returns `None` for every one of +// these), so they're built/read here rather than through that crate. + +/// A single-line, non-interactive row — the calc result, or a "nothing +/// typed yet" placeholder. Reuses `.app-name` so it inherits the same +/// `.bread-drawer row` typography `bread-launcher`'s own rows get. +fn mode_info_row(text: &str) -> gtk4::ListBoxRow { + let row = gtk4::ListBoxRow::new(); + row.set_selectable(false); + row.set_activatable(false); + let lbl = gtk4::Label::new(Some(text)); + lbl.add_css_class("app-name"); + lbl.set_xalign(0.0); + row.set_child(Some(&lbl)); + row +} + +/// A single-line, actionable row (a `>`-mode command, or the `.`-mode +/// "open this URL" prompt) — Enter/click spawns `action` once resolved by +/// [`run_mode_action`]. +fn mode_action_row(text: &str, action: ModeAction) -> gtk4::ListBoxRow { + let row = gtk4::ListBoxRow::new(); + let lbl = gtk4::Label::new(Some(text)); + lbl.add_css_class("app-name"); + lbl.set_xalign(0.0); + row.set_child(Some(&lbl)); + unsafe { row.set_data("mode_action", action) }; + row +} + +/// What Enter/click on a [`mode_action_row`] does. Two variants, not one +/// shell-command string, so a `.`-mode URL (arbitrary user-typed text) +/// never passes through `bash -c` at all — only a `>`-mode command's own +/// fixed, trusted `exec` string does. +#[derive(Clone)] +enum ModeAction { + RunShell(&'static str), + OpenUrl(String), +} + +fn mode_row_action(row: >k4::ListBoxRow) -> Option { + unsafe { row.data::("mode_action").map(|p| p.as_ref().clone()) } +} + +/// Pure half of the `.`-mode URL action: adds a scheme when the user typed +/// a bare host (`example.com` -> `https://example.com`), leaves anything +/// that already looks like `scheme://...` untouched. +fn url_open_target(url: &str) -> String { + if url.contains("://") { + url.to_string() + } else { + format!("https://{url}") + } +} + +fn run_mode_action(action: &ModeAction) { + match action { + ModeAction::RunShell(cmd) => { + if let Err(e) = std::process::Command::new("bash") + .args(["-c", cmd]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + eprintln!("breadbar: failed to run mode command {cmd:?}: {e}"); + } + } + ModeAction::OpenUrl(url) => { + // No scheme-adding shell involved — `xdg-open` gets the raw + // argument, so nothing in a `.`-mode query is ever parsed as + // shell syntax. + let target = url_open_target(url); + if let Err(e) = std::process::Command::new("xdg-open") + .arg(&target) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + eprintln!("breadbar: failed to open url {target:?}: {e}"); + } + } + } +} + +/// Moves `list`'s selection to the next/previous row — unlike +/// `bread_launcher::gtk::ResultsList::select_next`/`select_prev`, `mode_list` +/// never has hidden rows to skip (it's cleared and rebuilt from scratch on +/// every query change), so this is the plain, un-filtered version. +fn listbox_select_next(list: >k4::ListBox) { + let cur = list.selected_row().map(|r| r.index()).unwrap_or(-1); + if let Some(row) = list.row_at_index(cur + 1) { + list.select_row(Some(&row)); + } +} + +fn listbox_select_prev(list: >k4::ListBox) { + let cur = list.selected_row().map(|r| r.index()).unwrap_or(0); + if cur > 0 { + if let Some(row) = list.row_at_index(cur - 1) { + list.select_row(Some(&row)); + } + } +} + +/// Clears `mode_list` and rebuilds it for `parsed` — the calc result, +/// filtered `>`-mode commands, or the `.`-mode "open this URL" prompt. +/// Returns whether anything is now selectable (a real command/URL row, not +/// just an info row) so the caller knows whether Return has anything to do. +fn populate_mode_list(mode_list: >k4::ListBox, parsed: &bread_launcher::ParsedQuery) -> bool { + while let Some(row) = mode_list.row_at_index(0) { + mode_list.remove(&row); + } + match parsed.kind { + bread_launcher::QueryKind::Calc => { + match bread_launcher::eval_calc(&parsed.value) { + Some(result) => mode_list.append(&mode_info_row(&format!("= {result}"))), + None => mode_list.append(&mode_info_row("=")), + } + false + } + bread_launcher::QueryKind::Cmd => { + let matches = bread_launcher::filter_commands( + &parsed.value, + bread_launcher::builtin_commands(), + ); + if matches.is_empty() { + mode_list.append(&mode_info_row("No matching commands")); + false + } else { + for cmd in &matches { + mode_list.append(&mode_action_row(cmd.name, ModeAction::RunShell(cmd.exec))); + } + if let Some(first) = mode_list.row_at_index(0) { + mode_list.select_row(Some(&first)); + } + true + } + } + bread_launcher::QueryKind::Url => { + if parsed.value.is_empty() { + mode_list.append(&mode_info_row(".")); + false + } else { + mode_list.append(&mode_action_row( + &format!("Open {}", parsed.value), + ModeAction::OpenUrl(parsed.value.clone()), + )); + if let Some(first) = mode_list.row_at_index(0) { + mode_list.select_row(Some(&first)); + } + true + } + } + bread_launcher::QueryKind::Apps => false, + } +} + +fn popover_tab(label: &str) -> gtk4::ToggleButton { + 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, theme::shell_theme().tokens().chip_height() as i32); + if let Some(child) = btn.child() { + child.set_halign(gtk4::Align::Center); + child.set_valign(gtk4::Align::Center); + 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, theme::shell_theme().tokens().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, theme::shell_theme().tokens().icon_px() as u32) +} + +/// Rasterise at 2× the display size so Lucide strokes stay sharp when GTK +/// displays the texture at `px` via `Image::set_pixel_size`. pub(crate) fn svg_texture_sized(svg_src: &str, px: u32) -> gtk4::gdk::Texture { use resvg::{tiny_skia, usvg}; + let raster = px.saturating_mul(2).max(1); let fg = theme::fg_color(); - let dim = format!(r#"width="{px}" height="{px}""#); + let dim = format!(r#"width="{raster}" height="{raster}""#); let svg = svg_src .replace("currentColor", &fg) + .replace(r#"stroke-width="2""#, r#"stroke-width="2.35""#) .replace(r#"width="24" height="24""#, &dim); let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("parse svg"); let size = tree.size().to_int_size(); let (w, h) = (size.width(), size.height()); let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap"); - resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut()); + resvg::render( + &tree, + tiny_skia::Transform::identity(), + &mut pixmap.as_mut(), + ); let bytes = gtk4::glib::Bytes::from_owned(pixmap.take()); gtk4::gdk::MemoryTexture::new( w as i32, @@ -1471,6 +3305,8 @@ pub(crate) fn svg_texture_sized(svg_src: &str, px: u32) -> gtk4::gdk::Texture { .upcast() } + + fn stat_label() -> gtk4::Label { let lbl = gtk4::Label::new(None); lbl.add_css_class("stat-label"); @@ -1503,6 +3339,25 @@ fn main() { } }); + // Best-effort cleanup when killed (Ctrl+C in the terminal, a dev-loop + // `kill`, systemd stop): kill the OSD's `pactl subscribe` children + // before exiting; the exit hook in osd.rs covers normal exits and + // panics. Without either, each breadbar restart orphaned a `pactl + // subscribe` holding its PulseAudio connection open until + // pipewire-pulse's client cap filled up and new clients — settings + // apps among them — were refused ("no devices in settings"). + relm4::spawn(async { + use tokio::signal::unix::{signal, SignalKind}; + let mut term = signal(SignalKind::terminate()).expect("SIGTERM handler"); + let mut intr = signal(SignalKind::interrupt()).expect("SIGINT handler"); + tokio::select! { + _ = term.recv() => {} + _ = intr.recv() => {} + } + crate::osd::kill_watchers(); + std::process::exit(0); + }); + // `with_args(vec![])` stops relm4 from handing our own --screenshot/ // --output flags to GLib's option parser (`app.run()`'s default), which // would otherwise reject them as unrecognized before Cli::parse() above @@ -1515,5 +3370,314 @@ fn main() { if screenshot_req.is_some() { app.allow_multiple_instances(true); } - app.run::(screenshot_req); + app.run::(BarInit { + screenshot: screenshot_req, + monitor: None, + primary: true, + }); +} + +/// Live outputs only. `hyprctl monitors all` (and the hyprland crate's +/// `Monitors::get`) keep ghost connectors after a rename — `DVI-I-1` stayed +/// at 0×0 with `disabled=false` after the panel became `DVI-I-2`, and a +/// geometry fallback then stacked a second bar on the laptop. +#[derive(Debug, Clone, serde::Deserialize)] +struct HyprMon { + name: String, + x: i32, + y: i32, + #[serde(default)] + focused: bool, + #[serde(default)] + disabled: bool, +} + +fn hypr_monitors_live() -> Vec { + let output = match std::process::Command::new("hyprctl") + .args(["monitors", "-j"]) + .output() + { + Ok(o) if o.status.success() => o.stdout, + _ => return Vec::new(), + }; + serde_json::from_slice::>(&output) + .unwrap_or_default() + .into_iter() + .filter(|m| !m.disabled) + .collect() +} + +fn primary_hypr_monitor() -> Option { + let mons = hypr_monitors_live(); + mons.iter() + .find(|m| m.focused) + .or_else(|| mons.first()) + .map(|m| m.name.clone()) +} + +/// Where `AppInput::OpenLauncher` should be actually handled: locally (this +/// instance grabs its own capsule's focus), or forwarded to a specific +/// satellite instance. Pure decision logic, split out of the `update` match +/// arm so it's unit-testable without a live `App`/GTK/Hyprland stack. +#[derive(Debug, Clone, PartialEq, Eq)] +enum LauncherRoute { + Local, + Satellite(String), +} + +/// `focused`: the currently-focused Hyprland monitor, re-queried at +/// keybind-fire time (`None` if Hyprland's monitor query failed, e.g. +/// screenshot mode). `own`: this instance's own monitor, fixed at `init()`. +/// `satellites`: names of monitors this (necessarily primary) instance +/// tracks a `Controller` for. +/// +/// Falls back to `Local` whenever forwarding isn't possible or isn't +/// needed, so a caller can always make forward progress: no focused +/// monitor, the focused monitor is this instance's own, or the focused +/// monitor has no tracked satellite yet. +fn resolve_launcher_route(focused: Option<&str>, own: &str, satellites: &[&str]) -> LauncherRoute { + match focused { + Some(name) if name != own && satellites.contains(&name) => { + LauncherRoute::Satellite(name.to_string()) + } + _ => LauncherRoute::Local, + } +} + +fn hypr_monitor_names() -> Vec { + hypr_monitors_live() + .into_iter() + .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)) +} + +/// The bar/capsule's own layer-surface geometry for `monitor`, straight +/// from the compositor (`hyprctl layers -j`, ground truth — not derived +/// from anything GTK/gtk4-layer-shell reports client-side, since the +/// wlr-layer-shell protocol never hands a client its own assigned x/y back; +/// only width/height come through `configure`). Matched by `namespace` +/// ("breadbar", set via `root.set_namespace` above), which is unique per +/// output since each monitor gets its own bound `App` instance/window. +/// Returns the surface's horizontal center in Hyprland's global coordinate +/// space. `None` on any parse/lookup failure — callers must fall back to +/// the pre-existing, safe-but-broader dead-zone behaviour rather than +/// guess. +fn hypr_capsule_center_x(monitor: &str) -> Option { + let output = std::process::Command::new("hyprctl") + .args(["layers", "-j"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let root: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; + let levels = root.get(monitor)?.get("levels")?.as_object()?; + for arr in levels.values() { + let Some(items) = arr.as_array() else { + continue; + }; + for item in items { + if item.get("namespace").and_then(|v| v.as_str()) != Some("breadbar") { + continue; + } + let x = item.get("x")?.as_i64()? as i32; + let w = item.get("w")?.as_i64()? as i32; + return Some(x + w / 2); + } + } + None +} + +/// The click-away scrim's capsule-column hole, in coordinates local to the +/// `breadbar-dismiss` surface (see `PanelSet::show_capsule_dismiss`) — +/// pure arithmetic, split out for unit testing. `capsule_center_global` and +/// `monitor_origin_x` are both in Hyprland's global compositor space +/// (`hypr_capsule_center_x`/`hypr_monitor_origin`); `column_width` is the +/// capsule's own *configured* search-state width +/// (`[launcher].search_width`), not a live-queried one — this fires right +/// as `open_fn` starts the width-animation from idle to search width, so a +/// live query at that exact instant would catch it mid-transition. Using +/// the wider, settled target here (like `DRAWER_MAX_HEIGHT_PX` already does +/// for the vertical bound) means the hole is never narrower than the +/// capsule ever actually gets while the scrim is showing. +fn capsule_dismiss_hole( + capsule_center_global: i32, + monitor_origin_x: i32, + column_width: i32, +) -> (i32, i32) { + let local_center = capsule_center_global - monitor_origin_x; + (local_center - column_width / 2, column_width) +} + +/// Hyprland connector names and GDK connector names can disagree after a +/// hotplug (`DVI-I-1` vs `DVI-I-2`). Match the connector first, then the +/// output's origin — transform swaps width/height so size is not reliable. +/// 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 + } + }); +} + +#[cfg(test)] +mod launcher_route_tests { + use super::{resolve_launcher_route, LauncherRoute}; + + #[test] + fn focused_monitor_is_own_stays_local() { + assert_eq!( + resolve_launcher_route(Some("eDP-1"), "eDP-1", &["DVI-I-1"]), + LauncherRoute::Local + ); + } + + #[test] + fn focused_monitor_is_tracked_satellite_forwards() { + assert_eq!( + resolve_launcher_route(Some("DVI-I-1"), "eDP-1", &["DVI-I-1"]), + LauncherRoute::Satellite("DVI-I-1".to_string()) + ); + } + + #[test] + fn focused_monitor_with_no_tracked_satellite_falls_back_local() { + // e.g. reconcile hasn't caught up with a very recent hotplug yet. + assert_eq!( + resolve_launcher_route(Some("HDMI-A-1"), "eDP-1", &["DVI-I-1"]), + LauncherRoute::Local + ); + } + + #[test] + fn no_focused_monitor_falls_back_local() { + // Hyprland's monitor query failed (screenshot mode, hyprctl missing). + assert_eq!( + resolve_launcher_route(None, "eDP-1", &["DVI-I-1"]), + LauncherRoute::Local + ); + } +} + +#[cfg(test)] +mod capsule_dismiss_hole_tests { + use super::capsule_dismiss_hole; + + #[test] + fn centered_capsule_on_primary_monitor_at_origin() { + // A 520px-wide capsule centered on a 1920px-wide monitor at global + // origin (0,0): global center x = 960, monitor origin x = 0. + let (x, w) = capsule_dismiss_hole(960, 0, 520); + assert_eq!((x, w), (960 - 260, 520)); + } + + #[test] + fn negative_origin_secondary_monitor_converts_to_local() { + // This machine's own DVI-I-1 (`hyprctl layers -j`, quoted in this + // module's doc comments): monitor origin x = -1080. A capsule + // centered on that output's own 1080px-wide span sits at global + // center x = -1080 + 540 = -540. + let (x, w) = capsule_dismiss_hole(-540, -1080, 520); + // Local center is 540 (origin subtracted back out); hole starts + // 260px to either side of it, independent of the monitor's sign. + assert_eq!((x, w), (540 - 260, 520)); + } + + #[test] + fn hole_width_always_matches_requested_column_width() { + let (_, w) = capsule_dismiss_hole(100, 0, 480); + assert_eq!(w, 480); + } +} + +#[cfg(test)] +mod url_open_target_tests { + use super::url_open_target; + + #[test] + fn bare_host_gets_https_scheme() { + assert_eq!(url_open_target("example.com"), "https://example.com"); + } + + #[test] + fn existing_scheme_is_left_untouched() { + assert_eq!(url_open_target("http://example.com"), "http://example.com"); + assert_eq!( + url_open_target("ftp://example.com/file"), + "ftp://example.com/file" + ); + } } diff --git a/src/notifications/history.rs b/src/notifications/history.rs index 711f229..66c8783 100644 --- a/src/notifications/history.rs +++ b/src/notifications/history.rs @@ -1,9 +1,12 @@ use std::collections::VecDeque; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; +use gtk4_layer_shell::{KeyboardMode, LayerShell}; +use serde::{Deserialize, Serialize}; use super::Urgency; @@ -32,6 +35,30 @@ pub fn new_store() -> Store { Arc::new(Mutex::new(VecDeque::new())) } +/// Load the last [`LIMIT`] entries from `$XDG_STATE_HOME/breadbar/history.json` +/// (or `~/.local/state/breadbar/history.json`). Missing or corrupt files +/// yield an empty store — never fail startup. +pub fn load_store() -> Store { + let store = new_store(); + if let Some(path) = history_path() { + load_into(&store, &path); + } + store +} + +/// Next D-Bus notification id so persisted rows are not replaced on restart. +pub fn next_id(store: &Store) -> u32 { + store + .lock() + .unwrap() + .iter() + .map(|e| e.id) + .max() + .unwrap_or(0) + .saturating_add(1) + .max(1) +} + /// Insert or replace by `id`, newest first. Drops anything past [`LIMIT`]. pub fn record(store: &Store, entry: Entry) { let mut hist = store.lock().unwrap(); @@ -44,17 +71,123 @@ pub fn record(store: &Store, entry: Entry) { } } +/// Best-effort write of the in-memory store (already bounded) to the +/// XDG state file. Failures are silent — history stays in memory. +pub fn persist(store: &Store) { + if let Some(path) = history_path() { + let _ = persist_to(store, &path); + } +} + +fn history_path() -> Option { + Some(state_dir()?.join("history.json")) +} + +fn state_dir() -> Option { + if let Ok(xdg) = std::env::var("XDG_STATE_HOME") { + if !xdg.is_empty() { + return Some(PathBuf::from(xdg).join("breadbar")); + } + } + let home = std::env::var_os("HOME")?; + Some(PathBuf::from(home).join(".local/state/breadbar")) +} + +#[derive(Serialize, Deserialize)] +struct PersistedEntry { + id: u32, + app_name: String, + summary: String, + body: String, + urgency: String, + received_unix: u64, +} + +fn urgency_name(u: Urgency) -> &'static str { + match u { + Urgency::Low => "low", + Urgency::Normal => "normal", + Urgency::Critical => "critical", + } +} + +fn urgency_from_name(s: &str) -> Urgency { + match s { + "low" => Urgency::Low, + "critical" => Urgency::Critical, + _ => Urgency::Normal, + } +} + +fn to_persisted(entry: &Entry) -> PersistedEntry { + let received_unix = entry + .received + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + PersistedEntry { + id: entry.id, + app_name: entry.app_name.clone(), + summary: entry.summary.clone(), + body: entry.body.clone(), + urgency: urgency_name(entry.urgency).into(), + received_unix, + } +} + +fn from_persisted(entry: PersistedEntry) -> Entry { + Entry { + id: entry.id, + app_name: entry.app_name, + summary: entry.summary, + body: entry.body, + urgency: urgency_from_name(&entry.urgency), + received: SystemTime::UNIX_EPOCH + Duration::from_secs(entry.received_unix), + } +} + +fn persist_to(store: &Store, path: &Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let payload: Vec = store.lock().unwrap().iter().map(to_persisted).collect(); + let bytes = serde_json::to_vec(&payload).map_err(std::io::Error::other)?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, bytes)?; + fs::rename(&tmp, path) +} + +fn load_into(store: &Store, path: &Path) { + let Ok(bytes) = fs::read(path) else { + return; + }; + let Ok(parsed) = serde_json::from_slice::>(&bytes) else { + return; + }; + let mut hist = store.lock().unwrap(); + hist.clear(); + for entry in parsed.into_iter().take(LIMIT) { + hist.push_back(from_persisted(entry)); + } +} + pub fn build_window(store: Store) -> Ui { let window = gtk4::Window::new(); window.add_css_class("breadbar-history"); window.init_layer_shell(); - window.set_layer(Layer::Overlay); - window.set_anchor(Edge::Top, true); - window.set_anchor(Edge::Right, true); - window.set_margin(Edge::Top, 48); - window.set_margin(Edge::Right, 20); + window.set_namespace(Some("breadbar-notif")); + crate::surface::apply(&window, "breadbar-notif"); + // Overrides `[surfaces."breadbar-notif"].width` (320px, the live-toast + // popup's width — see `surface::apply`'s doc comment): the history + // window genuinely wants a different width on the same namespace, and + // that isn't something the manifest schema models today. Both calls + // must be overridden, not just `set_default_width` — `apply()` also + // pins `set_size_request` to the toast's 320px, and a bare width alone + // would lose to that pin the same way it lost to a wide child before. window.set_default_width(360); + window.set_size_request(360, -1); window.set_keyboard_mode(KeyboardMode::OnDemand); + crate::theme::bind_auto(&window); let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 8); outer.set_margin_top(10); @@ -157,8 +290,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"); @@ -271,4 +404,55 @@ mod tests { assert_eq!(truncate("hello", 10), "hello"); assert_eq!(truncate("hello world", 5), "hello…"); } + + #[test] + fn persist_roundtrip_keeps_newest_first_and_bound() { + let dir = std::env::temp_dir().join(format!( + "breadbar-history-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("history.json"); + let store = new_store(); + for i in 0..(LIMIT as u32 + 3) { + record(&store, entry(i, &format!("n{i}"))); + } + persist_to(&store, &path).unwrap(); + + let loaded = new_store(); + load_into(&loaded, &path); + assert_eq!(next_id(&loaded), LIMIT as u32 + 3); + let hist = loaded.lock().unwrap(); + assert_eq!(hist.len(), LIMIT); + assert_eq!(hist.front().unwrap().id, LIMIT as u32 + 2); + assert_eq!( + hist.front().unwrap().summary, + format!("n{}", LIMIT as u32 + 2) + ); + drop(hist); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn load_into_ignores_corrupt_file() { + let dir = std::env::temp_dir().join(format!( + "breadbar-history-bad-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("history.json"); + fs::write(&path, "not-json").unwrap(); + let store = new_store(); + load_into(&store, &path); + assert!(store.lock().unwrap().is_empty()); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index ccc8125..4668798 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -17,6 +17,28 @@ use zbus::zvariant::OwnedValue; /// warning) — it just piles up a new card next to it forever. const SYNCHRONOUS_HINT: &str = "x-canonical-private-synchronous"; +/// Spec + GNOME/KDE reserved action id for an inline reply field. Hidden +/// from the button row; submitting the field emits `NotificationReplied` +/// (and `ActionInvoked` with this key). See `popup::emit_replied`. +pub const INLINE_REPLY_KEY: &str = "inline-reply"; + +/// KDE placeholder hint. Presence (or an `inline-reply` action) is enough +/// to show the reply field — Discord/Telegram use the action, Plasma often +/// only the hint. +const KDE_REPLY_PLACEHOLDER: &str = "x-kde-reply-placeholder"; + +/// Advertised `GetCapabilities` strings. `body` is the original set; +/// `actions` / `inline-reply` are this change; `body-markup` is the usual +/// companion so senders can ship ``/`` instead of stripping tags. +const CAPABILITIES: &[&str] = &["body", "body-markup", "actions", "inline-reply"]; + +/// One `(id, localized label)` pair from the Notify `actions` array. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Action { + pub key: String, + pub label: String, +} + /// How long a shown notification should stay up before auto-dismissing. /// Distinct from `Option` mainly for readability at call sites — /// `Never` covers both the spec's `expire_timeout == 0` ("never expire") @@ -36,6 +58,9 @@ pub enum NotifEvent { body: String, urgency: Urgency, expire: Expire, + actions: Vec, + /// Placeholder for the inline-reply field, if one should be shown. + inline_reply: Option, }, Close(u32), ToggleHistory, @@ -68,6 +93,36 @@ impl Urgency { } } +/// Spec: `actions` is a flat list of pairs `(id, localized label)`. An +/// unpaired trailing id is ignored. Empty keys are dropped. +fn parse_actions(raw: &[String]) -> Vec { + raw.chunks_exact(2) + .filter(|c| !c[0].is_empty()) + .map(|c| Action { + key: c[0].clone(), + label: c[1].clone(), + }) + .collect() +} + +/// Show an inline reply field when the sender asked for `inline-reply` or +/// sent the KDE placeholder hint. Placeholder text prefers the hint. +fn inline_reply_placeholder( + actions: &[Action], + hints: &HashMap, +) -> Option { + let from_hint = hints + .get(KDE_REPLY_PLACEHOLDER) + .and_then(|v| String::try_from(v.clone()).ok()) + .filter(|s| !s.is_empty()); + let has_action = actions.iter().any(|a| a.key == INLINE_REPLY_KEY); + if has_action || from_hint.is_some() { + Some(from_hint.unwrap_or_else(|| "Reply".into())) + } else { + None + } +} + /// Maps a `Notify` call's `expire_timeout` (plus whether the `urgency` hint /// was critical) to our internal `Expire`, per the freedesktop notification /// spec: `0` always means never expire; a negative value means "server @@ -96,6 +151,8 @@ struct NotifServer { /// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`. sync_tags: Mutex>, history: history::Store, + /// Unit tests leave this off so `Notify` does not write `$XDG_STATE_HOME`. + persist_history: bool, } /// Private breadbar control surface on the same connection as @@ -120,8 +177,14 @@ const BAR_IFACE: &str = "dev.breadway.Bar"; /// `breadbar --history`; does not start a second bar. pub async fn toggle_history_remote() -> zbus::Result<()> { let conn = zbus::Connection::session().await?; - conn.call_method(Some(BAR_DEST), BAR_PATH, Some(BAR_IFACE), "ToggleHistory", &()) - .await?; + conn.call_method( + Some(BAR_DEST), + BAR_PATH, + Some(BAR_IFACE), + "ToggleHistory", + &(), + ) + .await?; Ok(()) } @@ -136,7 +199,7 @@ impl NotifServer { _app_icon: &str, summary: &str, body: &str, - _actions: Vec, + actions: Vec, hints: std::collections::HashMap, expire_timeout: i32, ) -> u32 { @@ -169,6 +232,8 @@ impl NotifServer { // when the sender left expire_timeout at the server-default (-1). let urgency = Urgency::from_hint(hints.get("urgency")); let expire = compute_expire(expire_timeout, urgency == Urgency::Critical); + let actions = parse_actions(&actions); + let inline_reply = inline_reply_placeholder(&actions, &hints); history::record( &self.history, @@ -181,6 +246,9 @@ impl NotifServer { received: SystemTime::now(), }, ); + if self.persist_history { + history::persist(&self.history); + } let _ = self .tx @@ -191,6 +259,8 @@ impl NotifServer { body: body.to_string(), urgency, expire, + actions, + inline_reply, }) .await; id @@ -201,7 +271,7 @@ impl NotifServer { } fn get_capabilities(&self) -> Vec { - vec!["body".to_string()] + CAPABILITIES.iter().map(|s| (*s).to_string()).collect() } fn get_server_information(&self) -> (String, String, String, String) { @@ -236,6 +306,8 @@ impl SampleKind { body: "This is what a notification card looks like.".into(), urgency, expire: Expire::Never, + actions: vec![], + inline_reply: None, } } } @@ -264,15 +336,17 @@ pub fn spawn(sample: Option) -> gtk4::Window { } None => { let (conn_tx, conn_rx) = tokio::sync::oneshot::channel(); - let store = history::new_store(); + let store = history::load_store(); + let next_id = history::next_id(&store); let history_ui = history::build_window(store.clone()); relm4::spawn(async move { let server = NotifServer { tx: tx.clone(), - next_id: AtomicU32::new(1), + next_id: AtomicU32::new(next_id), sync_tags: Mutex::new(HashMap::new()), history: store, + persist_history: true, }; let bar = BarService { tx }; // Builder failures here would only occur with invalid static strings — safe to unwrap. @@ -297,8 +371,7 @@ pub fn spawn(sample: Option) -> gtk4::Window { let window_for_loop = window.clone(); relm4::spawn_local(async move { if let Ok(conn) = conn_rx.await { - popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui)) - .await; + popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui)).await; } }); } @@ -353,6 +426,7 @@ mod tests { next_id: AtomicU32::new(1), sync_tags: Mutex::new(HashMap::new()), history: history::new_store(), + persist_history: false, }, rx, ) @@ -449,10 +523,28 @@ mod tests { async fn notify_records_history_newest_first() { let (server, _rx) = test_server(); server - .notify("app-a", 0, "", "first", "body-a", vec![], HashMap::new(), -1) + .notify( + "app-a", + 0, + "", + "first", + "body-a", + vec![], + HashMap::new(), + -1, + ) .await; server - .notify("app-b", 0, "", "second", "body-b", vec![], HashMap::new(), -1) + .notify( + "app-b", + 0, + "", + "second", + "body-b", + vec![], + HashMap::new(), + -1, + ) .await; let hist = server.history.lock().unwrap(); assert_eq!(hist.len(), 2); @@ -461,4 +553,115 @@ mod tests { assert_eq!(hist[0].body, "body-b"); assert_eq!(hist[1].summary, "first"); } + + #[test] + fn parse_actions_pairs_and_drops_trailing_id() { + let parsed = parse_actions(&[ + "default".into(), + "Open".into(), + "snooze".into(), + "Snooze".into(), + "orphan".into(), + ]); + assert_eq!( + parsed, + vec![ + Action { + key: "default".into(), + label: "Open".into(), + }, + Action { + key: "snooze".into(), + label: "Snooze".into(), + }, + ] + ); + } + + #[test] + fn parse_actions_skips_empty_keys() { + assert!(parse_actions(&["", "Nope"].map(String::from)).is_empty()); + } + + #[test] + fn inline_reply_from_action_or_kde_hint() { + let reply_action = vec![Action { + key: INLINE_REPLY_KEY.into(), + label: "Reply".into(), + }]; + assert_eq!( + inline_reply_placeholder(&reply_action, &HashMap::new()).as_deref(), + Some("Reply") + ); + assert!(inline_reply_placeholder(&[], &HashMap::new()).is_none()); + + let mut hints = HashMap::new(); + hints.insert( + KDE_REPLY_PLACEHOLDER.to_string(), + OwnedValue::try_from(zbus::zvariant::Value::from("Write a reply…")).unwrap(), + ); + assert_eq!( + inline_reply_placeholder(&[], &hints).as_deref(), + Some("Write a reply…") + ); + // Hint wins over the generic default when both are present. + assert_eq!( + inline_reply_placeholder(&reply_action, &hints).as_deref(), + Some("Write a reply…") + ); + } + + #[test] + fn get_capabilities_includes_actions_and_inline_reply() { + let (server, _rx) = test_server(); + let caps = server.get_capabilities(); + for wanted in ["body", "body-markup", "actions", "inline-reply"] { + assert!( + caps.iter().any(|c| c == wanted), + "missing capability {wanted}" + ); + } + } + + #[tokio::test] + async fn notify_forwards_actions_and_inline_reply() { + let (server, mut rx) = test_server(); + server + .notify( + "chat", + 0, + "", + "Alice", + "hello", + vec![ + "default".into(), + "Open".into(), + INLINE_REPLY_KEY.into(), + "Reply".into(), + ], + HashMap::new(), + -1, + ) + .await; + match rx.recv().await.expect("Show event") { + NotifEvent::Show { + actions, + inline_reply, + summary, + .. + } => { + assert_eq!(summary, "Alice"); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].key, "default"); + assert_eq!(actions[1].key, INLINE_REPLY_KEY); + assert_eq!(inline_reply.as_deref(), Some("Reply")); + } + _ => panic!("expected Show, got a different event"), + } + // History persist path is unchanged: actions are UI-only, not stored. + let hist = server.history.lock().unwrap(); + assert_eq!(hist.len(), 1); + assert_eq!(hist[0].summary, "Alice"); + assert_eq!(hist[0].body, "hello"); + } } diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 9ec29af..7e2abd4 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -1,10 +1,11 @@ -use std::{cell::RefCell, collections::HashMap, rc::Rc}; +use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Instant}; +use gtk4::glib::ControlFlow; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, Layer, LayerShell}; +use gtk4_layer_shell::{KeyboardMode, LayerShell}; use tokio::sync::mpsc::Receiver; -use super::{history, Expire, NotifEvent, Urgency}; +use super::{history, Action, Expire, NotifEvent, Urgency, INLINE_REPLY_KEY}; type Cards = Rc>>; // Bumped every time an id gets a (re)placed card — an auto-dismiss timer @@ -18,7 +19,6 @@ type Generations = Rc>>; /// NotificationClosed reason codes per the freedesktop spec. mod close_reason { pub const EXPIRED: u32 = 1; - #[allow(dead_code)] // no in-app dismiss button exists yet (see make_card) pub const DISMISSED_BY_USER: u32 = 2; pub const CLOSE_NOTIFICATION_CALL: u32 = 3; } @@ -35,6 +35,23 @@ pub fn build_window() -> (gtk4::Window, gtk4::Box) { cards_box.set_margin_start(8); cards_box.set_margin_end(8); window.set_child(Some(&cards_box)); + + // NOTIFICATION INTERACTION #B: the surface (and `window.surface()`) + // doesn't exist until map, same constraint `surface::click_through` + // documents — this is the initial-region counterpart of that hook, + // recomputing against whatever's already in `cards_box` at the moment + // the toast becomes visible (empty on the very first map, but this + // window remaps on every reappearance after being fully dismissed — + // see `dismiss`'s `window.set_visible(false)` — and by the time a new + // notification's `Show` handler calls `set_visible(true)` again, its + // card is already a child of `cards_box`). `refresh_hit_region`'s own + // per-event calls below are the steady-state path; this is the + // just-in-case one for the map race itself. + let cbox_for_map = cards_box.clone(); + window.connect_map(move |win| { + apply_hit_region(win, &cbox_for_map); + }); + (window, cards_box) } @@ -63,15 +80,38 @@ pub async fn run( body, urgency, expire, + actions, + inline_reply, } => { // Replace existing card with same id (replaces_id case) if let Some(old) = cards.borrow_mut().remove(&id) { cards_box.remove(&old); } - let card = make_card(&app_name, &summary, &body, urgency); + let card = make_card(CardSpec { + id, + app_name: &app_name, + summary: &summary, + body: &body, + urgency, + actions: &actions, + inline_reply: inline_reply.as_deref(), + conn: conn.clone(), + cards: cards.clone(), + cards_box: cards_box.clone(), + window: window.clone(), + }); cards_box.prepend(&card); cards.borrow_mut().insert(id, card.clone()); window.set_visible(true); + // ANIMATION WORK #6: spring the new card's own height in + // from 0 to its natural content height instead of it + // appearing at full size in one frame — since it's + // `prepend`ed (the vertical box's first child), the + // existing cards below get pushed down smoothly as this + // grows, rather than jumping straight to their new + // position. + spring_in_card(&card); + refresh_hit_region(&window, &cards_box); if let Some(ui) = &history_ui { history::refresh_if_visible(ui); } @@ -96,8 +136,7 @@ pub async fn run( gtk4::glib::timeout_future(duration).await; let still_current = generations_clone.borrow().get(&id) == Some(&my_generation); - if still_current - && dismiss(&cards_box_clone, &win_clone, &cards_clone, id) + if still_current && dismiss(&cards_box_clone, &win_clone, &cards_clone, id) { emit_closed(&conn_clone, id, close_reason::EXPIRED).await; } @@ -120,7 +159,13 @@ pub async fn run( /// Removes `id`'s card if present. Returns whether a card was actually /// removed, so callers only emit `NotificationClosed` for a real dismissal -/// (not a no-op on an id that's already gone or was never shown). +/// (not a no-op on an id that's already gone or was never shown). Every +/// caller (auto-expire, `CloseNotification`, an action/reply invocation, +/// and the card's own dismiss button) goes through this one function, so +/// this is also the one place that needs to recompute the hit region +/// (NOTIFICATION INTERACTION #B) on removal — a card gone from `cards_box` +/// but still counted in the input region would leave a dead click-through +/// hole where a live button used to be. fn dismiss(cards_box: >k4::Box, window: >k4::Window, cards: &Cards, id: u32) -> bool { let removed = cards.borrow_mut().remove(&id); let Some(card) = removed else { @@ -130,6 +175,7 @@ fn dismiss(cards_box: >k4::Box, window: >k4::Window, cards: &Cards, id: u32) if cards.borrow().is_empty() { window.set_visible(false); } + refresh_hit_region(window, cards_box); true } @@ -154,49 +200,396 @@ async fn emit_closed(conn: &Option, id: u32, reason: u32) { } } +/// ANIMATION WORK #6: springs `card`'s own height from 0 up to its natural +/// content height (`bread_theme::anim::spring_to` + `set_size_request`, +/// same "GTK4 CSS has no height transition" technique `main.rs`'s +/// `animate_drawer_height` already uses for the capsule drawer) instead of +/// it appearing at full size in one frame. Measured AFTER `card` is already +/// a child of `cards_box` (the caller's job), not before: an unparented +/// widget isn't rooted under this window's style provider chain yet, so its +/// `measure()` wouldn't see the real `.notification-card` padding/border — +/// only a widget that's actually in the tree gets an accurate natural size. +/// +/// One-shot and self-contained — no cancellation bookkeeping, unlike +/// `animate_drawer_height`/`animate_osd_fill`'s own `Rc>` +/// tick-id storage: a card's entrance can't be interrupted by a second one, +/// since a same-id replacement tears the whole card down and builds a +/// fresh one (see the `Show` handler's `cards_box.remove(&old)`) rather +/// than reusing it. +const CARD_GROW_MS: f64 = 380.0; + +fn spring_in_card(card: >k4::Box) { + let (_, target_h, _, _) = card.measure(gtk4::Orientation::Vertical, -1); + card.set_size_request(-1, 0); + let target = card.clone(); + bread_theme::anim::spring_to(card, 0, target_h, CARD_GROW_MS, move |h| { + target.set_size_request(-1, h.max(0)); + }); +} + +thread_local! { + // NOTIFICATION INTERACTION #B: the tick callback that keeps + // `refresh_hit_region` recomputing the toast's input region while a + // card's entrance (`spring_in_card` above) or the stack's push-down + // reflow could still be moving a button. One process-wide toast window + // (this crate registers a single `org.freedesktop.Notifications` name), + // so a thread-local — not a field threaded through every call site — is + // enough, same reasoning as `theme::SHELL_THEME_MONITOR`. + static HIT_TRACKER: RefCell> = const { RefCell::new(None) }; +} + +/// How long after a card set/layout change to keep recomputing the hit +/// region every frame — long enough to cover both `spring_in_card`'s +/// `CARD_GROW_MS` and the CSS `notif-in` keyframe's 0.45s slide-in (see +/// `theme.rs`'s `.notification-card` rule), whichever finishes last. +const HIT_TRACK_MS: f64 = 700.0; + +/// Recomputes the toast surface's clickable input region immediately, then +/// keeps recomputing it every frame for `HIT_TRACK_MS` — covering both a +/// newly-shown card's own entrance and the stack's push-down settle, either +/// of which can still be moving a button on the frame this is called. +/// Called any time the card set could have changed: shown, dismissed +/// (including via the new dismiss button — see `dismiss` above), or +/// expired. Cancels any previous tracking run first, so a rapid burst of +/// notifications doesn't accumulate overlapping tick callbacks. +fn refresh_hit_region(window: >k4::Window, cards_box: >k4::Box) { + apply_hit_region(window, cards_box); + + if let Some(id) = HIT_TRACKER.with(|c| c.borrow_mut().take()) { + id.remove(); + } + let started = Instant::now(); + let win = window.clone(); + let cbox = cards_box.clone(); + let id = window.add_tick_callback(move |_, _| { + apply_hit_region(&win, &cbox); + if started.elapsed().as_secs_f64() * 1000.0 >= HIT_TRACK_MS { + HIT_TRACKER.with(|c| c.borrow_mut().take()); + return ControlFlow::Break; + } + ControlFlow::Continue + }); + HIT_TRACKER.with(|c| *c.borrow_mut() = Some(id)); +} + +/// One frame's worth of `refresh_hit_region`'s work: walk `cards_box` for +/// every currently-interactive widget (action/dismiss buttons, the +/// inline-reply entry) and hand their rectangles to +/// `surface::set_hit_region`. Split out from `refresh_hit_region` so the +/// initial immediate call and the tracking tick callback share the exact +/// same logic. +fn apply_hit_region(window: >k4::Window, cards_box: >k4::Box) { + let mut widgets = Vec::new(); + collect_interactive(cards_box.upcast_ref::(), &mut widgets); + crate::surface::set_hit_region(window, &widgets); +} + +/// Depth-first walk of `root`'s widget tree collecting every `GtkButton` +/// (action buttons, the reply-send button, the dismiss button) and +/// `GtkEntry` (the inline-reply field) — the only things on a card a user +/// should ever be able to click into. Everything else (the summary/body +/// labels, the card's own background) stays click-through, same as the +/// blanket empty region did before NOTIFICATION INTERACTION #B. Walking +/// the real widget tree rather than tracking a flat list as cards/buttons +/// are built means this can't drift out of sync with `make_card`'s own +/// structure (e.g. the dismiss button living inside a `gtk4::Overlay` +/// rather than directly under `card`). +fn collect_interactive(root: >k4::Widget, out: &mut Vec) { + let mut child = root.first_child(); + while let Some(w) = child { + if w.is::() || w.is::() { + out.push(w.clone()); + } + collect_interactive(&w, out); + child = w.next_sibling(); + } +} + fn create_window() -> gtk4::Window { let window = gtk4::Window::new(); window.add_css_class("breadbar-notification"); window.init_layer_shell(); - window.set_layer(Layer::Overlay); - window.set_anchor(Edge::Top, true); - window.set_anchor(Edge::Right, true); - window.set_margin(Edge::Top, 20); - window.set_margin(Edge::Right, 20); - window.set_default_width(320); + window.set_namespace(Some("breadbar-notif")); + crate::surface::apply(&window, "breadbar-notif"); + // Toasts are purely informational — they never grab keyboard focus, + // full stop, regardless of what's clickable on them (KeyboardMode::None + // stays; do NOT change this — see the NOTIFICATION INTERACTION #B task + // note). Historically ("stop toast popups from stealing focus or + // blocking clicks") that also meant a fully empty input region: every + // pointer event passed straight through to whatever's underneath, but + // that made `make_card`'s own action buttons, its inline-reply + // `GtkEntry`, and the dismiss button below permanently unreachable too. + // `crate::surface::set_hit_region` (called from `build_window`'s + // `connect_map` and from `refresh_hit_region` below, any time the card + // set or layout could have changed) replaces the old blanket + // `surface::click_through` empty region with the union of just those + // widgets' own rectangles — everywhere else on the surface stays + // click-through, same as before. A toast that genuinely has none of + // them yet (`cards_box` empty) still gets the same all-empty region + // `click_through` set, since a rectangle union over zero widgets is + // the empty region. + window.set_keyboard_mode(KeyboardMode::None); + crate::theme::bind_auto(&window); window } -fn make_card(app_name: &str, summary: &str, body: &str, urgency: Urgency) -> gtk4::Box { +struct CardSpec<'a> { + id: u32, + app_name: &'a str, + summary: &'a str, + body: &'a str, + urgency: Urgency, + actions: &'a [Action], + inline_reply: Option<&'a str>, + conn: Option, + cards: Cards, + cards_box: gtk4::Box, + window: gtk4::Window, +} + +fn make_card(spec: CardSpec<'_>) -> gtk4::Box { let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4); card.add_css_class("notification-card"); - if let Some(class) = urgency.css_class() { + if let Some(class) = spec.urgency.css_class() { card.add_css_class(class); } + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + // Senders often set the title/summary to their own app name (e.g. a bare // "Spotify" notification) — showing app_name above an identical summary // is pure repetition, so skip the app label in that case. - if !app_name.is_empty() && !app_name.eq_ignore_ascii_case(summary) { - let lbl = gtk4::Label::new(Some(app_name)); + if !spec.app_name.is_empty() && !spec.app_name.eq_ignore_ascii_case(spec.summary) { + let lbl = gtk4::Label::new(Some(spec.app_name)); lbl.add_css_class("notification-app"); lbl.set_xalign(0.0); - card.append(&lbl); + content.append(&lbl); } - let summary_lbl = gtk4::Label::new(Some(summary)); + let summary_lbl = gtk4::Label::new(Some(spec.summary)); summary_lbl.add_css_class("notification-summary"); summary_lbl.set_xalign(0.0); summary_lbl.set_wrap(true); - card.append(&summary_lbl); + content.append(&summary_lbl); - if !body.is_empty() { - let body_lbl = gtk4::Label::new(Some(body)); + if !spec.body.is_empty() { + let body_lbl = gtk4::Label::new(None); body_lbl.add_css_class("notification-body"); body_lbl.set_xalign(0.0); body_lbl.set_wrap(true); - card.append(&body_lbl); + apply_body_text(&body_lbl, spec.body); + content.append(&body_lbl); + } + + if spec.actions.iter().any(|a| a.key == "default") { + content.add_css_class("notification-default"); + let gesture = gtk4::GestureClick::new(); + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + gesture.connect_released(move |_, _, _, _| { + invoke_action(invoke.clone(), "default"); + }); + content.add_controller(gesture); + } + + // NOTIFICATION INTERACTION #A: a direct dismiss control. Floated in + // the card's top-right corner via an Overlay rather than a full extra + // header row, so it doesn't add vertical bulk the approved demo's own + // card never has (see the `.notification-dismiss` CSS in theme.rs). + // `collect_interactive` (this module) picks it up the same way it + // picks up the action/reply buttons below, by walking the real widget + // tree — it doesn't need to know this button lives one level deeper, + // inside the overlay, than they do. + let overlay = gtk4::Overlay::new(); + overlay.set_child(Some(&content)); + + let dismiss_btn = gtk4::Button::with_label("×"); + dismiss_btn.add_css_class("notification-dismiss"); + dismiss_btn.set_halign(gtk4::Align::End); + dismiss_btn.set_valign(gtk4::Align::Start); + let dismiss_invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + dismiss_btn.connect_clicked(move |_| { + dismiss_card(dismiss_invoke.clone()); + }); + overlay.add_overlay(&dismiss_btn); + + card.append(&overlay); + + let visible: Vec<&Action> = spec + .actions + .iter() + .filter(|a| a.key != "default" && a.key != INLINE_REPLY_KEY) + .collect(); + if !visible.is_empty() { + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + row.add_css_class("notification-actions"); + row.set_halign(gtk4::Align::End); + for action in visible { + let btn = gtk4::Button::with_label(&action.label); + btn.add_css_class("notification-action"); + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + let key = action.key.clone(); + btn.connect_clicked(move |_| { + invoke_action(invoke.clone(), &key); + }); + row.append(&btn); + } + card.append(&row); + } + + if let Some(placeholder) = spec.inline_reply { + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + row.add_css_class("notification-reply"); + + let entry = gtk4::Entry::new(); + entry.add_css_class("notification-reply-entry"); + entry.set_placeholder_text(Some(placeholder)); + entry.set_hexpand(true); + + let send_label = spec + .actions + .iter() + .find(|a| a.key == INLINE_REPLY_KEY) + .map(|a| a.label.as_str()) + .filter(|l| !l.is_empty()) + .unwrap_or("Send"); + let send = gtk4::Button::with_label(send_label); + send.add_css_class("notification-action"); + + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + let entry_for_btn = entry.clone(); + let invoke_btn = invoke.clone(); + send.connect_clicked(move |_| { + submit_reply(&entry_for_btn, invoke_btn.clone()); + }); + entry.connect_activate(move |e| { + submit_reply(e, invoke.clone()); + }); + + row.append(&entry); + row.append(&send); + card.append(&row); } card } + +/// FDO `body-markup` is a small Pango-ish subset (``, ``, ``, +/// ``). Invalid markup falls back to plain text so a bad sender +/// doesn't blank the card. +fn apply_body_text(label: >k4::Label, body: &str) { + if body.contains('<') && gtk4::pango::parse_markup(body, '\0').is_ok() { + label.set_markup(body); + return; + } + label.set_text(body); +} + +#[derive(Clone)] +struct Invoke { + conn: Option, + cards: Cards, + cards_box: gtk4::Box, + window: gtk4::Window, + id: u32, +} + +fn invoke_action(invoke: Invoke, key: &str) { + let key = key.to_string(); + relm4::spawn_local(async move { + emit_action(&invoke.conn, invoke.id, &key).await; + if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { + emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; + } + }); +} + +/// NOTIFICATION INTERACTION #A: the card's own dismiss button. Unlike +/// `invoke_action`, this never emits `ActionInvoked` — there's no action +/// key here, the user just closed the toast unprompted — only the +/// spec-mandated `NotificationClosed(id, reason)`, with +/// `close_reason::DISMISSED_BY_USER` (freedesktop value 2, "dismissed by +/// the user") so clients are told properly, same reason code +/// `invoke_action` above and `submit_reply` below already use for their +/// own user-initiated dismissals. +fn dismiss_card(invoke: Invoke) { + relm4::spawn_local(async move { + if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { + emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; + } + }); +} + +fn submit_reply(entry: >k4::Entry, invoke: Invoke) { + let text = entry.text().to_string(); + if text.trim().is_empty() { + return; + } + relm4::spawn_local(async move { + emit_replied(&invoke.conn, invoke.id, &text).await; + emit_action(&invoke.conn, invoke.id, INLINE_REPLY_KEY).await; + if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { + emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; + } + }); +} + +async fn emit_action(conn: &Option, id: u32, action_key: &str) { + let Some(conn) = conn else { return }; + let result = conn + .emit_signal( + None::<&str>, + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "ActionInvoked", + &(id, action_key), + ) + .await; + if let Err(e) = result { + eprintln!("breadbar: failed to emit ActionInvoked for {id}: {e}"); + } +} + +/// GNOME/KDE (and clients such as Discord/Telegram) listen for this +/// non-spec signal on `org.freedesktop.Notifications` when the user +/// submits an inline reply. Signature: `NotificationReplied(u32 id, s text)`. +/// We also emit `ActionInvoked(id, "inline-reply")` so senders that only +/// watch the spec signal still see the send. +async fn emit_replied(conn: &Option, id: u32, text: &str) { + let Some(conn) = conn else { return }; + let result = conn + .emit_signal( + None::<&str>, + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "NotificationReplied", + &(id, text), + ) + .await; + if let Err(e) = result { + eprintln!("breadbar: failed to emit NotificationReplied for {id}: {e}"); + } +} diff --git a/src/osd.rs b/src/osd.rs index 5deb1d4..6860c3f 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -1,7 +1,13 @@ -use std::{cell::Cell, rc::Rc, time::Duration}; +use std::{ + cell::{Cell, RefCell}, + process::Child, + rc::Rc, + sync::{Mutex, Once}, + time::Duration, +}; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, Layer, LayerShell}; +use gtk4_layer_shell::LayerShell; use tokio::sync::mpsc; enum OsdEvent { @@ -18,6 +24,46 @@ pub enum SampleKind { Brightness, } +/// Live `pactl subscribe` children spawned by [`volume_watcher`]. Kept so a +/// best-effort cleanup can kill them when breadbar exits: `pactl subscribe` +/// blocks until the server connection dies, so without this every breadbar +/// restart orphaned one that kept its PulseAudio connection open — until +/// pipewire-pulse's client cap filled up and new clients (settings apps +/// included) were refused, showing "no devices". Also reaped here, so a +/// watcher that dies on its own never lingers as a zombie. +static WATCHER_CHILDREN: Mutex> = Mutex::new(Vec::new()); +static REGISTER_EXIT_HOOK: Once = Once::new(); + +extern "C" { + /// libc `atexit(3)`. Declared directly rather than pulling the libc + /// crate in for a single function. + fn atexit(cb: extern "C" fn()) -> i32; +} + +extern "C" fn exit_cleanup() { + kill_watchers(); +} + +fn register_exit_hook() { + // Safety: `atexit` is provided by libc on every Linux target; the + // callback is a `static` C-ABI fn valid for the whole process. + unsafe { + let _ = atexit(exit_cleanup); + } +} + +/// Kill any live `pactl subscribe` watcher children and reap them. Safe to +/// call more than once (an already-dead child is a no-op). Runs from the +/// process-exit hook and the SIGINT/SIGTERM handlers in `main`. +pub fn kill_watchers() { + if let Ok(mut children) = WATCHER_CHILDREN.lock() { + for mut child in children.drain(..) { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + impl SampleKind { fn sample_event(&self) -> OsdEvent { match self { @@ -69,6 +115,14 @@ fn volume_watcher(tx: mpsc::Sender) { }; let Some(stdout) = child.stdout.take() else { return }; + // The child is meant to outlive this reader loop (it blocks until + // breadbar itself dies), so hand it to `kill_watchers` — the exit + // hook plus the SIGINT/SIGTERM handlers in main — instead of letting + // it orphan on restart. + REGISTER_EXIT_HOOK.call_once(register_exit_hook); + if let Ok(mut children) = WATCHER_CHILDREN.lock() { + children.push(child); + } let reader = BufReader::new(stdout); for line in reader.lines().map_while(Result::ok) { @@ -163,9 +217,7 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver) { container.set_margin_end(14); window.set_child(Some(&container)); - let icon = gtk4::Image::from_paintable(Some(&crate::svg_texture( - crate::bar::stats::ICON_VOLUME, - ))); + let icon = crate::svg_image(crate::bar::stats::ICON_VOLUME); icon.add_css_class("osd-icon"); container.append(&icon); @@ -176,6 +228,15 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver) { container.append(&pbar); let dismiss_token = Rc::new(Cell::new(0u32)); + // OSD fill overshoot (ANIMATION WORK #5): current fraction (as a whole + // percent, matching `anim::spring_to`'s `i32` interpolation), the + // in-flight tick callback (so a fast double-tap of volume-up cancels the + // previous run instead of fighting it), and a generation token so a + // superseded run's queued second leg (see `animate_osd_fill` below) + // never applies after a newer event has already taken over. + let fill_pct = Rc::new(Cell::new(0i32)); + let fill_anim: Rc>> = Rc::new(RefCell::new(None)); + let fill_token = Rc::new(Cell::new(0u32)); while let Some(event) = rx.recv().await { let (icon_svg, pct, muted) = match event { @@ -184,12 +245,13 @@ 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::theme::shell_theme().tokens().icon_px() as i32); if muted { icon.add_css_class("osd-icon-muted"); } else { icon.remove_css_class("osd-icon-muted"); } - pbar.set_fraction(pct as f64 / 100.0); + animate_osd_fill(&pbar, &fill_pct, &fill_anim, &fill_token, pct); window.set_visible(true); let token = dismiss_token.get().wrapping_add(1); @@ -205,13 +267,85 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver) { } } +/// How many percentage points the fill runs past its real target before +/// easing back — subtle, matching the rest of the theme's "spring" motion +/// rather than a dramatic bounce. +const OSD_OVERSHOOT_PCT: i32 = 4; +/// Leg 1 (toward the overshoot point) / leg 2 (settling back onto the real +/// target) durations. Two legs, not one: `anim::spring_to` clamps every +/// frame to `[min(from, to), max(from, to)]` (see its own doc comment and +/// tests) specifically so a caller like the capsule drawer can never end up +/// with a negative size request — which also means a single `spring_to` +/// call can *never* visibly overshoot `to`, no matter how much the +/// underlying curve wants to. Chaining two calls — first to a point past +/// the target, then back onto it — is what actually produces the overshoot. +const OSD_LEG1_MS: f64 = 160.0; +const OSD_LEG2_MS: f64 = 200.0; + +/// Animates `pbar`'s fraction from wherever `current` says it currently is +/// to `target`, overshooting slightly past it and settling back — see the +/// constants above for why this takes two `spring_to` legs instead of one. +/// Cancels any run already in flight (a fast double volume-step must +/// continue from the current visual position, not fight or restart it) and +/// stamps a fresh generation token so a superseded run's leg 2, queued via +/// `timeout_add_local_once` below, is a no-op if it fires after a newer +/// call has already taken over. +fn animate_osd_fill( + pbar: >k4::ProgressBar, + current: &Rc>, + anim: &Rc>>, + token: &Rc>, + target: u8, +) { + if let Some(id) = anim.borrow_mut().take() { + id.remove(); + } + let my_token = token.get().wrapping_add(1); + token.set(my_token); + + let from = current.get(); + let to = i32::from(target); + if from == to { + pbar.set_fraction(f64::from(to) / 100.0); + return; + } + let overshoot = if to > from { + (to + OSD_OVERSHOOT_PCT).min(100) + } else { + (to - OSD_OVERSHOOT_PCT).max(0) + }; + + let leg1_bar = pbar.clone(); + let leg1_current = current.clone(); + let id = bread_theme::anim::spring_to(pbar, from, overshoot, OSD_LEG1_MS, move |v| { + leg1_current.set(v); + leg1_bar.set_fraction(f64::from(v) / 100.0); + }); + *anim.borrow_mut() = Some(id); + + let leg2_bar = pbar.clone(); + let leg2_current = current.clone(); + let leg2_anim = anim.clone(); + let leg2_token = token.clone(); + gtk4::glib::timeout_add_local_once(Duration::from_millis(OSD_LEG1_MS as u64), move || { + if leg2_token.get() != my_token { + return; // superseded by a newer OSD event before leg 1 finished + } + let inner_bar = leg2_bar.clone(); + let id = bread_theme::anim::spring_to(&leg2_bar, overshoot, to, OSD_LEG2_MS, move |v| { + leg2_current.set(v); + inner_bar.set_fraction(f64::from(v) / 100.0); + }); + *leg2_anim.borrow_mut() = Some(id); + }); +} + fn create_window() -> gtk4::Window { let window = gtk4::Window::new(); window.add_css_class("breadbar-osd"); window.init_layer_shell(); - window.set_layer(Layer::Overlay); - window.set_anchor(Edge::Bottom, true); - window.set_margin(Edge::Bottom, 80); - window.set_default_width(180); + window.set_namespace(Some("breadbar-osd")); + crate::surface::apply(&window, "breadbar-osd"); + crate::theme::bind_auto(&window); window } diff --git a/src/panel.rs b/src/panel.rs new file mode 100644 index 0000000..1c0a67c --- /dev/null +++ b/src/panel.rs @@ -0,0 +1,317 @@ +//! 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 std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use gtk4::gdk::Key; +use gtk4::prelude::*; +use gtk4_layer_shell::{Edge, KeyboardMode, LayerShell}; + +use crate::{bind_layer_monitor, theme}; + +/// Outside this rectangle's local x/y span, the dismiss window's own real +/// size (Wayland clips an input region to the surface's actual bounds, same +/// as `surface::set_hit_region`'s empty-region trick) — big enough to cover +/// any realistic monitor layout, including a negative-origin secondary +/// output (`hyprctl layers -j` reported `x: -1080` for this machine's own +/// DVI-I-1). Centered on the origin so it's safe regardless of which way a +/// hole's coordinates end up signed. +const HOLE_CANVAS_SPAN: i32 = 20_000; + +/// A boxed, ref-counted, optionally-unset click-away callback — see +/// `PanelSet::on_dismiss`'s own doc comment. +type DismissCallback = Rc>>>; + +/// The capsule-column hole punched in the dismiss scrim's input region — +/// local x, y, width, height — see `PanelSet::dismiss_hole`'s own doc +/// comment. Shared (not just passed by value) so `make_dismiss`'s +/// `connect_map` hook and every `show_capsule_dismiss`/`reset_dismiss_margin` +/// call agree on the current value. +type DismissHole = Rc>>; + +#[derive(Clone)] +pub struct PanelSet { + pub connectivity: gtk4::Window, + pub control: gtk4::Window, + pub media: gtk4::Window, + dismiss: gtk4::Window, + // Theme 04/spotlight's capsule (plan §7 phase 6c): an extra click-away + // callback invoked alongside the popover-dismiss path below, so the + // SAME `breadbar-dismiss` surface/click-catcher also collapses the + // capsule's drawer — see `show_capsule_dismiss`/`hide_dismiss` and + // `set_on_dismiss`. `None` under every other theme (never set). + on_dismiss: DismissCallback, + // The rectangle (local to `dismiss`'s own surface coordinates) that + // should stay click-through even while the scrim otherwise covers the + // screen — `show_capsule_dismiss`'s own doc comment explains why this + // exists and how it's computed. `None` = no hole, the plain + // margin-based popover behaviour applies instead. Read inside + // `dismiss`'s own `connect_map` (the input region can only be set once + // the surface is real — see `surface::set_hit_region`'s doc comment for + // the same constraint) and, for the case where `dismiss` is already + // mapped from a prior show, applied immediately too. + dismiss_hole: DismissHole, +} + +impl PanelSet { + 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_hole: DismissHole = Rc::new(Cell::new(None)); + let dismiss = make_dismiss(monitor, &dismiss_hole); + + let set = Self { + connectivity, + control, + media, + dismiss, + on_dismiss: Rc::new(RefCell::new(None)), + dismiss_hole, + }; + 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(); + // A prior capsule search (see `show_capsule_dismiss`) may have left + // the shared dismiss surface's top margin pushed down past its + // popover-shaped default — restore it before this popover uses it. + self.reset_dismiss_margin(); + // Dismiss first so the panel maps above it (same Overlay layer). + self.dismiss.set_visible(true); + self.dismiss.present(); + 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); + } + + /// Theme 04/spotlight's capsule (plan §7 phase 6c): registers `cb` to + /// run whenever the shared dismiss surface is clicked, alongside the + /// popovers' own `hide_all`. `cb` is expected to no-op when the capsule + /// isn't actually open (matching `close_fn`'s own guard in main.rs), so + /// this firing on an ordinary popover click-away is harmless. + pub fn set_on_dismiss(&self, cb: impl Fn() + 'static) { + *self.on_dismiss.borrow_mut() = Some(Rc::new(cb)); + } + + /// Shows the dismiss scrim with its clickable region starting at + /// `top_margin` px from the screen top, rather than the theme's own + /// popover-shaped default. See the call site in main.rs's capsule + /// `open_fn` for why this needs to be at least the capsule's own row + /// height plus the drawer's maximum possible height: the dismiss + /// surface's layer (`overlay`) always renders above the bar's own + /// (`top`), so if its clickable region ever reached up into where the + /// drawer is actually drawn, it would swallow clicks meant for a + /// result row instead of forwarding them. + /// + /// Before this fix, that safety was bought with a `set_margin` that + /// pushed the scrim's *entire width* down by `top_margin` — leaving a + /// full-screen-wide dead band above it (up to ~470px on a 1200px-tall + /// display) where a click neither dismissed nor hit anything else, the + /// "it only sometimes is dismissed when you click somewhere else" + /// report. `hole`, when known (local-to-this-surface x-start/width, in + /// `main.rs`'s `capsule_dismiss_hole`), keeps exactly the same + /// vertical safety margin but scopes the dead band to the capsule's + /// own column instead of the full width, so everywhere else in that + /// band is dismiss-clickable too. `None` (geometry unavailable, e.g. + /// `hyprctl` failed) falls back to the old full-width behaviour rather + /// than risk a hole in the wrong place. + pub fn show_capsule_dismiss(&self, top_margin: i32, hole: Option<(i32, i32)>) { + match hole { + Some((x, w)) if w > 0 => { + self.dismiss.set_margin(Edge::Top, 0); + self.dismiss_hole.set(Some((x, 0, w, top_margin))); + } + _ => { + self.dismiss.set_margin(Edge::Top, top_margin); + self.dismiss_hole.set(None); + } + } + apply_dismiss_hole(&self.dismiss, &self.dismiss_hole); + self.dismiss.set_visible(true); + self.dismiss.present(); + } + + /// Hides the dismiss scrim and restores its margin to the theme's own + /// popover default, so a later popover `show()` isn't left with a + /// leftover capsule-sized gap. + pub fn hide_dismiss(&self) { + self.reset_dismiss_margin(); + self.dismiss.set_visible(false); + } + + fn reset_dismiss_margin(&self) { + let theme = theme::shell_theme(); + if let Some(surf) = theme.surfaces().get("breadbar-dismiss") { + let top = surf.offset.first().copied().unwrap_or(0.0) as i32; + self.dismiss.set_margin(Edge::Top, top); + } + // A stale capsule-shaped hole must not leak into a popover's own + // full-width dead zone. + self.dismiss_hole.set(None); + apply_dismiss_hole(&self.dismiss, &self.dismiss_hole); + } + + fn wire_dismiss(&self) { + let set = self.clone(); + let click = gtk4::GestureClick::new(); + click.set_button(0); + click.connect_pressed(move |_, _, _, _| { + set.hide_all(); + if let Some(cb) = set.on_dismiss.borrow().as_ref() { + cb(); + } + }); + if let Some(child) = self.dismiss.child() { + child.add_controller(click); + } 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")); + crate::surface::apply(&window, "breadbar-panel"); + 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, hole: &DismissHole) -> 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. + // + // NOTE — deliberate, not a bug: this surface's top margin is 8px less + // than `breadbar-panel`'s (see `make_panel` above / `[surfaces.*]` in + // the active theme). The panels start 8px lower than the dismiss + // scrim's clickable region. This predates Phase 2 and is preserved + // exactly for pixel-identical rendering — do not "fix" this gap. + crate::surface::apply(&window, "breadbar-dismiss"); + window.set_exclusive_zone(-1); + window.set_keyboard_mode(KeyboardMode::None); + // An empty window never maps a hit region. A filling child + a hair of + // 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); + // The underlying `GdkSurface` (and therefore `window.surface()`, which + // `apply_dismiss_hole` needs) doesn't exist until the window is mapped + // — same constraint `surface::set_hit_region` documents. This surface + // gets hidden/shown repeatedly (every popover open/close, every + // capsule search), and GTK4 unmaps-then-remaps a toplevel each time + // its visibility toggles off then on, so re-applying here on every + // `map` (not just the first) is what keeps a freshly (re)shown surface + // honouring whatever hole was set before this particular `present()`. + { + let hole = Rc::clone(hole); + window.connect_map(move |win| apply_dismiss_hole(win, &hole)); + } + window +} + +/// Sets `dismiss`'s click-away input region to "everywhere" minus `hole` +/// (if any) — see `PanelSet::show_capsule_dismiss`'s doc comment for why. +/// Only takes effect once `dismiss.surface()` is real, i.e. the window is +/// currently mapped; harmlessly no-ops otherwise (the `connect_map` hook in +/// `make_dismiss` re-runs this the moment that stops being true). +fn apply_dismiss_hole(dismiss: >k4::Window, hole: &DismissHole) { + let Some(surface) = dismiss.surface() else { + return; + }; + match hole.get() { + Some((x, y, w, h)) => { + let canvas = gtk4::cairo::RectangleInt::new( + -HOLE_CANVAS_SPAN, + -HOLE_CANVAS_SPAN, + HOLE_CANVAS_SPAN * 2, + HOLE_CANVAS_SPAN * 2, + ); + let region = gtk4::cairo::Region::create_rectangle(&canvas); + let punch = gtk4::cairo::RectangleInt::new(x, y, w, h); + if region.subtract_rectangle(&punch).is_ok() { + surface.set_input_region(Some(®ion)); + } else { + // Punching the hole failed for some reason (an invalid + // cairo status on a plain rectangle op, effectively + // unreachable in practice) — falling back to `None` (the + // protocol's documented "no input region set: whole + // surface hits") is still safer than leaving whatever + // region predates this call in place, which could be + // stale from a completely different mode (e.g. an old + // popover-shaped margin-only region with no hole at all). + eprintln!( + "breadbar: could not punch capsule hole in dismiss scrim's input region" + ); + surface.set_input_region(None); + } + } + None => surface.set_input_region(None), + } +} diff --git a/src/screenshot.rs b/src/screenshot.rs index ecf75b3..47c0ff4 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -14,21 +14,15 @@ //! windows (`notifications::spawn`/`osd::spawn`, built and primed with //! sample data by `main.rs` before `dispatch` runs — see [`Handles`]). +use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH, SETTLE_DELAY}; use clap::Parser; use gtk4::prelude::*; use std::path::PathBuf; use std::time::Duration; -/// Extra settle time after `map` for the first frame to actually paint -/// before grim runs — `map` fires once the surface exists, not once -/// anything has been drawn into it. -const SETTLE_DELAY: Duration = Duration::from_millis(300); - -/// Settle time for views whose content depends on `bar::stats::spawn_poller`'s -/// 2-second background loop (control-panel's CPU/RAM/PWR/GPU/network labels, -/// gated on popover visibility) or a similar live-data popover load -/// (connectivity's wifi/bluetooth scan) — capturing any sooner leaves -/// placeholder dashes/"Scanning…" instead of real content. +/// Settle time for views whose content depends on a live-data popover load +/// (connectivity's wifi/bluetooth scan, control-panel sliders) — capturing +/// any sooner leaves placeholder dashes/"Scanning…" instead of real content. const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200); /// Delay between the bar's own `map` and calling `popover.popup()`. Calling @@ -38,7 +32,7 @@ const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200); /// popover at all) — presumably the parent widget's own allocation isn't /// settled yet at that exact point. Giving the initial layout pass a beat to /// finish first is what makes it actually render. -const PRE_POPUP_DELAY: Duration = Duration::from_millis(300); +const PRE_POPUP_DELAY: Duration = SETTLE_DELAY; const KNOWN_VIEWS: &[&str] = &[ "bar", @@ -51,6 +45,21 @@ const KNOWN_VIEWS: &[&str] = &[ "osd-volume", "osd-brightness", "wifi-add-dialog", + // Theme 04/spotlight's capsule (plan §6b) — see `dispatch`'s two new + // match arms. "capsule-collapsed" captures the same region "bar" + // always has (bar_capture_height already reflects the active theme's + // own window height/margin); it exists as its own name purely so a + // verification script doesn't have to already know "bar" means "the + // capsule, collapsed" under spotlight specifically. + "capsule-collapsed", + "capsule-expanded", + // Phase 6c: query sections and the `=` calc mode — see `dispatch`'s + // two new match arms below. "capsule-expanded" already exercises the + // search-state width/radius change (item E: it types a query, which + // now also drives `open_fn`'s `.searching` root class + capsule-width + // spring animation), so that gap doesn't need its own view. + "capsule-sections", + "capsule-calc", ]; #[derive(Parser)] @@ -68,11 +77,11 @@ pub struct Cli { /// Capture canvas width — matches the isolated compositor's output width /// (`bread-capture --isolate-width`) so the geometry passed to `grim` /// doesn't depend on querying anything at capture time. - #[arg(long, default_value_t = 1920)] + #[arg(long, default_value_t = DEFAULT_WIDTH)] pub width: u32, /// Capture canvas height — see `width`. - #[arg(long, default_value_t = 1080)] + #[arg(long, default_value_t = DEFAULT_HEIGHT)] pub height: u32, /// Toggle the in-memory notification history on a running breadbar, then @@ -89,16 +98,20 @@ pub struct ScreenshotRequest { } impl Cli { - /// `None` for a normal run. Exits the process with an error if - /// `--screenshot` was given without `--output`, before any GTK/relm4 + /// `None` for a normal run. Exits the process with an error if the + /// `--screenshot` / `--output` pair is incomplete, before any GTK/relm4 /// setup happens. pub fn screenshot_request(&self) -> Option { - let view = self.screenshot.clone()?; - let Some(output) = self.output.clone() else { - eprintln!("breadbar: --screenshot requires --output"); + if let Err(e) = validate_pair(self.screenshot.as_deref(), self.output.as_deref()) { + eprintln!("breadbar: {e}"); std::process::exit(1); - }; - Some(ScreenshotRequest { view, output, width: self.width, height: self.height }) + } + Some(ScreenshotRequest { + view: self.screenshot.clone()?, + output: self.output.clone()?, + width: self.width, + height: self.height, + }) } } @@ -107,11 +120,11 @@ impl Cli { /// outlive `init()` (never stored on `App`), so they have to be cloned out /// before dispatch time same as `control_popover` always was. pub struct Handles { - pub control_popover: gtk4::Popover, - pub connectivity_popover: gtk4::Popover, + pub control_panel: gtk4::Window, + pub connectivity_panel: gtk4::Window, pub wifi_tab_btn: gtk4::ToggleButton, pub bt_tab_btn: gtk4::ToggleButton, - pub media_popover: gtk4::Popover, + pub media_panel: gtk4::Window, pub media_widget: gtk4::Box, pub media_track_lbl: gtk4::Label, /// Already built and primed with sample content by `main.rs` (via @@ -120,36 +133,72 @@ pub struct Handles { pub notification_window: Option, /// Same deal as `notification_window`, via `osd::spawn(Some(kind))`. pub osd_window: Option, + /// Theme 04/spotlight's capsule centre module — see the + /// "capsule-expanded" view, which focuses it and types a query to + /// drive the drawer open before capturing. + pub launcher_entry: gtk4::Entry, + /// The `drawer` slot's own container — read back after the open + /// animation settles so "capsule-expanded"'s capture height matches + /// however tall the results actually grew, rather than a guessed + /// constant. + pub drawer_box: gtk4::Box, } -/// The bar's fixed height — matches `root.set_exclusive_zone(32)` / -/// `set_default_height: 32` in `main.rs`. Unlike the other views' full -/// canvas, this never varies with `--width`/`--height`. -const BAR_HEIGHT: i32 = 32; +/// Capture height for the `bar` view: layer-shell top margin + widget +/// height (the exclusive zone). Unlike the other views' full canvas, +/// this never varies with `--width`/`--height`. +fn bar_capture_height() -> i32 { + let window = crate::theme::shell_theme().window().clone(); + window.height + window.margin.top +} + +/// Y-origin for the `bar`/`capsule-collapsed` capture rectangle (axis 2, +/// daylight). Every theme through spotlight anchors top, so the bar's own +/// pixels always start at the canvas's own top edge (`y = 0`) — that's what +/// every existing call site here hardcoded. A bottom-anchored bar's real +/// on-screen footprint is instead the LAST `bar_capture_height()` pixels of +/// the canvas; capturing from `y = 0` unchanged grabs the isolated +/// compositor's empty background/gradient near the top of the output and +/// misses the actual bar entirely (confirmed empirically while verifying +/// this task — `bread-capture --app breadbar --view bar` under +/// `BREAD_SHELL_THEME=daylight` returned a plain gradient with no dock in +/// frame at all, before this fix). `canvas_height` is the capture canvas's +/// own height (`req.height`, i.e. `--height`/`isolate_height`), not this +/// crate's own bar height. +fn bar_capture_y(canvas_height: i32, bar_height: i32) -> i32 { + let window = crate::theme::shell_theme().window().clone(); + if window.anchors.iter().any(|a| a == "bottom") { + (canvas_height - bar_height).max(0) + } else { + 0 + } +} pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) { let output = req.output; let (width, height) = (req.width as i32, req.height as i32); + let bar_height = bar_capture_height(); + let bar_y = bar_capture_y(height, bar_height); match req.view.as_str() { "bar" => { root.connect_map(move |_| { let output = output.clone(); gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { - finish(bread_screenshots::capture_region(0, 0, width, BAR_HEIGHT, &output)); + finish(bread_screenshots::capture_region(0, bar_y, width, bar_height, &output)); }); }); } "control-panel" => { - open_popover_on_root_map(root, handles.control_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.control_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); } "connectivity-wifi" => { handles.wifi_tab_btn.set_active(true); - open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); } "connectivity-bluetooth" => { handles.bt_tab_btn.set_active(true); - open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); } "media-popover" => { // Real media state only shows the widget/text when something's @@ -157,8 +206,9 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: // run has nothing playing, so fake enough of it directly on the // widgets to get a representative capture. handles.media_widget.set_visible(true); + handles.media_widget.add_css_class("playing"); handles.media_track_lbl.set_text("Sample Track — Sample Artist"); - open_popover_on_root_map(root, handles.media_popover, SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.media_panel, SETTLE_DELAY, output, width, height); } "notification" | "notification-critical" => { let Some(window) = handles.notification_window else { @@ -174,6 +224,95 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: }; capture_standalone_window(window, output, width, height); } + "capsule-collapsed" => { + // Identical to "bar" — see KNOWN_VIEWS's doc comment on why + // this has its own name anyway. + root.connect_map(move |_| { + let output = output.clone(); + gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { + finish(bread_screenshots::capture_region(0, bar_y, width, bar_height, &output)); + }); + }); + } + "capsule-expanded" => { + // Focus + a real query, the same way a person opens the + // capsule (`connect_changed`/`EventControllerFocus::enter` in + // main.rs's capsule wiring do the rest: `open_fn` runs, + // `results.set_query` filters, and `animate_drawer_height` + // grows `drawer_box`). Capture height is bar height + however + // tall the drawer actually settled, not a hardcoded guess — + // this stays correct even if `dot_widths`/font/entry-count + // numbers change later. + let entry = handles.launcher_entry; + let drawer_box = handles.drawer_box; + root.connect_map(move |_| { + let output = output.clone(); + let entry = entry.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + entry.grab_focus(); + entry.set_text("f"); + let output = output.clone(); + let drawer_box = drawer_box.clone(); + // Past the 360ms spring_to run plus a normal capture + // settle, so the drawer's size_request (and the actual + // on-screen layer-shell surface it grows) has reached + // its final height before grabbing pixels. + gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || { + let drawer_h = drawer_box.size_request().1.max(0); + let capture_h = bar_height + drawer_h; + finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output)); + }); + }); + }); + } + "capsule-sections" => { + // Focus with NO query typed — the idle browse view + // (`ResultsList::new`'s "Recent"/"Apps" header rows are visible + // from construction; `set_query` is what would hide them, and + // it's never called here). Same settle timing as + // "capsule-expanded", just without the `entry.set_text` step. + let entry = handles.launcher_entry; + let drawer_box = handles.drawer_box; + root.connect_map(move |_| { + let output = output.clone(); + let entry = entry.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + entry.grab_focus(); + let output = output.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || { + let drawer_h = drawer_box.size_request().1.max(0); + let capture_h = bar_height + drawer_h; + finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output)); + }); + }); + }); + } + "capsule-calc" => { + // `=` mode (item C): the drawer's `mode_list` shows a single + // evaluated result row instead of `launcher_results.scroller` + // (see `populate_mode_list`'s `QueryKind::Calc` arm). + let entry = handles.launcher_entry; + let drawer_box = handles.drawer_box; + root.connect_map(move |_| { + let output = output.clone(); + let entry = entry.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + entry.grab_focus(); + entry.set_text("=6*7"); + let output = output.clone(); + let drawer_box = drawer_box.clone(); + gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || { + let drawer_h = drawer_box.size_request().1.max(0); + let capture_h = bar_height + drawer_h; + finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output)); + }); + }); + }); + } "wifi-add-dialog" => { let anchor = handles.wifi_tab_btn; root.connect_map(move |_| { @@ -196,27 +335,25 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: } } -/// Shared shape for every popover view: force it open shortly after the bar -/// maps (autohide disabled — a programmatic `popup()` has no real input -/// event serial to grab the Wayland seat with), then capture the whole -/// canvas after `settle` once the popover itself maps. -fn open_popover_on_root_map( +/// Shared shape for panel views: present the standalone layer window after +/// the bar maps, then capture the canvas once the panel itself maps. +fn open_panel_on_root_map( root: >k4::ApplicationWindow, - popover: gtk4::Popover, + panel: gtk4::Window, settle: Duration, output: PathBuf, width: i32, height: i32, ) { - let popover_to_open = popover.clone(); + let panel_to_open = panel.clone(); root.connect_map(move |_| { - popover_to_open.set_autohide(false); - let popover_to_open = popover_to_open.clone(); + let panel_to_open = panel_to_open.clone(); gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { - popover_to_open.popup(); + panel_to_open.set_visible(true); + panel_to_open.present(); }); }); - popover.connect_map(move |_| { + panel.connect_map(move |_| { let output = output.clone(); gtk4::glib::timeout_add_local_once(settle, move || { finish(bread_screenshots::capture_region(0, 0, width, height, &output)); diff --git a/src/surface.rs b/src/surface.rs new file mode 100644 index 0000000..88b094f --- /dev/null +++ b/src/surface.rs @@ -0,0 +1,166 @@ +//! Applies a `[surfaces.]` entry (plan §4/§6, Phase 2) to a +//! satellite layer-shell window: anchor, margin (from `offset`), width and +//! layer. Deliberately narrow — it only understands the four anchor shapes +//! breadbar's four built-in surfaces actually use today ("breadbar-notif", +//! "breadbar-osd", "breadbar-panel", "breadbar-dismiss": `top_right`, +//! `bottom_right`, `bottom_centre`, `fill`), not a general anchor DSL (the +//! plan's own anti-goal, §2). `bottom_right` was added for daylight (plan +//! §11 phase 7, axis 2) — the first bottom-anchored bar, whose satellites +//! need to hug the bottom-right corner the way every top-anchored theme's +//! already hug the top-right one. `exclusive` zone and `keyboard` mode +//! aren't part of the `[surfaces.*]` schema (`bread_theme::shell::Surface` +//! has no such fields) and stay hardcoded at each call site, same as before +//! this refactor. + +use bread_theme::shell::SurfaceWidth; +use gtk4::prelude::*; +use gtk4_layer_shell::{Edge, Layer, LayerShell}; + +/// `namespace` should be a key in the active theme's `[surfaces.*]` table — +/// every call site in this crate passes one of breadbar's own namespace +/// literals, so a miss here means the active theme fell out of sync with +/// the Rust source, not a bad runtime value. Logs and leaves the window at +/// gtk4-layer-shell's own defaults rather than panicking, matching every +/// other "malformed/incomplete theme" fallback in this system. +/// +/// The width applied here is not authoritative for a namespace shared by +/// more than one window with genuinely different widths (`breadbar-notif`'s +/// live toast is 320px, its history sibling is 360px, and only the toast's +/// width is modeled in `[surfaces.*]` — see the Phase 0 constant inventory); +/// callers that need a different width than the theme's own set it +/// explicitly afterward. Because a `Px` width is pinned with BOTH +/// `set_default_width` and `set_size_request` (see below — the latter is +/// what actually holds against a wide child), such a caller must override +/// both, not just `set_default_width`, or the pin from here wins. +pub fn apply(window: >k4::Window, namespace: &str) { + let theme = crate::theme::shell_theme(); + let Some(surf) = theme.surfaces().get(namespace) else { + eprintln!( + "breadbar: no [surfaces.{namespace}] entry in the active theme; \ + window left at layer-shell defaults" + ); + return; + }; + + window.set_layer(if surf.layer == "top" { + Layer::Top + } else { + Layer::Overlay + }); + + match surf.anchor.as_str() { + "top_right" => { + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Right, true); + // offset = [right, top] for this anchor shape. + let right = surf.offset.first().copied().unwrap_or(0.0) as i32; + let top = surf.offset.get(1).copied().unwrap_or(0.0) as i32; + window.set_margin(Edge::Right, right); + window.set_margin(Edge::Top, top); + } + // Axis 2 (daylight): every theme through spotlight anchors its bar + // to the TOP, so `top_right` always put breadbar-notif/ + // breadbar-panel naturally close to the bar. A bottom-anchored bar + // has nothing in the original three shapes that keeps its + // satellites near it — `top_right` would land them at the opposite + // corner of the screen from the dock they belong to. Mirrors + // `top_right` exactly, just on the bottom edge. + "bottom_right" => { + window.set_anchor(Edge::Bottom, true); + window.set_anchor(Edge::Right, true); + // offset = [right, bottom], same convention as top_right's + // [right, top]. + let right = surf.offset.first().copied().unwrap_or(0.0) as i32; + let bottom = surf.offset.get(1).copied().unwrap_or(0.0) as i32; + window.set_margin(Edge::Right, right); + window.set_margin(Edge::Bottom, bottom); + } + "bottom_centre" => { + window.set_anchor(Edge::Bottom, true); + let bottom = surf.offset.first().copied().unwrap_or(0.0) as i32; + window.set_margin(Edge::Bottom, bottom); + } + "fill" => { + for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { + window.set_anchor(edge, true); + } + // offset = [top, bottom] — a fullscreen click-away scrim that + // leaves a gap clear of the bar on whichever edge the bar + // actually anchors to. Every theme through spotlight anchors + // top, so only `offset[0]` (top) was ever meaningful before + // daylight; a single-value `offset` (every existing theme's + // manifest) still means exactly what it always did, since + // `offset.get(1)` falls back to 0 — a bottom-anchored theme is + // the first to give this a real, nonzero bottom value instead. + let top = surf.offset.first().copied().unwrap_or(0.0) as i32; + let bottom = surf.offset.get(1).copied().unwrap_or(0.0) as i32; + window.set_margin(Edge::Top, top); + window.set_margin(Edge::Bottom, bottom); + } + other => eprintln!( + "breadbar: surfaces.{namespace}.anchor = \"{other}\" is not one of \ + top_right|bottom_right|bottom_centre|fill — breadbar's satellite \ + windows don't understand any other shape yet, leaving this window \ + unanchored" + ), + } + + if let SurfaceWidth::Px(px) = surf.width { + window.set_default_width(px); + // set_default_width alone is only a preference — a wide child (an + // unwrapped app-name label, or a long summary/body before GTK has + // any allocation narrower than its natural width to wrap against) + // overrides it, so the window renders wider than the theme's + // requested px and stops matching the theme. Same trap, same fix, + // as main.rs's capsule `Width::Px` handling — see its comment. + window.set_size_request(px, -1); + } +} + +/// Sets `window`'s input region to the union of `widgets`' current +/// allocations, each measured relative to `window` itself (the surface's +/// own coordinate space, same as `bar::workspaces::button_geom`'s own +/// `compute_bounds` call relative to its Fixed host) — everywhere else on +/// the surface stays click-through. An empty (or all-invisible/all- +/// unallocated) `widgets` slice is not a special case: +/// `Region::create_rectangles(&[])` is already the fully click-through +/// empty region — passing `&[]` here is exactly the old blanket +/// `Region::create()` this function replaces (see git history around +/// `notifications/popup.rs`'s "stop toast popups from stealing focus or +/// blocking clicks" fix, and NOTIFICATION INTERACTION #B in the current +/// task notes: a toast must never block clicks or steal focus from +/// whatever's underneath it EXCEPT on its own buttons — an all-empty +/// region made those unreachable too). +/// +/// Only meaningful after `window.surface()` exists (i.e. from +/// `connect_map` onward — the surface doesn't exist before the window is +/// mapped) and after `widgets` have a real allocation — a widget with no +/// allocation yet (`compute_bounds` returning `None`) is simply skipped +/// rather than contributing a garbage rectangle, so a call made one frame +/// too early just yields a smaller-than-intended region for that one frame +/// rather than a wrong one. +/// +/// Callers are responsible for RECOMPUTING this every time the hittable +/// set could have moved: a widget added or removed, or a layout pass (an +/// entrance animation, a push-down reflow) still in flight. A stale region +/// either swallows clicks meant for the window below or leaves a real +/// button dead. +pub fn set_hit_region(window: >k4::Window, widgets: &[gtk4::Widget]) { + let Some(surface) = window.surface() else { + return; + }; + let rects: Vec = widgets + .iter() + .filter(|w| w.is_visible()) + .filter_map(|w| { + let b = w.compute_bounds(window)?; + Some(gtk4::cairo::RectangleInt::new( + b.x().floor() as i32, + b.y().floor() as i32, + b.width().ceil() as i32, + b.height().ceil() as i32, + )) + }) + .collect(); + surface.set_input_region(Some(>k4::cairo::Region::create_rectangles(&rects))); +} diff --git a/src/theme.rs b/src/theme.rs index 0e7186f..4e279cb 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,13 +1,56 @@ -use bread_theme::{gtk as bgtk, hex_to_rgba, ink_on, load_palette}; +use bread_theme::shell::ShellTheme; +use bread_theme::{gtk as bgtk, ink_on, load_palette, load_palette_for, Palette}; +use gtk4::prelude::IsA; use gtk4::CssProvider; use std::cell::RefCell; +use std::rc::Rc; thread_local! { static USER_PROVIDER: RefCell> = const { RefCell::new(None) }; + // Loaded lazily on first access and cached — the underlying + // `bread_theme::shell::load()` call happens at most once per process, + // not once per read site (plan §5/§6, Phase 2). Stored as an `Rc` so + // window/surface-geometry call sites elsewhere in the crate can hold a + // cheap clone rather than re-reading the cell each time. + static SHELL_THEME: RefCell> = + RefCell::new(Rc::new(bread_theme::shell::load())); +} + +/// The active shell theme — window geometry, `[surfaces.*]`, and CSS tokens +/// (plan §5). Every consumer (this module's own `load_css`, plus main.rs, +/// osd.rs, panel.rs, notifications/, and `surface::apply`) reads through +/// this single shared instance instead of calling `bread_theme::shell::load()` +/// itself. +pub fn shell_theme() -> Rc { + SHELL_THEME.with(|cell| cell.borrow().clone()) +} + +/// Replaces the shared shell theme in place. Only used by the optional +/// `theme.toml` hot-reload watch (see `watch_hot_reload` below) — per plan +/// §10, a window-spec change (anchors, margins, exclusive zone, keyboard) +/// still needs a restart to take effect, since those are read once at +/// window-construction time; only CSS token values re-resolve live, the +/// next time `load_css` runs. +pub fn set_shell_theme(theme: ShellTheme) { + SHELL_THEME.with(|cell| *cell.borrow_mut() = Rc::new(theme)); +} + +/// The one bar-chip height every chip in the row shares (vol/wifi/battery/ +/// menu/media, and the workspace pills for Trail/Pill styles) — see the +/// `chip_h` local in `load_css` for why this is a hardcoded per-` +/// WorkspaceStyle` override rather than `Tokens::chip_height()`. Used both +/// by that CSS and by `bar::workspaces::make_button`'s `set_size_request`, +/// which otherwise still forces the stale token value as a hard GTK +/// minimum that a CSS `min-height` alone cannot out-rank. +pub fn approved_chip_height(style: bread_theme::shell::WorkspaceStyle) -> i64 { + use bread_theme::shell::WorkspaceStyle::*; + match style { + Trail => 26, + Pill | Dots => 22, + } } 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,94 +58,588 @@ fn load_css() -> String { // pywal hands a given slot. `on_*` are luminance-picked ink (black/white) for // that background — the pywal hues themselves are untouched. // - // Shared tokens: one radius and one padding rhythm reused across every - // popover/card/OSD surface so they read as one design system rather than - // four different ones. `radius_pill` is only for the tiny transient OSD. - let radius = "10px"; - let radius_sm = "0px"; - let radius_pill = "20px"; - let pad = "10px"; + // Glass workbench: 16px island on the bar, 12px cards/popovers, pill OSD. + // Hyprland `layerrule = blur, breadbar` frosts the translucent fills — + // the CSS just leaves alpha. Colours are bread-theme tokens so pywal + // accents (`@accent`) flow through on SIGHUP / `bread-theme reload`. + // + // These ~250 lines are breadbar-specific chrome (notifications, wifi + // popover, control panel, media widget) that `ShellTheme::css()` does + // not template — only the window/workspace/clock chrome the manifest's + // own concepts model does (plan §6 scope note). This function stays + // hand-written CSS; it now just reads its radius/pad/easing numbers from + // the theme's tokens instead of hardcoding them. + let theme = shell_theme(); + let tokens = theme.tokens(); + let radius = format!("{}px", tokens.radius_card()); + let radius_bar = format!("{}px", tokens.radius_bar()); + let radius_sm = format!("{}px", tokens.radius_sm()); + let radius_pill = format!("{}px", tokens.radius_pill()); + let pad = format!("{}px", tokens.pad()); + // Two curves, not one: `spring` is the overshoot/bounce curve (clock + // flips, pop-ins, the workspace caret draw); `spring_settle` is the + // flatter curve used for hovers and background/opacity transitions. + // Do not collapse these — they read differently and cover different + // sites below (see the Phase 0 constant inventory). + let spring = tokens.spring(); + let spring_settle = tokens.spring_settle(); + let bg_alpha = tokens.bg_alpha(); + // Palette token NAME (never hex — see every builtin theme.toml's own + // comment on this), used below by the dots/launcher-entry/drawer rules. + // liquid-motion/glass-workbench never render those (see the match arms + // and unconditional-but-unused block below), so this being "accent" vs + // "green" vs "pink" per theme has no visible effect on them. + let accent_from = tokens.accent_from(); + // `accent_to` (Trail's own gradient end stop) and `accent2` (a second, + // independent accent — daylight's amber equaliser, distinct from its + // teal workspace-trail) — both palette token names, never hex, same + // reasoning as `accent_from` above. + let accent_to = tokens.accent_to(); + let accent2 = tokens.accent2(); + + // Axis 1 (daylight, plan §11 phase 7): `tokens.light()` — see that + // method's doc comment (bread-theme) for the full reasoning. Every + // surface/ink pair in this stylesheet through spotlight hardcoded + // `@bg` (a FIXED, never-pywal-derived dark hex — see + // `bread_theme::palette`'s `FIXED_BACKGROUND`) as the translucent + // surface fill and `@on-bg` (that fixed dark colour's computed-legible, + // therefore always near-white, ink) as the text/wash colour. That is + // exactly backwards for an ink-on-paper theme: `panel`/`ink` swap which + // of those two FIXED, anti-correlated tokens plays which role. This is + // NOT a general "pick any light surface colour" mechanism — it works + // only because `@bg` is pinned dark and `@on-bg` is its computed + // opposite, by construction, regardless of pywal. See the task report + // for the full inventory of every site this swap had to reach. + let light = tokens.light(); + let (panel, ink): (&str, &str) = if light { ("@on-bg", "@bg") } else { ("@bg", "@on-bg") }; + // The notification/history/OSD/wifi-add-dialog cards (0.70) and the + // control/wifi/media popover window (0.72) hardcode their own alpha + // literals independent of `tokens.bg_alpha()` — reasonable for a dark + // "glass" surface (0.70-0.72 alpha over a dark fill still reads as + // deliberately translucent glass), but daylight's demo draws these as + // fully OPAQUE paper (`.note`/`.osd { background: var(--paper) }`, no + // alpha at all) — at the old 0.70 literal, daylight's near-white + // `{panel}` fill instead reads as pale, background-tinted glass, which + // is exactly the "physical object" read the design brief calls for + // NOT doing. Swapping these two literals wholesale to `{bg_alpha}` + // would also move liquid-motion/glass-workbench/spotlight's own + // rendering (0.70/0.72 -> 0.72/0.82 for spotlight) — a real, visible + // regression the task rules out — so this only substitutes `bg_alpha` + // (0.94 for daylight) in for `light` themes and keeps the exact prior + // literal for every other theme. + let card_alpha = if light { bg_alpha } else { 0.70 }; + let panel_surface_alpha = if light { bg_alpha } else { 0.72 }; + // The OSD/widget-node progress troughs' UNFILLED track is + // `alpha(@accent, 0.25)` — a teal-at-25%-alpha tint that reads as a + // faint, visible track against a dark pill (every theme through + // spotlight), but is nearly indistinguishable from daylight's own + // near-white pill (confirmed empirically: the OSD volume slider's + // empty track was barely visible against its own paper background). + // `{ink}`-based for light themes gives a neutral faint-dark track + // instead, independent of whatever hue the accent happens to be; + // every other theme's literal `alpha(@accent, 0.25)` is unchanged. + let trough_bg = if light { + format!("alpha({ink}, 0.14)") + } else { + "alpha(@accent, 0.25)".to_string() + }; + // `[launcher].search_radius` (plan §7 phase 6c) — `LauncherMode:: + // Embedded` only (spotlight); `.launcher().radius` itself already + // equals `radius_bar` for that theme (see its own theme.toml comment), + // so a theme that omits `search_radius` gets `radius_search == + // radius_bar` here too, i.e. no visible shrink, matching bread-theme's + // own "default to the idle value" fallback. + let radius_search = format!("{}px", theme.launcher().search_radius); + + // `tokens.bar_border()` (plan §11 Phase 5): "full" (default, liquid- + // motion's floating island) draws a border on all four edges; "bottom" + // (glass-workbench's flush edge-to-edge bar) draws only the hairline + // the demo's `.bar { border-bottom: 1px solid #ffffff12 }` calls for — + // a full border on a bar flush against the screen's top/left/right + // edges would otherwise show as a stray line along those edges an + // island never has to worry about. Reused below for the centerbox's + // horizontal padding too: the flush bar's demo padding (`0 12px`, + // symmetric) differs from the island's own asymmetric `0 8px 0 6px`. + let flush = tokens.bar_border() == "bottom"; + // Axis 3 (daylight, plan §11 phase 7): `bar_border == "segmented"` — + // `window.breadbar` itself draws NO fill/border/radius/shadow at all + // (fully transparent); the bar's three slot-group containers + // (`workspace_row`/`center_area`/`stats_box`, each carrying a + // `.bar-segment` class added unconditionally in main.rs) draw their own + // pill surfaces instead — see `segment_css` below. This is what lets + // one GTK window read as three detached floating pills rather than one + // continuous strip; see the task report for exactly what this can and + // can't express (the window is still ONE input-hit-testable surface — + // clicking in a transparent gap between pills does not click through to + // whatever's behind the bar, only true multi-window segmentation would + // do that). + let segmented = tokens.bar_border() == "segmented"; + let window_chrome = if segmented { + "background-color: transparent; border: none; box-shadow: none;".to_string() + } else if flush { + format!( + "background-color: alpha({panel}, {bg_alpha}); border: none; \ + border-bottom: 1px solid alpha({ink}, 0.07);" + ) + } else { + format!("background-color: alpha({panel}, {bg_alpha}); border: 1px solid alpha({ink}, 0.08);") + }; + let bar_radius = if segmented { + "0px".to_string() + } else { + radius_bar.clone() + }; + let centerbox_padding = if flush || segmented { "0 14px" } else { "0 8px 0 6px" }; + // The three detached pills themselves — a no-op empty rule under every + // non-segmented theme, so `.bar-segment` (added unconditionally to all + // three slot-group boxes in main.rs) renders nothing extra for them. + // Demo: `.seg { background: rgba(255,255,255,.94); border: 1px solid + // rgba(26,29,34,.10); border-radius: 14px; + // box-shadow: 0 2px 10px rgba(26,29,34,.13), 0 0 0 .5px rgba(255,255,255,.7) inset }`. + // Safe to pair a real box-shadow with an unblurred surface (axis 4: + // daylight's own `[compositor.breadbar].blur = false`) — a box-shadow + // above a BLURRED, `ignore_alpha`d surface is the shadow-halo bug fixed + // in breadbox by removing the shadow outright; with blur off there is + // no blur pass to catch this rectangle inside. + let segment_css = if segmented { + format!( + ".bar-segment {{ background-color: alpha({panel}, {bg_alpha}); \ + border: 1px solid alpha({ink}, 0.10); border-radius: {radius_bar}; \ + box-shadow: 0 2px 10px alpha({ink}, 0.13); }}\ + " + ) + } else { + String::new() + }; + + // Radius for `.stat-pair` (vol/wifi/battery/hamburger chips): radius_sm + // for liquid-motion (9px) and glass-workbench (6px, exact match to that + // demo's `.chip` radius) reads as "the same small-control rounding this + // theme uses everywhere else" — but spotlight's overall language is + // dramatically rounder (radius_bar 22px, workspace dots at radius_pill + // 999px) than either sibling theme, so its one `.stat-pair` occupant + // (battery — the only slot entry besides a Lua widget under + // `[bar.slots].right`) looked like a stray sharp-cornered rectangle + // dropped inside a capsule and next to fully-round dots (reported: + // "the spotlight battery chip... radii that don't match their + // neighbours"). Keying off the same `WorkspaceStyle` enum + // `workspace_css` below already switches on, rather than the theme id, + // so this stays in step if a future theme ever reuses the "dots" style. + let chip_radius = match theme.modules().workspaces.style { + bread_theme::shell::WorkspaceStyle::Dots => radius_pill.clone(), + _ => radius_sm.clone(), + }; + + // ONE chip highlight height per bar, vertically centred — every chip + // (vol/wifi/battery/menu/media, icon-only and labelled alike) shares + // it so their fills align, instead of each sizing to its own content + // box (reported: battery sits high, wifi/menu are taller than their + // neighbours). Liquid Motion 26px / Glass Workbench 22px / Spotlight + // 22px, per the approved demo spec. + // + // HARDCODED, not `tokens.chip_height()`: that token is `breadbar:: + // CHIP_HEIGHT` (32) carried over from before this design pass and was + // never updated for the three builtin `theme.toml`s (32/20/36) — it + // predates and disagrees with the demo numbers above. bread-ecosystem + // is owned by a sibling agent this pass, so this stays a local + // override (same `WorkspaceStyle` this file already keys `chip_radius` + // off) rather than an edit to that repo's schema/manifests. Flagged in + // the task report: `chip_height` should become 26/22/22 upstream. + let chip_h = approved_chip_height(theme.modules().workspaces.style); + let chip_height_px = format!("{chip_h}px"); + + // `modules.workspaces.style` (plan §11 Phase 5): "trail" (default, + // liquid-motion) is exactly today's CSS, unchanged byte-for-byte — + // dimmed/translucent buttons with the gradient trail overlay supplying + // the active fill. "pill"/"dots" (glass-workbench, Phase 6+) render the + // active state as a solid accent fill on the button itself instead, + // since neither style ever calls `WorkspaceTrail::place`/`stretch` + // (see `App::rebuild_buttons`) — the trail's own `.workspace-trail` + // pill CSS is therefore irrelevant for them (it's never made visible). + let workspace_css = match theme.modules().workspaces.style { + // Radius and height were hardcoded (12px, 28px) instead of reading + // from this theme's own tokens/demo — 12px is neither radius_sm + // (9px) nor any other token this theme defines, and the demo's + // `.ws-btn`/`.trail` both draw a 26px-tall, 9px-radius pill (not + // 28px/12px). radius_sm happens to be an exact match for the + // demo's 9px here, unlike glass-workbench's Pill style below where + // it's also used but for a different, already-correct reason. + // Reported: "the pills on liquid motion just look off". + bread_theme::shell::WorkspaceStyle::Trail => format!( + // `@{{accent_from}}`/`@{{accent_to}}`, not the literal + // `@accent`/`@teal` this hardcoded through spotlight: harmless + // while every Trail-style theme's own accent_from/accent_to + // happened to BE "accent"/"teal" (liquid-motion — the only + // other Trail theme so far), but daylight sets + // accent_from = accent_to = "teal" for a FLAT fill, and the old + // hardcode would have painted a stray blue-to-teal gradient + // over it regardless of that setting. See Tokens::accent_to's + // doc comment (bread-theme) — this is the fix that finally + // consumes it for real. + ".workspace-trail {{ background-image: linear-gradient(90deg, @{accent_from}, @{accent_to});\ + background-color: @{accent_from}; border-radius: {radius_sm}; }}\ + .workspace-btn {{ background: transparent; opacity: 0.36; color: {ink};\ + border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\ + min-width: 28px; min-height: {chip_height_px}; margin: 0; padding: 0 7px;\ + font-size: 22px; font-weight: bold;\ + transition: opacity 0.22s {spring_settle},\ + background-color 0.22s {spring_settle}; }}\ + .workspace-btn:hover {{ opacity: 0.85; background: alpha({ink}, 0.08); }}\ + .workspace-btn.occupied {{ opacity: 0.78; }}\ + .workspace-btn.active {{ background: transparent; color: @on-accent; opacity: 1; }}\ + .workspace-btn.active:hover {{ background: transparent; }}\ + .workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}", + ), + bread_theme::shell::WorkspaceStyle::Pill => { + let accent = theme.tokens().accent_from(); + format!( + ".workspace-btn {{ background: transparent; opacity: 1; color: alpha({ink}, 0.4);\ + border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\ + min-width: 22px; min-height: {chip_height_px}; margin: 0; padding: 0 6px;\ + font-size: 12px; font-weight: 600;\ + transition: background-color 0.22s {spring_settle},\ + color 0.22s {spring_settle}, opacity 0.22s {spring_settle}; }}\ + .workspace-btn:hover {{ background: alpha({ink}, 0.08); }}\ + .workspace-btn.occupied {{ color: alpha({ink}, 0.8); }}\ + .workspace-btn:not(.occupied):not(.active) {{ opacity: 0.35; }}\ + .workspace-btn.active {{ background: @{accent}; color: @on-accent; opacity: 1; }}\ + .workspace-btn.active:hover {{ background: @{accent}; }}\ + .workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}", + ) + } + // "dots" (theme 04/spotlight): a label-less pill whose WIDTH comes + // from `modules.workspaces.dot_widths` and is set directly via + // `Widget::set_size_request` in `bar::workspaces::make_dot_button` + // — GTK CSS has no per-instance variable width, so unlike the demo's + // `.dots button[data-n="N"]` rules this class only supplies colour/ + // opacity/radius, never a width. `04-spotlight.html`'s own base + // rule (`background: #5a4a54`) is a *dim, desaturated* grey, not the + // bar's ink colour — approximated here as a low-alpha `@on-bg` fill + // so it still tracks pywal instead of hardcoding a hex that would + // clash with a light palette. + bread_theme::shell::WorkspaceStyle::Dots => { + let accent = theme.tokens().accent_from(); + format!( + // 9px, not the demo's 6px — kept in sync with + // `bar::workspaces::make_dot_button`'s `DOT_HEIGHT` const, + // which is the value that actually governs the rendered + // size (a direct `set_size_request`, not CSS min-height + // participating in layout the normal way) — see that + // constant's own doc comment for why. This min-height + // exists mainly so the property isn't silently absent from + // the stylesheet a reader would expect to define it. + ".workspace-dot {{ background-color: alpha({ink}, 0.35); color: transparent;\ + border-radius: {radius_pill}; border: none; outline: none; box-shadow: none;\ + min-height: 9px; margin: 0; padding: 0;\ + transition: background-color 0.25s {spring_settle},\ + opacity 0.25s {spring_settle}; }}\ + .workspace-dot:hover {{ background-color: alpha({ink}, 0.55); }}\ + .workspace-dot:not(.occupied):not(.active) {{ opacity: 0.35; }}\ + .workspace-dot.active {{ background-color: @{accent}; opacity: 1; }}\ + .workspace-dot.active:hover {{ background-color: @{accent}; }}", + ) + } + }; format!( - "window.breadbar {{ background-color: {bg_rgba}; color: {on_bg}; border-radius: 0; }}\ - .workspace-btn {{ background: transparent; opacity: 0.45; color: {on_bg};\ - border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\ - min-width: 20px; margin: 5px 2px; padding: 2px 9px; }}\ - .workspace-btn:hover {{ opacity: 0.8; }}\ - .workspace-btn.active {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\ - .stats-box {{ margin-right: 8px; }}\ - .stat-pair {{ margin-right: 14px; }}\ - .stat-icon {{ margin-right: 2px; }}\ - .bt-icon {{ margin-right: 14px; }}\ - separator.bar-sep {{ min-height: 14px; margin: 0 8px 0 0; background: alpha({on_bg}, 0.14); }}\ - window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; }}\ - window.breadbar-history {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg};\ - border-radius: {radius}; }}\ - .notification-card {{ background: {surface}; color: {on_surface}; border-radius: {radius};\ - padding: {pad}; margin-bottom: 8px; border-left: 3px solid transparent; }}\ - .notification-card.urgency-critical {{ border-left-color: {critical}; }}\ - .notification-card.urgency-normal {{ border-left-color: {accent}; }}\ + "@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\ + @keyframes osd-in {{ from {{ opacity: 0; margin-bottom: -8px; }} }}\ + @keyframes media-eq {{ to {{ min-height: 14px; }} }}\ + @keyframes pop-in {{ from {{ opacity: 0; margin-top: -10px; }} to {{ opacity: 1; margin-top: 0; }} }}\ + @keyframes pop-out {{ from {{ opacity: 1; margin-top: 0; }} to {{ opacity: 0; margin-top: -6px; }} }}\ + @keyframes row-in {{ from {{ opacity: 0; margin-top: 8px; }} to {{ opacity: 1; margin-top: 0; }} }}\ + @keyframes digit-flip {{ from {{ opacity: 0; margin-top: 7px; }} to {{ opacity: 1; margin-top: 0; }} }}\ + @keyframes caret-draw {{ from {{ margin-right: 200px; opacity: 0.2; }} to {{ margin-right: 4px; opacity: 1; }} }}\ + /* ANIMATION WORK #3, bar entrance on first map: opacity ONLY —\ + no margin/geometry term — so this can never perturb any\ + descendant's own box-model size (see main.rs's own long\ + comment on this, next to where `bar-entrance` gets added, for\ + why that matters to the workspace trail specifically).\ + Liquid Motion additionally springs the surface's own\ + layer-shell top margin via `anim::spring_to` in Rust, which\ + this keyframe knows nothing about; glass-workbench never adds\ + this class at all. */\ + @keyframes bar-in {{ from {{ opacity: 0; }} }}\ + .bar-entrance {{ animation: bar-in 0.4s {spring_settle} both; }}\ + window.breadbar {{ color: {ink}; border-radius: {bar_radius}; {window_chrome}\ + transition: border-radius 0.3s {spring_settle}; }}\ + /* `[launcher].search_radius` (plan §7 phase 6c, spotlight only —\ + `launcher_entry` never gets focus under any other theme, so\ + `.searching` never lands on `window.breadbar` there). */\ + window.breadbar.searching {{ border-radius: {radius_search}; }}\ + /* `> box > centerbox`, not `> centerbox`: the root is a vbox (bar\ + row + drawer, plan §2) as of the `drawer` slot wiring — every\ + theme's centerbox is now one level deeper than before, this\ + selector just follows it there. Since `padding` doesn't depend\ + on nesting depth, liquid-motion/glass-workbench render byte-\ + identical CSS either way. */\ + window.breadbar > box > centerbox {{ padding: {centerbox_padding}; }}\ + /* `color` here for the same reason `window.breadbar-panel button`\ + (below) needs it: a real GtkButton's own text (the bar's\ + hamburger, `.control-panel-btn`) doesn't inherit `color` from\ + this window — the shared, ecosystem-wide `button {{ color:\ + @on-surface }}` rule (lib.rs) matches it directly first. See\ + that rule's own comment for the full explanation. */\ + window.breadbar button {{ min-height: 0; min-width: 0; color: {ink}; }}\ + {segment_css}\ + {workspace_css}\ + .clock-box {{ padding: 0 4px; }}\ + .clock-label {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\ + min-height: 0; padding: 0; margin-top: 3px; }}\ + .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 {spring} both; }}\ + .clock-plain {{ padding: 0 4px; }}\ + .clock-plain-time {{ font-size: 15px; font-weight: 600; letter-spacing: 0.04em; }}\ + .date-label {{ font-size: 12px; opacity: 0.48; letter-spacing: 0.04em; }}\ + .stat-label {{ font-size: 14px; letter-spacing: 0.02em; opacity: 0.92; }}\ + .stat-label.tick {{ animation: digit-flip 0.35s {spring} both; }}\ + /* Odometer digit chips (volume/battery, ANIMATION WORK #2): one\ + `.stat-label` per character instead of one label for the whole\ + number, each with a fixed min-width so a `9` -> `10` or\ + `8` -> `9` transition doesn't jitter the chip's overall width\ + as narrower/wider glyphs swap in. `.flip` reuses the exact\ + `digit-flip` keyframe + timing the clock's `.clock-digit.flip`\ + already plays. */\ + .stat-digit {{ min-width: 9px; padding: 0; margin: 0; }}\ + .stat-digit.flip {{ animation: digit-flip 0.35s {spring} both; }}\ + .stats-box {{ margin-right: 0; }}\ + /* Radius was a hardcoded 10px here regardless of theme — right by\ + coincidence for liquid-motion's demo (`.chip {{ border-radius:\ + 10px }}`, this theme's radius_sm is 9px, a 1px rounding-off),\ + wrong for glass-workbench (demo's `.chip` is 6px, exactly this\ + theme's radius_sm — the hardcoded 10px never matched it), and\ + wrong-in-spirit for spotlight even though no `.chip` class\ + exists in that demo to compare against: a small, sharp-ish\ + radius reads as a stray rectangle inside a 22px-radius capsule\ + sitting right next to 999px-radius workspace dots (reported:\ + spotlight's battery chip not matching its neighbours). See\ + `chip_radius` above — radius_sm for the other two themes,\ + radius_pill for spotlight, so every theme's stat chips round\ + the way that theme's *other* rounded chrome already does,\ + instead of all three sharing one borrowed hardcoded number. */\ + /* min-height (not the old `min-height: 0`): decision #1 — ONE\ + chip highlight height per bar, vertically centred, shared by\ + every chip so their fills align instead of each sizing to its\ + own content box (reported: battery sat high, wifi/menu were\ + taller than their neighbours). See `chip_height_px` above. */\ + .stat-pair {{ margin: 0; border-radius: {chip_radius}; padding: 5px 9px;\ + min-height: {chip_height_px};\ + transition: background-color 0.22s {spring_settle},\ + opacity 0.18s ease; }}\ + .stat-pair:hover {{ background: alpha({ink}, 0.12); }}\ + .stat-pair:active {{ background: alpha({ink}, 0.18); }}\ + /* No border-radius override here (was a hardcoded 999px, making\ + wifi/hamburger — the only two `.icon-only` chips — fully\ + circular while their row neighbours vol/battery stayed a\ + rounded rect at `.stat-pair`'s own radius: a visible rounding\ + mismatch inside one row, reported against the liquid-motion\ + hamburger specifically). Every demo's `.chip` class (liquid-\ + motion, glass-workbench) draws vol/wifi/bat/menu identically,\ + none of them circular — dropping the override here just lets\ + `.stat-pair`'s own `chip_radius` cascade through unchanged, so\ + the icon-only chips match their siblings instead of standing\ + out (spotlight has no icon-only chip today, but would get the\ + same pill radius as its one `.stat-pair` sibling if it ever did). */\ + .stat-pair.icon-only {{ padding: 4px;\ + min-width: {chip_height_px}; min-height: {chip_height_px}; }}\ + .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({ink}, 0.10); }}\ + window.breadbar-notification {{ background-color: transparent; color: {ink}; }}\ + window.breadbar-history {{ background-color: alpha({panel}, {card_alpha}); color: {ink};\ + border-radius: {radius}; border: 1px solid alpha({ink}, 0.10);\ + animation: pop-in 0.45s {spring} both; }}\ + .notification-card {{ background: alpha({panel}, {card_alpha}); color: {ink}; border-radius: {radius};\ + padding: {pad}; margin-bottom: 8px; border: 1px solid alpha({ink}, 0.10);\ + border-left: 3px solid transparent;\ + animation: notif-in 0.45s {spring_settle} both; }}\ + .notification-card.urgency-critical {{ border-left-color: @red; }}\ + .notification-card.urgency-normal {{ border-left-color: @accent; }}\ .notification-summary {{ font-weight: bold; }}\ - .notification-app {{ opacity: 0.6; }}\ + .notification-app {{ opacity: 0.55; font-size: 11px; letter-spacing: 0.04em; }}\ + .notification-actions {{ margin-top: 6px; }}\ + .notification-action {{ padding: 2px 8px; font-size: 11px; border-radius: {radius_sm}; }}\ + .notification-reply {{ margin-top: 6px; }}\ + .notification-reply-entry {{ min-width: 0; }}\ + /* NOTIFICATION INTERACTION #A: a direct dismiss control, floated\ + in the card's top-right corner via an Overlay (see popup.rs's\ + `make_card`) rather than a full extra header row, so it doesn't\ + add vertical bulk the approved demo's own card never has. */\ + .notification-dismiss {{ min-width: 18px; min-height: 18px; padding: 0;\ + margin: 2px; border-radius: {radius_pill}; background: transparent;\ + color: {ink}; opacity: 0.45; font-size: 12px; font-weight: bold;\ + border: none; outline: none; box-shadow: none;\ + transition: background-color 0.18s {spring_settle}, opacity 0.18s ease; }}\ + .notification-dismiss:hover {{ opacity: 1; background: alpha({ink}, 0.16); }}\ + .notification-dismiss:active {{ background: alpha({ink}, 0.24); }}\ .history-title {{ font-weight: bold; font-size: 13px; }}\ .history-close {{ padding: 2px 8px; }}\ .history-empty {{ opacity: 0.5; padding: 8px 0; }}\ .history-time {{ opacity: 0.5; font-size: 11px; }}\ .history-body {{ opacity: 0.75; }}\ .history-card {{ margin-bottom: 6px; }}\ - window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: {radius_pill}; }}\ + window.breadbar-osd {{ background-color: alpha({panel}, {card_alpha}); color: {ink};\ + border-radius: {radius_pill}; border: 1px solid alpha({ink}, 0.10);\ + animation: osd-in 0.4s {spring_settle} both; }}\ .osd-icon {{ opacity: 0.85; margin-right: 8px; }}\ .osd-icon-muted {{ opacity: 0.35; }}\ progressbar.osd-bar {{ min-height: 6px; }}\ - progressbar.osd-bar trough {{ background-image: none; background-color: {trough}; border-radius: 3px; min-height: 6px; }}\ - progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 3px; min-height: 6px; }}\ - .wifi-pair {{ border-radius: {radius_sm}; padding: 0 2px; }}\ - .wifi-pair:hover {{ background: alpha({on_bg}, 0.12); }}\ - .wifi-popover-inner {{ min-width: 200px; padding: {pad}; }}\ - .popover-tab-row {{ margin-bottom: {pad}; }}\ - .popover-tab {{ background: transparent; color: {on_bg}; border: none; box-shadow: none;\ - outline: none; border-radius: {radius_sm}; padding: 4px 10px; font-size: 11px;\ - font-weight: bold; opacity: 0.55; }}\ + progressbar.osd-bar trough {{ background-image: none; background-color: {trough_bg};\ + border-radius: 3px; min-height: 6px; }}\ + progressbar.osd-bar trough progress {{ background-image: none; background-color: @accent;\ + border-radius: 3px; min-height: 6px; }}\ + window.breadbar-panel {{ background-color: alpha({panel}, {panel_surface_alpha}); color: {ink};\ + border-radius: 14px; border: 1px solid alpha({ink}, 0.12); }}\ + /* A real GtkButton's own label text does NOT inherit `color` from\ + an ancestor window: `bread_theme::stylesheet()`'s shared,\ + ecosystem-wide `button {{ color: @on-surface }}` rule (lib.rs,\ + applied to every bread app before this file's CSS layers on\ + top) matches the button element directly, and a direct match\ + always beats inheritance regardless of specificity. `@on-surface`\ + is `ink_on(@surface)`, and `@surface` is `bread_theme::palette`'s\ + FIXED_SURFACE constant — pinned dark, same as `@bg` — so every\ + button's label (power row, hamburger, wifi/bluetooth popover\ + rows, the add-network dialog's Cancel/Connect) rendered\ + near-white regardless of the active shell theme. Invisible-but-\ + correct on every dark theme through spotlight; confirmed\ + empirically as near-invisible ghost text under daylight (isolated\ + `bread-capture` control-panel screenshot, pre-fix). One rule,\ + scoped by ancestor class so its specificity beats the shared\ + unscoped `button` rule, instead of patching each `.power-btn`/\ + `.control-panel-btn`/`.wifi-popover-row`/etc. class individually. */\ + window.breadbar-panel button, window.wifi-popover button,\ + window.wifi-add-dialog button, window.breadbar-notification button,\ + window.breadbar-history button {{ color: {ink}; }}\ + 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 {spring} both; }}\ + .wifi-popover-inner {{ min-width: 228px; padding: {pad}; }}\ + window.wifi-popover button {{ min-height: 0; min-width: 0; }}\ + .popover-tab-row {{ background: alpha({ink}, 0.06); border-radius: 10px;\ + padding: 3px; margin-bottom: 10px; }}\ + .popover-tab {{ background: transparent; color: {ink}; 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 {spring_settle},\ + opacity 0.22s ease, color 0.22s ease; }}\ .popover-tab:hover {{ opacity: 0.8; }}\ - .popover-tab:checked {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\ - .wifi-popover-ssid {{ font-weight: bold; font-size: 13px; }}\ - .wifi-popover-ip {{ opacity: 0.6; font-size: 11px; }}\ - .wifi-popover-status {{ font-size: 11px; margin-top: 2px; }}\ - .wifi-popover-section {{ font-size: 10px; font-weight: bold; opacity: 0.5; letter-spacing: 0.08em; }}\ + .popover-tab:checked {{ background: alpha(@accent, 0.22); color: @accent; opacity: 1; }}\ + .popover-tab label {{ padding: 0; margin: 0; }}\ + .wifi-popover-ssid {{ font-weight: bold; font-size: 18px; }}\ + .wifi-popover-ip {{ opacity: 0.6; font-size: 16px; }}\ + .wifi-popover-status {{ font-size: 16px; margin-top: 2px; }}\ + .wifi-popover-section {{ font-size: 13px; font-weight: bold; opacity: 0.45;\ + letter-spacing: 0.12em; }}\ .wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\ - border-radius: {radius_sm}; padding: 4px 6px; }}\ - .wifi-popover-row:hover {{ background: alpha({on_bg}, 0.08); }}\ - .wifi-popover-row-active {{ color: {accent}; }}\ + outline: none; border-radius: 10px; padding: 0 12px; min-height: 42px;\ + transition: background-color 0.18s {spring_settle}; }}\ + .wifi-popover-row label {{ font-size: 18px; }}\ + .wifi-popover-row:hover {{ background: alpha({ink}, 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 {spring} both; }}\ + .stagger-0 {{ animation-delay: 0ms; }} .stagger-1 {{ animation-delay: 28ms; }}\ + .stagger-2 {{ animation-delay: 56ms; }} .stagger-3 {{ animation-delay: 84ms; }}\ + .stagger-4 {{ animation-delay: 112ms; }} .stagger-5 {{ animation-delay: 140ms; }}\ + .stagger-6 {{ animation-delay: 168ms; }} .stagger-7 {{ animation-delay: 196ms; }}\ + .stagger-8 {{ animation-delay: 224ms; }} .stagger-9 {{ animation-delay: 252ms; }}\ + .stagger-10 {{ animation-delay: 280ms; }} .stagger-11 {{ animation-delay: 308ms; }}\ .wifi-popover-row-unsaved {{ opacity: 0.4; }}\ .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ - window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px;\ - border-radius: {radius}; }}\ - window.wifi-add-dialog headerbar {{ background-color: {bg_rgba}; color: {on_bg};\ + switch.bt-switch, switch.bt-switch:hover, switch.bt-switch:checked,\ + switch.bt-switch:checked:hover {{ min-width: 42px; min-height: 24px; padding: 2px;\ + border: none; outline: none; box-shadow: none; background-image: none;\ + border-radius: 99px; }}\ + switch.bt-switch {{ background-color: alpha({ink}, 0.14);\ + transition: background-color 0.25s {spring_settle}; }}\ + switch.bt-switch:checked {{ background-color: @accent; }}\ + switch.bt-switch slider {{ min-width: 20px; min-height: 20px; margin: 0;\ + border-radius: 99px; border: none; outline: none; box-shadow: none;\ + background-image: none; background-color: {ink}; }}\ + window.wifi-add-dialog {{ background-color: alpha({panel}, {card_alpha}); color: {ink}; min-width: 240px;\ + border-radius: {radius}; border: 1px solid alpha({ink}, 0.10);\ + animation: pop-in 0.45s {spring} both; }}\ + window.wifi-add-dialog headerbar {{ background-color: alpha({panel}, {card_alpha}); color: {ink};\ border-top-left-radius: {radius}; border-top-right-radius: {radius};\ - border-bottom: 1px solid alpha({on_bg}, 0.08); box-shadow: none; }}\ + border-bottom: 1px solid alpha({ink}, 0.10); box-shadow: none; }}\ .confirm-button {{ background-color: @accent; color: @on-accent; }}\ .confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\ - .media-widget {{ border-radius: {radius_sm}; padding: 0 6px; }}\ - .media-widget:hover {{ background: alpha({on_bg}, 0.10); }}\ - .media-indicator {{ font-size: 11px; opacity: 0.7; margin-right: 2px; }}\ - .media-track-lbl {{ font-size: 12px; }}\ - .media-controls {{ padding: 2px; }}\ - .media-btn {{ min-width: 32px; padding: 4px 8px; }}\ - .control-panel-btn {{ padding: 0 6px; margin-left: 6px; border-radius: {radius_sm}; }}\ + /* min-height: decision #1 — the media chip is a bar chip like\ + any other, so it shares the same row height instead of sizing\ + to its own eq-bar/label content. */\ + .media-widget {{ border-radius: 10px; padding: 4px 8px; min-height: {chip_height_px};\ + transition: background-color 0.22s {spring_settle}; }}\ + .media-widget:hover {{ background: alpha({ink}, 0.08); }}\ + .media-widget.media-in {{ animation: row-in 0.4s {spring} both; }}\ + .media-eq {{ min-height: 14px; margin-right: 4px; }}\ + .media-eq-bar {{ min-width: 3px; min-height: 5px; background-color: @{accent2};\ + 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({ink}, 0.10); }}\ + /* No padding/border-radius/min-width/min-height here (was\ + `padding: 5px 8px; border-radius: 10px; min-width: 0;\ + min-height: 0`): the hamburger is the only button carrying both\ + `.stat-pair.icon-only` AND `.control-panel-btn`, and because\ + this rule sits later in the cascade its hardcoded 10px radius\ + and 0 min-size were silently winning over `.stat-pair`'s own\ + `chip_radius`/`chip_height_px` — the exact hamburger-corner-\ + mismatch bug decision #2 describes, and a second copy of\ + decision #1's height bug, both reintroduced by\ + this one class alone. Dropping the four properties lets\ + `.stat-pair`/`.stat-pair.icon-only` cascade through unchanged,\ + same fix shape as the icon-only border-radius removal above. */\ + .control-panel-btn {{ margin: 0;\ + opacity: 0.92; font-size: 18px; line-height: 1;\ + background: transparent; border: none; outline: none; box-shadow: none;\ + transition: background-color 0.22s {spring_settle},\ + opacity 0.18s ease; }}\ + .control-panel-btn:hover {{ opacity: 1; background: alpha({ink}, 0.10); }}\ + .control-panel-btn:active {{ background: alpha({ink}, 0.16); }}\ .control-panel {{ }}\ - .control-panel-inner {{ min-width: 220px; padding: {pad}; }}\ - .control-panel-row {{ margin: 4px 0; }}\ - .control-panel-row-icon {{ opacity: 1; margin-right: 4px; }}\ - .control-panel-slider {{ margin: 0; }}\ - .control-panel-stats {{ margin: {pad} 0; }}\ - .control-panel-stat {{ font-size: 12px; opacity: 0.85; margin: 1px 0; }}\ - .control-panel-section {{ margin: {pad} 0; }}\ - .control-panel-section-header {{ font-size: 10px; font-weight: bold; opacity: 0.5;\ - letter-spacing: 0.08em; margin-bottom: 4px; }}\ - .control-panel-sink-dropdown {{ }}\ - .power-row {{ margin-top: 2px; }}\ - .power-btn {{ min-width: 40px; padding: 8px; border-radius: {radius_sm}; }}\ - separator {{ margin: 4px 0; }}\ + .control-panel-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({ink}, 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({ink}, 0.08); font-size: 13px; border: none;\ + outline: none; box-shadow: none;\ + transition: background-color 0.2s {spring_settle}; }}\ + .power-btn:hover {{ background: alpha({ink}, 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({ink}, 0.10); }}\ /* Lua-declared widgets (see Documentation.md's Widgets §style): the\ slot rule below is what the four inline `.bread-widget-slot`\ containers in main.rs rely on for the same 12px stat-pair rhythm\ @@ -115,7 +652,7 @@ fn load_css() -> String { of Adwaita's default blue-on-gray, and let `style.color` retint\ that fill the same way it retints label/icon text. */\ .bread-widget-slot {{ margin-right: 12px; }}\ - progressbar.bread-widget-node trough {{ background-image: none; background-color: alpha(@accent, 0.25); border-radius: 3px; min-height: 6px; }}\ + progressbar.bread-widget-node trough {{ background-image: none; background-color: {trough_bg}; border-radius: 3px; min-height: 6px; }}\ progressbar.bread-widget-node trough progress {{ background-image: none; background-color: @accent; border-radius: 3px; min-height: 6px; }}\ progressbar.bread-widget-node.bread-color-fg trough progress {{ background-color: @fg; }}\ progressbar.bread-widget-node.bread-color-dim trough progress {{ background-color: alpha(@fg, 0.6); }}\ @@ -152,23 +689,115 @@ fn load_css() -> String { .bread-padding-none {{ padding: 0; }}\ .bread-padding-xs {{ padding: 4px; }}\ .bread-padding-sm {{ padding: 8px; }}\ - .bread-padding-md {{ padding: 12px; }}", - bg_plain = p.background, - bg_rgba = hex_to_rgba(&p.background, 0.92), - surface = p.color0, - accent = p.color4, - critical = p.color1, - on_bg = ink_on(&p.background), - on_surface = ink_on(&p.color0), - trough = hex_to_rgba(&p.color4, 0.25), + .bread-padding-md {{ padding: 12px; }}\ + /* Theme 04/spotlight's embedded launcher (plan §7). Unconditional,\ + like `.clock-plain-time` above: `launcher_entry`/`launcher_results`\ + are built regardless of the active theme (see main.rs's \"Assemble\"\ + section), just never placed in a slot outside spotlight, so these\ + rules render nothing on liquid-motion/glass-workbench. */\ + .launcher-entry {{ background: transparent; color: {ink}; border: none;\ + outline: none; box-shadow: none; caret-color: @{accent_from};\ + font-size: 13px; font-weight: 500; letter-spacing: 0.06em;\ + padding: 0; margin: 0; min-height: 0; }}\ + .launcher-entry.searching {{ font-size: 15px; letter-spacing: 0; }}\ + .bread-drawer {{ min-height: 0; }}\ + .bread-drawer.open {{ border-top: 1px solid alpha({ink}, 0.08);\ + margin-top: 6px; padding-top: 2px; }}\ + .bread-drawer listbox {{ background: transparent; padding: 2px 0; }}\ + .bread-drawer row {{ padding: 8px 14px; border-radius: {radius_sm};\ + color: {ink}; background-color: transparent; }}\ + .bread-drawer row:hover {{ background-color: alpha({ink}, 0.08); }}\ + .bread-drawer row:selected {{ background-color: alpha(@{accent_from}, 0.18);\ + color: {ink}; }}\ + .bread-drawer .app-name {{ font-size: 14px; font-weight: 500; }}\ + .bread-drawer .app-muted {{ opacity: 0.45; font-size: 11px; }}\ + /* `[launcher].sections` (plan §7 phase 6c) — the idle drawer's\ + \"Recent\"/\"Apps\" group labels (`bread_launcher::gtk::\ + build_header_row`). Unconditional, same reasoning as every other\ + launcher rule above: only spotlight ever builds a row with this\ + class at all. */\ + .bread-drawer-section-header {{ padding: 6px 14px 2px; }}\ + .section-header-label {{ font-size: 11px; font-weight: 600;\ + letter-spacing: 0.08em; text-transform: uppercase;\ + opacity: 0.45; }}", + // Implicit capture (2021 edition) for every `{name}` above: each + // matches an in-scope `let` binding of the same name (`radius`, + // `spring`, `ink`, `panel`, `bar_radius`, `window_chrome`, + // `segment_css`, `accent_from`, `accent2`, ...) rather than a hand- + // maintained `name = name,` list — axis 1's `panel`/`ink` swap and + // axis 3's `segment_css`/`window_chrome`/`bar_radius` locals both + // needed a growing, easy-to-desync explicit list to stay in step + // with the string body above; switching the whole call to implicit + // capture removes that failure mode instead of extending it further. + // (`radius_bar`, `flush`, `light`, `segmented`, `window_border` + // itself, and `accent_to` are each read by name ABOVE this literal, + // not inside it, so they're deliberately absent here — an unused + // implicit-capture name is a hard compile error, same discipline + // this crate already applies to unread `theme.toml` keys.) ) } /// Returns the ink colour for icon tinting in the stats bar — the same -/// luminance-picked colour the bar's text uses, so icons stay legible on the bar -/// whatever lightness pywal gives the background. +/// luminance-picked colour the bar's text uses, so icons stay legible on the +/// bar whatever lightness pywal gives the background. +/// +/// FIXED — axis 1, daylight: this used to be unconditionally +/// `ink_on(&load_palette().background)`. `load_palette().background` is +/// `bread_theme::palette::FIXED_BACKGROUND` (`"#0c0c0c"`), pinned dark +/// regardless of pywal OR the active shell theme (see that constant's own +/// doc comment) — so this always resolved to the SAME near-white value, +/// baked directly into a rasterised SVG texture at icon-build time +/// (`svg_texture_sized`, the only call site), completely outside CSS and +/// therefore untouched by `load_css`'s own `panel`/`ink` swap. Every icon +/// built through [`crate::svg_image`]/[`crate::svg_texture`] (volume, wifi, +/// battery, hamburger, media transport, the OSD glyph) was near-white on +/// every existing (dark) theme, which read as correct by construction — +/// until daylight's near-white paper pills made the SAME near-white glyph +/// nearly invisible against its own background. Confirmed empirically +/// (isolated `bread-capture` OSD-volume screenshot, pre-fix) before this +/// fix. Mirrors `load_css`'s own `ink` local exactly: the dark theme's +/// unchanged `ink_on(background)` (near-white), or — for a light theme — +/// `background` itself (the fixed dark hex IS the correct dark ink, the +/// same identity `load_css`'s `ink = "@bg"` case relies on). pub fn fg_color() -> String { - ink_on(&load_palette().background).to_string() + let p = load_palette(); + if shell_theme().tokens().light() { + p.background.clone() + } else { + ink_on(&p.background).to_string() + } +} + +/// Ink colour for the given Hyprland output's wallpaper palette. See +/// [`fg_color`]'s doc comment for the same light-theme fix; kept in step +/// even though this accessor has no call site today. +#[allow(dead_code)] +pub fn fg_color_for(output: &str) -> String { + let p = load_palette_for(output); + if shell_theme().tokens().light() { + p.background.clone() + } else { + ink_on(&p.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`. @@ -185,3 +814,31 @@ pub fn apply() { let user_path = std::path::PathBuf::from(format!("{home}/.config/breadbar/style.css")); USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell)); } + +thread_local! { + // `bread_theme::shell::ThemeWatch`, not a bare `gio::FileMonitor`: the + // watch now re-arms itself onto a new theme's directory when the active + // theme id changes underneath it (see that type's doc comment), so the + // handle we keep alive is opaque, not a single fixed monitor. + static SHELL_THEME_MONITOR: RefCell> = + const { RefCell::new(None) }; +} + +/// Wires `bread_theme::shell::watch()` (plan §10) so editing the active +/// theme's `theme.toml`/`extra.css` on disk re-resolves CSS tokens without a +/// restart, the same way a pywal palette change already does via +/// `apply_app_css`. Window-spec values (anchors, margins, exclusive zone, +/// keyboard mode) are read once at window-construction time and are *not* +/// re-applied here — per plan §10 those need a restart, since live-swapping +/// a mapped layer-shell surface's anchors/exclusive-zone is a lot of +/// teardown risk for a rare operation. +/// +/// Call once at startup (primary instance only — every satellite window +/// calling this would just re-arm the same watch redundantly). +pub fn watch_hot_reload() { + let monitor = bread_theme::shell::watch(|new_theme| { + set_shell_theme(new_theme); + apply(); + }); + SHELL_THEME_MONITOR.with(|cell| *cell.borrow_mut() = Some(monitor)); +} diff --git a/src/widgets/render.rs b/src/widgets/render.rs index 53de47b..785470b 100644 --- a/src/widgets/render.rs +++ b/src/widgets/render.rs @@ -24,7 +24,8 @@ const DEFAULT_LABEL_MAX_WIDTH_CHARS: i32 = 32; fn bundled_icon(name: &str) -> Option<&'static str> { use crate::bar::stats::{ AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_OFF, BT_ON, ICON_BRIGHTNESS, ICON_LOCK, - ICON_VOLUME, WIFI_OFF, WIFI_STRONG, + ICON_RESTART, ICON_SHUTDOWN, ICON_SLEEP, ICON_VOLUME, WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG, + WIFI_WEAK, }; Some(match name { "ac-power" => AC_POWER, @@ -34,8 +35,13 @@ fn bundled_icon(name: &str) -> Option<&'static str> { "bluetooth-on" => BT_ON, "bluetooth-off" => BT_OFF, "wifi-strong" => WIFI_STRONG, + "wifi-medium" => WIFI_MEDIUM, + "wifi-weak" => WIFI_WEAK, "wifi-off" => WIFI_OFF, "lock" => ICON_LOCK, + "sleep" => ICON_SLEEP, + "restart" => ICON_RESTART, + "shutdown" => ICON_SHUTDOWN, "volume" => ICON_VOLUME, "brightness" => ICON_BRIGHTNESS, _ => return None, @@ -171,6 +177,7 @@ pub fn build_node(node: &WidgetNode, widget_id: &str) -> gtk4::Widget { let px = size.unwrap_or(16).max(1) as u32; let texture = icon_texture(widget_id, name.as_deref(), path.as_deref(), px); let image = gtk4::Image::from_paintable(texture.as_ref()); + crate::prepare_icon(&image, px as i32); image.upcast() } WidgetNode::Progress { value, .. } => {