Compare commits

..

5 commits
v0.3.3 ... main

Author SHA1 Message Date
5169901b95 Merge pull request 'Adopt bread-launcher (reconciled with the liquid-motion redesign)' (#4) from feature/launcher-adopt into main
All checks were successful
dev release / build (push) Successful in 1m28s
2026-08-31 18:14:19 +08:00
Breadway
09530e236d Adopt bread-launcher: shared matching/ranking/rows + manifest theming
All checks were successful
check / check (push) Successful in 1m29s
breadbox's launcher is now a thin host over the `bread-launcher` crate
(bread-ecosystem v0.7.5) — the same core breadbar's embedded capsule
uses — instead of a private implementation:

- `breadbox-shared` becomes a re-export shim over `bread_launcher`
  (`DesktopEntry`, `LaunchHistory`, `config_dir`, `LAUNCHER_APP`); the
  local fuzzy-match / ranking / desktop-file parsing is deleted.
- `run_ui` builds `bread_launcher::gtk::ResultsList` for the results
  list and calls `bread_launcher::do_launch` / `load_sorted_entries`;
  the hand-rolled row/icon/score code is gone.
- Launcher geometry and style come from the active shell-theme manifest
  (`bread_theme::shell`, via the new `src/theme.rs` accessor mirroring
  breadbar's) — width, radius, panel alpha, top margin, icon size,
  footer wording. The Daylight / liquid-motion look is the manifest's
  job now, not hardcoded CSS.
- Keeps `main`'s recent improvements on top of the branch work: the
  overlay window + outside-click come from `bread_utils::gtk_popup`
  (`new_overlay_window` / `bind_window_auto` / `close_on_outside_click`),
  replacing the manual layer-shell + GestureClick boilerplate.
- Embedded-open redirect: when the active theme embeds the launcher
  (spotlight capsule), breadbox emits `bread.box.open_requested` on the
  bus instead of mapping its own window; falls back to its own overlay
  when breadd is unreachable.
- Pins: bread-theme / bread-utils / bread-screenshots / bread-launcher
  → v0.7.5 (bread-launcher and `bread_theme::shell` first ship there).
  `.cargo/` gitignored — the local sibling-checkout source override
  (`fa85fc1`) is no longer needed now that v0.7.5 is tagged.

Reconciles the long-lived `feature/launcher-core` (13 commits, incl.
`feature/launcher-theming`) with `main`'s independent launcher redesign
(`feature/liquid-motion` + `bread_utils::gtk_popup` adoption). Applied
as one squashed reconciliation rather than a 13-commit rebase because
the two lines rewrote the same ~200-line `run_ui`/`build_css` region
in incompatible directions.

cargo build --locked / test --locked / clippy --all-targets --locked
-D warnings all clean.
2026-08-31 15:59:32 +08:00
Breadway
7c55ea4756 Adopt bread_utils::gtk_popup for the launcher overlay
All checks were successful
check / check (push) Successful in 54s
dev release / build (push) Successful in 1m19s
Replace the hand-rolled layer-shell window, visible-row Up/Down
navigation, and click-outside-to-close gesture with the shared
helpers already used by breadclip. Keep singleton::toggle_or_kill
and bind_window_auto.
2026-08-23 14:39:11 +08:00
b52bf77ed3 Merge pull request 'can't be bothered writing a commit message' (#3) from feature/liquid-motion into main
All checks were successful
dev release / build (push) Successful in 2m14s
Reviewed-on: #3
2026-08-16 14:31:10 +08:00
Breadway
28ff4bc982 can't be bothered writing a commit message
All checks were successful
check / check (push) Successful in 8m6s
2026-08-16 14:29:24 +08:00
10 changed files with 740 additions and 658 deletions

3
.gitignore vendored
View file

@ -25,3 +25,6 @@ CLAUDE.md
# graphify knowledge-graph output (local tool cache, not for commit) # graphify knowledge-graph output (local tool cache, not for commit)
graphify-out/ graphify-out/
# Local-only source overrides (see .cargo/config.toml).
.cargo/

200
Cargo.lock generated
View file

@ -82,10 +82,20 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bread-launcher"
version = "0.7.5"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8"
dependencies = [
"bread-utils",
"gtk4",
"serde_json",
]
[[package]] [[package]]
name = "bread-screenshots" name = "bread-screenshots"
version = "0.7.2" version = "0.7.5"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-utils", "bread-utils",
@ -94,33 +104,39 @@ dependencies = [
[[package]] [[package]]
name = "bread-shared" name = "bread-shared"
version = "0.7.0" version = "0.8.0"
source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0#cdd5de8f58e437b3fc6d9b9087eb7b3d0fd09704"
dependencies = [ dependencies = [
"dirs", "dirs 6.0.0",
"serde", "serde",
"serde_json", "serde_json",
"toml 0.8.23", "toml 0.8.23",
"uuid",
] ]
[[package]] [[package]]
name = "bread-theme" name = "bread-theme"
version = "0.7.4" version = "0.7.5"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8"
dependencies = [ dependencies = [
"dirs", "anyhow",
"dirs 5.0.1",
"gtk4", "gtk4",
"serde", "serde",
"serde_json", "serde_json",
"toml 0.8.23",
"tracing",
] ]
[[package]] [[package]]
name = "bread-utils" name = "bread-utils"
version = "0.7.2" version = "0.7.5"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8"
dependencies = [ dependencies = [
"bread-shared", "bread-shared",
"dirs", "dirs 5.0.1",
"gtk4",
"gtk4-layer-shell",
"serde", "serde",
"serde_json", "serde_json",
] ]
@ -130,6 +146,7 @@ name = "breadbox"
version = "0.3.3" version = "0.3.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-launcher",
"bread-screenshots", "bread-screenshots",
"bread-theme", "bread-theme",
"bread-utils", "bread-utils",
@ -144,6 +161,7 @@ dependencies = [
name = "breadbox-shared" name = "breadbox-shared"
version = "0.3.3" version = "0.3.3"
dependencies = [ dependencies = [
"bread-launcher",
"serde", "serde",
"serde_json", "serde_json",
"toml 0.8.23", "toml 0.8.23",
@ -158,6 +176,12 @@ dependencies = [
"ureq", "ureq",
] ]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]] [[package]]
name = "cairo-rs" name = "cairo-rs"
version = "0.22.0" version = "0.22.0"
@ -268,7 +292,16 @@ version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [ dependencies = [
"dirs-sys", "dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys 0.5.0",
] ]
[[package]] [[package]]
@ -279,10 +312,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users", "redox_users 0.4.6",
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.7" version = "0.2.7"
@ -466,6 +511,17 @@ dependencies = [
"wasi", "wasi",
] ]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]] [[package]]
name = "gio" name = "gio"
version = "0.22.8" version = "0.22.8"
@ -840,6 +896,17 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]] [[package]]
name = "khronos_api" name = "khronos_api"
version = "3.1.0" version = "3.1.0"
@ -993,15 +1060,32 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.4.6" version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.17",
"libredox", "libredox",
"thiserror", "thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.20",
] ]
[[package]] [[package]]
@ -1012,7 +1096,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [ dependencies = [
"cc", "cc",
"cfg-if", "cfg-if",
"getrandom", "getrandom 0.2.17",
"libc", "libc",
"untrusted", "untrusted",
"windows-sys 0.52.0", "windows-sys 0.52.0",
@ -1062,6 +1146,12 @@ dependencies = [
"untrusted", "untrusted",
] ]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.28" version = "1.0.28"
@ -1242,7 +1332,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl 2.0.20",
] ]
[[package]] [[package]]
@ -1256,6 +1355,17 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.4" version = "0.8.4"
@ -1441,6 +1551,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@ -1453,6 +1574,51 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [
"unicode-ident",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "0.26.11" version = "0.26.11"

View file

@ -14,13 +14,21 @@ connection. Command verbs are only received while `breadbox listen` is
running — that process holds the `bread.command.box.**` subscription running — that process holds the `bread.command.box.**` subscription
open. open.
Under an `[launcher] mode = "embedded"` theme (spotlight), breadbar's
own bar-drawer capsule *is* the launcher UI, and breadbox redirects
instead of mapping its own overlay window — see the "Embedded launcher
theme" section below. Everything else on this page describes the
default (`overlay`-mode) behavior.
## Events published (`bread.box.*`) ## Events published (`bread.box.*`)
| Event | Data | When | | Event | Data | When |
|-------|------|------| |-------|------|------|
| `bread.box.launched` | `{ "id": "<desktop id or exec>", "name": "<display name>" }` | The user launched an app (Enter / keypad Enter on the selected row, or activating a row) **and** the spawn succeeded. Not emitted if `Command::spawn` fails (missing terminal, `exec` that cannot start). `id` is the desktop-file id (the `.desktop` filename, e.g. `firefox.desktop`), falling back to the stripped `Exec=` line when that id is empty. `name` is the desktop-entry display name. | | `bread.box.launched` | `{ "id": "<desktop id or exec>", "name": "<display name>" }` | The user launched an app (Enter / keypad Enter on the selected row, or activating a row) **and** the spawn succeeded. Not emitted if `Command::spawn` fails (missing terminal, `exec` that cannot start). `id` is the desktop-file id (the `.desktop` filename, e.g. `firefox.desktop`), falling back to the stripped `Exec=` line when that id is empty. `name` is the desktop-entry display name. |
| `bread.box.open.done` | `{}` | `bread.command.box.open` was received and `breadbox` was spawned. This is the command confirmation, not proof the overlay mapped — the spawned process is the same toggle as a keybind. | | `bread.box.open.done` | `{}` | `bread.command.box.open` was received (via `breadbox listen`) **and this binary was spawned** — non-embedded themes only. Not proof the overlay mapped, just that the process was started (same toggle as a keybind). Never emitted under an embedded theme — see below. |
| `bread.box.open.failed` | `{ "error": "<message>" }` | `bread.command.box.open` was received but this binary could not be started. | | `bread.box.open.failed` | `{ "error": "<message>" }` | `bread.command.box.open` was received but this binary could not be started — non-embedded themes only. Unreachable under an embedded theme, since that branch returns before ever attempting to spawn. |
| `bread.box.open_requested` | `{}` | Emitted by the plain `breadbox` binary itself (not `breadbox listen`) instead of mapping its own overlay window, only when the active theme's `[launcher].mode` is `embedded`. breadbar's capsule (`launcher_command.rs`) subscribes to this and opens itself. Own-namespace event, not a command — see `dispatch_embedded_open`'s doc comment for why `bread.command.box.open` would be the wrong shape here. |
| `bread.box.open.redirected` | `{}` | Emitted by `breadbox listen`'s `handle_open`, only when `bread.command.box.open` is received while an embedded theme is active. Replaces `.done`/`.failed` in that case: this process never spawns anything (breadbar's capsule is the intended handler) and has no way to confirm breadbar actually received or handled the event — pub/sub here is one-way with no ack. This event means "redirect happened, outcome unknown", not "succeeded". |
Launch history is local to breadbox (`~/.cache/breadbox/history.json`); Launch history is local to breadbox (`~/.cache/breadbox/history.json`);
the event bus is a notification that a launch happened, not a channel the event bus is a notification that a launch happened, not a channel
@ -34,15 +42,21 @@ bread convention, not a breadbox bug.
| Verb | Data | Effect | | Verb | Data | Effect |
|------|------|--------| |------|------|--------|
| `open` | none | Same as running `breadbox` (toggle the launcher overlay via the existing singleton). Emits `bread.box.open.done` / `.failed`. | | `open` | none | Under a non-embedded theme: same as running `breadbox` (toggle the launcher overlay via the existing singleton). Emits `bread.box.open.done` / `.failed`. Under an embedded theme: never spawns; emits `bread.box.open.redirected` instead — see "Embedded launcher theme" below. |
```lua ```lua
-- Non-embedded themes: `.done` is a real completion signal.
bread.spawn(function() bread.spawn(function()
bread.emit("bread.command.box.open") bread.emit("bread.command.box.open")
bread.wait("bread.box.open.done", { timeout = 5000 }) bread.wait("bread.box.open.done", { timeout = 5000 })
end) end)
``` ```
Under an embedded theme this `.done` wait will time out — `.done` is
never emitted there. A workflow that needs to work under every theme
should wait on `bread.box.open.redirected` too (or treat a timeout as
"probably fine, embedded themes have no ack" rather than a failure).
### Not implemented: extra verbs ### Not implemented: extra verbs
There is no `launch` / `close` / `query` command verb. Picking a desktop There is no `launch` / `close` / `query` command verb. Picking a desktop
@ -50,12 +64,44 @@ id from the bus would be a new product surface. If/when that exists, add
the corresponding `bread.command.box.*` verb at the same time, not the corresponding `bread.command.box.*` verb at the same time, not
stubbed as a no-op ahead of it. stubbed as a no-op ahead of it.
## Embedded launcher theme (spotlight)
When the active shell theme's `[launcher].mode` is `embedded`, running
`breadbox` directly (e.g. from a keybind) does **not** map this binary's
overlay window — that would stack a second launcher on top of
breadbar's own capsule. Instead:
1. `breadbox`'s `main` checks `BreadClient::health()` — a real,
bounded-timeout round trip to breadd, not just "did the socket
exist".
2. If breadd **is** reachable, it emits `bread.box.open_requested` and
returns. breadbar's capsule (subscribed only while its own active
theme is also `embedded`) is expected to open itself in response.
There is no ack for this — if breadbar isn't running, or is running
under a different theme and therefore never subscribed, this is
still a silent no-op on the bus with nothing further to fall back
to. (`BreadClient` has no "is anyone subscribed" query.)
3. If breadd is **not** reachable, `breadbox` logs that to stderr and
falls back to mapping its own overlay window anyway — a keybind
press always opens *something*, rather than the historical fully
silent no-op when the bus was down.
`breadbox listen`'s `handle_open` (the `bread.command.box.open`
handler) applies the same "never map a second launcher" rule but from
the command side: under an embedded theme it never spawns, logs to
stderr, and emits `bread.box.open.redirected` instead of `.done` — see
the events table above for why `.done` would be a false claim here.
## Fail-safe behavior ## Fail-safe behavior
- If breadd isn't installed or isn't running, `emit` is a silent no-op - If breadd isn't installed or isn't running, `emit` is a silent no-op
(`BreadClient::emit` never blocks or errors the caller) and the (`BreadClient::emit` never blocks or errors the caller) and the
command subscription simply never receives anything — launching, command subscription simply never receives anything — launching,
history, theming, and the singleton toggle are entirely unaffected. history, theming, and the singleton toggle are entirely unaffected.
Under an embedded theme specifically, a direct `breadbox` invocation
additionally checks reachability up front and falls back to mapping
its own overlay window when breadd is down (see above) — so this
invariant now holds there too, not just for every other theme.
- If breadd restarts, the command subscription reconnects automatically - If breadd restarts, the command subscription reconnects automatically
(`BreadClient::subscribe`'s background thread has its own backoff (`BreadClient::subscribe`'s background thread has its own backoff
loop); no restart of `breadbox listen` is needed. loop); no restart of `breadbox listen` is needed.
@ -63,3 +109,8 @@ stubbed as a no-op ahead of it.
the bus (no subscriber). The CLI still works, and one-shot invocations the bus (no subscriber). The CLI still works, and one-shot invocations
still emit `bread.box.launched` on their own short-lived connection. still emit `bread.box.launched` on their own short-lived connection.
- Closing the launcher without launching anything emits nothing. - Closing the launcher without launching anything emits nothing.
- What is **not** covered: under an embedded theme, if breadd is
reachable but breadbar itself isn't running (or isn't subscribed),
neither the direct-keybind path nor the command path can detect that
or fall back further — both are fire-and-forget with no ack. This is
a known limitation of the current transport, not an oversight.

