Compare commits
55 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db4be9bb43 | |||
|
|
e7c1f775ec | ||
|
|
a806ac08c1 | ||
|
|
0aac9afcaa | ||
|
|
31138da46e | ||
|
|
dbeb693643 | ||
|
|
9761b85112 | ||
|
|
c0a013c8ed | ||
|
|
3eada87c7d | ||
|
|
ba4e3480d4 | ||
|
|
4cdd6f0213 | ||
|
|
bb9d08b8d3 | ||
|
|
1fe62f6bd9 | ||
|
|
74f280eaed | ||
|
|
1b523eab63 | ||
|
|
42d5acb736 | ||
|
|
d724ec6dd4 | ||
|
|
70fe814593 | ||
|
|
dd5618a5ea | ||
|
|
583d73c53c | ||
|
|
1c028b59be | ||
|
|
0a0bd5f431 | ||
|
|
55d0e399bd | ||
|
|
fecac7671a | ||
|
|
030cf28096 | ||
|
|
2cbdb58f37 | ||
|
|
110ad2c6f9 | ||
|
|
1d30818510 | ||
|
|
02ed92ae9d | ||
|
|
6b15f24fb2 | ||
|
|
6c4df8813c | ||
|
|
a7db4d093d | ||
|
|
e52c507f32 | ||
|
|
c68ed0d7e9 | ||
|
|
33181a7461 | ||
|
|
2694dc0e13 | ||
|
|
88c1bb0b07 | ||
|
|
1fb10a10cd | ||
|
|
df6b568cc1 | ||
|
|
8d80a05d9b | ||
|
|
c5f7dd1ee3 | ||
|
|
bbb1a6c8d2 | ||
|
|
297207a7aa | ||
|
|
3049a20be3 | ||
|
|
700fc3ed16 | ||
|
|
9c4205b1b2 | ||
|
|
cd7465a18b | ||
|
|
e4c12e9b62 | ||
|
|
241dfd17a7 | ||
|
|
96d666b3cb | ||
|
|
1806c6f912 | ||
|
|
ae1fee3591 | ||
|
|
62c6dd5ea3 | ||
|
|
110f0cd3df | ||
|
|
4fddad510f |
24 changed files with 6193 additions and 911 deletions
|
|
@ -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
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
240
Cargo.lock
generated
240
Cargo.lock
generated
|
|
@ -123,9 +123,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.91"
|
||||
version = "0.1.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -150,31 +150,29 @@ version = "2.13.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bread-launcher"
|
||||
version = "0.7.5"
|
||||
dependencies = [
|
||||
"bread-utils 0.7.5",
|
||||
"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",
|
||||
"bread-utils 0.7.5 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5)",
|
||||
"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 +183,33 @@ 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"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs 5.0.1",
|
||||
"gtk4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml 0.8.23",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-utils"
|
||||
version = "0.7.5"
|
||||
dependencies = [
|
||||
"bread-shared",
|
||||
"dirs 5.0.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[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,13 +217,14 @@ 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",
|
||||
"bread-utils 0.7.5 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5)",
|
||||
"clap",
|
||||
"futures-lite",
|
||||
"gtk4",
|
||||
|
|
@ -265,7 +276,7 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54"
|
|||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"system-deps 7.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -286,9 +297,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 +307,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.2"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
||||
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
|
|
@ -564,9 +575,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 +590,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 +600,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 +617,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 +636,32 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
||||
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
|
|
@ -685,7 +696,7 @@ dependencies = [
|
|||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"system-deps 7.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -718,7 +729,7 @@ dependencies = [
|
|||
"libc",
|
||||
"pango-sys",
|
||||
"pkg-config",
|
||||
"system-deps",
|
||||
"system-deps 7.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -771,7 +782,7 @@ dependencies = [
|
|||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"system-deps 7.0.8",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
|
|
@ -835,7 +846,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"system-deps",
|
||||
"system-deps 7.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -846,7 +857,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
|
|||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"system-deps 7.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -867,7 +878,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04"
|
|||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"system-deps 7.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -898,7 +909,7 @@ dependencies = [
|
|||
"graphene-sys",
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"system-deps",
|
||||
"system-deps 7.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -924,9 +935,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 +950,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 +989,7 @@ dependencies = [
|
|||
"gsk4-sys",
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"system-deps",
|
||||
"system-deps 7.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1058,9 +1069,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 +1104,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
|||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.18"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
|
||||
checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
|
@ -1214,7 +1225,7 @@ dependencies = [
|
|||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"system-deps 7.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1243,9 +1254,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
|||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
|
|
@ -1321,7 +1332,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
|||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 2.0.19",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1624,6 +1635,19 @@ dependencies = [
|
|||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83779a5c956bcb6ba627a4ecf0a9d7625db47d7537e0892d97f712ac995648a3"
|
||||
dependencies = [
|
||||
"cfg-expr",
|
||||
"heck",
|
||||
"pkg-config",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
|
|
@ -1654,11 +1678,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.19"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.19",
|
||||
"thiserror-impl 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1674,9 +1698,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.19"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -1728,13 +1752,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.1"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1919,9 +1943,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
|||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.24.0"
|
||||
version = "1.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
|
|
@ -1943,9 +1967,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 +1980,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 +1990,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 +2003,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 +2111,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 +2123,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 +2153,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.18.0"
|
||||
version = "5.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
|
||||
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
|
|
@ -2153,6 +2177,15 @@ dependencies = [
|
|||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zcheapstr"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
|
@ -2161,40 +2194,41 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
|||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.13.1"
|
||||
version = "5.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
|
||||
checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 1.0.4",
|
||||
"zcheapstr",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.13.1"
|
||||
version = "5.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
|
||||
checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.5.0"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
|
||||
checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
|
|
|||
19
Cargo.toml
19
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"] }
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
1
assets/GPU.svg
Normal file
1
assets/GPU.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="18" height="12" rx="2"/><rect x="8" y="9" width="6" height="6" rx="1"/><path d="M7 18v2"/><path d="M12 18v2"/><path d="M17 18v2"/><path d="M17 6V4"/></svg>
|
||||
|
After Width: | Height: | Size: 362 B |
|
|
@ -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<App>) {
|
||||
|
|
|
|||
|
|
@ -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<App>) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
86
src/bar/slots.rs
Normal file
86
src/bar/slots.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
//! Module registry for the theme manifest's `[bar.slots]` (plan Phase 3a),
|
||||
//! extended in Phase 3b to also route `widget:<key>` 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:<key>`, where `<key>` 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<gtk4::Widget>) {
|
||||
self.0.insert(name, widget.clone().upcast());
|
||||
}
|
||||
|
||||
/// Walks every entry named in `names` (a manifest slot list, in theme
|
||||
/// order). A `widget:<key>` 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:<key>` 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<String, gtk4::Box>,
|
||||
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()
|
||||
}
|
||||
|
|
@ -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<f32>,
|
||||
pub net_rx_kbs: f32,
|
||||
pub net_tx_kbs: f32,
|
||||
pub volume_pct: u8,
|
||||
}
|
||||
|
||||
struct CpuSnapshot {
|
||||
|
|
@ -76,7 +85,7 @@ static PREV_CPU: OnceLock<Mutex<CpuSnapshot>> = OnceLock::new();
|
|||
static BAT_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
static AC_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
static WIFI_CACHE: LazyLock<Mutex<(String, &'static str)>> =
|
||||
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<String> {
|
|||
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::<f64>().ok())
|
||||
.map(|v| (v * 100.0).round().clamp(0.0, 150.0) as u8)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn spawn_poller(sender: ComponentSender<App>) {
|
||||
relm4::spawn(async move {
|
||||
loop {
|
||||
|
|
|
|||
111
src/bar/wifi.rs
111
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<ScanEntry>,
|
||||
/// False while nmcli is still listing APs — profiles must still be usable.
|
||||
pub scan_ready: bool,
|
||||
}
|
||||
|
||||
async fn fetch_status() -> Option<CrumbsStatus> {
|
||||
|
|
@ -73,31 +75,57 @@ async fn fetch_profile_list() -> Vec<(String, bool)> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
async fn saved_ssids() -> std::collections::HashSet<String> {
|
||||
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<ScanEntry> {
|
||||
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::Value> =
|
||||
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::<u8>().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::<u8>().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<App>) {
|
|||
});
|
||||
}
|
||||
|
||||
/// 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<App>) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<crate::App>) {
|
||||
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<crate::App>) {
|
||||
|
|
@ -41,13 +92,21 @@ pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
|
|||
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<crate::App>) {
|
|||
});
|
||||
}
|
||||
|
||||
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<gtk4::TickCallbackId>,
|
||||
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<RefCell<TrailInner>>,
|
||||
}
|
||||
|
||||
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<Geom> {
|
||||
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<Geom> {
|
||||
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<RefCell<TrailInner>>, 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
53
src/launcher_command.rs
Normal file
53
src/launcher_command.rs
Normal file
|
|
@ -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<App>) {
|
||||
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.<app_id>.*` 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);
|
||||
}
|
||||
}
|
||||
3268
src/main.rs
3268
src/main.rs
File diff suppressed because it is too large
Load diff
|
|
@ -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<PathBuf> {
|
||||
Some(state_dir()?.join("history.json"))
|
||||
}
|
||||
|
||||
fn state_dir() -> Option<PathBuf> {
|
||||
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<PersistedEntry> = 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::<Vec<PersistedEntry>>(&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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 `<b>`/`<i>` 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<Duration>` 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<Action>,
|
||||
/// Placeholder for the inline-reply field, if one should be shown.
|
||||
inline_reply: Option<String>,
|
||||
},
|
||||
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<Action> {
|
||||
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<String, OwnedValue>,
|
||||
) -> Option<String> {
|
||||
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<HashMap<(String, String), u32>>,
|
||||
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<String>,
|
||||
actions: Vec<String>,
|
||||
hints: std::collections::HashMap<String, OwnedValue>,
|
||||
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<String> {
|
||||
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<SampleKind>) -> 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<SampleKind>) -> 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RefCell<HashMap<u32, gtk4::Box>>>;
|
||||
// Bumped every time an id gets a (re)placed card — an auto-dismiss timer
|
||||
|
|
@ -18,7 +19,6 @@ type Generations = Rc<RefCell<HashMap<u32, u64>>>;
|
|||
/// 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<zbus::Connection>, 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<RefCell<..>>`
|
||||
/// 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<Option<gtk4::TickCallbackId>> = 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::<gtk4::Widget>(), &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<gtk4::Widget>) {
|
||||
let mut child = root.first_child();
|
||||
while let Some(w) = child {
|
||||
if w.is::<gtk4::Button>() || w.is::<gtk4::Entry>() {
|
||||
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<zbus::Connection>,
|
||||
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 (`<b>`, `<i>`, `<u>`,
|
||||
/// `<a href>`). 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<zbus::Connection>,
|
||||
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<zbus::Connection>, 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<zbus::Connection>, 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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
154
src/osd.rs
154
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<Vec<Child>> = 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<OsdEvent>) {
|
|||
};
|
||||
|
||||
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<OsdEvent>) {
|
|||
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<OsdEvent>) {
|
|||
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<RefCell<Option<gtk4::TickCallbackId>>> = 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<OsdEvent>) {
|
|||
};
|
||||
|
||||
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<OsdEvent>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<Cell<i32>>,
|
||||
anim: &Rc<RefCell<Option<gtk4::TickCallbackId>>>,
|
||||
token: &Rc<Cell<u32>>,
|
||||
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
|
||||
}
|
||||
|
|
|
|||
317
src/panel.rs
Normal file
317
src/panel.rs
Normal file
|
|
@ -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<RefCell<Option<Rc<dyn Fn()>>>>;
|
||||
|
||||
/// 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<Cell<Option<(i32, i32, i32, i32)>>>;
|
||||
|
||||
#[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<gtk4::Widget>,
|
||||
control_child: &impl IsA<gtk4::Widget>,
|
||||
media_child: &impl IsA<gtk4::Widget>,
|
||||
) -> 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<gtk4::Widget>, 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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ScreenshotRequest> {
|
||||
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<gtk4::Window>,
|
||||
/// Same deal as `notification_window`, via `osd::spawn(Some(kind))`.
|
||||
pub osd_window: Option<gtk4::Window>,
|
||||
/// 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));
|
||||
|
|
|
|||
166
src/surface.rs
Normal file
166
src/surface.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
//! Applies a `[surfaces.<namespace>]` 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<gtk4::cairo::RectangleInt> = 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)));
|
||||
}
|
||||
823
src/theme.rs
823
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<Option<CssProvider>> = 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<Rc<ShellTheme>> =
|
||||
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<ShellTheme> {
|
||||
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<gtk4::Widget>, 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<gtk4::Native>) {
|
||||
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<Option<bread_theme::shell::ThemeWatch>> =
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, .. } => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue