Compare commits

..

3 commits
main ... v0.2.6

Author SHA1 Message Date
Breadway
eaf3fdc93f 0.2.6: search placeholder text, tighten fuzzy-match noise
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
release / build (push) Successful in 1m16s
Build and publish package / package (push) Successful in 1m59s
Search entry's placeholder read "breadbox" -- the app's own name --
easily mistaken for an already-typed query. Now reads "Search apps...".

Bare-subsequence matching (e.g. "zen" matching "Avahi Zeroconf Browser"
via z...e...n) surfaced alongside real name-based hits like "Zen
Browser". The filter now hides subsequence-only rows whenever a
stronger, name-based match exists elsewhere in the results -- that kind
of noise now only shows up when it's the best any entry can do.

Note: bumping to 0.2.6, not 0.2.5 -- Cargo.toml had drifted one release
behind the actual latest tag (v0.2.5 already existed).
2026-07-05 09:15:42 +08:00
Breadway
6385a080f1 CI: migrate release workflow from GitHub Actions to Forgejo Actions
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
GitHub Actions self-hosted runners need per-repo registration on a
personal account; Forgejo Actions' runner already serves every repo
with zero setup. Moves release publishing there (dl.breadway.dev stays
the primary bakery target; GitHub release upload is kept as the
fallback via an explicit token, since Forgejo Actions has no ambient
GITHUB_TOKEN) and adds a mirror workflow to keep GitHub in sync
automatically.
2026-07-03 14:10:02 +08:00
Breadway
7ece4fd762 CI: use /tmp for ecosystem clone, avoid permissions conflict
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
2026-06-19 08:38:38 +08:00
26 changed files with 975 additions and 1740 deletions

View file

@ -1,24 +0,0 @@
name: check
# Fast-fail lint/test on short-lived work branches, before it ever reaches
# main and triggers a dev-track release build.
on:
push:
branches: ['feature/**', 'fix/**']
jobs:
check:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: clippy
run: cd src && bash ci/build.sh cargo clippy --workspace --all-targets --locked -- -D warnings
- name: test
run: cd src && bash ci/build.sh cargo test --workspace --locked

View file

@ -1,80 +0,0 @@
name: dev release
# Publishes a dev-track build on every push to `main` (the trunk
# branch — there is no separate `dev` branch). See bread-ecosystem's
# docs/release-channels.md for the release-track policy this is part of.
on:
push:
branches: ['main']
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch main --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && bash ci/build.sh cargo build --release --locked
- name: compute dev version
run: |
set -euo pipefail
cd src
# Base the dev version off the latest published stable tag,
# not Cargo.toml — Cargo.toml can go stale relative to the last
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
# breadpaper), which would make a dev build sort as OLDER than
# what's already installed and bakery would correctly refuse it.
LATEST_TAG="$(git ls-remote --tags --refs \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
if [ -n "${LATEST_TAG}" ]; then
CUR="${LATEST_TAG}"
else
CUR="$(grep -m1 '^version' breadbox/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
fi
IFS='.' read -r MA MI PA <<< "${CUR}"
SHA="$(git rev-parse --short HEAD)"
TS="$(date -u +%Y%m%d%H%M%S)"
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
- name: prepare artifacts
run: |
set -euo pipefail
PKG_DIR="/srv/breadway-dl/dev/breadbox/${VERSION}"
mkdir -p "${PKG_DIR}"
for bin in breadbox breadbox-sync; do
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
strip "${PKG_DIR}/${bin}-x86_64"
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/${bin}-x86_64.sha256"
done
cp src/packaging/breadbox-sync.service "${PKG_DIR}/"
cp src/config.example.toml "${PKG_DIR}/"
cp src/LICENSE "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadbox/latest"
# No GitHub Release upload — dev, like the other non-stable track,
# is only distributed via dl.breadway.dev/dev/.
- name: regenerate dev 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 dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
# mktemp: a fixed clone path races when multiple repos' dev/beta
# workflows run close together on the same self-hosted runner.
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -0,0 +1,19 @@
name: Mirror to GitHub
on:
push:
branches: ['**']
tags: ['**']
jobs:
mirror:
runs-on: [self-hosted, hestia]
steps:
- name: Mirror to GitHub
run: |
set -euo pipefail
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
cd repo.git
git push --prune \
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadbox.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'

View file

@ -0,0 +1,40 @@
name: Build and publish package
on:
push:
tags: ['v*']
jobs:
package:
runs-on: [self-hosted, hestia]
container:
image: archlinux:latest
steps:
# Note: no actions/checkout — the archlinux image has no Node, which JS
# actions require. Everything runs as shell steps and clones manually.
- name: Build and publish
env:
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell librsvg
useradd -m builder
git config --global --add safe.directory '*'
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
cd /home/builder/src
git archive --format=tar.gz --prefix="breadbox-${VERSION}/" HEAD \
> packaging/arch/breadbox-${VERSION}.tar.gz
SHA=$(sha256sum packaging/arch/breadbox-${VERSION}.tar.gz | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD
chown -R builder:builder /home/builder/src
# --nocheck: packaging builds the artifact; tests belong in a CI job.
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck"
PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1)
curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PKG}" \
"https://git.breadway.dev/api/packages/Breadway/arch/os"