View file

@ -8,3 +8,8 @@ license = "MIT"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "0.8" toml = "0.8"
# The desktop-entry/icon-cache/launch-history substance this crate used to
# own has moved to bread-ecosystem's bread-launcher (feature/launcher-core)
# so breadbar can share it too — see src/lib.rs. No `gtk` feature: this
# shim only re-exports the headless half.
bread-launcher = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5" }

View file

@ -1,264 +1,59 @@
use std::{ //! **Compatibility shim.** This crate used to own desktop-entry parsing,
collections::HashMap, //! icon caching, and launch history; that substance now lives in
env, //! `bread-launcher` (`bread-ecosystem`, feature/launcher-core) so breadbar's
fs::{self, File}, //! future embedded capsule can share it with breadbox's overlay window —
io::{BufRead, BufReader}, //! one launcher implementation, two hosts (see
path::{Path, PathBuf}, //! `bos-ui-demos/THEME_SYSTEM_PLAN.md` §3, §7).
}; //!
//! Everything below is either a direct re-export of `bread_launcher`, or a
//! thin wrapper that just bakes in `"breadbox"` as the app name
//! `bread_launcher`'s path/cache functions now take explicitly (so more
//! than one host can use that crate without colliding on
//! `~/.cache/<app>`). This exists purely so `breadbox` and `breadbox-sync`
//! keep compiling with minimal churn — plan to remove it once both callers
//! depend on `bread-launcher` directly instead.
//!
//! `Config`/`Context` are NOT part of that move: they're breadbox's own
//! per-workspace launch-priority config format, not launcher substance, so
//! they stay here.
use std::{fs, path::PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// ---- XDG path helpers ------------------------------------------------------- pub use bread_launcher::{
app_dirs, home_dir, load_all_desktop_entries, parse_desktop, strip_exec_codes, DesktopEntry,
IconCache, LaunchHistory,
};
pub fn home_dir() -> PathBuf { /// The launcher's shared identity (`bread_launcher::LAUNCHER_APP`, currently
PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into())) /// `"breadbox"`) — NOT a breadbox-specific literal. breadbar's embedded
} /// capsule (theme 04/spotlight) reads/writes this exact same on-disk cache
/// and history, so the two surfaces stay one launcher with one ranking
/// instead of forking into two (see `LAUNCHER_APP`'s own doc comment).
const APP: &str = bread_launcher::LAUNCHER_APP;
pub fn cache_dir() -> PathBuf { pub fn cache_dir() -> PathBuf {
env::var("XDG_CACHE_HOME") bread_launcher::cache_dir(APP)
.map(PathBuf::from)
.unwrap_or_else(|_| home_dir().join(".cache"))
.join("breadbox")
} }
pub fn config_dir() -> PathBuf { pub fn config_dir() -> PathBuf {
env::var("XDG_CONFIG_HOME") bread_launcher::config_dir(APP)
.map(PathBuf::from)
.unwrap_or_else(|_| home_dir().join(".config"))
.join("breadbox")
} }
pub fn app_dirs() -> Vec<PathBuf> { pub fn icon_cache() -> IconCache {
let home = home_dir(); IconCache::new(APP)
let mut dirs = vec![PathBuf::from("/usr/share/applications")];
let xdg_data_dirs = env::var("XDG_DATA_DIRS")
.unwrap_or_else(|_| "/usr/local/share:/usr/share".into());
for d in xdg_data_dirs.split(':') {
let p = PathBuf::from(d).join("applications");
if p != dirs[0] {
dirs.push(p);
}
} }
dirs.push( pub fn icon_manifest_path() -> PathBuf {
env::var("XDG_DATA_HOME") IconCache::manifest_path(APP)
.map(PathBuf::from)
.unwrap_or_else(|_| home.join(".local/share"))
.join("applications"),
);
dirs
} }
// ---- Desktop entry ---------------------------------------------------------- pub fn launch_history() -> LaunchHistory {
LaunchHistory::load(APP)
#[derive(Debug, Clone)]
pub struct DesktopEntry {
/// Desktop file id (the `.desktop` filename, e.g. `firefox.desktop`).
/// Empty only if the path had no file name; callers fall back to `exec`.
pub id: String,
pub name: String,
pub exec: String,
pub icon_name: String,
pub icon_path: Option<PathBuf>, // resolved by caller from manifest
pub categories: Vec<String>,
pub wm_class: Option<String>,
pub terminal: bool,
} }
pub fn strip_exec_codes(exec: &str) -> String { // ---- Config (breadbox-specific, not launcher substance) --------------------
let mut out = String::with_capacity(exec.len());
let mut chars = exec.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
match chars.peek().copied() {
Some('%') => {
chars.next();
out.push('%');
}
Some(n) if n.is_ascii_alphabetic() => {
chars.next();
}
_ => out.push(c),
}
} else {
out.push(c);
}
}
out
}
/// Returns `None` for entries that should not be shown (hidden, NoDisplay, non-Application type).
pub fn parse_desktop(path: &Path) -> Option<DesktopEntry> {
let file = File::open(path).ok()?;
let mut in_entry = false;
let mut name: Option<String> = None;
let mut exec: Option<String> = None;
let mut icon: Option<String> = None;
let mut categories: Option<String> = None;
let mut wm_class: Option<String> = None;
let mut app_type: Option<String> = None;
let mut no_display = false;
let mut hidden = false;
let mut terminal = false;
for line in BufReader::new(file).lines() {
let Ok(raw) = line else { continue };
let s = raw.trim();
if s.starts_with('#') || s.is_empty() {
continue;
}
if s.starts_with('[') {
in_entry = s == "[Desktop Entry]";
continue;
}
if !in_entry {
continue;
}
if let Some(v) = s.strip_prefix("Name=") {
name.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("Exec=") {
exec.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("Icon=") {
icon.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("Categories=") {
categories.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("StartupWMClass=") {
wm_class.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("Type=") {
app_type.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("NoDisplay=") {
no_display = v == "true";
} else if let Some(v) = s.strip_prefix("Hidden=") {
hidden = v == "true";
} else if let Some(v) = s.strip_prefix("Terminal=") {
terminal = v == "true" || v == "1";
}
}
if no_display || hidden {
return None;
}
if app_type.as_deref() != Some("Application") {
return None;
}
let name = name?.trim().to_string();
let exec = strip_exec_codes(exec?.trim()).trim().to_string();
if name.is_empty() || exec.is_empty() {
return None;
}
let icon_name = icon.unwrap_or_default().trim().to_string();
let cats = categories
.unwrap_or_default()
.split(';')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
let id = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.filter(|s| !s.is_empty())
.unwrap_or_default();
Some(DesktopEntry {
id,
name,
exec,
icon_name,
icon_path: None,
categories: cats,
wm_class: wm_class.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
terminal,
})
}
/// Walk all configured application directories and return deduplicated entries.
/// Entries from later directories (user-local) override those from earlier ones.
pub fn load_all_desktop_entries() -> Vec<DesktopEntry> {
let mut seen: std::collections::HashMap<String, DesktopEntry> = std::collections::HashMap::new();
for dir in app_dirs() {
let Ok(entries) = fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
let key = entry.file_name().to_string_lossy().into_owned();
if let Some(app) = parse_desktop(&path) {
seen.insert(key, app);
}
}
}
seen.into_values().collect()
}
// ---- Icon cache -------------------------------------------------------------
pub struct IconCache {
pub dir: PathBuf,
}
impl IconCache {
pub fn new() -> Self {
IconCache { dir: cache_dir().join("icons") }
}
pub fn path_for(&self, icon_name: &str) -> PathBuf {
self.dir.join(format!("{}.png", icon_name))
}
pub fn manifest_path() -> PathBuf {
cache_dir().join("manifest.json")
}
pub fn ensure_dir(&self) -> std::io::Result<()> {
fs::create_dir_all(&self.dir)
}
}
impl Default for IconCache {
fn default() -> Self {
Self::new()
}
}
// ---- Launch history ---------------------------------------------------------
pub struct LaunchHistory {
counts: HashMap<String, u32>,
path: PathBuf,
}
impl LaunchHistory {
pub fn load() -> Self {
let path = cache_dir().join("history.json");
let counts = fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
LaunchHistory { counts, path }
}
pub fn count(&self, name: &str) -> u32 {
self.counts.get(name).copied().unwrap_or(0)
}
pub fn increment(&mut self, name: &str) {
*self.counts.entry(name.to_string()).or_insert(0) += 1;
}
pub fn save(&self) {
if let Ok(json) = serde_json::to_string(&self.counts) {
let _ = fs::write(&self.path, json);
}
}
}
// ---- Config -----------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config { pub struct Config {

View file

@ -205,7 +205,7 @@ fn main() {
} }
fn run() -> Result<(), Box<dyn std::error::Error>> { fn run() -> Result<(), Box<dyn std::error::Error>> {
let icon_cache = IconCache::new(); let icon_cache = breadbox_shared::icon_cache();
icon_cache.ensure_dir()?; icon_cache.ensure_dir()?;
let theme = current_icon_theme(); let theme = current_icon_theme();
@ -266,7 +266,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
let manifest_path = IconCache::manifest_path(); let manifest_path = breadbox_shared::icon_manifest_path();
let json = serde_json::to_string_pretty(&manifest)?; let json = serde_json::to_string_pretty(&manifest)?;
let tmp = manifest_path.with_extension("tmp"); let tmp = manifest_path.with_extension("tmp");
fs::write(&tmp, &json)?; fs::write(&tmp, &json)?;

View file

@ -9,10 +9,13 @@ name = "breadbox"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["gtk"] }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["bread-client", "gtk"] }
# Capture primitives for `--screenshot` mode — see src/screenshot.rs. # 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" }
# Matching/ranking/launching plus the results-list widget (see src/main.rs
# run_ui) — the same core breadbar's embedded capsule will use next phase.
bread-launcher = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["gtk"] }
breadbox-shared = { path = "../breadbox-shared" } breadbox-shared = { path = "../breadbox-shared" }
gtk4 = { version = "0.11", features = ["v4_12"] } gtk4 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8" gtk4-layer-shell = "0.8"

View file

@ -44,11 +44,48 @@ fn handle_command(event: &BreadEvent) {
} }
fn handle_open() { fn handle_open() {
let client = BreadClient::connect(APP_ID);
// Under an embedded-launcher theme (spotlight), breadbar's own capsule
// subscribes to both this command and `bread.box.open_requested` (see
// `main.rs`'s `dispatch_embedded_open` doc comment) — it, not this
// process, is the thing that should react. Spawning `breadbox` here
// would just re-run that same binary's own embedded-mode redirect
// (`main`'s `dispatch_embedded_open`), emitting
// `bread.box.open_requested` back onto the bus; breadbar would open the
// capsule again and this still-running subscription would keep spawning
// on every command it sees. So this branch never spawns — that part is
// correct and stays.
//
// It used to also emit `bread.box.open.done` here unconditionally, which
// was a false-positive success report: `.done`'s documented meaning
// (EVENTS.md) is "breadbox was spawned", which is literally untrue in
// this branch, and there is no ack from breadbar on this one-way
// pub/sub bus — `BreadClient` has no way to ask "did anything actually
// pick this up" (same limitation `main.rs`'s `dispatch_embedded_open`
// documents). Claiming `.done` reported a completion this process
// cannot observe — the same shape of bug as bug #6 (a namespace
// violation making a false claim), just an over-eager "done" instead of
// an under-eager warning. Since confirming the handoff isn't possible
// with the current transport, this does the honest thing instead: log
// locally (so the failure mode is diagnosable from `breadbox listen`'s
// own output) and emit a distinct, explicitly-unconfirmed event so a bus
// observer can tell "redirected, outcome unknown" apart from "breadbox
// spawned" rather than being told a specific untrue thing.
if crate::theme::shell_theme().launcher().mode == bread_theme::shell::LauncherMode::Embedded {
eprintln!(
"breadbox: bread.command.box.open received under an embedded launcher \
theme; breadbar's capsule is the intended handler and this process \
cannot confirm it received the event"
);
client.emit("bread.box.open.redirected", serde_json::json!({}));
return;
}
// Same as running `breadbox` from a keybind: toggle the overlay via the // Same as running `breadbox` from a keybind: toggle the overlay via the
// existing singleton. Spawn success is the command confirmation — we do // existing singleton. Spawn success is the command confirmation — we do
// not wait for the GTK window to map. // not wait for the GTK window to map.
let result = spawn_self(); let result = spawn_self();
let client = BreadClient::connect(APP_ID);
match result { match result {
Ok(_) => client.emit("bread.box.open.done", serde_json::json!({})), Ok(_) => client.emit("bread.box.open.done", serde_json::json!({})),
Err(e) => { Err(e) => {

View file

@ -1,14 +1,12 @@
use bread_launcher::gtk::{row_entry, ResultsList};
use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette}; use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette};
use bread_utils::bread_client::BreadClient;
use std::{ use std::{
cell::RefCell, cell::RefCell,
collections::HashMap, collections::HashMap,
env, env, fs,
fs,
io::{Read, Write}, io::{Read, Write},
os::unix::net::UnixStream, os::unix::net::UnixStream,
path::{Path, PathBuf}, path::PathBuf,
process::{Command, Stdio},
rc::Rc, rc::Rc,
}; };
@ -16,20 +14,24 @@ use std::{
/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.box.*`. /// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.box.*`.
const APP_ID: &str = "box"; const APP_ID: &str = "box";
use breadbox_shared::{ /// Emitted instead of mapping breadbox's own window when the active shell
config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, LaunchHistory, /// theme's launcher is embedded (theme 04's capsule). Must stay inside
}; /// `bread.box.*` — see [`dispatch_embedded_open`] for why.
pub const EMBEDDED_OPEN_EVENT: &str = "bread.box.open_requested";
/// Published via `bread_launcher::do_launch` after a successful launch —
/// see `EVENTS.md`.
const LAUNCHED_EVENT: &str = "bread.box.launched";
use breadbox_shared::{config_dir, Config, DesktopEntry, LaunchHistory};
use gtk4::{ use gtk4::{
glib, glib, prelude::*, Application, Box as GBox, CssProvider,
pango::EllipsizeMode, EventControllerKey, Label, Orientation, SearchEntry,
prelude::*,
Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label,
ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode,
}; };
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
mod listen; mod listen;
mod screenshot; mod screenshot;
mod theme;
// ---- Hyprland IPC ----------------------------------------------------------- // ---- Hyprland IPC -----------------------------------------------------------
@ -52,7 +54,7 @@ fn get_active_workspace() -> Option<String> {
// ---- Manifest --------------------------------------------------------------- // ---- Manifest ---------------------------------------------------------------
fn load_manifest() -> HashMap<String, PathBuf> { fn load_manifest() -> HashMap<String, PathBuf> {
let path = IconCache::manifest_path(); let path = breadbox_shared::icon_manifest_path();
let content = fs::read_to_string(&path).unwrap_or_default(); let content = fs::read_to_string(&path).unwrap_or_default();
serde_json::from_str::<HashMap<String, String>>(&content) serde_json::from_str::<HashMap<String, String>>(&content)
.unwrap_or_default() .unwrap_or_default()
@ -61,232 +63,154 @@ fn load_manifest() -> HashMap<String, PathBuf> {
.collect() .collect()
} }
// ---- Entry loading and sorting ----------------------------------------------
fn load_sorted_entries(
manifest: &HashMap<String, PathBuf>,
priority: &[String],
history: &LaunchHistory,
) -> Vec<DesktopEntry> {
let mut entries = load_all_desktop_entries();
// Populate icon_path from manifest
for entry in &mut entries {
if let Some(path) = manifest.get(&entry.icon_name) {
if path.exists() {
entry.icon_path = Some(path.clone());
}
}
}
let priority_lower: Vec<String> = priority.iter().map(|s| s.to_lowercase()).collect();
entries.sort_by(|a, b| {
let ai = priority_rank(a, &priority_lower);
let bi = priority_rank(b, &priority_lower);
match (ai, bi) {
(Some(i), Some(j)) => i.cmp(&j),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => {
// Most-launched first, then alphabetical
history.count(&b.name).cmp(&history.count(&a.name))
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
}
}
});
entries
}
fn priority_rank(entry: &DesktopEntry, priority_lower: &[String]) -> Option<usize> {
let name_l = entry.name.to_lowercase();
let wm_l = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
priority_lower
.iter()
.position(|p| matches_term(&name_l, p) || matches_term(&wm_l, p))
}
/// Whole-word / exact match of `term` within `field` (both lowercase). Avoids
/// "code" matching "vscodium" while still matching "Code", "code-oss", and
/// "Visual Studio Code".
fn matches_term(field: &str, term: &str) -> bool {
if term.is_empty() || field.is_empty() {
return false;
}
if field == term {
return true;
}
let bytes = field.as_bytes();
let tlen = term.len();
let mut start = 0;
while let Some(pos) = field[start..].find(term) {
let i = start + pos;
let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
let after = i + tlen;
let after_ok = after >= bytes.len() || !bytes[after].is_ascii_alphanumeric();
if before_ok && after_ok {
return true;
}
start = i + 1;
if start >= field.len() {
break;
}
}
false
}
// ---- Theming ---------------------------------------------------------------- // ---- Theming ----------------------------------------------------------------
fn build_css(p: &Palette) -> String { fn build_css(
let bg_panel = hex_to_rgba(&p.background, 0.60); p: &Palette,
// breadbox-specific rules only — fonts, palette, and generic widgets come launcher: &bread_theme::shell::Launcher,
// from the shared ecosystem stylesheet (applied first in connect_activate). tokens: &bread_theme::shell::Tokens,
// Colour is set on each surface (panel, search box, hovered/selected row) so ) -> String {
// child labels inherit the legible ink for that background. `on_*` are // Panel opacity comes from the theme, not a hardcoded constant. 0.60 made
// luminance-picked black/white — the pywal hues are untouched. Without this a // the launcher wash out over a bright wallpaper and its text hard to read —
// light `surface` slot makes the selected row's text vanish. // a bar can be that translucent because it covers a thin strip, a full
// panel cannot. The approved reference uses 0.95/0.93 per theme.
let bg_panel = hex_to_rgba(&p.background, launcher.panel_alpha as f32);
let radius = format!("{}px", launcher.radius);
let on_bg = ink_on(&p.background);
// breadbox-specific rules only — the generic widget baseline (buttons,
// switches, plain `entry`/`spinbutton`) comes from the shared ecosystem
// stylesheet, applied first (lower priority) in connect_activate via
// `apply_shared`. Colour is set on each surface (panel, search box,
// hovered/selected row) so child labels inherit the legible ink for that
// background. `on_*` are luminance-picked black/white — the pywal hues
// are untouched. Without this a light `surface` slot makes the selected
// row's text vanish.
//
// Selectors matter here: GTK4's `GtkSearchEntry` CSS node is `entry`
// carrying a `.search` style class (confirmed against
// `/usr/share/gir-1.0/Gtk-4.0.gir`'s `SearchEntry` "## CSS Nodes" doc,
// `entry.search ╰── text`) — NOT a node literally named `searchentry`,
// and `GtkListBox`'s node is `list`, NOT `listbox`. Both bogus selectors
// shipped here previously and silently matched nothing, which is why
// this rule block's `border`/`outline` suppression never actually beat
// the shared stylesheet's `entry:focus-within { border-color: @accent }`
// rule (`bread_theme::stylesheet`, still layered in underneath at
// APPLICATION priority) — the accent-coloured focus "outline" the user
// was seeing was that shared rule showing through unopposed. `entry`
// alone (no `.search`) would work too since this window has only one
// entry, but `.search` documents which GtkSearchEntry state this targets
// and is what actually renders.
//
// Per-theme geometry now comes entirely from `[launcher]`/`[tokens]` —
// liquid-motion and glass-workbench set every one of these to different
// values (see their `assets/shell/*/theme.toml`), which is what makes
// the two themes read as different instruments rather than one
// launcher recoloured.
format!( format!(
"window {{ background-color: transparent; }}\ "window {{ background-color: transparent; }}\
.launcher-bg {{ background-color: {bg_panel}; color: {on_bg}; border-radius: 8px;\ .launcher-bg {{ background-color: {bg_panel}; color: {on_bg}; border-radius: {radius};\
box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\ /* NO drop shadow. In a browser `backdrop-filter` blurs only the\
searchentry {{ background-color: {surface}; color: {on_surface}; caret-color: {accent};\ element's own box, so the demo's shadow is free. Hyprland\
border: none; outline: none; box-shadow: none;\ blurs any surface pixel above `ignore_alpha` (0.2), and a\
padding: 12px 16px; border-radius: 6px 6px 0 0; }}\ 32px shadow at 0.6 alpha is far above it so the compositor\
listbox {{ background-color: transparent; padding: 4px; }}\ blurred the shadow as well, painting a rectangular blurred\
row {{ padding: 8px 12px; color: {on_bg}; background-color: transparent;\ halo around the rounded panel. That halo is the \"blur\
border-radius: 6px; }}\ backdrop whose radius doesn't match the content\" problem.\
row:hover {{ background-color: {surface}; color: {on_surface}; }}\ The blur behind a 0.95-opaque panel already gives plenty of\
row:selected {{ background-color: {surface}; color: {on_surface}; }}\ separation; the shadow only fought it. */\
.app-name {{ font-size: 14px; }}\ font-family: \"{font_family}\", {font_fallback}; }}\
.app-muted {{ opacity: 0.6; font-size: 12px; }}\ entry.search {{ background-color: transparent; color: {on_bg}; caret-color: {accent};\
image {{ margin-right: 8px; }}", border: none; outline: none; box-shadow: none; border-radius: 0;\
border-bottom: 1px solid {hairline};\
padding: {search_pv}px {search_ph}px; font-size: {search_fs}px; }}\
entry.search:focus, entry.search:focus-within {{\
outline: none; box-shadow: none; border-color: transparent;\
border-bottom: 1px solid {hairline}; }}\
list {{ background-color: transparent; padding: 4px 0; }}\
row {{ padding: {row_pv}px {row_ph}px; margin: 0 {row_inset}px; color: {on_bg};\
background-color: transparent; border-radius: {row_radius}px;\
font-size: {row_fs}px; }}\
row:hover, row:selected {{ background-color: {selection_bg}; color: {on_bg}; }}\
.app-muted {{ opacity: 0.4; font-size: 11px; }}\
/* No background tile: these are real app icons, not the demo's\
placeholder .ico boxes. A tint behind a real icon reads as a\
failed/unloaded image. */\
image {{ margin-right: 8px; border-radius: {icon_radius}px; }}\
/* \"Recent\"/\"Apps\" headers (`[launcher].sections`, liquid-motion\
only glass-workbench's flat list never builds these rows). */\
.section-header-label {{ font-size: 10px; letter-spacing: 0.14em;\
text-transform: uppercase; font-weight: 600; opacity: 0.45; }}\
.bread-drawer-section-header {{ padding: 11px 16px 5px; }}\
.launcher-footer {{ padding: 8px 16px 12px; font-size: 10px; opacity: 0.4;\
{footer_case} }}",
bg_panel = bg_panel, bg_panel = bg_panel,
surface = p.color0, accent = hex_to_rgba(&p.color4, 0.9),
accent = p.color4, on_bg = on_bg,
on_bg = ink_on(&p.background), hairline = hex_to_rgba(on_bg, 0.08),
on_surface = ink_on(&p.color0), selection_bg = hex_to_rgba(&p.color4, launcher.selection_alpha as f32),
radius = radius,
font_family = tokens.font_family(),
font_fallback = tokens.font_fallback(),
row_fs = tokens.font_size_base(),
row_pv = launcher.row_padding_v,
row_ph = launcher.row_padding_h,
row_inset = launcher.row_inset,
row_radius = launcher.row_radius,
icon_radius = launcher.icon_radius,
search_pv = launcher.search_padding_v,
search_ph = launcher.search_padding_h,
search_fs = launcher.search_font_size,
// Section headers and an uppercase, letter-spaced footer travel
// together in both demos (liquid-motion has both; glass-workbench's
// flat list has neither) — reusing `sections` here instead of a
// dedicated schema key for this one footer detail.
footer_case = if launcher.sections {
"text-transform: uppercase; letter-spacing: 0.12em;"
} else {
""
},
) )
} }
// ---- Icon loading ----------------------------------------------------------- /// `[launcher].footer`'s noun: `"count_apps"` → "applications", anything
/// else (`"count_results"` included — every other current/future value
fn make_icon(icon_name: &str, icon_path: Option<&Path>) -> gtk4::Image { /// falls back to this rather than a hard error, matching the "never fails
// Try loading from resolved cached path via gio::File /// to start over a theme string" rule the rest of this schema follows) →
if let Some(path) = icon_path { /// "results". `n`'s plural/singular form is picked here rather than baked
let gio_file = gtk4::gio::File::for_path(path); /// into `[launcher].footer` itself, since neither built-in theme's value is
if let Ok(texture) = gtk4::gdk::Texture::from_file(&gio_file) { /// singular-aware.
let img = gtk4::Image::new(); fn footer_text(footer_kind: &str, n: usize) -> String {
img.set_paintable(Some(&texture)); let noun = if footer_kind == "count_apps" {
img.set_pixel_size(32); "application"
return img;
}
}
// Fall back to GTK icon theme lookup by name
let name = if icon_name.is_empty() {
"application-x-executable"
} else { } else {
icon_name "result"
}; };
let img = gtk4::Image::from_icon_name(name); if n == 1 {
img.set_pixel_size(32); format!("1 {noun}")
img
}
// ---- Launch -----------------------------------------------------------------
fn pick_terminal() -> String {
if let Ok(t) = env::var("TERMINAL") {
if !t.is_empty() {
return t;
}
}
let path_var = env::var("PATH").unwrap_or_default();
for t in ["foot", "kitty", "alacritty", "wezterm", "ghostty", "xterm"] {
if path_var.split(':').any(|d| Path::new(d).join(t).exists()) {
return t.to_string();
}
}
"xterm".to_string()
}
fn do_launch(entry: &DesktopEntry) {
let cmd = entry.exec.trim();
let spawned = if entry.terminal {
let term = pick_terminal();
Command::new(&term)
.args(["-e", "bash", "-c", cmd])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
} else { } else {
Command::new("bash") format!("{n} {noun}s")
.args(["-c", cmd])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
};
if spawned.is_ok() {
emit_launched(entry);
} }
} }
/// Publishes `bread.box.launched` after a successful spawn. Fire-and-forget /// Counts the rows currently shown as real (non-header) app matches — the
/// and non-fatal (`BreadClient::emit` never blocks or errors this caller) — /// same "visible AND carries a `DesktopEntry`" test `ResultsList` itself
/// breadd being absent must never affect launching itself. /// uses for keyboard selection (`select_next`/`select_prev` in
fn emit_launched(entry: &DesktopEntry) { /// `bread_launcher::gtk`), reimplemented here read-only since `ResultsList`
let id = if entry.id.is_empty() { /// doesn't expose a count of its own.
entry.exec.as_str() fn visible_app_count(list: &gtk4::ListBox) -> usize {
} else { let mut i = 0i32;
entry.id.as_str() let mut n = 0usize;
}; while let Some(row) = list.row_at_index(i) {
BreadClient::connect(APP_ID).emit( if row.is_visible() && bread_launcher::gtk::row_entry(&row).is_some() {
"bread.box.launched", n += 1;
serde_json::json!({ "id": id, "name": entry.name }),
);
} }
i += 1;
// ---- Fuzzy matching ---------------------------------------------------------
fn fuzzy_matches(pattern: &str, text: &str) -> bool {
if pattern.is_empty() {
return true;
} }
let mut chars = text.chars(); n
for pc in pattern.chars() {
let pl = pc.to_lowercase().next().unwrap_or(pc);
if !chars
.by_ref()
.any(|tc| tc.to_lowercase().next().unwrap_or(tc) == pl)
{
return false;
}
}
true
}
fn fuzzy_score(query: &str, entry: &DesktopEntry) -> u32 {
let q = query.to_lowercase();
let name = entry.name.to_lowercase();
let wm = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
if name == q || wm == q { return 0; }
if name.starts_with(&q) { return 1; }
if name.contains(&q) { return 2; }
if wm.starts_with(&q) || wm.contains(&q) { return 3; }
4 // subsequence match
} }
// ---- UI --------------------------------------------------------------------- // ---- UI ---------------------------------------------------------------------
fn get_row_entry(row: &gtk4::ListBoxRow) -> Option<DesktopEntry> {
unsafe {
row.data::<DesktopEntry>("entry")
.map(|p| p.as_ref().clone())
}
}
fn run_ui( fn run_ui(
entries: Vec<DesktopEntry>, entries: Vec<DesktopEntry>,
history: LaunchHistory, history: LaunchHistory,
@ -303,15 +227,24 @@ fn run_ui(
let app = builder.build(); let app = builder.build();
let history_rc = Rc::new(RefCell::new(history)); let history_rc = Rc::new(RefCell::new(history));
let query_rc: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
let is_screenshot_run = screenshot_req.is_some(); let is_screenshot_run = screenshot_req.is_some();
app.connect_activate(move |app| { app.connect_activate(move |app| {
// Shell theme, loaded once per process and cached (see src/theme.rs)
// — the source of the launcher's geometry and style values below,
// per THEME_SYSTEM_PLAN.md §4/§11 Phase 4.
let shell_theme = theme::shell_theme();
let launcher = shell_theme.launcher().clone();
// Shared ecosystem base (fonts, palette, generic widgets) first, then // Shared ecosystem base (fonts, palette, generic widgets) first, then
// breadbox-specific CSS layered on top — both hot-reload on // breadbox-specific CSS layered on top — both hot-reload on
// `bread-theme reload` (the closure re-reads the pywal palette). // `bread-theme reload` (the closure re-reads the pywal palette).
bread_theme::gtk::apply_shared(); bread_theme::gtk::apply_shared();
bread_theme::gtk::apply_app_css(|| build_css(&load_palette())); {
let launcher = launcher.clone();
let tokens = shell_theme.tokens().clone();
bread_theme::gtk::apply_app_css(move || build_css(&load_palette(), &launcher, &tokens));
}
// User CSS override // User CSS override
{ {
@ -320,16 +253,8 @@ fn run_ui(
bread_theme::gtk::apply_user_css(&user_css_path, &user_cell); bread_theme::gtk::apply_user_css(&user_css_path, &user_cell);
} }
// Full-screen transparent window; clicks outside the launcher panel close it. // Full-screen transparent overlay; panel widget is positioned inside it.
let window = ApplicationWindow::builder().application(app).build(); let window = bread_utils::gtk_popup::new_overlay_window(app, "breadbox");
window.init_layer_shell();
window.set_namespace(Some("breadbox"));
window.set_layer(Layer::Overlay);
window.set_keyboard_mode(KeyboardMode::Exclusive);
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
window.set_anchor(edge, true);
}
window.set_exclusive_zone(0);
bread_theme::gtk::bind_window_auto(&window); bread_theme::gtk::bind_window_auto(&window);
let close_all: Rc<dyn Fn()> = Rc::new({ let close_all: Rc<dyn Fn()> = Rc::new({
@ -343,118 +268,69 @@ fn run_ui(
vbox.add_css_class("launcher-bg"); vbox.add_css_class("launcher-bg");
vbox.set_halign(gtk4::Align::Center); vbox.set_halign(gtk4::Align::Center);
vbox.set_valign(gtk4::Align::Start); vbox.set_valign(gtk4::Align::Start);
vbox.set_margin_top(120); vbox.set_margin_top(theme::top_margin_px(&launcher.top));
vbox.set_size_request(600, -1); // `set_size_request` only pins a MINIMUM, not a maximum — the same
// class of bug that made breadbar's capsule stretch to its widest
// row (`surface.rs`/`main.rs`'s `set_size_request` pin comment).
// This is safe here because `results.scroller` below is the
// `bread_launcher::gtk::ResultsList` widget, which already calls
// `scroller.set_propagate_natural_width(false)` specifically so its
// `ListBox`'s widest row (icon + full app name + wm-class) can never
// push a host wider than what the host requests — see that widget's
// own comment in `bread-launcher/src/gtk.rs`. `search` (a plain
// `SearchEntry`) has no unbounded natural width either. So nothing
// under `vbox` can grow past `launcher.width`, and this minimum-only
// request is effectively exact.
vbox.set_size_request(launcher.width, -1);
let search = SearchEntry::new(); let search = SearchEntry::new();
search.set_placeholder_text(Some("breadbox")); // "Search", not the app's own name — matches both design demos'
// `<input placeholder="Search">` (bos-ui-demos/proposed/
// {liquid-motion,glass-workbench}.html); no user-visible copy here
// should read like an internal identifier.
search.set_placeholder_text(Some("Search"));
vbox.append(&search); vbox.append(&search);
let scroll = ScrolledWindow::new(); // Row building, fuzzy filtering, match/history sorting, and
scroll.set_policy(PolicyType::Never, PolicyType::Automatic); // keyboard-style selection movement all live in bread-launcher's
scroll.set_max_content_height(480); // `ResultsList` — the widget breadbar's embedded capsule also uses
scroll.set_propagate_natural_height(true); // (THEME_SYSTEM_PLAN.md §7). `[launcher].sections`: liquid-motion
// groups the idle view into "Recent"/"Apps" headers, glass-workbench
// stays the flat, ungrouped list — no longer hardcoded to `false`.
let results = ResultsList::new(
&entries,
launcher.icon_px,
Rc::clone(&history_rc),
launcher.sections,
);
let list = ListBox::new(); vbox.append(&results.scroller);
list.set_selection_mode(SelectionMode::Browse);
for (idx, entry) in entries.iter().enumerate() { // Footer: "N applications" (liquid-motion) / "N results"
let row = gtk4::ListBoxRow::new(); // (glass-workbench) — `[launcher].footer` selects the noun (see
let hbox = GBox::new(Orientation::Horizontal, 0); // `footer_text`), updated alongside the query on every keystroke.
hbox.set_margin_start(6); let footer = Label::new(None);
hbox.set_margin_end(6); footer.add_css_class("launcher-footer");
hbox.set_valign(gtk4::Align::Center); footer.set_xalign(0.0);
footer.set_label(&footer_text(&launcher.footer, visible_app_count(&results.list)));
vbox.append(&footer);
let icon = make_icon(&entry.icon_name, entry.icon_path.as_deref());
hbox.append(&icon);
let name_lbl = Label::new(Some(&entry.name));
name_lbl.add_css_class("app-name");
name_lbl.set_xalign(0.0);
name_lbl.set_hexpand(true);
name_lbl.set_ellipsize(EllipsizeMode::End);
hbox.append(&name_lbl);
if let Some(ref wm) = entry.wm_class {
let wm_lbl = Label::new(Some(wm));
wm_lbl.add_css_class("app-muted");
wm_lbl.set_xalign(1.0);
hbox.append(&wm_lbl);
}
row.set_child(Some(&hbox));
unsafe { row.set_data("entry", entry.clone()) };
unsafe { row.set_data("initial_order", idx as u32) };
list.append(&row);
}
// Sort by match quality + launch count when a query is active;
// fall back to insertion order (priority + launch frequency) when empty.
let sort_query = Rc::clone(&query_rc);
let sort_history = Rc::clone(&history_rc);
list.set_sort_func(move |row_a, row_b| {
let query = sort_query.borrow();
if query.is_empty() {
let oa = unsafe { row_a.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref()) };
let ob = unsafe { row_b.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref()) };
return oa.cmp(&ob).into();
}
let (Some(ea), Some(eb)) = (get_row_entry(row_a), get_row_entry(row_b)) else {
return std::cmp::Ordering::Equal.into();
};
let sa = fuzzy_score(&query, &ea);
let sb = fuzzy_score(&query, &eb);
let history = sort_history.borrow();
let ca = history.count(&ea.name);
let cb = history.count(&eb.name);
sa.cmp(&sb)
.then(cb.cmp(&ca))
.then(ea.name.to_lowercase().cmp(&eb.name.to_lowercase()))
.into()
});
if let Some(first) = list.row_at_index(0) {
list.select_row(Some(&first));
}
scroll.set_child(Some(&list));
vbox.append(&scroll);
window.set_child(Some(&vbox)); window.set_child(Some(&vbox));
// Filter on keystroke // Filter on keystroke
let list_f = list.clone(); let results_f = results.clone();
let filter_query = Rc::clone(&query_rc); let footer_f = footer.clone();
let footer_kind = launcher.footer.clone();
search.connect_changed(move |entry| { search.connect_changed(move |entry| {
let text = entry.text(); results_f.set_query(entry.text().as_str());
let query = text.as_str(); footer_f.set_label(&footer_text(&footer_kind, visible_app_count(&results_f.list)));
*filter_query.borrow_mut() = query.to_string();
let mut i = 0i32;
while let Some(row) = list_f.row_at_index(i) {
let vis = get_row_entry(&row)
.map(|e| {
fuzzy_matches(query, &e.name)
|| e.wm_class
.as_deref()
.is_some_and(|w| fuzzy_matches(query, w))
|| fuzzy_matches(query, &e.exec)
})
.unwrap_or(false);
row.set_visible(vis);
i += 1;
}
list_f.invalidate_sort();
let first_vis = (0i32..).find_map(|j| {
list_f.row_at_index(j).filter(|r| r.is_visible())
});
list_f.select_row(first_vis.as_ref());
}); });
// Keyboard handling — capture phase on window // Keyboard handling — capture phase on window
let key_ctrl = EventControllerKey::new(); let key_ctrl = EventControllerKey::new();
key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture);
let close_k = Rc::clone(&close_all); let close_k = Rc::clone(&close_all);
let list_k = list.clone(); let results_k = results.clone();
let history_k = Rc::clone(&history_rc);
key_ctrl.connect_key_pressed(move |_, key, _, _| { key_ctrl.connect_key_pressed(move |_, key, _, _| {
use gtk4::gdk::Key; use gtk4::gdk::Key;
match key { match key {
@ -463,47 +339,19 @@ fn run_ui(
glib::Propagation::Stop glib::Propagation::Stop
} }
Key::Return | Key::KP_Enter => { Key::Return | Key::KP_Enter => {
if let Some(row) = list_k.selected_row() { if let Some(entry) = results_k.selected_entry() {
if let Some(entry) = get_row_entry(&row) { results_k.record_launch(&entry);
history_k.borrow_mut().increment(&entry.name); bread_launcher::do_launch(&entry, APP_ID, LAUNCHED_EVENT);
history_k.borrow().save();
do_launch(&entry);
close_k(); close_k();
} }
}
glib::Propagation::Stop glib::Propagation::Stop
} }
Key::Down => { Key::Down => {
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1); results_k.select_next();
let mut i = cur + 1;
loop {
match list_k.row_at_index(i) {
Some(r) if r.is_visible() => {
list_k.select_row(Some(&r));
break;
}
Some(_) => i += 1,
None => break,
}
}
glib::Propagation::Stop glib::Propagation::Stop
} }
Key::Up => { Key::Up => {
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0); results_k.select_prev();
let mut i = cur - 1;
loop {
if i < 0 {
break;
}
match list_k.row_at_index(i) {
Some(r) if r.is_visible() => {
list_k.select_row(Some(&r));
break;
}
Some(_) => i -= 1,
None => break,
}
}
glib::Propagation::Stop glib::Propagation::Stop
} }
_ => glib::Propagation::Proceed, _ => glib::Propagation::Proceed,
@ -513,33 +361,20 @@ fn run_ui(
// Row click launches // Row click launches
let close_a = Rc::clone(&close_all); let close_a = Rc::clone(&close_all);
let history_a = Rc::clone(&history_rc); let results_a = results.clone();
list.connect_row_activated(move |_, row| { results.list.connect_row_activated(move |_, row| {
if let Some(entry) = get_row_entry(row) { if let Some(entry) = row_entry(row) {
history_a.borrow_mut().increment(&entry.name); results_a.record_launch(&entry);
history_a.borrow().save(); bread_launcher::do_launch(&entry, APP_ID, LAUNCHED_EVENT);
do_launch(&entry);
close_a(); close_a();
} }
}); });
// Click outside launcher panel → close // Click outside launcher panel → close
let close_outside = Rc::clone(&close_all);
let vbox_ref = vbox.clone();
let win_ref = window.clone();
let outside_click = gtk4::GestureClick::new();
outside_click.connect_pressed(move |_, _, x, y| {
if let Some(b) = vbox_ref.compute_bounds(&win_ref) {
if x < b.x() as f64
|| x > (b.x() + b.width()) as f64
|| y < b.y() as f64
|| y > (b.y() + b.height()) as f64
{ {
close_outside(); let close_outside = Rc::clone(&close_all);
bread_utils::gtk_popup::close_on_outside_click(&window, &vbox, move || close_outside());
} }
}
});
window.add_controller(outside_click);
if let Some(req) = screenshot_req.clone() { if let Some(req) = screenshot_req.clone() {
screenshot::dispatch(&window, req); screenshot::dispatch(&window, req);
@ -571,6 +406,32 @@ fn main() {
let cli = screenshot::Cli::parse(); let cli = screenshot::Cli::parse();
let screenshot_req = cli.screenshot_request(); let screenshot_req = cli.screenshot_request();
// Under an `[launcher] mode = "embedded"` theme (spotlight,
// THEME_SYSTEM_PLAN.md §7 phase 6c), breadbar's own bar-drawer capsule
// IS the launcher — mapping this binary's overlay window on top of it
// would stack a second launcher over the capsule, exactly the bug a
// keybind that still directly execs `breadbox` (see the CLAUDE.md task
// notes: `bash -c breadbox` in ~/.config/hypr/binds.json) would hit
// every time. `--screenshot` runs are exempt — those exist to capture
// THIS binary's own overlay for its own screenshot views regardless of
// whatever theme happens to be active on the machine running them.
if screenshot_req.is_none()
&& crate::theme::shell_theme().launcher().mode
== bread_theme::shell::LauncherMode::Embedded
{
// `dispatch_embedded_open` only returns `true` once it has confirmed
// breadd itself is reachable (`BreadClient::health`) and emitted the
// redirect — see its doc comment for why that is the strongest
// guarantee this transport can give (there is no ack from breadbar).
// When breadd is unreachable, fall through to mapping breadbox's own
// overlay below instead of returning: a keybind press must always
// open *something*, even under an embedded theme, rather than
// silently doing nothing because the bus happens to be down.
if dispatch_embedded_open() {
return;
}
}
// `toggle_or_kill` kills whatever's holding the single-instance lock — // `toggle_or_kill` kills whatever's holding the single-instance lock —
// a real, already-running breadbox included. A screenshot run must // a real, already-running breadbox included. A screenshot run must
// never touch it: it's a separate, disposable instance by design (same // never touch it: it's a separate, disposable instance by design (same
@ -587,7 +448,9 @@ fn main() {
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard), Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
Ok(bread_utils::singleton::Toggle::KilledExisting) => return, Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
Err(e) => { Err(e) => {
eprintln!("breadbox: single-instance lock unavailable ({e}); continuing without it"); eprintln!(
"breadbox: single-instance lock unavailable ({e}); continuing without it"
);
None None
} }
} }
@ -600,9 +463,70 @@ fn main() {
.map(|c| c.priority.clone()) .map(|c| c.priority.clone())
.unwrap_or_default(); .unwrap_or_default();
let history = LaunchHistory::load(); let history = breadbox_shared::launch_history();
let manifest = load_manifest(); let manifest = load_manifest();
let entries = load_sorted_entries(&manifest, &priority, &history); let entries = bread_launcher::load_sorted_entries(&manifest, &priority, &history);
run_ui(entries, history, screenshot_req); run_ui(entries, history, screenshot_req);
} }
/// Redirects an embedded-theme launch to the bus instead of mapping a
/// window (see the `main` call site). breadbar's capsule (spotlight)
/// subscribes to this and focuses/opens itself on receipt — see
/// `breadbar/src/launcher_command.rs`.
///
/// Emits `bread.box.open_requested`, NOT `bread.command.box.open`. Two
/// reasons, one mechanical and one semantic:
///
/// `BreadClient::emit` enforces that an app may only publish within its own
/// `bread.<app_id>.*` namespace (`bread_utils::bread_client`'s
/// `validate_app_namespace`). breadbox's app id is `box`, so a
/// `bread.command.*` event is refused outright and the redirect silently did
/// nothing but print a warning.
///
/// The guard is right, and the original name had the direction backwards.
/// `bread.command.<app>.*` is a command addressed TO an app by an outside
/// trigger — it is what breadbox's own `listen` subscribes to. An app
/// emitting a command at itself would mean breadbox both sends and receives
/// the same verb, which is also how the respawn loop `listen.rs` guards
/// against arises. What actually happened here is an event: breadbox was
/// asked to open and is reporting that, so it belongs in breadbox's own
/// namespace as a past-tense fact.
///
/// Fire-and-forget, same as every other `BreadClient::emit` here — breadd
/// being unreachable must never turn a keybind press into an error dialog or
/// a hung process. But "nobody was listening" used to also mean "the
/// keybind silently does nothing at all", which is the worst failure mode
/// available for a launcher: no window, no error, nothing on stderr. This
/// now checks reachability first (`BreadClient::health`, a real round trip
/// with a bounded timeout) so the `main` call site can fall back to mapping
/// breadbox's own overlay window when breadd itself is down — see its call
/// site.
///
/// That fallback only covers "breadd is unreachable", not "breadd is up but
/// nobody is subscribed" (breadbar isn't running, or is running under a
/// non-Embedded theme and therefore never subscribed —
/// `launcher_command.rs::spawn`). `BreadClient`'s API has no way to ask "is
/// anything actually subscribed to this event" — `emit` has no ack and
/// `health` only reports breadd's own liveness — so that narrower gap can't
/// be closed without a bus-level ack protocol, which is out of scope here.
/// Returns `true` iff breadd was reachable and the redirect was sent.
fn dispatch_embedded_open() -> bool {
let client = bread_utils::bread_client::BreadClient::connect(APP_ID);
if client.health().is_none() {
eprintln!(
"breadbox: embedded launcher theme active but breadd is unreachable \
(health check failed); falling back to breadbox's own overlay window \
instead of a silent no-op"
);
return false;
}
client.emit(EMBEDDED_OPEN_EVENT, serde_json::json!({}));
eprintln!(
"breadbox: embedded launcher theme active; redirected the open request to \
'{EMBEDDED_OPEN_EVENT}' for breadbar's capsule to handle (breadd is \
reachable, but this is fire-and-forget with no ack if breadbar isn't \
running or isn't subscribed, this is still a silent no-op on the bus)"
);
true
}

98
breadbox/src/theme.rs Normal file
View file

@ -0,0 +1,98 @@
//! The active shell theme, loaded once per process (plan §5/§6, Phase 4b-i).
//!
//! Mirrors breadbar's `src/theme.rs::shell_theme()` accessor: the underlying
//! `bread_theme::shell::load()` call happens at most once per process (via
//! the thread-local cache below), not once per call site — every place in
//! this crate that needs launcher geometry/style reads through this shared
//! instance instead of calling `bread_theme::shell::load()` itself.
//!
//! Only `ShellTheme::launcher()` is consumed today; window-spec/tokens/slots
//! accessors exist on the type but breadbox has no bar/workspace/clock
//! chrome to drive from them.
use bread_theme::shell::ShellTheme;
use std::cell::RefCell;
use std::rc::Rc;
thread_local! {
static SHELL_THEME: RefCell<Rc<ShellTheme>> =
RefCell::new(Rc::new(bread_theme::shell::load()));
}
/// The active shell theme. Loaded at most once per process; subsequent calls
/// just clone the cached `Rc`.
pub fn shell_theme() -> Rc<ShellTheme> {
SHELL_THEME.with(|cell| cell.borrow().clone())
}
/// Fallback margin (pixels) for any `[launcher] top` value this function
/// can't make sense of — breadbox's pre-theme hardcoded default.
const DEFAULT_TOP_MARGIN_PX: i32 = 120;
/// Parses a `[launcher] top` value (e.g. `"120px"`) into whole pixels for
/// `Widget::set_margin_top`. Falls back to [`DEFAULT_TOP_MARGIN_PX`] — with a
/// logged reason — for any form this Phase 4b-i doesn't understand yet (a
/// bare percentage like theme 04's `"16%"`, or a malformed value in a
/// hand-edited theme) — per the manifest's "never fails to start" rule (plan
/// §4), a launcher window is not worth refusing to open over an unparseable
/// margin.
///
/// A value that parses but is negative (e.g. `"-50px"`) is clamped to `0`
/// rather than passed straight through: `Widget::set_margin_top` requires a
/// non-negative margin, and passing it a negative one silently does nothing
/// useful (a `g_return_if_fail` on the GTK side) rather than the "push the
/// panel up off-screen" a theme author might expect — 0 (flush with the top
/// edge) is the closest sane interpretation.
pub fn top_margin_px(top: &str) -> i32 {
let trimmed = top.trim().trim_end_matches("px");
match trimmed.parse::<i32>() {
Ok(px) if px < 0 => {
eprintln!(
"breadbox: [launcher] top = {top:?} parsed to a negative margin \
({px}px); clamping to 0"
);
0
}
Ok(px) => px,
Err(_) => {
eprintln!(
"breadbox: [launcher] top = {top:?} is not a plain pixel value \
(e.g. \"120px\"); falling back to the default \
{DEFAULT_TOP_MARGIN_PX}px margin"
);
DEFAULT_TOP_MARGIN_PX
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_pixel_value() {
assert_eq!(top_margin_px("120px"), 120);
}
#[test]
fn bare_number_without_unit_suffix() {
assert_eq!(top_margin_px("120"), 120);
}
#[test]
fn percentage_form_falls_back_to_default() {
// The doc comment's own anticipated edge case (theme 04's original
// "16%" idea) — not parseable as a plain pixel count today.
assert_eq!(top_margin_px("16%"), DEFAULT_TOP_MARGIN_PX);
}
#[test]
fn garbage_falls_back_to_default() {
assert_eq!(top_margin_px("not-a-length"), DEFAULT_TOP_MARGIN_PX);
}
#[test]
fn negative_value_clamps_to_zero() {
assert_eq!(top_margin_px("-50px"), 0);
}
}