View file

@ -1,61 +0,0 @@
name: beta (rc) release
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag
# pushed to `main` — there is no separate `beta` branch; "freezing" is
# just pausing pushes to main while an RC gets tested. See
# bread-ecosystem's docs/release-channels.md for the release-track policy.
on:
push:
tags: ['v*']
jobs:
build:
if: ${{ contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && bash ci/build.sh cargo build --release --locked
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/beta/breadbox/${VERSION}"
mkdir -p "${PKG_DIR}"
for bin in breadbox breadbox-sync; do
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
strip "${PKG_DIR}/${bin}-x86_64"
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/${bin}-x86_64.sha256"
done
cp src/packaging/breadbox-sync.service "${PKG_DIR}/"
cp src/config.example.toml "${PKG_DIR}/"
cp src/LICENSE "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadbox/latest"
# No GitHub Release upload — beta, like dev, is only distributed via
# dl.breadway.dev/beta/.
- name: regenerate beta 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 beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
# mktemp: a fixed clone path races when multiple repos' dev/beta
# workflows run close together on the same self-hosted runner.
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -6,7 +6,6 @@ on:
jobs:
build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
@ -17,16 +16,7 @@ jobs:
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
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
}
run: cd src && cargo build --release --locked
- name: prepare artifacts
run: |
@ -42,19 +32,12 @@ jobs:
done
cp src/packaging/breadbox-sync.service "${PKG_DIR}/"
cp src/config.example.toml "${PKG_DIR}/"
cp src/LICENSE "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/breadbox/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

1
.github/README.md vendored
View file

@ -1 +0,0 @@
Forgejo (`.forgejo/workflows`) is the canonical CI. This `.github` tree is unused.

9
.gitignore vendored
View file

@ -19,12 +19,3 @@ Thumbs.db
# Claude Code session data
.claude/
# Local hygiene notes (not for commit)
CLAUDE.md
# graphify knowledge-graph output (local tool cache, not for commit)
graphify-out/
# Local-only source overrides (see .cargo/config.toml).
.cargo/

View file

@ -1,13 +0,0 @@
# AGENTS.md — Repo hygiene
Follow [`CONTRIBUTING.md`](CONTRIBUTING.md). Single-trunk: `main` plus short-lived `feature/` / `fix/` branches. `dev`/`beta` are bakery tracks (tags / main), not git branches.
## Remotes
- `origin` — Forgejo (`git.breadway.dev`) — authoritative.
- `github` — mirror. Day-to-day push `origin` only.
## Product
GTK4 app launcher + `breadbox-sync` icon cache. Theme via `bread-theme` (pin by tag on `git.breadway.dev`). Toggle uses `bread-utils::singleton`, not a homegrown PID file. `EVENTS.md` is the bread-event contract (app id `box`); emit `bread.box.launched` after a successful launch. `breadbox listen` honors `bread.command.box.open`.
## Distribution
Bakery (`bakery.toml`). Forgejo `.forgejo/workflows/` is canonical; do not re-add a GitHub Actions release workflow.

View file

@ -1,84 +0,0 @@
# Contributing
`breadbox` — App launcher for Hyprland / Wayland.
Part of the bread ecosystem; this repo follows the same branch/release
workflow as every other ecosystem product.
## Branches
There is one long-lived branch: **`main`**. All day-to-day work lands here.
Every push to `main` automatically builds and publishes a **dev-track**
build (see Tracks below) — a real install you can test before cutting
anything more formal.
New work — features and bug fixes alike — goes on a short-lived branch:
```
feature/<short-name>
fix/<issue-number-or-short-name>
```
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
branches get deleted on merge — they never accumulate the kind of drift a
second long-lived branch does.
## The release cycle
There's no separate `beta` or release branch — "stable" and "beta" are both
just **tags** on `main`, not branches that need to be kept in sync:
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
auto-publishes a dev build — install it with `bakery track set dev` and
`bakery update --all`, then fix anything broken with another push.
2. When you want to stabilize before a real release, tag a release
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
both remotes). That tag alone triggers a beta-track build —
"freezing" is just pausing pushes to `main` while you test it, not a
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
3. Once an RC has gone without issues, tag the real release:
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
signed stable release build.
## Tracks, from a user's perspective
```
bakery track show # what you're currently on (defaults to stable)
bakery track set dev # or beta, or stable
bakery update --all # pull the latest build on your current track
```
| Track | What it is | Published from |
|--------|-----------|-----------------|
| `stable` | The last tagged release | a `vX.Y.Z` tag |
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
| `dev` | Bleeding edge | `main`, on every push |
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
latest published stable tag, so they always sort as newer than what you
have installed — no manual version bumping needed. Beta versions are just
the RC tag itself (already valid semver, already sorts below the real
release it's a candidate for).
## Local development
```sh
cargo build --release --workspace
cargo test --release --workspace
```
## CI
- `dev-release.yml` — triggered on push to `main`.
- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push.
- `release.yml` — triggered on any other `v*` tag push, cuts the actual
stable release.
All CI runs on a self-hosted runner; nothing runs automatically on plain
commits or PRs beyond the track builds above. See
[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md)
for the full policy, including how a new product gets wired onto these tracks.
## Questions
Open an issue on this repo's Forgejo tracker.

733
Cargo.lock generated

File diff suppressed because it is too large Load diff

116
EVENTS.md
View file

@ -1,116 +0,0 @@
# breadbox — bread event integration
breadbox is a standalone app launcher: it works exactly the same with or
without `breadd` running. When breadd *is* present, the GTK launcher
publishes a single event into the shared bread automation fabric after a
successful launch. See the parent `bread` repo's `Documentation.md`
specifically its "Namespaces" and "Integrating a bread\* app" sections —
for the general convention this follows.
App id: **`box`**. Transport: `bread-utils`'s `bread_client` module
(feature `bread-client`) — `breadbox` links it directly. One-shot
launcher invocations each `emit` on their own fire-and-forget
connection. Command verbs are only received while `breadbox listen` is
running — that process holds the `bread.command.box.**` subscription
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.*`)
| 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.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 — 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`);
the event bus is a notification that a launch happened, not a channel
for the exec line's arguments or the resulting process.
## Commands honored (`bread.command.box.*`)
These are only received while `breadbox listen` is running. Publishing a
command with no subscriber is a silent no-op — that is the documented
bread convention, not a breadbox bug.
| Verb | Data | Effect |
|------|------|--------|
| `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
-- Non-embedded themes: `.done` is a real completion signal.
bread.spawn(function()
bread.emit("bread.command.box.open")
bread.wait("bread.box.open.done", { timeout = 5000 })
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
There is no `launch` / `close` / `query` command verb. Picking a desktop
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
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
- 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
command subscription simply never receives anything — launching,
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
(`BreadClient::subscribe`'s background thread has its own backoff
loop); no restart of `breadbox listen` is needed.
- If `breadbox listen` is not running, commands are a graceful no-op at
the bus (no subscriber). The CLI still works, and one-shot invocations
still emit `bread.box.launched` on their own short-lived connection.
- 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

@ -12,9 +12,10 @@ breadbox GTK4 layer-shell launcher
- Layer-shell window, centered 600 px wide, keyboard-exclusive
- Reads the active Hyprland workspace and sorts apps by context priority
- Fuzzy filtering as you type; Enter launches, Escape closes
- Launch history: non-priority apps sort by most-launched first, then alphabetically
- Fuzzy filtering as you type; Enter or click to launch, Escape or click outside to close
- App icons loaded from the resolved icon cache (see `breadbox-sync`)
- pywal accents from `~/.cache/wal/colors.json`; background/surface/overlay/foreground stay fixed BOS dark
- pywal palette auto-detected from `~/.cache/wal/colors.json`, falls back to Catppuccin Mocha
- User CSS override at `~/.config/breadbox/style.css`
- Toggle/dismiss: running a second instance kills the first
@ -88,10 +89,9 @@ breadbox-sync
```
Icon resolution order:
1. System icon theme (`~/.local/share/icons`, `/usr/share/icons`, `/usr/share/pixmaps`) — 64 px > 48 px PNG, then SVG
2. Flathub media server — for reverse-DNS app IDs (e.g. `org.gnome.Gedit`)
3. icon.horse — downloaded and cached
4. `application-x-executable` fallback from system theme
1. System icon theme (`~/.local/share/icons`, `/usr/share/icons`, `/usr/share/pixmaps`) — 64 px > 48 px > 128 px > 32 px > 256 px PNG, then SVG
2. Flathub appstream CDN — for reverse-DNS app IDs (e.g. `org.gnome.Gedit`)
3. `application-x-executable` fallback from system theme
### Systemd service (run on login)

View file

@ -4,7 +4,6 @@ binaries = ["breadbox", "breadbox-sync"]
system_deps = ["gtk4", "gtk4-layer-shell", "librsvg"]
optional_system_deps = ["hyprland"]
bread_deps = []
license_file = "LICENSE"
[[service]]
unit = "breadbox-sync.service"

View file

@ -1,6 +1,6 @@
[package]
name = "breadbox-shared"
version = "0.3.3"
version = "0.2.6"
edition = "2021"
license = "MIT"
@ -8,8 +8,3 @@ license = "MIT"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
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,59 +1,254 @@
//! **Compatibility shim.** This crate used to own desktop-entry parsing,
//! icon caching, and launch history; that substance now lives in
//! `bread-launcher` (`bread-ecosystem`, feature/launcher-core) so breadbar's
//! future embedded capsule can share it with breadbox's overlay window —
//! one launcher implementation, two hosts (see
//! `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 std::{
collections::HashMap,
env,
fs::{self, File},
io::{BufRead, BufReader},
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
pub use bread_launcher::{
app_dirs, home_dir, load_all_desktop_entries, parse_desktop, strip_exec_codes, DesktopEntry,
IconCache, LaunchHistory,
};
// ---- XDG path helpers -------------------------------------------------------
/// The launcher's shared identity (`bread_launcher::LAUNCHER_APP`, currently
/// `"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 home_dir() -> PathBuf {
PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into()))
}
pub fn cache_dir() -> PathBuf {
bread_launcher::cache_dir(APP)
env::var("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home_dir().join(".cache"))
.join("breadbox")
}
pub fn config_dir() -> PathBuf {
bread_launcher::config_dir(APP)
env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home_dir().join(".config"))
.join("breadbox")
}
pub fn icon_cache() -> IconCache {
IconCache::new(APP)
pub fn app_dirs() -> Vec<PathBuf> {
let home = home_dir();
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);
}
}
pub fn icon_manifest_path() -> PathBuf {
IconCache::manifest_path(APP)
dirs.push(
env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home.join(".local/share"))
.join("applications"),
);
dirs
}
pub fn launch_history() -> LaunchHistory {
LaunchHistory::load(APP)
// ---- Desktop entry ----------------------------------------------------------
#[derive(Debug, Clone)]
pub struct DesktopEntry {
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,
}
// ---- Config (breadbox-specific, not launcher substance) --------------------
pub fn strip_exec_codes(exec: &str) -> String {
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();
Some(DesktopEntry {
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)]
pub struct Config {

View file

@ -1,6 +1,6 @@
[package]
name = "breadbox-sync"
version = "0.3.3"
version = "0.2.6"
edition = "2021"
license = "MIT"

View file

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

View file

@ -1,6 +1,6 @@
[package]
name = "breadbox"
version = "0.3.3"
version = "0.2.6"
edition = "2021"
license = "MIT"
@ -9,16 +9,8 @@ name = "breadbox"
path = "src/main.rs"
[dependencies]
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.5", features = ["bread-client", "gtk"] }
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
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"] }
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] }
breadbox-shared = { path = "../breadbox-shared" }
gtk4 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8"
serde_json = "1"
clap = { version = "4", features = ["derive"] }
anyhow = "1"

View file

@ -1,121 +0,0 @@
//! Long-running command subscription for `bread.command.box.*`.
//!
//! `breadbox` is still a one-shot toggle overlay by default. `breadbox listen`
//! is the optional persistent process that can honor bus commands. See
//! `EVENTS.md`.
use bread_utils::bread_client::{BreadClient, BreadEvent};
/// Sibling-app id in `bread_shared::apps::KNOWN_APPS`.
const APP_ID: &str = "box";
/// Subscribe to `bread.command.box.**` and block until the process is killed.
///
/// breadd being absent is not an error: [`BreadClient::subscribe`] reconnects
/// with backoff, and `on_event` simply isn't called until the daemon is up.
pub fn run() {
let client = BreadClient::connect(APP_ID);
if client.health().is_none() {
eprintln!("breadbox: breadd unreachable; command subscription will connect when it comes back");
}
let _commands = client.subscribe("bread.command.box.**", |event| {
handle_command(&event);
});
eprintln!("breadbox: listening for bread.command.box.**");
loop {
std::thread::park();
}
}
/// Reacts to `bread.command.box.*` verbs. Only `open` is honored today —
/// other verbs are ignored, not stubbed as no-ops that pretend to succeed.
fn handle_command(event: &BreadEvent) {
let Some(verb) = command_verb(&event.event) else {
return;
};
match verb {
"open" => handle_open(),
other => {
eprintln!("breadbox: ignoring unrecognized bread.command.box.{other}");
}
}
}
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
// existing singleton. Spawn success is the command confirmation — we do
// not wait for the GTK window to map.
let result = spawn_self();
match result {
Ok(_) => client.emit("bread.box.open.done", serde_json::json!({})),
Err(e) => {
eprintln!("breadbox: bread.command.box.open failed: {e}");
client.emit(
"bread.box.open.failed",
serde_json::json!({ "error": e.to_string() }),
);
}
}
}
fn spawn_self() -> std::io::Result<std::process::Child> {
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadbox"));
std::process::Command::new(exe).spawn()
}
fn command_verb(event_name: &str) -> Option<&str> {
event_name.strip_prefix("bread.command.box.")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_verb_strips_box_prefix() {
assert_eq!(command_verb("bread.command.box.open"), Some("open"));
assert_eq!(command_verb("bread.command.box.launch"), Some("launch"));
assert_eq!(command_verb("bread.command.clip.clear"), None);
assert_eq!(command_verb("bread.box.launched"), None);
}
}

View file

@ -1,37 +1,27 @@
use bread_launcher::gtk::{row_entry, ResultsList};
use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette};
use std::{
cell::RefCell,
collections::HashMap,
env, fs,
env,
fs,
io::{Read, Write},
os::unix::net::UnixStream,
path::PathBuf,
path::{Path, PathBuf},
process::{Command, Stdio},
rc::Rc,
};
/// This app's id in bread's sibling-app namespace registry
/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.box.*`.
const APP_ID: &str = "box";
/// Emitted instead of mapping breadbox's own window when the active shell
/// 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::{
glib, prelude::*, Application, Box as GBox, CssProvider,
EventControllerKey, Label, Orientation, SearchEntry,
use breadbox_shared::{
config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, LaunchHistory,
};
mod listen;
mod screenshot;
mod theme;
use gtk4::{
glib,
pango::EllipsizeMode,
prelude::*,
Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label,
ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode,
};
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
// ---- Hyprland IPC -----------------------------------------------------------
@ -54,7 +44,7 @@ fn get_active_workspace() -> Option<String> {
// ---- Manifest ---------------------------------------------------------------
fn load_manifest() -> HashMap<String, PathBuf> {
let path = breadbox_shared::icon_manifest_path();
let path = IconCache::manifest_path();
let content = fs::read_to_string(&path).unwrap_or_default();
serde_json::from_str::<HashMap<String, String>>(&content)
.unwrap_or_default()
@ -63,188 +53,298 @@ fn load_manifest() -> HashMap<String, PathBuf> {
.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 ----------------------------------------------------------------
fn build_css(
p: &Palette,
launcher: &bread_theme::shell::Launcher,
tokens: &bread_theme::shell::Tokens,
) -> String {
// Panel opacity comes from the theme, not a hardcoded constant. 0.60 made
// the launcher wash out over a bright wallpaper and its text hard to read —
// 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.
fn build_css(p: &Palette) -> String {
let bg_panel = hex_to_rgba(&p.background, 0.60);
// breadbox-specific rules only — fonts, palette, and generic widgets come
// from the shared ecosystem stylesheet (applied first in connect_activate).
// 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.
format!(
"window {{ background-color: transparent; }}\
.launcher-bg {{ background-color: {bg_panel}; color: {on_bg}; border-radius: {radius};\
/* NO drop shadow. In a browser `backdrop-filter` blurs only the\
element's own box, so the demo's shadow is free. Hyprland\
blurs any surface pixel above `ignore_alpha` (0.2), and a\
32px shadow at 0.6 alpha is far above it so the compositor\
blurred the shadow as well, painting a rectangular blurred\
halo around the rounded panel. That halo is the \"blur\
backdrop whose radius doesn't match the content\" problem.\
The blur behind a 0.95-opaque panel already gives plenty of\
separation; the shadow only fought it. */\
font-family: \"{font_family}\", {font_fallback}; }}\
entry.search {{ background-color: transparent; color: {on_bg}; caret-color: {accent};\
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} }}",
.launcher-bg {{ background-color: {bg_panel}; color: {on_bg}; border-radius: 8px;\
box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\
searchentry {{ background-color: {surface}; color: {on_surface}; caret-color: {accent};\
border: none; outline: none; box-shadow: none;\
padding: 12px 16px; border-radius: 6px 6px 0 0; }}\
listbox {{ background-color: transparent; padding: 4px; }}\
row {{ padding: 8px 12px; color: {on_bg}; background-color: transparent;\
border-radius: 6px; }}\
row:hover {{ background-color: {surface}; color: {on_surface}; }}\
row:selected {{ background-color: {surface}; color: {on_surface}; }}\
.app-name {{ font-size: 14px; }}\
.app-muted {{ opacity: 0.6; font-size: 12px; }}\
image {{ margin-right: 8px; }}",
bg_panel = bg_panel,
accent = hex_to_rgba(&p.color4, 0.9),
on_bg = on_bg,
hairline = hex_to_rgba(on_bg, 0.08),
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 {
""
},
surface = p.color0,
accent = p.color4,
on_bg = ink_on(&p.background),
on_surface = ink_on(&p.color0),
)
}
/// `[launcher].footer`'s noun: `"count_apps"` → "applications", anything
/// else (`"count_results"` included — every other current/future value
/// falls back to this rather than a hard error, matching the "never fails
/// to start over a theme string" rule the rest of this schema follows) →
/// "results". `n`'s plural/singular form is picked here rather than baked
/// into `[launcher].footer` itself, since neither built-in theme's value is
/// singular-aware.
fn footer_text(footer_kind: &str, n: usize) -> String {
let noun = if footer_kind == "count_apps" {
"application"
// ---- Icon loading -----------------------------------------------------------
fn make_icon(icon_name: &str, icon_path: Option<&Path>) -> gtk4::Image {
// Try loading from resolved cached path via gio::File
if let Some(path) = icon_path {
let gio_file = gtk4::gio::File::for_path(path);
if let Ok(texture) = gtk4::gdk::Texture::from_file(&gio_file) {
let img = gtk4::Image::new();
img.set_paintable(Some(&texture));
img.set_pixel_size(32);
return img;
}
}
// Fall back to GTK icon theme lookup by name
let name = if icon_name.is_empty() {
"application-x-executable"
} else {
"result"
icon_name
};
if n == 1 {
format!("1 {noun}")
let img = gtk4::Image::from_icon_name(name);
img.set_pixel_size(32);
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();
if entry.terminal {
let term = pick_terminal();
let _ = Command::new(&term)
.args(["-e", "bash", "-c", cmd])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
} else {
format!("{n} {noun}s")
let _ = Command::new("bash")
.args(["-c", cmd])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
}
/// Counts the rows currently shown as real (non-header) app matches — the
/// same "visible AND carries a `DesktopEntry`" test `ResultsList` itself
/// uses for keyboard selection (`select_next`/`select_prev` in
/// `bread_launcher::gtk`), reimplemented here read-only since `ResultsList`
/// doesn't expose a count of its own.
fn visible_app_count(list: &gtk4::ListBox) -> usize {
let mut i = 0i32;
let mut n = 0usize;
while let Some(row) = list.row_at_index(i) {
if row.is_visible() && bread_launcher::gtk::row_entry(&row).is_some() {
n += 1;
// ---- Fuzzy matching ---------------------------------------------------------
fn fuzzy_matches(pattern: &str, text: &str) -> bool {
if pattern.is_empty() {
return true;
}
i += 1;
let mut chars = text.chars();
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;
}
n
}
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
}
/// Same tiers as `fuzzy_score`, but `None` when nothing matches at all
/// (rather than falling through to a bare-subsequence score), and folds in
/// `exec` as a tier-4 (weakest) match too — used for filtering, not sorting.
/// Tier 4 is loose enough that e.g. querying "zen" matches "Avahi Zeroconf
/// Browser" (z…e…n as a subsequence) alongside the real "Zen Browser" hit;
/// the filter hides tier-4-only rows whenever a tier ≤2 (name-based) match
/// exists elsewhere in the list, so that kind of noise only shows up when
/// it's the best any entry can do.
fn match_tier(query: &str, entry: &DesktopEntry) -> Option<u32> {
if query.is_empty() {
return Some(0);
}
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 Some(0);
}
if name.starts_with(&q) {
return Some(1);
}
if name.contains(&q) {
return Some(2);
}
if wm.starts_with(&q) || wm.contains(&q) {
return Some(3);
}
if fuzzy_matches(query, &entry.name)
|| entry.wm_class.as_deref().is_some_and(|w| fuzzy_matches(query, w))
|| fuzzy_matches(query, &entry.exec)
{
return Some(4);
}
None
}
// ---- PID file toggle --------------------------------------------------------
fn pid_file() -> PathBuf {
env::var("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"))
.join("breadbox.pid")
}
fn is_breadbox_pid(pid: u32) -> bool {
fs::read_to_string(format!("/proc/{}/comm", pid))
.map(|s| s.trim() == "breadbox")
.unwrap_or(false)
}
// Returns false if an existing instance was killed (caller should exit).
fn toggle_or_continue() -> bool {
let pf = pid_file();
if let Ok(content) = fs::read_to_string(&pf) {
if let Ok(pid) = content.trim().parse::<u32>() {
if is_breadbox_pid(pid) {
let _ = Command::new("kill").arg(pid.to_string()).status();
return false;
}
}
}
let _ = fs::write(&pf, std::process::id().to_string());
true
}
fn cleanup_pid() {
let _ = fs::remove_file(pid_file());
}
// ---- UI ---------------------------------------------------------------------
fn run_ui(
entries: Vec<DesktopEntry>,
history: LaunchHistory,
screenshot_req: Option<screenshot::ScreenshotRequest>,
) {
let mut builder = Application::builder().application_id("com.breadway.breadbox");
if screenshot_req.is_some() {
// GApplication is single-instance by default; this machine typically
// already has a real breadbox instance, so without this a screenshot
// run would just message the *existing* instance instead of
// starting a fresh one that ever sees `screenshot_req`.
builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE);
fn get_row_entry(row: &gtk4::ListBoxRow) -> Option<DesktopEntry> {
unsafe {
row.data::<DesktopEntry>("entry")
.map(|p| p.as_ref().clone())
}
let app = builder.build();
}
fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
let app = Application::builder()
.application_id("com.breadway.breadbox")
.build();
let history_rc = Rc::new(RefCell::new(history));
let is_screenshot_run = screenshot_req.is_some();
let query_rc: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
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
// breadbox-specific CSS layered on top — both hot-reload on
// `bread-theme reload` (the closure re-reads the pywal palette).
bread_theme::gtk::apply_shared();
{
let launcher = launcher.clone();
let tokens = shell_theme.tokens().clone();
bread_theme::gtk::apply_app_css(move || build_css(&load_palette(), &launcher, &tokens));
}
bread_theme::gtk::apply_app_css(|| build_css(&load_palette()));
// User CSS override
{
@ -253,13 +353,21 @@ fn run_ui(
bread_theme::gtk::apply_user_css(&user_css_path, &user_cell);
}
// Full-screen transparent overlay; panel widget is positioned inside it.
let window = bread_utils::gtk_popup::new_overlay_window(app, "breadbox");
bread_theme::gtk::bind_window_auto(&window);
// Full-screen transparent window; clicks outside the launcher panel close it.
let window = ApplicationWindow::builder().application(app).build();
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);
let close_all: Rc<dyn Fn()> = Rc::new({
let w = window.clone();
move || {
cleanup_pid();
w.close();
}
});
@ -268,69 +376,124 @@ fn run_ui(
vbox.add_css_class("launcher-bg");
vbox.set_halign(gtk4::Align::Center);
vbox.set_valign(gtk4::Align::Start);
vbox.set_margin_top(theme::top_margin_px(&launcher.top));
// `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);
vbox.set_margin_top(120);
vbox.set_size_request(600, -1);
let search = SearchEntry::new();
// "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"));
search.set_placeholder_text(Some("Search apps…"));
vbox.append(&search);
// Row building, fuzzy filtering, match/history sorting, and
// keyboard-style selection movement all live in bread-launcher's
// `ResultsList` — the widget breadbar's embedded capsule also uses
// (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 scroll = ScrolledWindow::new();
scroll.set_policy(PolicyType::Never, PolicyType::Automatic);
scroll.set_max_content_height(480);
scroll.set_propagate_natural_height(true);
vbox.append(&results.scroller);
let list = ListBox::new();
list.set_selection_mode(SelectionMode::Browse);
// Footer: "N applications" (liquid-motion) / "N results"
// (glass-workbench) — `[launcher].footer` selects the noun (see
// `footer_text`), updated alongside the query on every keystroke.
let footer = Label::new(None);
footer.add_css_class("launcher-footer");
footer.set_xalign(0.0);
footer.set_label(&footer_text(&launcher.footer, visible_app_count(&results.list)));
vbox.append(&footer);
for (idx, entry) in entries.iter().enumerate() {
let row = gtk4::ListBoxRow::new();
let hbox = GBox::new(Orientation::Horizontal, 0);
hbox.set_margin_start(6);
hbox.set_margin_end(6);
hbox.set_valign(gtk4::Align::Center);
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));
// Filter on keystroke
let results_f = results.clone();
let footer_f = footer.clone();
let footer_kind = launcher.footer.clone();
let list_f = list.clone();
let filter_query = Rc::clone(&query_rc);
search.connect_changed(move |entry| {
results_f.set_query(entry.text().as_str());
footer_f.set_label(&footer_text(&footer_kind, visible_app_count(&results_f.list)));
let text = entry.text();
let query = text.as_str();
*filter_query.borrow_mut() = query.to_string();
// Two passes: first collect each row's match tier, then decide
// visibility — a tier-4 (bare subsequence) row only gets hidden
// once we know whether some *other* row has a real tier ≤2 hit.
let mut rows = Vec::new();
let mut i = 0i32;
while let Some(row) = list_f.row_at_index(i) {
let tier = get_row_entry(&row).and_then(|e| match_tier(query, &e));
rows.push((row, tier));
i += 1;
}
let has_direct_hit = rows.iter().any(|(_, t)| matches!(t, Some(0..=2)));
for (row, tier) in &rows {
let vis = match tier {
None => false,
Some(4) if has_direct_hit => false,
Some(_) => true,
};
row.set_visible(vis);
}
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
let key_ctrl = EventControllerKey::new();
key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture);
let close_k = Rc::clone(&close_all);
let results_k = results.clone();
let list_k = list.clone();
let history_k = Rc::clone(&history_rc);
key_ctrl.connect_key_pressed(move |_, key, _, _| {
use gtk4::gdk::Key;
match key {
@ -339,19 +502,47 @@ fn run_ui(
glib::Propagation::Stop
}
Key::Return | Key::KP_Enter => {
if let Some(entry) = results_k.selected_entry() {
results_k.record_launch(&entry);
bread_launcher::do_launch(&entry, APP_ID, LAUNCHED_EVENT);
if let Some(row) = list_k.selected_row() {
if let Some(entry) = get_row_entry(&row) {
history_k.borrow_mut().increment(&entry.name);
history_k.borrow().save();
do_launch(&entry);
close_k();
}
}
glib::Propagation::Stop
}
Key::Down => {
results_k.select_next();
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1);
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
}
Key::Up => {
results_k.select_prev();
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0);
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::Proceed,
@ -361,101 +552,49 @@ fn run_ui(
// Row click launches
let close_a = Rc::clone(&close_all);
let results_a = results.clone();
results.list.connect_row_activated(move |_, row| {
if let Some(entry) = row_entry(row) {
results_a.record_launch(&entry);
bread_launcher::do_launch(&entry, APP_ID, LAUNCHED_EVENT);
let history_a = Rc::clone(&history_rc);
list.connect_row_activated(move |_, row| {
if let Some(entry) = get_row_entry(row) {
history_a.borrow_mut().increment(&entry.name);
history_a.borrow().save();
do_launch(&entry);
close_a();
}
});
// Click outside launcher panel → close
{
let close_outside = Rc::clone(&close_all);
bread_utils::gtk_popup::close_on_outside_click(&window, &vbox, move || close_outside());
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();
}
if let Some(req) = screenshot_req.clone() {
screenshot::dispatch(&window, req);
}
});
window.add_controller(outside_click);
window.connect_destroy(|_| cleanup_pid());
window.present();
search.grab_focus();
});
if is_screenshot_run {
// GLib's own option parser otherwise rejects --screenshot/--output
// before clap ever sees them (`Cli::parse()` already ran in `main`,
// over the real argv).
app.run_with_args(&[] as &[&str]);
} else {
app.run();
}
}
// ---- Main -------------------------------------------------------------------
fn main() {
if std::env::args().nth(1).as_deref() == Some("listen") {
listen::run();
if !toggle_or_continue() {
return;
}
use clap::Parser;
let cli = screenshot::Cli::parse();
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 —
// a real, already-running breadbox included. A screenshot run must
// never touch it: it's a separate, disposable instance by design (same
// reasoning as breadbar's `allow_multiple_instances`), not a toggle of
// the operator's real launcher.
//
// Kept alive for the rest of `main` — dropping it releases the
// single-instance lock and removes the pid file, which happens
// naturally once `run_ui` returns (after the window closes).
let _singleton_guard = if screenshot_req.is_some() {
None
} else {
match bread_utils::singleton::toggle_or_kill("breadbox") {
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
Err(e) => {
eprintln!(
"breadbox: single-instance lock unavailable ({e}); continuing without it"
);
None
}
}
};
let config = Config::load();
let workspace = get_active_workspace().unwrap_or_default();
let priority = config
@ -463,70 +602,9 @@ fn main() {
.map(|c| c.priority.clone())
.unwrap_or_default();
let history = breadbox_shared::launch_history();
let history = LaunchHistory::load();
let manifest = load_manifest();
let entries = bread_launcher::load_sorted_entries(&manifest, &priority, &history);
let entries = load_sorted_entries(&manifest, &priority, &history);
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
run_ui(entries, history);
}

View file

@ -1,95 +0,0 @@
//! `--screenshot` CLI mode: render breadbox's launcher panel, capture it via
//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s
//! `bread-capture` orchestrator, or run standalone for one-off captures.
//!
//! breadbox has only one view worth capturing: the launcher panel itself
//! (search box + app list). It's a `halign: Center` panel over a full-screen
//! transparent overlay window, not its own layer surface, so — same
//! reasoning as breadbar's control-panel view — the simplest reliable
//! capture is the whole known-size canvas, not a hand-tracked panel
//! geometry.
use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH, SETTLE_DELAY};
use clap::Parser;
use gtk4::prelude::*;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "breadbox")]
pub struct Cli {
/// Render the named view, capture it, then exit instead of running
/// normally. Known views: "launcher".
#[arg(long)]
pub screenshot: Option<String>,
/// PNG path to write the capture to. Required together with --screenshot.
#[arg(long)]
pub output: Option<PathBuf>,
/// Capture canvas width — matches the isolated compositor's output width
/// (`bread-capture --isolate-width`).
#[arg(long, default_value_t = DEFAULT_WIDTH)]
pub width: u32,
/// Capture canvas height — see `width`.
#[arg(long, default_value_t = DEFAULT_HEIGHT)]
pub height: u32,
}
#[derive(Clone)]
pub struct ScreenshotRequest {
pub view: String,
pub output: PathBuf,
pub width: u32,
pub height: u32,
}
impl Cli {
/// `None` for a normal run. Exits the process with an error if the
/// `--screenshot` / `--output` pair is incomplete, before any GTK setup
/// happens.
pub fn screenshot_request(&self) -> Option<ScreenshotRequest> {
if let Err(e) = validate_pair(self.screenshot.as_deref(), self.output.as_deref()) {
eprintln!("breadbox: {e}");
std::process::exit(1);
}
Some(ScreenshotRequest {
view: self.screenshot.clone()?,
output: self.output.clone()?,
width: self.width,
height: self.height,
})
}
}
/// Wire up the given view's screenshot sequence against an already-built,
/// not-yet-presented window. Every path here ends by exiting the process —
/// it never returns control to the normal launcher UI.
pub fn dispatch(window: &gtk4::ApplicationWindow, req: ScreenshotRequest) {
match req.view.as_str() {
"launcher" => {
let output = req.output;
let (width, height) = (req.width as i32, req.height as i32);
window.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
});
});
}
other => {
eprintln!("breadbox: unknown screenshot view '{other}' (known: launcher)");
std::process::exit(1);
}
}
}
fn finish(result: anyhow::Result<()>) {
match result {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("breadbox: screenshot capture failed: {e}");
std::process::exit(1);
}
}
}

View file

@ -1,98 +0,0 @@
//! 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);
}
}

View file

@ -1 +0,0 @@
147cfbbf96ae4b171027defa1130d2caddb934b1

View file

@ -1,21 +0,0 @@
#!/usr/bin/env bash
# Delegates to bread-ecosystem's shared CI build image/script, pinned to
# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI
# files now affect every product's release pipeline, so bumping the pin
# is a deliberate act instead of silent drift (see the bread-theme test
# that broke here for exactly that reason, before it was pinned by rev).
#
# Usage: ci/build.sh cargo build --release --locked
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")"
CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}"
if [ ! -d "$CACHE_DIR" ]; then
rm -rf /tmp/bread-ecosystem-ci-*
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR"
git -C "$CACHE_DIR" checkout --quiet "$REV"
fi
bash "${CACHE_DIR}/ci/build.sh" breadbox "$ROOT" "$@"

39
packaging/arch/PKGBUILD Normal file
View file

@ -0,0 +1,39 @@
# Maintainer: Breadway <rileyhorsham@gmail.com>
pkgname=breadbox
pkgver=0.1.0
pkgrel=1
pkgdesc="App launcher for Hyprland / Wayland"
arch=('x86_64')
url="https://github.com/Breadway/breadbox"
license=('MIT')
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
# causing undefined-symbol errors. Disable LTO.
options=(!lto !debug)
depends=('gtk4' 'gtk4-layer-shell' 'librsvg')
optdepends=(
'hyprland: window and workspace integration'
)
makedepends=('rust' 'cargo')
source=("${pkgname}-${pkgver}.tar.gz")
sha256sums=('SKIP')
build() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo build --release --locked
}
check() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo test --release --locked --workspace
}
package() {
cd "${srcdir}/${pkgname}-${pkgver}"
install -Dm755 target/release/breadbox "${pkgdir}/usr/bin/breadbox"
install -Dm755 target/release/breadbox-sync "${pkgdir}/usr/bin/breadbox-sync"
install -Dm644 packaging/breadbox-sync.service \
"${pkgdir}/usr/lib/systemd/user/breadbox-sync.service"
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}