From d6baab24fc4a4fdb6855f36f27ff3895eaedfc4f Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 21 Jul 2026 19:17:42 +0800 Subject: [PATCH 01/39] ci: remove GitHub push-mirror workflow --- .forgejo/workflows/mirror.yml | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 .forgejo/workflows/mirror.yml diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index 26a9b37..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -1,19 +0,0 @@ -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/breadbar.git" \ - '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' From 174440bcbf9dc555baf86b578580a1a4f5dc7387 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 09:58:03 +0800 Subject: [PATCH 02/39] ci: add dev/beta build track workflows Adds dev-release.yml (publishes on every push to dev) and beta-release.yml (publishes on a beta-v* tag), mirroring the pattern landing in bread-ecosystem/bread. See bread-ecosystem/docs/release-channels.md for the three-track policy. --- .forgejo/workflows/beta-release.yml | 53 ++++++++++++++++++++++++ .forgejo/workflows/dev-release.yml | 62 +++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 .forgejo/workflows/beta-release.yml create mode 100644 .forgejo/workflows/dev-release.yml diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml new file mode 100644 index 0000000..284d340 --- /dev/null +++ b/.forgejo/workflows/beta-release.yml @@ -0,0 +1,53 @@ +name: beta release + +# Publishes a beta-track build when a `beta-v*` tag is pushed — +# separate from release.yml's tag-triggered stable releases. See +# bread-ecosystem's docs/release-channels.md for the three-track policy +# this is part of. +on: + push: + tags: ['beta-v*'] + +jobs: + build: + 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 && cargo build --release --locked + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#beta-v}" + PKG_DIR="/srv/breadway-dl/beta/breadbar/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/breadbar" "${PKG_DIR}/breadbar-x86_64" + strip "${PKG_DIR}/breadbar-x86_64" + sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/breadbar-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadbar/latest" + + # No GitHub Release upload — beta, like the other non-stable track, + # 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 + # --branch dev: the TRACK-aware gen-index.sh isn't merged to + # bread-ecosystem's main yet. + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci + TRACK=beta bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml new file mode 100644 index 0000000..07c9cfd --- /dev/null +++ b/.forgejo/workflows/dev-release.yml @@ -0,0 +1,62 @@ +name: dev release + +# Publishes a dev-track build on every push to `dev` — +# separate from release.yml's tag-triggered stable releases. See +# bread-ecosystem's docs/release-channels.md for the three-track policy +# this is part of. +on: + push: + branches: ['dev'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch dev --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: compute dev version + run: | + set -euo pipefail + cd src + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + 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/breadbar/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/breadbar" "${PKG_DIR}/breadbar-x86_64" + strip "${PKG_DIR}/breadbar-x86_64" + sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/breadbar-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadbar/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 + # --branch dev: the TRACK-aware gen-index.sh isn't merged to + # bread-ecosystem's main yet. + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci + TRACK=dev bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh From 4d33c0e9ae7b21faa894498d6cb3daf19fb1cef6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 10:10:00 +0800 Subject: [PATCH 03/39] ci: retrigger dev-track build now that BAKERY_MINISIGN_SEC_KEY_PATH is set From 3996bce3a9cf07353335bf97d89332672e1690eb Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 10:24:13 +0800 Subject: [PATCH 04/39] ci: use a unique temp dir for the bread-ecosystem clone in dev/beta CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixed /tmp/bread-ecosystem-ci path races when multiple repos' dev/beta workflows run close together on the same self-hosted runner — one job's rm -rf/clone can stomp another's in-progress checkout, causing the regenerate-index step to fail intermittently. Switch to mktemp -d. --- .forgejo/workflows/beta-release.yml | 12 +++++++----- .forgejo/workflows/dev-release.yml | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index 284d340..fba7b2a 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -46,8 +46,10 @@ jobs: 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 - # --branch dev: the TRACK-aware gen-index.sh isn't merged to - # bread-ecosystem's main yet. - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci - TRACK=beta bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + 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 dev 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}" diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 07c9cfd..baffa6b 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -55,8 +55,10 @@ jobs: 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 - # --branch dev: the TRACK-aware gen-index.sh isn't merged to - # bread-ecosystem's main yet. - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci - TRACK=dev bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + 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 dev 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}" From 15f2b111f192be02e8d4ff545b24c3b3cda2ba89 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 11:44:24 +0800 Subject: [PATCH 05/39] random commit message, read it yourself --- .gitignore | 3 +++ src/theme.rs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 816e2ad..7660082 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ logs/ # Internal design documents (not for distribution) aster-brief.md + +# Local hygiene notes (not for commit) +CLAUDE.md diff --git a/src/theme.rs b/src/theme.rs index 6cee541..ea20c89 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -19,7 +19,7 @@ fn load_css() -> String { // popover/card/OSD surface so they read as one design system rather than // four different ones. `radius_pill` is only for the tiny transient OSD. let radius = "10px"; - let radius_sm = "6px"; + let radius_sm = "0px"; let radius_pill = "20px"; let pad = "10px"; From e8cd2c88bc76e2e213c33ce2b2552b3fe5d31c91 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 13:51:44 +0800 Subject: [PATCH 06/39] ci: base dev version on the latest published tag, not Cargo.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo.toml can drift stale relative to the actual last release (observed on breadbox/breadpad/breadcrumbs/breadpaper), which made the auto-bumped dev version sort as OLDER than what's already installed — bakery's semver check correctly refused those "updates". Deriving the base version from git ls-remote --tags instead is self-healing regardless of Cargo.toml drift, with a Cargo.toml fallback only for a repo with no tags yet. --- .forgejo/workflows/dev-release.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index baffa6b..097228a 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -26,7 +26,19 @@ jobs: run: | set -euo pipefail cd src - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + # 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//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' 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)" From 5af8b6097d2cc2e9e4cae5ccb2e4bc8095c95ebb Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 18:37:15 +0800 Subject: [PATCH 07/39] ci: make beta a branch-triggered freeze track, not a one-off tag Beta is now a real stabilization branch: publishes on every push to `beta` (mirroring dev's model, auto-versioned X.Y.Z-beta.+, base version from the latest published tag) instead of a manual beta-v* tag. Fixes made during the freeze land via fix/ branches merged into `beta` directly. The gen-index.sh clone for beta pulls bread-ecosystem's default branch (main) rather than pinning to dev, since beta is the more stable track and main now carries the TRACK-aware script. --- .forgejo/workflows/beta-release.yml | 41 ++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index fba7b2a..16eff05 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -1,12 +1,12 @@ name: beta release -# Publishes a beta-track build when a `beta-v*` tag is pushed — -# separate from release.yml's tag-triggered stable releases. See -# bread-ecosystem's docs/release-channels.md for the three-track policy -# this is part of. +# Publishes a beta-track build on every push to `beta` — a frozen +# stabilization branch cut from `dev` when ready to stabilize; only +# fix/ branches merged into `beta` should land here afterward. +# See bread-ecosystem's docs/release-channels.md for the three-track policy. on: push: - tags: ['beta-v*'] + branches: ['beta'] jobs: build: @@ -16,16 +16,37 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + git clone --branch beta --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build run: cd src && cargo build --release --locked + - name: compute beta version + run: | + set -euo pipefail + cd src + # Base the beta 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 beta 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//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' 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))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" + - name: prepare artifacts run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#beta-v}" PKG_DIR="/srv/breadway-dl/beta/breadbar/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/breadbar" "${PKG_DIR}/breadbar-x86_64" @@ -35,8 +56,8 @@ jobs: cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadbar/latest" - # No GitHub Release upload — beta, like the other non-stable track, - # is only distributed via dl.breadway.dev/beta/. + # 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 }} @@ -50,6 +71,6 @@ jobs: # 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 dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + 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}" From 905d91580d04c15421c526571cb088cdfa890c87 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 19:40:34 +0800 Subject: [PATCH 08/39] docs: add CONTRIBUTING.md Documents the dev/beta/main branch and release-track workflow shared across the bread ecosystem. See bread-ecosystem's docs/release-channels.md for the full policy this implements. --- CONTRIBUTING.md | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1dac2b5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,91 @@ +# Contributing + +`breadbar` — Minimal status bar and notification daemon for Hyprland. + +Part of the bread ecosystem; this repo follows the same branch/release +workflow as every other ecosystem product. + +## Branches + +- **`main`** — release branch, always tag-ready. Nothing is committed to it + directly; it only moves forward via a `beta` merge (see below). +- **`dev`** — integration branch. All day-to-day work lands here first. + Every push to `dev` automatically builds and publishes a **dev-track** + build (see Tracks below) — use this to test your change in a real install + before it goes any further. +- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. + Every push to `beta` automatically builds and publishes a **beta-track** + build. While a freeze is active, only fixes for issues found *in that + freeze* should land on `beta`. + +New work — features and bug fixes alike — goes on a short-lived branch: + +``` +feature/ +fix/ +``` + +Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing +something reported against an active `beta` freeze, branch off `beta` +instead, merge the fix there to unblock testers, and also forward the same +fix into `dev` so it doesn't quietly reappear next cycle. + +## The release cycle + +1. Work accumulates on `dev` 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 report or fix anything broken with another + push to `dev`. +2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut + fresh from `dev`'s current tip. This freezes it as the stabilization + target — `dev` keeps moving independently starting the next cycle. +3. `beta` is open for anyone to test: `bakery track set beta` and + `bakery update --all`. **File issues against anything you find on this + repo's Forgejo issue tracker.** Fixes land via `fix/` branches + merged into `beta`. +4. Once `beta` has gone roughly **a month** without new issues, it's merged + into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the + stable release build. `beta` is then reset from `dev` to start the next + cycle. + +## 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 | `main`, on a `vX.Y.Z` tag push | +| `beta` | Current stabilization freeze | `beta`, on every push | +| `dev` | Bleeding edge | `dev`, on every push | + +Dev/beta versions are auto-computed (`X.Y.Z-dev.+` / +`-beta.…`) from the latest published stable tag, so they always sort as +newer than what you have installed — no manual version bumping needed when +pushing to `dev` or `beta`. + +## Local development + +```sh +cargo build --release +cargo test --release +``` + +## CI + +- `dev-release.yml` — triggered on push to `dev`. +- `beta-release.yml` — triggered on push to `beta`. +- `release.yml` — triggered on a `v*` tag push, cuts the actual stable release. +- `package.yml` — publishes to the `[breadway]` pacman repo, also tag-triggered. + +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. From 175af5d483320e128a7562101ab6c91a9837a9bb Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 19:53:51 +0800 Subject: [PATCH 09/39] Will change this commit message to mean something later --- Cargo.lock | 105 ++++++++++++++++++--- Cargo.toml | 15 +++ src/main.rs | 142 +++++++++++++++++++++++++--- src/theme.rs | 52 ++++++++++- src/widgets/client.rs | 106 +++++++++++++++++++++ src/widgets/mod.rs | 7 ++ src/widgets/render.rs | 212 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 611 insertions(+), 28 deletions(-) create mode 100644 src/widgets/client.rs create mode 100644 src/widgets/mod.rs create mode 100644 src/widgets/render.rs diff --git a/Cargo.lock b/Cargo.lock index 4d73507..2ecdfeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,16 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bread-shared" +version = "0.7.0" +dependencies = [ + "dirs", + "serde", + "serde_json", + "toml 0.8.23", +] + [[package]] name = "bread-theme" version = "0.2.3" @@ -111,11 +121,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-utils" +version = "0.3.1" +dependencies = [ + "bread-shared", + "dirs", + "serde", + "serde_json", +] + [[package]] name = "breadbar" version = "0.3.0" dependencies = [ + "bread-shared", "bread-theme", + "bread-utils", "futures-lite", "gtk4", "gtk4-layer-shell", @@ -1115,7 +1137,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -1316,6 +1338,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1426,7 +1457,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml", + "toml 1.1.3+spec-1.1.0", "version-compare", ] @@ -1523,6 +1554,18 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "1.1.3+spec-1.1.0" @@ -1531,11 +1574,20 @@ checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -1547,6 +1599,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.13+spec-1.1.0" @@ -1554,9 +1620,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -1565,9 +1631,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.4", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.2+spec-1.1.0" @@ -1814,6 +1886,15 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" @@ -1865,7 +1946,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -1893,7 +1974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow", + "winnow 1.0.4", "zvariant", ] @@ -1912,7 +1993,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 1.0.4", "zvariant_derive", "zvariant_utils", ] @@ -1940,5 +2021,5 @@ dependencies = [ "quote", "serde", "syn", - "winnow", + "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index 49ddb62..673a8c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,12 @@ categories = ["gui"] [dependencies] bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } +# Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) +# for talking to breadd's IPC socket, and bread-shared purely for the +# WidgetSpec/WidgetNode wire types so we deserialize into real structs +# instead of hand-parsing serde_json::Value. See src/widgets/. +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.1", features = ["bread-client"] } +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } @@ -24,6 +30,15 @@ serde_json = "1" # are vector-only). Needed because librsvg dropped its gdk-pixbuf SVG loader. resvg = { version = "0.44", default-features = false } +# TEMPORARY local-dev override: the widget feature's bread-shared/bread-utils +# changes aren't tagged/released yet, so point the git deps above at the +# local checkouts instead. Remove this section (and bump the tags above) +# once bread-ecosystem and bread have real release tags for this work. +[patch."https://git.breadway.dev/Breadway/bread-ecosystem"] +bread-utils = { path = "../bread-ecosystem/bread-utils" } +[patch."https://git.breadway.dev/Breadway/bread"] +bread-shared = { path = "../bread/bread-shared" } + [profile.release] lto = "thin" codegen-units = 1 diff --git a/src/main.rs b/src/main.rs index 7e2c756..4bdb45e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod bar; mod notifications; mod osd; mod theme; +mod widgets; /// Thresholds above which the bar's CPU/RAM/power-draw readouts appear at /// all — see `AppInput::StatsUpdate`. Below these, the bar stays quiet. @@ -94,6 +95,15 @@ pub struct App { tray_sep: gtk4::Separator, tray_box: gtk4::Box, tray_items: std::collections::HashMap, + + // ── Lua-declared widgets ───────────────────────────────────────────── + // One container per WidgetPlacement (see bread_shared::widget), fully + // rebuilt on every AppInput::WidgetsUpdate — see widgets::client's + // module doc for why that's simpler than incremental patching here. + widget_containers: + std::collections::HashMap, + widget_tray_section: gtk4::Box, + widget_tray_sep: gtk4::Separator, } #[derive(Debug)] @@ -109,6 +119,7 @@ pub enum AppInput { BtPopoverData(bar::bluetooth::BtPopoverData), MediaUpdate(bar::media::MediaState), ControlPanelData(bar::control::ControlPanelData), + WidgetsUpdate(Vec), } #[relm4::component(pub)] @@ -125,17 +136,6 @@ impl SimpleComponent for App { #[name = "center_box"] gtk::CenterBox { - #[wrap(Some)] - set_start_widget = >k::Box { - set_orientation: gtk::Orientation::Horizontal, - set_spacing: 0, - - #[name = "workspace_box"] - gtk::Box { - set_orientation: gtk::Orientation::Horizontal, - set_spacing: 4, - } - }, } } } @@ -153,6 +153,32 @@ impl SimpleComponent for App { root.set_anchor(Edge::Right, true); root.set_exclusive_zone(32); + // ── Workspace row (left) ──────────────────────────────────────── + // Built imperatively (not via the view! macro) so a widget + // container can sit as a plain sibling of workspace_box — see + // WidgetPlacement::RightOfWorkspaces below. + let workspace_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + let workspace_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + workspace_row.append(&workspace_box); + + // ── Lua-declared widget containers ────────────────────────────── + // One per WidgetPlacement; positioned into the layout below as each + // surrounding section (workspace row / center area / stats box / + // control popover) is built. Populated by widgets::client's + // events.subscribe-driven refresh loop, started at the end of init. + use bread_shared::widget::WidgetPlacement; + let widget_right_of_workspaces = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_right_of_workspaces.add_css_class("bread-widget-slot"); + workspace_row.append(&widget_right_of_workspaces); + + let widget_left_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_left_of_clock.add_css_class("bread-widget-slot"); + let widget_right_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_right_of_clock.add_css_class("bread-widget-slot"); + + let widget_left_of_stats = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_left_of_stats.add_css_class("bread-widget-slot"); + // ── SVG icon sets ──────────────────────────────────────────────── use bar::stats::{ AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, WIFI_MEDIUM, @@ -278,15 +304,18 @@ impl SimpleComponent for App { let clock_lbl = gtk4::Label::new(Some(&bar::clock::current())); clock_lbl.add_css_class("clock-label"); - // Center area: [media_widget · clock] + // Center area: [media_widget · widgets · clock · widgets] let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); center_area.add_css_class("center-area"); center_area.append(&media_widget); + center_area.append(&widget_left_of_clock); center_area.append(&clock_lbl); + center_area.append(&widget_right_of_clock); // ── Stats box (right side) ─────────────────────────────────────── let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); stats_box.add_css_class("stats-box"); + stats_box.append(&widget_left_of_stats); // CPU/RAM/power draw: hidden by default (see StatsUpdate), so this // whole sub-group — plus its separator — collapses away when quiet. @@ -509,6 +538,24 @@ impl SimpleComponent for App { tray_sep.set_visible(false); panel_inner.append(&tray_sep); + // Widgets section — Lua-declared widgets with placement = "tray". + // Same collapse-when-empty idiom as the Apps section above. + let widget_tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + widget_tray_section.add_css_class("control-panel-section"); + let widget_tray_header = gtk4::Label::new(Some("Widgets")); + widget_tray_header.add_css_class("control-panel-section-header"); + widget_tray_header.set_xalign(0.0); + let widget_tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_tray_box.add_css_class("tray-box"); + widget_tray_section.append(&widget_tray_header); + widget_tray_section.append(&widget_tray_box); + widget_tray_section.set_visible(false); + panel_inner.append(&widget_tray_section); + + let widget_tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); + widget_tray_sep.set_visible(false); + panel_inner.append(&widget_tray_sep); + // Power section let power_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); power_section.add_css_class("control-panel-section"); @@ -583,15 +630,24 @@ impl SimpleComponent for App { stats_box.append(&hamburger_btn); + let widget_containers = std::collections::HashMap::from([ + (WidgetPlacement::RightOfWorkspaces, widget_right_of_workspaces), + (WidgetPlacement::LeftOfClock, widget_left_of_clock), + (WidgetPlacement::RightOfClock, widget_right_of_clock), + (WidgetPlacement::LeftOfStats, widget_left_of_stats), + (WidgetPlacement::Tray, widget_tray_box), + ]); + // ── Assemble ───────────────────────────────────────────────────── let widgets = view_output!(); + widgets.center_box.set_start_widget(Some(&workspace_row)); widgets.center_box.set_center_widget(Some(¢er_area)); widgets.center_box.set_end_widget(Some(&stats_box)); - let mut model = App { + let model = App { workspaces: vec![], active_ws: 1, - workspace_box: gtk4::Box::new(gtk4::Orientation::Horizontal, 4), + workspace_box, button_map: std::collections::HashMap::new(), time_str: bar::clock::current(), clock_lbl, @@ -641,8 +697,10 @@ impl SimpleComponent for App { tray_sep, tray_box, tray_items: std::collections::HashMap::new(), + widget_containers, + widget_tray_section, + widget_tray_sep, }; - model.workspace_box = widgets.workspace_box.clone(); theme::apply(); bar::workspaces::spawn_watcher(sender.clone()); @@ -651,6 +709,7 @@ impl SimpleComponent for App { bar::tray::spawn_watcher(sender.clone()); bar::wifi::spawn_status_poller(sender.clone()); bar::media::spawn_poller(sender.clone()); + widgets::client::spawn(sender.clone()); notifications::spawn(); osd::spawn(); @@ -872,11 +931,64 @@ impl SimpleComponent for App { }); self.panel_sink_signal = Some(id); } + AppInput::WidgetsUpdate(specs) => { + self.reconcile_widgets(specs); + } } } } impl App { + fn reconcile_widgets(&mut self, specs: Vec) { + for container in self.widget_containers.values() { + while let Some(child) = container.first_child() { + container.remove(&child); + } + } + + let mut by_placement: std::collections::HashMap< + bread_shared::widget::WidgetPlacement, + Vec<&bread_shared::widget::WidgetSpec>, + > = std::collections::HashMap::new(); + for spec in &specs { + by_placement.entry(spec.placement).or_default().push(spec); + } + + for (placement, mut group) in by_placement { + let Some(container) = self.widget_containers.get(&placement) else { + continue; + }; + group.sort_by_key(|s| s.order); + for spec in group { + if !spec.visible { + continue; + } + let node = widgets::build_node(&spec.root, &spec.id); + if let Some(tooltip) = &spec.tooltip { + node.set_tooltip_text(Some(tooltip)); + } + container.append(&node); + } + } + + // The Tray placement has its own section/separator (handled below, + // same as the existing SNI tray items) — an empty inline slot has no + // such wrapper, so it must hide itself to stop contributing to + // center_area's `spacing` gap. + for (placement, container) in &self.widget_containers { + if *placement == bread_shared::widget::WidgetPlacement::Tray { + continue; + } + container.set_visible(container.first_child().is_some()); + } + + let has_tray_widgets = specs.iter().any(|s| { + s.visible && s.placement == bread_shared::widget::WidgetPlacement::Tray + }); + self.widget_tray_section.set_visible(has_tray_widgets); + self.widget_tray_sep.set_visible(has_tray_widgets); + } + fn apply_wifi_label(&self) { let label = match &self.wifi_profile { Some(p) => format!("{p} · {}", self.current_ssid), diff --git a/src/theme.rs b/src/theme.rs index ea20c89..5bb20b1 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -89,7 +89,57 @@ fn load_css() -> String { .control-panel-sink-dropdown {{ }}\ .power-row {{ margin-top: 2px; }}\ .power-btn {{ min-width: 40px; padding: 8px; border-radius: {radius_sm}; }}\ - separator {{ margin: 4px 0; }}", + separator {{ margin: 4px 0; }}\ + /* Lua-declared widgets (see Documentation.md's Widgets §style): the\ + slot rule below is what the four inline `.bread-widget-slot`\ + containers in main.rs rely on for the same 12px stat-pair rhythm\ + everything else in the bar uses (they carried the class with no\ + rule defining it until now). Everything after that is the fixed,\ + closed `style` vocabulary a `WidgetNode` can opt into — one class\ + per enum variant, so a module can only ever pick from this set,\ + never inject arbitrary CSS. The progress-bar rules give an\ + unstyled Progress node an intentional accent-colored fill instead\ + of Adwaita's default blue-on-gray, and let `style.color` retint\ + that fill the same way it retints label/icon text. */\ + .bread-widget-slot {{ margin-right: 12px; }}\ + progressbar.bread-widget-node trough {{ background-image: none; background-color: alpha(@accent, 0.25); border-radius: 3px; min-height: 6px; }}\ + progressbar.bread-widget-node trough progress {{ background-image: none; background-color: @accent; border-radius: 3px; min-height: 6px; }}\ + progressbar.bread-widget-node.bread-color-fg trough progress {{ background-color: @fg; }}\ + progressbar.bread-widget-node.bread-color-dim trough progress {{ background-color: alpha(@fg, 0.6); }}\ + progressbar.bread-widget-node.bread-color-accent trough progress {{ background-color: @accent; }}\ + progressbar.bread-widget-node.bread-color-red trough progress {{ background-color: @red; }}\ + progressbar.bread-widget-node.bread-color-green trough progress {{ background-color: @green; }}\ + progressbar.bread-widget-node.bread-color-yellow trough progress {{ background-color: @yellow; }}\ + progressbar.bread-widget-node.bread-color-blue trough progress {{ background-color: @blue; }}\ + progressbar.bread-widget-node.bread-color-pink trough progress {{ background-color: @pink; }}\ + progressbar.bread-widget-node.bread-color-teal trough progress {{ background-color: @teal; }}\ + .bread-color-fg {{ color: @fg; }}\ + .bread-color-dim {{ color: @fg; opacity: 0.6; }}\ + .bread-color-accent {{ color: @accent; }}\ + .bread-color-red {{ color: @red; }}\ + .bread-color-green {{ color: @green; }}\ + .bread-color-yellow {{ color: @yellow; }}\ + .bread-color-blue {{ color: @blue; }}\ + .bread-color-pink {{ color: @pink; }}\ + .bread-color-teal {{ color: @teal; }}\ + .bread-weight-normal {{ font-weight: normal; }}\ + .bread-weight-bold {{ font-weight: bold; }}\ + .bread-size-xs {{ font-size: 10px; }}\ + .bread-size-sm {{ font-size: 12px; }}\ + .bread-size-md {{ font-size: 14px; }}\ + .bread-size-lg {{ font-size: 16px; }}\ + .bread-size-xl {{ font-size: 20px; }}\ + .bread-bg-none {{ background-color: transparent; }}\ + .bread-bg-surface {{ background-color: @surface; color: @on-surface; }}\ + .bread-bg-card {{ background-color: @surface; color: @on-surface; border-radius: 8px; padding: 12px; }}\ + .bread-radius-none {{ border-radius: 0; }}\ + .bread-radius-sm {{ border-radius: 4px; }}\ + .bread-radius-md {{ border-radius: 8px; }}\ + .bread-radius-full {{ border-radius: 999px; }}\ + .bread-padding-none {{ padding: 0; }}\ + .bread-padding-xs {{ padding: 4px; }}\ + .bread-padding-sm {{ padding: 8px; }}\ + .bread-padding-md {{ padding: 12px; }}", bg_plain = p.background, bg_rgba = hex_to_rgba(&p.background, 0.92), surface = p.color0, diff --git a/src/widgets/client.rs b/src/widgets/client.rs new file mode 100644 index 0000000..84d043b --- /dev/null +++ b/src/widgets/client.rs @@ -0,0 +1,106 @@ +//! Connects to breadd's IPC socket and keeps the bar's widget set in sync. +//! +//! breadbar is level-triggered here, not edge-triggered: `bread.widget.*` +//! events are used purely as a "something changed, go re-fetch" signal, not +//! applied as incremental patches. Every dirty signal (and the initial +//! connect) re-requests the complete widget list and hands it to `update()` +//! as one `AppInput::WidgetsUpdate`, which reconciles the bar's containers +//! from scratch. This sidesteps event-ordering/drop concerns entirely, and +//! widget registries are small enough that re-fetching the full list on +//! every change is not a real cost. + +use crate::{App, AppInput}; +use bread_shared::widget::WidgetSpec; +use bread_utils::bread_client::BreadClient; +use relm4::ComponentSender; +use std::time::Duration; + +/// breadbar's own registered app id — already reserved in +/// `bread_shared::apps::KNOWN_APPS` (see `Documentation.md`'s Namespaces +/// section). Used both to fetch widgets and to publish click events. +pub const APP_ID: &str = "bar"; + +/// Safety-net poll interval. `bread.widget.cleared` (emitted once per +/// daemon reload, including a full restart — see breadd's `reload_internal`) +/// is meant to catch the case where a module stops registering widgets +/// without anything else re-triggering a fetch, but a *restart* (as opposed +/// to a live `bread reload`) drops the subscription entirely; if that one +/// event fires before `BreadClient::subscribe`'s reconnect-with-backoff +/// finishes re-establishing the stream, it's missed and there's no second +/// chance from the event side. This poll is the backstop for that race — +/// infrequent enough that it's not a real cost, frequent enough that a missed +/// event self-heals well within a session rather than needing a manual +/// breadbar restart to clear stale widgets. +const POLL_INTERVAL: Duration = Duration::from_secs(30); + +/// Start the widget subsystem: an initial fetch, a live subscription that +/// re-fetches on every `bread.widget.*` change, and a low-frequency poll as +/// a backstop against the reconnect race described above. Call once from +/// `init`. +pub fn spawn(sender: ComponentSender) { + // BreadClient::request is blocking std I/O; run it off the tokio + // runtime breadbar's other pollers rely on, same as the reasoning in + // `BreadClient::subscribe`'s own background-thread design. + let initial = sender.clone(); + std::thread::spawn(move || fetch_and_send(&initial)); + + // `subscribe` already reconnects with backoff on its own background + // thread for the lifetime of the process — there is no natural point to + // stop it before the app exits, so the handle is intentionally leaked + // rather than threaded through App just to be dropped at shutdown. + let live = sender.clone(); + let client = BreadClient::connect(APP_ID); + let subscription = client.subscribe("bread.widget.**", move |_event| { + fetch_and_send(&live); + }); + std::mem::forget(subscription); + + let polled = sender.clone(); + relm4::spawn(async move { + loop { + tokio::time::sleep(POLL_INTERVAL).await; + let polled = polled.clone(); + std::thread::spawn(move || fetch_and_send(&polled)); + } + }); +} + +fn fetch_and_send(sender: &ComponentSender) { + let client = BreadClient::connect(APP_ID); + let Some(result) = client.request("widgets.list", serde_json::Value::Null) else { + return; + }; + // Decode element-wise rather than `Vec` in one shot — one + // malformed entry from any module (a bad `class`, an unknown enum value, + // ...) must not blank out every other module's widgets. + let raw: Vec = serde_json::from_value(result).unwrap_or_default(); + let specs: Vec = raw + .into_iter() + .filter_map(|v| { + // `id`/`module` are read before the value is consumed by the + // failed parse below, so a malformed spec still names itself in + // the warning instead of just printing a bare serde error. + let id = v.get("id").and_then(|x| x.as_str()).unwrap_or("?").to_string(); + let module = v.get("module").and_then(|x| x.as_str()).unwrap_or("?").to_string(); + match serde_json::from_value::(v) { + Ok(spec) => Some(spec), + Err(e) => { + eprintln!( + "breadbar: dropping malformed widget spec (id={id}, module={module}): {e}" + ); + None + } + } + }) + .collect(); + sender.input(AppInput::WidgetsUpdate(specs)); +} + +/// Publish a widget click back to breadd. `action` is whatever opaque value +/// the Lua module put in the clicked node's `on_click`. +pub fn emit_click(widget_id: &str, action: &serde_json::Value) { + BreadClient::connect(APP_ID).emit( + "bread.bar.widget_clicked", + serde_json::json!({ "widget_id": widget_id, "action": action }), + ); +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..019f434 --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1,7 @@ +//! Lua-declared, live-updating widgets (see `Documentation.md`'s "Widgets" +//! section in the `bread` repo) rendered into breadbar's fixed layout slots. + +pub mod client; +mod render; + +pub use render::build_node; diff --git a/src/widgets/render.rs b/src/widgets/render.rs new file mode 100644 index 0000000..53de47b --- /dev/null +++ b/src/widgets/render.rs @@ -0,0 +1,212 @@ +//! Turns a `WidgetNode` tree into a live GTK4 widget tree. +//! +//! There is no diffing at the node level — see `client.rs`'s module doc for +//! why the whole thing is simply rebuilt whenever a widget's spec changes. +//! This keeps the renderer a pure, stateless `WidgetNode -> gtk4::Widget` +//! function. + +use super::client; +use bread_shared::widget::{ + Align as StyleAlign, Background, FontWeight, Orientation as NodeOrientation, Padding, Radius, + SemanticColor, TextSize, WidgetNode, WidgetStyle, +}; +use gtk4::prelude::*; + +/// Default max width for a Label node, in characters, absent an explicit +/// `size`/other override — the `style` vocabulary (see Documentation.md's +/// Widgets §style) has no dedicated width field yet, so this stays fixed for +/// every label rather than becoming a half-exposed knob. +const DEFAULT_LABEL_MAX_WIDTH_CHARS: i32 = 32; + +/// Curated bundled icons a widget can reference by name, so module authors +/// don't need to ship an SVG just to show a battery or bluetooth glyph. +/// Anything else goes through `icon.path` instead (see `bundled_or_path_icon`). +fn bundled_icon(name: &str) -> Option<&'static str> { + use crate::bar::stats::{ + AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_OFF, BT_ON, ICON_BRIGHTNESS, ICON_LOCK, + ICON_VOLUME, WIFI_OFF, WIFI_STRONG, + }; + Some(match name { + "ac-power" => AC_POWER, + "battery-high" => BAT_HIGH, + "battery-mid" => BAT_MID, + "battery-low" => BAT_LOW, + "bluetooth-on" => BT_ON, + "bluetooth-off" => BT_OFF, + "wifi-strong" => WIFI_STRONG, + "wifi-off" => WIFI_OFF, + "lock" => ICON_LOCK, + "volume" => ICON_VOLUME, + "brightness" => ICON_BRIGHTNESS, + _ => return None, + }) +} + +fn icon_texture( + widget_id: &str, + name: Option<&str>, + path: Option<&str>, + px: u32, +) -> Option { + if let Some(n) = name { + return match bundled_icon(n) { + Some(svg) => Some(crate::svg_texture_sized(svg, px)), + None => { + eprintln!("breadbar: widget {widget_id}: unknown bundled icon name '{n}'"); + None + } + }; + } + let Some(path) = path else { + eprintln!("breadbar: widget {widget_id}: icon node has neither 'name' nor 'path'"); + return None; + }; + let expanded = bread_shared::expand_path(path); + match std::fs::read_to_string(&expanded) { + Ok(svg) => Some(crate::svg_texture_sized(&svg, px)), + Err(e) => { + eprintln!("breadbar: widget {widget_id}: failed to read icon path '{path}': {e}"); + None + } + } +} + +/// Map a node's typed `style` onto predefined CSS classes (see `theme.rs` for +/// the class definitions) — this is the only path from Lua's `style` field to +/// the widget, kept as narrow `Some(field) -> one class` mappings so there is +/// no way for it to become raw style injection. +fn apply_style(widget: >k4::Widget, style: &WidgetStyle) { + if let Some(color) = style.color { + widget.add_css_class(match color { + SemanticColor::Fg => "bread-color-fg", + SemanticColor::Dim => "bread-color-dim", + SemanticColor::Accent => "bread-color-accent", + SemanticColor::Red => "bread-color-red", + SemanticColor::Green => "bread-color-green", + SemanticColor::Yellow => "bread-color-yellow", + SemanticColor::Blue => "bread-color-blue", + SemanticColor::Pink => "bread-color-pink", + SemanticColor::Teal => "bread-color-teal", + }); + } + if let Some(weight) = style.weight { + widget.add_css_class(match weight { + FontWeight::Normal => "bread-weight-normal", + FontWeight::Bold => "bread-weight-bold", + }); + } + if let Some(size) = style.size { + widget.add_css_class(match size { + TextSize::Xs => "bread-size-xs", + TextSize::Sm => "bread-size-sm", + TextSize::Md => "bread-size-md", + TextSize::Lg => "bread-size-lg", + TextSize::Xl => "bread-size-xl", + }); + } + if let Some(background) = style.background { + widget.add_css_class(match background { + Background::None => "bread-bg-none", + Background::Surface => "bread-bg-surface", + Background::Card => "bread-bg-card", + }); + } + if let Some(radius) = style.radius { + widget.add_css_class(match radius { + Radius::None => "bread-radius-none", + Radius::Sm => "bread-radius-sm", + Radius::Md => "bread-radius-md", + Radius::Full => "bread-radius-full", + }); + } + if let Some(padding) = style.padding { + widget.add_css_class(match padding { + Padding::None => "bread-padding-none", + Padding::Xs => "bread-padding-xs", + Padding::Sm => "bread-padding-sm", + Padding::Md => "bread-padding-md", + }); + } + // GTK CSS has no text-align/justify-content equivalent — alignment is a + // widget property, not a stylesheet rule, so it's set directly instead + // of routing through an inert CSS class like the fields above. + if let Some(align) = style.align { + widget.set_halign(match align { + StyleAlign::Start => gtk4::Align::Start, + StyleAlign::Center => gtk4::Align::Center, + StyleAlign::End => gtk4::Align::End, + }); + } +} + +/// Build (or rebuild) the GTK widget tree for `node`, belonging to widget +/// `widget_id` (fully-qualified `.`, used to tag any click). +pub fn build_node(node: &WidgetNode, widget_id: &str) -> gtk4::Widget { + let widget: gtk4::Widget = match node { + WidgetNode::Box { + orientation, + spacing, + children, + .. + } => { + let gtk_orientation = match orientation { + NodeOrientation::Horizontal => gtk4::Orientation::Horizontal, + NodeOrientation::Vertical => gtk4::Orientation::Vertical, + }; + let container = gtk4::Box::new(gtk_orientation, spacing.unwrap_or(4)); + for child in children { + container.append(&build_node(child, widget_id)); + } + container.upcast() + } + WidgetNode::Label { text, .. } => { + let label = gtk4::Label::new(Some(text)); + // Unbounded, this is a bar-width-blowout waiting to happen from + // any buggy or malicious module — see Documentation.md issue #6. + label.set_ellipsize(gtk4::pango::EllipsizeMode::End); + label.set_max_width_chars(DEFAULT_LABEL_MAX_WIDTH_CHARS); + label.upcast() + } + WidgetNode::Icon { name, path, size, .. } => { + let px = size.unwrap_or(16).max(1) as u32; + let texture = icon_texture(widget_id, name.as_deref(), path.as_deref(), px); + let image = gtk4::Image::from_paintable(texture.as_ref()); + image.upcast() + } + WidgetNode::Progress { value, .. } => { + let bar = gtk4::ProgressBar::new(); + bar.set_fraction(value.clamp(0.0, 1.0)); + // GtkProgressBar's natural expand behavior is to fill all + // available width, which — unlike Label/Box/Image, which hug + // their content by default — propagates up through every + // ancestor Box that doesn't set hexpand explicitly, all the way + // to the bar's end_widget. Pin it to a small fixed footprint so + // it reads as an inline meter instead of swallowing the bar. + bar.set_hexpand(false); + bar.set_valign(gtk4::Align::Center); + bar.set_size_request(40, 6); + bar.upcast() + } + }; + + widget.add_css_class("bread-widget-node"); + if let Some(class) = node.class() { + widget.add_css_class(class); + } + if let Some(style) = node.style() { + apply_style(&widget, style); + } + + if let Some(action) = node.on_click() { + widget.add_css_class("clickable"); + let widget_id = widget_id.to_string(); + let action = action.clone(); + let gesture = gtk4::GestureClick::new(); + gesture.connect_released(move |_, _, _, _| { + client::emit_click(&widget_id, &action); + }); + widget.add_controller(gesture); + } + + widget +} From 552d771e15bcdbfc81efb07493b10916f5cf93eb Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 23 Jul 2026 10:12:52 +0800 Subject: [PATCH 10/39] fix: honor x-canonical-private-synchronous hint for notification replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notify-send-based senders (e.g. breadcrumbs) fire a new process per notification, so replaces_id is always 0 — they instead tag related notifications with the x-canonical-private-synchronous hint and expect the daemon to replace whatever's currently showing from that app. We weren't honoring it, so a persistent (Expire::Never) critical notification like breadcrumbs' "no Wi-Fi adapter" during a suspend transition could never be superseded by a later notification, leaving it stuck until breadbar was restarted. Fixes #2. --- src/notifications/mod.rs | 130 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index fbb29df..b1f1760 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -1,10 +1,21 @@ pub mod popup; +use std::collections::HashMap; use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Mutex; use std::time::Duration; use tokio::sync::mpsc; use zbus::zvariant::OwnedValue; +/// Hint key used by `notify-send` and honored by notify-osd/dunst: senders +/// that fire off a new process per notification (so `replaces_id` is always +/// 0) tag related notifications with the same `(app_name, tag)` pair to mean +/// "replace whatever from this app is already showing." Without honoring +/// this, a fire-and-forget sender can never supersede an earlier +/// `Expire::Never` notification from itself (e.g. a critical hardware +/// warning) — it just piles up a new card next to it forever. +const SYNCHRONOUS_HINT: &str = "x-canonical-private-synchronous"; + /// How long a shown notification should stay up before auto-dismissing. /// Distinct from `Option` mainly for readability at call sites — /// `Never` covers both the spec's `expire_timeout == 0` ("never expire") @@ -79,6 +90,9 @@ fn compute_expire(expire_timeout: i32, urgency_critical: bool) -> Expire { struct NotifServer { tx: mpsc::Sender, next_id: AtomicU32, + /// (app_name, synchronous-hint tag) -> id, for senders relying on + /// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`. + sync_tags: Mutex>, } #[zbus::interface(name = "org.freedesktop.Notifications")] @@ -96,8 +110,24 @@ impl NotifServer { hints: std::collections::HashMap, expire_timeout: i32, ) -> u32 { + let sync_tag = hints + .get(SYNCHRONOUS_HINT) + .and_then(|v| String::try_from(v.clone()).ok()); + let id = if replaces_id != 0 { + if let Some(tag) = &sync_tag { + self.sync_tags + .lock() + .unwrap() + .insert((app_name.to_string(), tag.clone()), replaces_id); + } replaces_id + } else if let Some(tag) = &sync_tag { + let key = (app_name.to_string(), tag.clone()); + let mut sync_tags = self.sync_tags.lock().unwrap(); + *sync_tags + .entry(key) + .or_insert_with(|| self.next_id.fetch_add(1, Ordering::Relaxed)) } else { self.next_id.fetch_add(1, Ordering::Relaxed) }; @@ -150,6 +180,7 @@ pub fn spawn() { let server = NotifServer { tx, next_id: AtomicU32::new(1), + sync_tags: Mutex::new(HashMap::new()), }; // Builder failures here would only occur with invalid static strings — safe to unwrap. let conn = zbus::connection::Builder::session() @@ -212,4 +243,103 @@ mod tests { Expire::Never => panic!("expected 1500ms, got Never"), } } + + fn test_server() -> (NotifServer, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(32); + ( + NotifServer { + tx, + next_id: AtomicU32::new(1), + sync_tags: Mutex::new(HashMap::new()), + }, + rx, + ) + } + + fn sync_hints(tag: &str) -> HashMap { + let mut hints = HashMap::new(); + hints.insert( + SYNCHRONOUS_HINT.to_string(), + OwnedValue::try_from(zbus::zvariant::Value::from(tag)).unwrap(), + ); + hints + } + + #[tokio::test] + async fn synchronous_hint_reuses_id_for_same_app_and_tag() { + let (server, _rx) = test_server(); + let first = server + .notify( + "breadcrumbs", + 0, + "", + "no Wi-Fi adapter", + "", + vec![], + sync_hints("breadcrumbs"), + -1, + ) + .await; + let second = server + .notify( + "breadcrumbs", + 0, + "", + "back online", + "", + vec![], + sync_hints("breadcrumbs"), + -1, + ) + .await; + assert_eq!( + first, second, + "same app+tag should replace, not stack, a prior notification" + ); + } + + #[tokio::test] + async fn synchronous_hint_is_scoped_per_app_name() { + let (server, _rx) = test_server(); + let first = server + .notify( + "breadcrumbs", + 0, + "", + "no Wi-Fi adapter", + "", + vec![], + sync_hints("breadcrumbs"), + -1, + ) + .await; + let second = server + .notify( + "other-app", + 0, + "", + "unrelated", + "", + vec![], + sync_hints("breadcrumbs"), + -1, + ) + .await; + assert_ne!( + first, second, + "same tag from a different app must not collide" + ); + } + + #[tokio::test] + async fn no_synchronous_hint_always_allocates_a_new_id() { + let (server, _rx) = test_server(); + let first = server + .notify("breadcrumbs", 0, "", "one", "", vec![], HashMap::new(), -1) + .await; + let second = server + .notify("breadcrumbs", 0, "", "two", "", vec![], HashMap::new(), -1) + .await; + assert_ne!(first, second); + } } From f5daf902cedbc5bc7f8162c2b63970802d67c13b Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 23 Jul 2026 10:27:52 +0800 Subject: [PATCH 11/39] Drop pacman packaging, bakery-only distribution bakery already fully covers what the PKGBUILD provided (binary, dependency declarations) except a LICENSE copy, which bakery.toml's new license_file field now closes. Removes packaging/arch/ and .forgejo/workflows/package.yml; adds the LICENSE artifact to each release/dev-release/beta-release workflow's prepare step. Not pacman-installed inside BOS today (BOS already consumes these apps exclusively via build-local.sh's skel-staging), so this only removes the option to `pacman -S` outside of BOS/bakery. --- .forgejo/workflows/beta-release.yml | 1 + .forgejo/workflows/dev-release.yml | 1 + .forgejo/workflows/package.yml | 40 ----------------------------- .forgejo/workflows/release.yml | 1 + bakery.toml | 1 + packaging/arch/PKGBUILD | 36 -------------------------- 6 files changed, 4 insertions(+), 76 deletions(-) delete mode 100644 .forgejo/workflows/package.yml delete mode 100644 packaging/arch/PKGBUILD diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index 16eff05..ed482ba 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -53,6 +53,7 @@ jobs: strip "${PKG_DIR}/breadbar-x86_64" sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/breadbar-x86_64.sha256" + cp src/LICENSE "${PKG_DIR}/" cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadbar/latest" diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 097228a..5ffa25a 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -53,6 +53,7 @@ jobs: strip "${PKG_DIR}/breadbar-x86_64" sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/breadbar-x86_64.sha256" + cp src/LICENSE "${PKG_DIR}/" cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadbar/latest" diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml deleted file mode 100644 index 2895a76..0000000 --- a/.forgejo/workflows/package.yml +++ /dev/null @@ -1,40 +0,0 @@ -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 libpulse iw - 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="breadbar-${VERSION}/" HEAD \ - > packaging/arch/breadbar-${VERSION}.tar.gz - SHA=$(sha256sum packaging/arch/breadbar-${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" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 8124ec6..264d787 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -28,6 +28,7 @@ jobs: strip "${PKG_DIR}/breadbar-x86_64" sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/breadbar-x86_64.sha256" + cp src/LICENSE "${PKG_DIR}/" cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/breadbar/latest" diff --git a/bakery.toml b/bakery.toml index bd1153f..1b305d5 100644 --- a/bakery.toml +++ b/bakery.toml @@ -4,6 +4,7 @@ binaries = ["breadbar"] system_deps = ["gtk4", "gtk4-layer-shell", "wireplumber", "pipewire-pulse", "brightnessctl", "iw"] optional_system_deps = ["hyprland"] bread_deps = [] +license_file = "LICENSE" [config] dir = "~/.config/breadbar" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD deleted file mode 100644 index e8570e0..0000000 --- a/packaging/arch/PKGBUILD +++ /dev/null @@ -1,36 +0,0 @@ -# Maintainer: Breadway - -pkgname=breadbar -pkgver=0.2.0 -pkgrel=1 -pkgdesc="Minimal status bar and notification daemon for Hyprland" -arch=('x86_64') -url="https://git.breadway.dev/Breadway/breadbar" -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' 'wireplumber' 'pipewire-pulse' 'brightnessctl' 'iw') -optdepends=( - 'hyprland: workspace and window data 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 -} - -package() { - cd "${srcdir}/${pkgname}-${pkgver}" - install -Dm755 target/release/breadbar "${pkgdir}/usr/bin/breadbar" - install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" -} From 56e8b02599a88438a558bd468f10fcf7ed821fe1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 23 Jul 2026 11:25:06 +0800 Subject: [PATCH 12/39] Will change this commit message to mean something later --- Cargo.lock | 21 +++++++++++++++++---- Cargo.toml | 15 +++------------ 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ecdfeb..08bf11d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,18 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-shared" version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +dependencies = [ + "dirs", + "serde", + "serde_json", + "toml 0.8.23", +] + +[[package]] +name = "bread-shared" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?branch=dev#8a794c03bfc7f2f294ff89d92494ad324afc30f5" dependencies = [ "dirs", "serde", @@ -112,8 +124,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780" +version = "0.3.1" +source = "git+https://github.com/Breadway/bread-ecosystem?branch=dev#77bca8a1cf90d7fd38745270d2deb291b8b22bfe" dependencies = [ "dirs", "gtk4", @@ -124,8 +136,9 @@ dependencies = [ [[package]] name = "bread-utils" version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#77bca8a1cf90d7fd38745270d2deb291b8b22bfe" dependencies = [ - "bread-shared", + "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0)", "dirs", "serde", "serde_json", @@ -135,7 +148,7 @@ dependencies = [ name = "breadbar" version = "0.3.0" dependencies = [ - "bread-shared", + "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?branch=dev)", "bread-theme", "bread-utils", "futures-lite", diff --git a/Cargo.toml b/Cargo.toml index 673a8c9..02275a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,13 +10,13 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", branch = "dev", features = ["gtk"] } # Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs # instead of hand-parsing serde_json::Value. See src/widgets/. -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.1", features = ["bread-client"] } -bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev", features = ["bread-client"] } +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", branch = "dev" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } @@ -30,15 +30,6 @@ serde_json = "1" # are vector-only). Needed because librsvg dropped its gdk-pixbuf SVG loader. resvg = { version = "0.44", default-features = false } -# TEMPORARY local-dev override: the widget feature's bread-shared/bread-utils -# changes aren't tagged/released yet, so point the git deps above at the -# local checkouts instead. Remove this section (and bump the tags above) -# once bread-ecosystem and bread have real release tags for this work. -[patch."https://git.breadway.dev/Breadway/bread-ecosystem"] -bread-utils = { path = "../bread-ecosystem/bread-utils" } -[patch."https://git.breadway.dev/Breadway/bread"] -bread-shared = { path = "../bread/bread-shared" } - [profile.release] lto = "thin" codegen-units = 1 From a00934a53d1cc5c7d340c621af84598698559f9b Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 23 Jul 2026 11:48:36 +0800 Subject: [PATCH 13/39] update deps --- Cargo.lock | 65 ++++++++++++++++++++++++++++++------------------------ Cargo.toml | 2 +- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 08bf11d..a90294c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,12 +88,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.1" @@ -187,7 +181,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" dependencies = [ - "bitflags 2.13.1", + "bitflags", "cairo-sys-rs", "glib", "libc", @@ -698,7 +692,7 @@ version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" dependencies = [ - "bitflags 2.13.1", + "bitflags", "futures-channel", "futures-core", "futures-executor", @@ -825,7 +819,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" dependencies = [ - "bitflags 2.13.1", + "bitflags", "gdk4", "glib", "glib-sys", @@ -927,9 +921,9 @@ dependencies = [ [[package]] name = "imagesize" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "indexmap" @@ -966,12 +960,13 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kurbo" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" dependencies = [ "arrayvec", "euclid", + "polycool", "smallvec", ] @@ -1133,17 +1128,26 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "png" -version = "0.17.16" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 1.3.2", + "bitflags", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1230,9 +1234,9 @@ dependencies = [ [[package]] name = "resvg" -version = "0.44.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a325d5e8d1cebddd070b13f44cec8071594ab67d1012797c121f27a669b7958" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" dependencies = [ "log", "pico-args", @@ -1253,9 +1257,12 @@ dependencies = [ [[package]] name = "roxmltree" -version = "0.20.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] [[package]] name = "rustc_version" @@ -1272,7 +1279,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -1442,9 +1449,9 @@ dependencies = [ [[package]] name = "svgtypes" -version = "0.15.3" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ "kurbo", "siphasher", @@ -1515,9 +1522,9 @@ dependencies = [ [[package]] name = "tiny-skia" -version = "0.11.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" dependencies = [ "arrayref", "arrayvec", @@ -1530,9 +1537,9 @@ dependencies = [ [[package]] name = "tiny-skia-path" -version = "0.11.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" dependencies = [ "arrayref", "bytemuck", @@ -1721,9 +1728,9 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "usvg" -version = "0.44.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7447e703d7223b067607655e625e0dbca80822880248937da65966194c4864e6" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" dependencies = [ "base64", "data-url", diff --git a/Cargo.toml b/Cargo.toml index 02275a7..ad660fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" # Pure-Rust SVG rasteriser (default features off → no text/font deps; the icons # are vector-only). Needed because librsvg dropped its gdk-pixbuf SVG loader. -resvg = { version = "0.44", default-features = false } +resvg = { version = "0.47", default-features = false } [profile.release] lto = "thin" From ac6ccfe88a4e1c4d018b48cb84b9a0fdf1359f08 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:15:57 +0800 Subject: [PATCH 14/39] Add --screenshot CLI mode for automated capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a single view ("bar" or "control-panel"), captures it via bread-screenshots, then exits — driven by bread-ecosystem's bread-capture orchestrator, or runnable standalone for one-off captures. Waits on GTK's map signal (plus a short settle delay) before capturing rather than guessing a fixed sleep upfront: the control-panel view in particular needs the popover's autohide disabled (a programmatic popup() has no input-event serial to grab the Wayland seat with) and a longer settle window so the CPU/RAM/PWR/GPU/network stats — populated by a 2-second background poller, not the popover's own open — have had at least one full cycle to fill in before the capture fires. --width/--height match whatever canvas bread-capture's isolation sizes the compositor to, so the "bar" view's geometry doesn't depend on querying anything at capture time. Needs RelmApp::with_args(vec![]) (GLib's own arg parser otherwise rejects --screenshot/--output before clap ever sees them) and allow_multiple_instances(true) for screenshot runs specifically, since GApplication is single-instance by default and this machine typically already has a real breadbar running. --- Cargo.lock | 377 ++++++++++++++++++++++++++++++++-------------- Cargo.toml | 4 + src/main.rs | 32 +++- src/screenshot.rs | 142 +++++++++++++++++ 4 files changed, 439 insertions(+), 116 deletions(-) create mode 100644 src/screenshot.rs diff --git a/Cargo.lock b/Cargo.lock index a90294c..1e67cb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,62 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arrayref" version = "0.3.9" @@ -40,7 +96,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -62,18 +118,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -94,12 +150,22 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bread-screenshots" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#686af0d3dc93bf8039d62fbbd1e04a8b2db90154" +dependencies = [ + "anyhow", + "bread-utils", + "tracing", +] + [[package]] name = "bread-shared" version = "0.7.0" source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" dependencies = [ - "dirs", + "dirs 5.0.1", "serde", "serde_json", "toml 0.8.23", @@ -108,9 +174,9 @@ dependencies = [ [[package]] name = "bread-shared" version = "0.7.0" -source = "git+https://git.breadway.dev/Breadway/bread?branch=dev#8a794c03bfc7f2f294ff89d92494ad324afc30f5" +source = "git+https://git.breadway.dev/Breadway/bread?branch=dev#34854f147135143ca4baf1d628e8c609e2c1cc8c" dependencies = [ - "dirs", + "dirs 6.0.0", "serde", "serde_json", "toml 0.8.23", @@ -119,9 +185,9 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.3.1" -source = "git+https://github.com/Breadway/bread-ecosystem?branch=dev#77bca8a1cf90d7fd38745270d2deb291b8b22bfe" +source = "git+https://github.com/Breadway/bread-ecosystem?branch=dev#686af0d3dc93bf8039d62fbbd1e04a8b2db90154" dependencies = [ - "dirs", + "dirs 5.0.1", "gtk4", "serde", "serde_json", @@ -130,10 +196,10 @@ dependencies = [ [[package]] name = "bread-utils" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#77bca8a1cf90d7fd38745270d2deb291b8b22bfe" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#686af0d3dc93bf8039d62fbbd1e04a8b2db90154" dependencies = [ "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0)", - "dirs", + "dirs 5.0.1", "serde", "serde_json", ] @@ -142,9 +208,12 @@ dependencies = [ name = "breadbar" version = "0.3.0" dependencies = [ + "anyhow", + "bread-screenshots", "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?branch=dev)", "bread-theme", "bread-utils", + "clap", "futures-lite", "gtk4", "gtk4-layer-shell", @@ -165,9 +234,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.1" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "bytes" @@ -215,14 +284,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "concurrent-queue" -version = "2.5.0" +name = "clap" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ - "crossbeam-utils", + "clap_builder", + "clap_derive", ] +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "convert_case" version = "0.10.0" @@ -241,12 +347,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - [[package]] name = "data-url" version = "0.3.2" @@ -272,7 +372,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.119", "unicode-xid", ] @@ -282,7 +382,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" 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]] @@ -293,15 +402,27 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] [[package]] -name = "either" -version = "1.16.0" +name = "dirs-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "endi" @@ -327,7 +448,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -357,11 +478,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -378,11 +498,11 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", ] [[package]] @@ -510,7 +630,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -611,20 +731,6 @@ dependencies = [ "wasi", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -632,8 +738,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", - "r-efi 6.0.0", + "r-efi", + "wasm-bindgen", ] [[package]] @@ -716,7 +824,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -850,7 +958,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -916,7 +1024,7 @@ checksum = "31157e6ccefbad4b0cd7e549db6696691a70c11b108f26bf6bf76eef26af8c10" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -935,6 +1043,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -972,9 +1086,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" @@ -1057,6 +1171,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" @@ -1159,28 +1279,22 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1195,7 +1309,18 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "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.19", ] [[package]] @@ -1229,7 +1354,7 @@ checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1306,9 +1431,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1316,29 +1441,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1349,13 +1474,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -1447,6 +1572,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "svgtypes" version = "0.16.1" @@ -1468,6 +1599,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "system-deps" version = "7.0.8" @@ -1477,7 +1619,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "version-compare", ] @@ -1506,7 +1648,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", ] [[package]] @@ -1517,7 +1668,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] @@ -1548,9 +1710,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1571,7 +1733,7 @@ checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1588,9 +1750,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", @@ -1647,9 +1809,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -1685,7 +1847,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1748,6 +1910,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.24.0" @@ -1771,15 +1939,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -1812,7 +1971,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -1924,12 +2083,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "xml-rs" version = "0.8.28" @@ -1981,7 +2134,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "zbus_names", "zvariant", "zvariant_utils", @@ -2027,7 +2180,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "zvariant_utils", ] @@ -2040,6 +2193,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.119", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index ad660fe..23acc2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,8 @@ bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", branch = "d # instead of hand-parsing serde_json::Value. See src/widgets/. bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev", features = ["bread-client"] } bread-shared = { git = "https://git.breadway.dev/Breadway/bread", branch = "dev" } +# Capture primitives for `--screenshot` mode — see src/screenshot.rs. +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } @@ -26,6 +28,8 @@ zbus = { version = "5", default-features = false, features = ["tokio"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "process", "signal", "sync"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +clap = { version = "4", features = ["derive"] } +anyhow = "1" # Pure-Rust SVG rasteriser (default features off → no text/font deps; the icons # are vector-only). Needed because librsvg dropped its gdk-pixbuf SVG loader. resvg = { version = "0.47", default-features = false } diff --git a/src/main.rs b/src/main.rs index 4bdb45e..82377e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ macro_rules! asset { mod bar; mod notifications; mod osd; +mod screenshot; mod theme; mod widgets; @@ -124,7 +125,7 @@ pub enum AppInput { #[relm4::component(pub)] impl SimpleComponent for App { - type Init = (); + type Init = Option; type Input = AppInput; type Output = (); @@ -141,7 +142,7 @@ impl SimpleComponent for App { } fn init( - _: Self::Init, + screenshot_req: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { @@ -644,6 +645,10 @@ impl SimpleComponent for App { widgets.center_box.set_center_widget(Some(¢er_area)); widgets.center_box.set_end_widget(Some(&stats_box)); + // Captured before `control_popover` moves into `model` below — needed + // by the screenshot dispatch just before this function returns. + let control_popover_for_screenshot = control_popover.clone(); + let model = App { workspaces: vec![], active_ws: 1, @@ -713,6 +718,10 @@ impl SimpleComponent for App { notifications::spawn(); osd::spawn(); + if let Some(req) = screenshot_req { + screenshot::dispatch(&root, req, control_popover_for_screenshot); + } + ComponentParts { model, widgets } } @@ -1411,6 +1420,10 @@ fn stat_label() -> gtk4::Label { } fn main() { + use clap::Parser; + let cli = screenshot::Cli::parse(); + let screenshot_req = cli.screenshot_request(); + relm4::spawn(async { use tokio::signal::unix::{signal, SignalKind}; let mut stream = signal(SignalKind::hangup()).expect("SIGHUP handler"); @@ -1420,6 +1433,17 @@ fn main() { } }); - let app = RelmApp::new("sh.breadway.breadbar"); - app.run::(()); + // `with_args(vec![])` stops relm4 from handing our own --screenshot/ + // --output flags to GLib's option parser (`app.run()`'s default), which + // would otherwise reject them as unrecognized before Cli::parse() above + // ever sees argv. allow_multiple_instances is needed for screenshot runs + // specifically: GApplication is single-instance by default, and a normal + // breadbar is typically already running, so without this a screenshot + // invocation would just activate that existing instance instead of + // starting a fresh one whose `init()` receives the request at all. + let app = RelmApp::new("sh.breadway.breadbar").with_args(vec![]); + if screenshot_req.is_some() { + app.allow_multiple_instances(true); + } + app.run::(screenshot_req); } diff --git a/src/screenshot.rs b/src/screenshot.rs new file mode 100644 index 0000000..d276be8 --- /dev/null +++ b/src/screenshot.rs @@ -0,0 +1,142 @@ +//! `--screenshot` CLI mode: render a specific view, capture it via +//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s +//! `bread-capture` orchestrator, or run standalone for one-off captures. +//! +//! Capture waits on GTK's `map` signal rather than a blind sleep before +//! grabbing pixels — the surface (or, for popover views, the popover itself) +//! genuinely isn't on screen yet before that fires, so a fixed delay would +//! either race a slow first paint or pad every fast one for nothing. + +use clap::Parser; +use gtk4::prelude::*; +use std::path::PathBuf; +use std::time::Duration; + +/// Extra settle time after `map` for the first frame to actually paint +/// before grim runs — `map` fires once the surface exists, not once +/// anything has been drawn into it. +const SETTLE_DELAY: Duration = Duration::from_millis(300); + +/// Settle time for the control-panel view specifically: longer than +/// [`SETTLE_DELAY`] because the CPU/RAM/PWR/GPU/network labels there aren't +/// populated by the popover's own load (that only covers volume/brightness/ +/// sinks, see `bar::control::spawn_load`) — they're refreshed by +/// `bar::stats::spawn_poller`'s 2-second background loop, gated on +/// `control_popover.is_visible()` at each tick. Capturing any sooner than one +/// full poll interval after the popover opens leaves them at their initial +/// placeholder dashes. +const CONTROL_PANEL_SETTLE_DELAY: Duration = Duration::from_millis(2_200); + +/// Delay between the bar's own `map` and calling `popover.popup()`. Calling +/// `popup()` synchronously from inside the root window's `map` handler +/// produces a popover that reports itself `map`ped but never actually paints +/// (confirmed by an independent `grim` capture taken mid-sequence, showing no +/// popover at all) — presumably the parent widget's own allocation isn't +/// settled yet at that exact point. Giving the initial layout pass a beat to +/// finish first is what makes it actually render. +const PRE_POPUP_DELAY: Duration = Duration::from_millis(300); + +#[derive(Parser)] +#[command(name = "breadbar")] +pub struct Cli { + /// Render the named view, capture it, then exit instead of running + /// normally. Known views: "bar", "control-panel". + #[arg(long)] + pub screenshot: Option, + + /// PNG path to write the capture to. Required together with --screenshot. + #[arg(long)] + pub output: Option, + + /// Capture canvas width — matches the isolated compositor's output width + /// (`bread-capture --isolate-width`) so the geometry passed to `grim` + /// doesn't depend on querying anything at capture time. + #[arg(long, default_value_t = 1920)] + pub width: u32, + + /// Capture canvas height — see `width`. + #[arg(long, default_value_t = 1080)] + pub height: u32, +} + +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 + /// `--screenshot` was given without `--output`, before any GTK/relm4 + /// setup happens. + pub fn screenshot_request(&self) -> Option { + let view = self.screenshot.clone()?; + let Some(output) = self.output.clone() else { + eprintln!("breadbar: --screenshot requires --output"); + std::process::exit(1); + }; + Some(ScreenshotRequest { view, output, width: self.width, height: self.height }) + } +} + +/// Wire up the given view's screenshot sequence. Called once from `init()`, +/// after the window and its popovers exist but before the component finishes +/// initializing — every path here ends by exiting the process, it never +/// returns control to the normal bar UI. +/// The bar's fixed height — matches `root.set_exclusive_zone(32)` / +/// `set_default_height: 32` in `main.rs`. Unlike the control-panel's full +/// canvas, this never varies with `--width`/`--height`. +const BAR_HEIGHT: i32 = 32; + +pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, control_popover: gtk4::Popover) { + match req.view.as_str() { + "bar" => { + let output = req.output; + let width = req.width as i32; + root.connect_map(move |_| { + let output = output.clone(); + gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { + finish(bread_screenshots::capture_region(0, 0, width, BAR_HEIGHT, &output)); + }); + }); + } + "control-panel" => { + let output = req.output; + let (width, height) = (req.width as i32, req.height as i32); + let popover_to_open = control_popover.clone(); + root.connect_map(move |_| { + // Autohide (the default) tries to grab the Wayland seat on + // popup, keyed to a real input event's serial — a + // programmatic popup() has no such event to grab with. + // Screenshot mode never needs the popover to dismiss itself + // anyway. + popover_to_open.set_autohide(false); + let popover_to_open = popover_to_open.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + popover_to_open.popup(); + }); + }); + control_popover.connect_map(move |_| { + let output = output.clone(); + gtk4::glib::timeout_add_local_once(CONTROL_PANEL_SETTLE_DELAY, move || { + finish(bread_screenshots::capture_region(0, 0, width, height, &output)); + }); + }); + } + other => { + eprintln!("breadbar: unknown screenshot view '{other}' (known: bar, control-panel)"); + std::process::exit(1); + } + } +} + +fn finish(result: anyhow::Result<()>) { + match result { + Ok(()) => std::process::exit(0), + Err(e) => { + eprintln!("breadbar: screenshot capture failed: {e}"); + std::process::exit(1); + } + } +} From 059e11cdebd547532303f36753e365811a10dcf4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:23:20 +0800 Subject: [PATCH 15/39] Bump bread-utils/bread-screenshots lockfile refs to bread-ecosystem's headless-Sway dev commit --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1e67cb8..b48bacd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,7 +153,7 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-screenshots" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#686af0d3dc93bf8039d62fbbd1e04a8b2db90154" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#27b3b17c58b81f947a40cc0d3f9cd0862e7885ff" dependencies = [ "anyhow", "bread-utils", @@ -196,7 +196,7 @@ dependencies = [ [[package]] name = "bread-utils" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#686af0d3dc93bf8039d62fbbd1e04a8b2db90154" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#27b3b17c58b81f947a40cc0d3f9cd0862e7885ff" dependencies = [ "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0)", "dirs 5.0.1", From 566aeeed8bb2610225a671aa205dcbdf611be952 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 17:17:17 +0800 Subject: [PATCH 16/39] breadbar: capture every view, not just bar/control-panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit breadbar is "a bar + the notification daemon + the OSD" — the screenshot mode only covered the bar and its control-panel popover, missing seven more distinct surfaces: the WiFi/Bluetooth connectivity popover (both tabs), the media-controls popover, the standalone notification window (both normal and critical urgency), the standalone OSD window (volume and brightness), and the wifi add-network dialog. All ten views are now --screenshot targets. Two real refactors needed to make the standalone notification/OSD windows screenshot-able at all, not just bigger match arms: - Both windows are built deep inside an async task (`run_osd`/ `popup::run`), only reachable after the real event loop starts — no window handle ever existed for a caller to hook `connect_map` on before that. Window construction is now synchronous in `osd::spawn`/ `notifications::spawn`, handed to the async loop as a parameter instead of created inside it. - Screenshot mode seeds each with one fixed sample event (SampleKind) via the same channel the real pactl/backlight/D-Bus sources feed, instead of waiting for real hardware/dbus activity. For notifications specifically this also means skipping the real org.freedesktop.Notifications D-Bus registration entirely in screenshot mode — claiming that well-known name would just race the real breadbar (if running) for it, for no benefit, since nothing external needs to reach a screenshot-only instance. show_add_network_dialog gained an `on_build` hook (called before `.present()`, the only point `connect_map` can still catch the map) so screenshot mode can capture it without changing its one real call site's behavior — and its `anchor` parameter widened from `&Button` to `&impl IsA` since the screenshot path anchors off a `ToggleButton`, not a `Button`. --- src/main.rs | 59 +++++++++++-- src/notifications/mod.rs | 110 ++++++++++++++++------- src/notifications/popup.rs | 26 +++++- src/osd.rs | 50 +++++++++-- src/screenshot.rs | 174 +++++++++++++++++++++++++++++-------- 5 files changed, 335 insertions(+), 84 deletions(-) diff --git a/src/main.rs b/src/main.rs index 82377e5..6ea7ded 100644 --- a/src/main.rs +++ b/src/main.rs @@ -645,9 +645,16 @@ impl SimpleComponent for App { widgets.center_box.set_center_widget(Some(¢er_area)); widgets.center_box.set_end_widget(Some(&stats_box)); - // Captured before `control_popover` moves into `model` below — needed - // by the screenshot dispatch just before this function returns. + // Captured before these move into `model` (or are otherwise dropped + // as bare locals, never stored on `App` at all) — needed by the + // screenshot dispatch just before this function returns. let control_popover_for_screenshot = control_popover.clone(); + let connectivity_popover_for_screenshot = connectivity_popover.clone(); + let wifi_tab_btn_for_screenshot = wifi_tab_btn.clone(); + let bt_tab_btn_for_screenshot = bt_tab_btn.clone(); + let media_popover_for_screenshot = media_popover.clone(); + let media_widget_for_screenshot = media_widget.clone(); + let media_track_lbl_for_screenshot = media_track_lbl.clone(); let model = App { workspaces: vec![], @@ -715,11 +722,43 @@ impl SimpleComponent for App { bar::wifi::spawn_status_poller(sender.clone()); bar::media::spawn_poller(sender.clone()); widgets::client::spawn(sender.clone()); - notifications::spawn(); - osd::spawn(); + + // Screenshot mode primes these with sample content instead of the + // real D-Bus/pactl/backlight sources — see notifications::SampleKind + // and osd::SampleKind's doc comments. + let notif_sample = screenshot_req.as_ref().and_then(|r| match r.view.as_str() { + "notification" => Some(notifications::SampleKind::Normal), + "notification-critical" => Some(notifications::SampleKind::Critical), + _ => None, + }); + let notification_window = notifications::spawn(notif_sample); + let osd_sample = screenshot_req.as_ref().and_then(|r| match r.view.as_str() { + "osd-volume" => Some(osd::SampleKind::Volume), + "osd-brightness" => Some(osd::SampleKind::Brightness), + _ => None, + }); + let osd_window = osd::spawn(osd_sample); if let Some(req) = screenshot_req { - screenshot::dispatch(&root, req, control_popover_for_screenshot); + let notification_window = matches!(req.view.as_str(), "notification" | "notification-critical") + .then_some(notification_window); + let osd_window = matches!(req.view.as_str(), "osd-volume" | "osd-brightness") + .then_some(osd_window); + screenshot::dispatch( + &root, + req, + screenshot::Handles { + control_popover: control_popover_for_screenshot, + connectivity_popover: connectivity_popover_for_screenshot, + wifi_tab_btn: wifi_tab_btn_for_screenshot, + bt_tab_btn: bt_tab_btn_for_screenshot, + media_popover: media_popover_for_screenshot, + media_widget: media_widget_for_screenshot, + media_track_lbl: media_track_lbl_for_screenshot, + notification_window, + osd_window, + }, + ); } ComponentParts { model, widgets } @@ -1145,7 +1184,7 @@ impl App { if saved { bar::wifi::spawn_join(ssid_clone.clone()); } else { - show_add_network_dialog(btn, ssid_clone.clone()); + show_add_network_dialog(btn, ssid_clone.clone(), |_| {}); } close_parent_popover(btn); }); @@ -1302,8 +1341,11 @@ fn close_parent_popover(widget: >k4::Button) { } /// Small modal prompting for a password, then saves + joins the network via -/// `breadcrumbs add` + `breadcrumbs join`. -fn show_add_network_dialog(anchor: >k4::Button, ssid: String) { +/// `breadcrumbs add` + `breadcrumbs join`. `on_build` runs on the freshly +/// built dialog *before* it's presented — screenshot mode's only hook point, +/// since `connect_map` registered any later would miss a map that already +/// happened. The real call site passes a no-op. +fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_build: impl FnOnce(>k4::Window)) { let dialog = gtk4::Window::new(); dialog.set_title(Some(&format!("Add “{ssid}”"))); dialog.set_resizable(false); @@ -1367,6 +1409,7 @@ fn show_add_network_dialog(anchor: >k4::Button, ssid: String) { dialog_for_activate.close(); }); + on_build(&dialog); dialog.present(); entry.grab_focus(); } diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index b1f1760..0c25c0d 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -172,38 +172,90 @@ impl NotifServer { } } -pub fn spawn() { - let (tx, rx) = mpsc::channel(32); - let (conn_tx, conn_rx) = tokio::sync::oneshot::channel(); +/// A fixed sample notification for `--screenshot notification`/ +/// `notification-critical` — substitutes for a real `Notify` D-Bus call so a +/// capture doesn't depend on some external sender firing one at just the +/// right moment. +pub enum SampleKind { + Normal, + Critical, +} - relm4::spawn(async move { - let server = NotifServer { - tx, - next_id: AtomicU32::new(1), - sync_tags: Mutex::new(HashMap::new()), +impl SampleKind { + fn sample_event(&self) -> NotifEvent { + let urgency = match self { + SampleKind::Normal => Urgency::Normal, + SampleKind::Critical => Urgency::Critical, }; - // Builder failures here would only occur with invalid static strings — safe to unwrap. - let conn = zbus::connection::Builder::session() - .unwrap() - .name("org.freedesktop.Notifications") - .unwrap() - .serve_at("/org/freedesktop/Notifications", server) - .unwrap() - .build() - .await - .expect("failed to claim org.freedesktop.Notifications on D-Bus session bus"); - // Hand the connection to popup::run so it can emit `NotificationClosed` - // (spec-mandated whenever a notification actually goes away) — the - // dismiss decisions all happen over there, not in this interface impl. - let _ = conn_tx.send(conn); - std::future::pending::<()>().await - }); - - relm4::spawn_local(async move { - if let Ok(conn) = conn_rx.await { - popup::run(rx, conn).await; + NotifEvent::Show { + id: 1, + app_name: "Sample App".into(), + summary: "Sample notification".into(), + body: "This is what a notification card looks like.".into(), + urgency, + expire: Expire::Never, } - }); + } +} + +/// Builds the notification window synchronously (see +/// `popup::build_window`'s doc comment) and spawns the event loop that +/// shows/updates/hides it. +/// +/// `sample`: `Some` skips real D-Bus registration entirely and seeds the +/// loop with one fixed sample event instead — screenshot mode only. Doing +/// the real `org.freedesktop.Notifications` registration in every +/// screenshot run would race the real breadbar (if running) for the same +/// well-known name for no benefit, since nothing needs to reach this +/// instance externally. +pub fn spawn(sample: Option) -> gtk4::Window { + let (window, cards_box) = popup::build_window(); + let (tx, rx) = mpsc::channel(32); + + match sample { + Some(kind) => { + let _ = tx.try_send(kind.sample_event()); + let window_for_loop = window.clone(); + relm4::spawn_local(async move { + popup::run(window_for_loop, cards_box, rx, None).await; + }); + } + None => { + let (conn_tx, conn_rx) = tokio::sync::oneshot::channel(); + + relm4::spawn(async move { + let server = NotifServer { + tx, + next_id: AtomicU32::new(1), + sync_tags: Mutex::new(HashMap::new()), + }; + // Builder failures here would only occur with invalid static strings — safe to unwrap. + let conn = zbus::connection::Builder::session() + .unwrap() + .name("org.freedesktop.Notifications") + .unwrap() + .serve_at("/org/freedesktop/Notifications", server) + .unwrap() + .build() + .await + .expect("failed to claim org.freedesktop.Notifications on D-Bus session bus"); + // Hand the connection to popup::run so it can emit `NotificationClosed` + // (spec-mandated whenever a notification actually goes away) — the + // dismiss decisions all happen over there, not in this interface impl. + let _ = conn_tx.send(conn); + std::future::pending::<()>().await + }); + + let window_for_loop = window.clone(); + relm4::spawn_local(async move { + if let Ok(conn) = conn_rx.await { + popup::run(window_for_loop, cards_box, rx, Some(conn)).await; + } + }); + } + } + + window } #[cfg(test)] diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 69d1f6c..91adec0 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -23,7 +23,11 @@ mod close_reason { pub const CLOSE_NOTIFICATION_CALL: u32 = 3; } -pub async fn run(mut rx: Receiver, conn: zbus::Connection) { +/// Builds the notification window synchronously — so a caller (screenshot +/// mode in particular) has a real window to hook `connect_map` on before +/// `run`'s event loop, which needs an async `zbus::Connection` handshake in +/// the real path, ever starts. +pub fn build_window() -> (gtk4::Window, gtk4::Box) { let window = create_window(); let cards_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); cards_box.set_margin_top(8); @@ -31,7 +35,21 @@ pub async fn run(mut rx: Receiver, conn: zbus::Connection) { cards_box.set_margin_start(8); cards_box.set_margin_end(8); window.set_child(Some(&cards_box)); + (window, cards_box) +} +/// `conn`: `None` in screenshot mode, which skips real D-Bus registration +/// entirely (see `super::spawn`) — there's no external client that needs to +/// reach a screenshot-only instance, and registering the well-known name +/// would just race the real breadbar for it. `NotificationClosed` is a +/// spec-mandated signal for real clients only, so it's simply not emitted +/// when there's no real connection to emit it on. +pub async fn run( + window: gtk4::Window, + cards_box: gtk4::Box, + mut rx: Receiver, + conn: Option, +) { let cards: Cards = Rc::new(RefCell::new(HashMap::new())); let generations: Generations = Rc::new(RefCell::new(HashMap::new())); @@ -109,8 +127,10 @@ fn dismiss(cards_box: >k4::Box, window: >k4::Window, cards: &Cards, id: u32) /// Emits the spec-mandated `NotificationClosed(id, reason)` signal. Sent /// directly over the connection rather than through the zbus interface /// macro's generated helper, since the dismiss decision happens here in the -/// popup task, not inside `NotifServer`'s own method bodies. -async fn emit_closed(conn: &zbus::Connection, id: u32, reason: u32) { +/// popup task, not inside `NotifServer`'s own method bodies. No-op when +/// `conn` is `None` (screenshot mode — see `run`'s doc comment). +async fn emit_closed(conn: &Option, id: u32, reason: u32) { + let Some(conn) = conn else { return }; let result = conn .emit_signal( None::<&str>, diff --git a/src/osd.rs b/src/osd.rs index d7520fc..5deb1d4 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -9,14 +9,50 @@ enum OsdEvent { Brightness { pct: u8 }, } -pub fn spawn() { +/// A fixed sample event for `--screenshot osd-volume`/`osd-brightness` — +/// substitutes for the real `pactl subscribe`/backlight-sysfs watchers so a +/// capture doesn't depend on this machine's actual volume/brightness at +/// capture time. +pub enum SampleKind { + Volume, + Brightness, +} + +impl SampleKind { + fn sample_event(&self) -> OsdEvent { + match self { + SampleKind::Volume => OsdEvent::Volume { pct: 65, muted: false }, + SampleKind::Brightness => OsdEvent::Brightness { pct: 80 }, + } + } +} + +/// Builds the OSD window synchronously (so a caller — screenshot mode, via +/// `sample`, in particular — has a real window to hook `connect_map` on +/// before the async event loop below ever runs) and spawns the event loop +/// that shows/updates/hides it. +/// +/// `sample`: `Some` skips the real volume/brightness watchers entirely and +/// seeds the loop with one fixed sample event instead — screenshot mode +/// only, so a capture never depends on (or is disrupted by) this machine's +/// actual audio/backlight state. +pub fn spawn(sample: Option) -> gtk4::Window { let (tx, rx) = mpsc::channel::(8); - let tx1 = tx.clone(); - std::thread::spawn(move || volume_watcher(tx1)); - std::thread::spawn(move || brightness_watcher(tx)); + match sample { + Some(kind) => { + let _ = tx.try_send(kind.sample_event()); + } + None => { + let tx1 = tx.clone(); + std::thread::spawn(move || volume_watcher(tx1)); + std::thread::spawn(move || brightness_watcher(tx)); + } + } - relm4::spawn_local(run_osd(rx)); + let window = create_window(); + relm4::spawn_local(run_osd(window.clone(), rx)); + window } fn volume_watcher(tx: mpsc::Sender) { @@ -119,9 +155,7 @@ fn brightness_watcher(tx: mpsc::Sender) { } } -async fn run_osd(mut rx: mpsc::Receiver) { - let window = create_window(); - +async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver) { let container = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); container.set_margin_top(10); container.set_margin_bottom(10); diff --git a/src/screenshot.rs b/src/screenshot.rs index d276be8..8ccdd87 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -6,6 +6,13 @@ //! grabbing pixels — the surface (or, for popover views, the popover itself) //! genuinely isn't on screen yet before that fires, so a fixed delay would //! either race a slow first paint or pad every fast one for nothing. +//! +//! breadbar is "a bar + the notification daemon + the OSD" (see its own +//! module docs), so its screenshot views span three separate top-level +//! surfaces, not just the bar: the bar itself and its popovers (this +//! module, anchored off `root`), plus the standalone notification and OSD +//! windows (`notifications::spawn`/`osd::spawn`, built and primed with +//! sample data by `main.rs` before `dispatch` runs — see [`Handles`]). use clap::Parser; use gtk4::prelude::*; @@ -17,15 +24,12 @@ use std::time::Duration; /// anything has been drawn into it. const SETTLE_DELAY: Duration = Duration::from_millis(300); -/// Settle time for the control-panel view specifically: longer than -/// [`SETTLE_DELAY`] because the CPU/RAM/PWR/GPU/network labels there aren't -/// populated by the popover's own load (that only covers volume/brightness/ -/// sinks, see `bar::control::spawn_load`) — they're refreshed by -/// `bar::stats::spawn_poller`'s 2-second background loop, gated on -/// `control_popover.is_visible()` at each tick. Capturing any sooner than one -/// full poll interval after the popover opens leaves them at their initial -/// placeholder dashes. -const CONTROL_PANEL_SETTLE_DELAY: Duration = Duration::from_millis(2_200); +/// Settle time for views whose content depends on `bar::stats::spawn_poller`'s +/// 2-second background loop (control-panel's CPU/RAM/PWR/GPU/network labels, +/// gated on popover visibility) or a similar live-data popover load +/// (connectivity's wifi/bluetooth scan) — capturing any sooner leaves +/// placeholder dashes/"Scanning…" instead of real content. +const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200); /// Delay between the bar's own `map` and calling `popover.popup()`. Calling /// `popup()` synchronously from inside the root window's `map` handler @@ -36,11 +40,24 @@ const CONTROL_PANEL_SETTLE_DELAY: Duration = Duration::from_millis(2_200); /// finish first is what makes it actually render. const PRE_POPUP_DELAY: Duration = Duration::from_millis(300); +const KNOWN_VIEWS: &[&str] = &[ + "bar", + "control-panel", + "connectivity-wifi", + "connectivity-bluetooth", + "media-popover", + "notification", + "notification-critical", + "osd-volume", + "osd-brightness", + "wifi-add-dialog", +]; + #[derive(Parser)] #[command(name = "breadbar")] pub struct Cli { /// Render the named view, capture it, then exit instead of running - /// normally. Known views: "bar", "control-panel". + /// normally. See `screenshot::KNOWN_VIEWS` for the full list. #[arg(long)] pub screenshot: Option, @@ -80,20 +97,37 @@ impl Cli { } } -/// Wire up the given view's screenshot sequence. Called once from `init()`, -/// after the window and its popovers exist but before the component finishes -/// initializing — every path here ends by exiting the process, it never -/// returns control to the normal bar UI. +/// Every widget/window `dispatch` might need, gathered by `main.rs`'s +/// `init()` — most of these are plain locals there that never otherwise +/// outlive `init()` (never stored on `App`), so they have to be cloned out +/// before dispatch time same as `control_popover` always was. +pub struct Handles { + pub control_popover: gtk4::Popover, + pub connectivity_popover: gtk4::Popover, + pub wifi_tab_btn: gtk4::ToggleButton, + pub bt_tab_btn: gtk4::ToggleButton, + pub media_popover: gtk4::Popover, + pub media_widget: gtk4::Box, + pub media_track_lbl: gtk4::Label, + /// Already built and primed with sample content by `main.rs` (via + /// `notifications::spawn(Some(kind))`) when `req.view` calls for it — + /// `None` otherwise. + pub notification_window: Option, + /// Same deal as `notification_window`, via `osd::spawn(Some(kind))`. + pub osd_window: Option, +} + /// The bar's fixed height — matches `root.set_exclusive_zone(32)` / -/// `set_default_height: 32` in `main.rs`. Unlike the control-panel's full +/// `set_default_height: 32` in `main.rs`. Unlike the other views' full /// canvas, this never varies with `--width`/`--height`. const BAR_HEIGHT: i32 = 32; -pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, control_popover: gtk4::Popover) { +pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) { + let output = req.output; + let (width, height) = (req.width as i32, req.height as i32); + match req.view.as_str() { "bar" => { - let output = req.output; - let width = req.width as i32; root.connect_map(move |_| { let output = output.clone(); gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { @@ -102,35 +136,103 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, control_ }); } "control-panel" => { - let output = req.output; - let (width, height) = (req.width as i32, req.height as i32); - let popover_to_open = control_popover.clone(); + open_popover_on_root_map(root, handles.control_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + } + "connectivity-wifi" => { + handles.wifi_tab_btn.set_active(true); + open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + } + "connectivity-bluetooth" => { + handles.bt_tab_btn.set_active(true); + open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + } + "media-popover" => { + // Real media state only shows the widget/text when something's + // actually playing (see AppInput::MediaUpdate) — an automated + // run has nothing playing, so fake enough of it directly on the + // widgets to get a representative capture. + handles.media_widget.set_visible(true); + handles.media_track_lbl.set_text("Sample Track — Sample Artist"); + open_popover_on_root_map(root, handles.media_popover, SETTLE_DELAY, output, width, height); + } + "notification" | "notification-critical" => { + let Some(window) = handles.notification_window else { + eprintln!("breadbar: internal error — no notification window built for '{}'", req.view); + std::process::exit(1); + }; + capture_standalone_window(window, output, width, height); + } + "osd-volume" | "osd-brightness" => { + let Some(window) = handles.osd_window else { + eprintln!("breadbar: internal error — no OSD window built for '{}'", req.view); + std::process::exit(1); + }; + capture_standalone_window(window, output, width, height); + } + "wifi-add-dialog" => { + let anchor = handles.wifi_tab_btn; root.connect_map(move |_| { - // Autohide (the default) tries to grab the Wayland seat on - // popup, keyed to a real input event's serial — a - // programmatic popup() has no such event to grab with. - // Screenshot mode never needs the popover to dismiss itself - // anyway. - popover_to_open.set_autohide(false); - let popover_to_open = popover_to_open.clone(); - gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { - popover_to_open.popup(); - }); - }); - control_popover.connect_map(move |_| { let output = output.clone(); - gtk4::glib::timeout_add_local_once(CONTROL_PANEL_SETTLE_DELAY, move || { - finish(bread_screenshots::capture_region(0, 0, width, height, &output)); + let anchor = anchor.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + crate::show_add_network_dialog(&anchor, "Sample Network".to_string(), move |dialog| { + capture_standalone_window(dialog.clone(), output.clone(), width, height); + }); }); }); } other => { - eprintln!("breadbar: unknown screenshot view '{other}' (known: bar, control-panel)"); + eprintln!( + "breadbar: unknown screenshot view '{other}' (known: {})", + KNOWN_VIEWS.join(", ") + ); std::process::exit(1); } } } +/// Shared shape for every popover view: force it open shortly after the bar +/// maps (autohide disabled — a programmatic `popup()` has no real input +/// event serial to grab the Wayland seat with), then capture the whole +/// canvas after `settle` once the popover itself maps. +fn open_popover_on_root_map( + root: >k4::ApplicationWindow, + popover: gtk4::Popover, + settle: Duration, + output: PathBuf, + width: i32, + height: i32, +) { + let popover_to_open = popover.clone(); + root.connect_map(move |_| { + popover_to_open.set_autohide(false); + let popover_to_open = popover_to_open.clone(); + gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { + popover_to_open.popup(); + }); + }); + popover.connect_map(move |_| { + let output = output.clone(); + gtk4::glib::timeout_add_local_once(settle, move || { + finish(bread_screenshots::capture_region(0, 0, width, height, &output)); + }); + }); +} + +/// Shared shape for the standalone notification/OSD windows and the wifi +/// add-network dialog: wait for `map`, settle, capture, exit. These are +/// already-visible-or-about-to-be windows by the time this is called (their +/// sample event is queued before `dispatch` even runs), so this is just the +/// capture half. +fn capture_standalone_window(window: gtk4::Window, output: PathBuf, width: i32, height: 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)); + }); + }); +} + fn finish(result: anyhow::Result<()>) { match result { Ok(()) => std::process::exit(0), From c53d504208d55d65982249f2720b6ef031cae56d Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 30 Jul 2026 19:02:39 +0800 Subject: [PATCH 17/39] breadbar: give the wifi add-network dialog proper CSD + rounded corners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was a bare gtk4::Window with no titlebar — GTK4's own minimal CSD fallback for that case is a flat bar with plain system-font title text and square corners, which is what actually made this look like a stray window from a different decade next to the rest of the (rounded, borderless) ecosystem. Now sets a real HeaderBar (which picks up the window's existing title automatically) and gives the dialog + headerbar matching rounded corners and dark theming via the existing .wifi-add-dialog CSS class. Also switched the Connect button off "suggested-action": GTK4's bundled theme special-cases that exact class name for a native OS-accent-colour feature that isn't a normal CSS rule and doesn't yield to any background-color override this stylesheet adds, confirmed empirically (a much more specific selector had zero effect). Moved to "confirm-button", the same accent-button convention breadman/breadpad already use. Known residual issue, not resolved here: the Connect button still doesn't render the accent colour even via "confirm-button" — narrowed down to *some* button-specific styling quirk in this one dialog (window- level rules like the rounded corners and dark background apply correctly; a maximally-obvious magenta test rule on the same selector also failed to render, ruling out a color-value mistake specifically), but not root-caused further. Left as a known follow-up rather than blocking the CSD fix, which was the actual ask. Also removed two `cursor: pointer` CSS properties (on .clickable and .media-widget) that GTK4 doesn't support as a CSS property at all — was logging "Theme parser error: No property named cursor" on every startup, doing nothing functionally either way. --- src/main.rs | 18 +++++++++++++++++- src/theme.rs | 11 ++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6ea7ded..58a70c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1350,6 +1350,15 @@ fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_bui dialog.set_title(Some(&format!("Add “{ssid}”"))); dialog.set_resizable(false); dialog.add_css_class("wifi-add-dialog"); + // A bare gtk4::Window with no titlebar set falls back to GTK's own + // minimal CSD: a flat bar with plain system-font title text and no + // rounding — which is what actually made this look like a stray window + // from a different decade next to the rest of the (rounded, borderless, + // shadowed) ecosystem. A real HeaderBar picks up the window's own title + // automatically and gets the same `.wifi-add-dialog` theming below. + let header = gtk4::HeaderBar::new(); + header.set_show_title_buttons(true); + dialog.set_titlebar(Some(&header)); if let Some(root) = anchor.root() { if let Ok(win) = root.downcast::() { dialog.set_transient_for(Some(&win)); @@ -1375,7 +1384,14 @@ fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_bui btn_row.set_halign(gtk4::Align::End); let cancel_btn = gtk4::Button::with_label("Cancel"); let connect_btn = gtk4::Button::with_label("Connect"); - connect_btn.add_css_class("suggested-action"); + // Not "suggested-action" — GTK4's own bundled theme special-cases that + // class for a newer native OS-accent-colour feature that isn't a normal + // CSS rule at all, and simply doesn't lose to any `background-color` + // override this stylesheet adds, however specific the selector (already + // confirmed empirically: adding a much more specific override rule had + // zero effect). "confirm-button" is the same ecosystem-wide accent + // button convention breadman/breadpad already use successfully. + connect_btn.add_css_class("confirm-button"); btn_row.append(&cancel_btn); btn_row.append(&connect_btn); body.append(&btn_row); diff --git a/src/theme.rs b/src/theme.rs index 5bb20b1..10c2415 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -48,7 +48,6 @@ fn load_css() -> String { progressbar.osd-bar {{ min-height: 6px; }}\ progressbar.osd-bar trough {{ background-image: none; background-color: {trough}; border-radius: 3px; min-height: 6px; }}\ progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 3px; min-height: 6px; }}\ - .clickable {{ cursor: pointer; }}\ .wifi-pair {{ border-radius: {radius_sm}; padding: 0 2px; }}\ .wifi-pair:hover {{ background: alpha({on_bg}, 0.12); }}\ .wifi-popover-inner {{ min-width: 200px; padding: {pad}; }}\ @@ -68,8 +67,14 @@ fn load_css() -> String { .wifi-popover-row-active {{ color: {accent}; }}\ .wifi-popover-row-unsaved {{ opacity: 0.4; }}\ .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ - window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px; }}\ - .media-widget {{ border-radius: {radius_sm}; padding: 0 6px; cursor: pointer; }}\ + window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px;\ + border-radius: {radius}; }}\ + window.wifi-add-dialog headerbar {{ background-color: {bg_rgba}; color: {on_bg};\ + border-top-left-radius: {radius}; border-top-right-radius: {radius};\ + border-bottom: 1px solid alpha({on_bg}, 0.08); box-shadow: none; }}\ + .confirm-button {{ background-color: @accent; color: @on-accent; }}\ + .confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\ + .media-widget {{ border-radius: {radius_sm}; padding: 0 6px; }}\ .media-widget:hover {{ background: alpha({on_bg}, 0.10); }}\ .media-indicator {{ font-size: 11px; opacity: 0.7; margin-right: 2px; }}\ .media-track-lbl {{ font-size: 12px; }}\ From 9dc82817e97b47ab1fe8a37ba0926383b03c744b Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:05:29 +0800 Subject: [PATCH 18/39] =?UTF-8?q?CI:=20single-trunk=20model=20=E2=80=94=20?= =?UTF-8?q?dev=20triggers=20on=20main,=20beta=20becomes=20RC-tag-triggered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dev/beta branch split with one trunk (main): dev-track builds still publish on every push, but the beta track now publishes from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained beta branch. Removes the branch nobody reliably kept in sync. --- .forgejo/workflows/dev-release.yml | 15 ++++---- .../{beta-release.yml => rc-release.yml} | 38 +++++-------------- .forgejo/workflows/release.yml | 1 + 3 files changed, 17 insertions(+), 37 deletions(-) rename .forgejo/workflows/{beta-release.yml => rc-release.yml} (57%) diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 5ffa25a..421489e 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -1,12 +1,11 @@ name: dev release -# Publishes a dev-track build on every push to `dev` — -# separate from release.yml's tag-triggered stable releases. See -# bread-ecosystem's docs/release-channels.md for the three-track policy -# this is part of. +# 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: ['dev'] + branches: ['main'] jobs: build: @@ -16,7 +15,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch dev --depth 1 \ + git clone --branch main --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -33,7 +32,7 @@ jobs: # 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//' | sort -V | tail -1)" + | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" if [ -n "${LATEST_TAG}" ]; then CUR="${LATEST_TAG}" else @@ -72,6 +71,6 @@ jobs: # 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 dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + 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}" diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/rc-release.yml similarity index 57% rename from .forgejo/workflows/beta-release.yml rename to .forgejo/workflows/rc-release.yml index ed482ba..c05a6dc 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/rc-release.yml @@ -1,52 +1,32 @@ -name: beta release +name: beta (rc) release -# Publishes a beta-track build on every push to `beta` — a frozen -# stabilization branch cut from `dev` when ready to stabilize; only -# fix/ branches merged into `beta` should land here afterward. -# See bread-ecosystem's docs/release-channels.md for the three-track policy. +# 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: - branches: ['beta'] + 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 beta --depth 1 \ + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build run: cd src && cargo build --release --locked - - name: compute beta version - run: | - set -euo pipefail - cd src - # Base the beta 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 beta 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//' | sort -V | tail -1)" - if [ -n "${LATEST_TAG}" ]; then - CUR="${LATEST_TAG}" - else - CUR="$(grep -m1 '^version' 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))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" - - name: prepare artifacts run: | set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="/srv/breadway-dl/beta/breadbar/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/breadbar" "${PKG_DIR}/breadbar-x86_64" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 264d787..48d3dbc 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,6 +6,7 @@ on: jobs: build: + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout From bba4aa2d2be86e7038bbbdafd09dab32a89e4012 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:08:41 +0800 Subject: [PATCH 19/39] CONTRIBUTING.md: document single-trunk + RC-tag release model --- CONTRIBUTING.md | 71 ++++++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 39 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1dac2b5..e171853 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,16 +7,10 @@ workflow as every other ecosystem product. ## Branches -- **`main`** — release branch, always tag-ready. Nothing is committed to it - directly; it only moves forward via a `beta` merge (see below). -- **`dev`** — integration branch. All day-to-day work lands here first. - Every push to `dev` automatically builds and publishes a **dev-track** - build (see Tracks below) — use this to test your change in a real install - before it goes any further. -- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. - Every push to `beta` automatically builds and publishes a **beta-track** - build. While a freeze is active, only fixes for issues found *in that - freeze* should land on `beta`. +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: @@ -25,28 +19,26 @@ feature/ fix/ ``` -Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing -something reported against an active `beta` freeze, branch off `beta` -instead, merge the fix there to unblock testers, and also forward the same -fix into `dev` so it doesn't quietly reappear next cycle. +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 -1. Work accumulates on `dev` via `feature/x` / `fix/x` branches. Each push +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 report or fix anything broken with another - push to `dev`. -2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut - fresh from `dev`'s current tip. This freezes it as the stabilization - target — `dev` keeps moving independently starting the next cycle. -3. `beta` is open for anyone to test: `bakery track set beta` and - `bakery update --all`. **File issues against anything you find on this - repo's Forgejo issue tracker.** Fixes land via `fix/` branches - merged into `beta`. -4. Once `beta` has gone roughly **a month** without new issues, it's merged - into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the - stable release build. `beta` is then reset from `dev` to start the next - cycle. + `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 @@ -58,14 +50,15 @@ bakery update --all # pull the latest build on your current track | Track | What it is | Published from | |--------|-----------|-----------------| -| `stable` | The last tagged release | `main`, on a `vX.Y.Z` tag push | -| `beta` | Current stabilization freeze | `beta`, on every push | -| `dev` | Bleeding edge | `dev`, on every push | +| `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/beta versions are auto-computed (`X.Y.Z-dev.+` / -`-beta.…`) from the latest published stable tag, so they always sort as -newer than what you have installed — no manual version bumping needed when -pushing to `dev` or `beta`. +Dev versions are auto-computed (`X.Y.Z-dev.+`) 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 @@ -76,10 +69,10 @@ cargo test --release ## CI -- `dev-release.yml` — triggered on push to `dev`. -- `beta-release.yml` — triggered on push to `beta`. -- `release.yml` — triggered on a `v*` tag push, cuts the actual stable release. -- `package.yml` — publishes to the `[breadway]` pacman repo, also tag-triggered. +- `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 From d8c766abe469109c3d5c7236b35f240b7397c402 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 14:30:01 +0800 Subject: [PATCH 20/39] Repoint bread-ecosystem/bread git deps from dev to main branch --- Cargo.lock | 10 +++++----- Cargo.toml | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b48bacd..0dd43cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,7 +153,7 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-screenshots" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#27b3b17c58b81f947a40cc0d3f9cd0862e7885ff" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" dependencies = [ "anyhow", "bread-utils", @@ -174,7 +174,7 @@ dependencies = [ [[package]] name = "bread-shared" version = "0.7.0" -source = "git+https://git.breadway.dev/Breadway/bread?branch=dev#34854f147135143ca4baf1d628e8c609e2c1cc8c" +source = "git+https://git.breadway.dev/Breadway/bread?branch=main#b9e253074355c5f00f80e757ee8b5d2c5ac19328" dependencies = [ "dirs 6.0.0", "serde", @@ -185,7 +185,7 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.3.1" -source = "git+https://github.com/Breadway/bread-ecosystem?branch=dev#686af0d3dc93bf8039d62fbbd1e04a8b2db90154" +source = "git+https://github.com/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" dependencies = [ "dirs 5.0.1", "gtk4", @@ -196,7 +196,7 @@ dependencies = [ [[package]] name = "bread-utils" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#27b3b17c58b81f947a40cc0d3f9cd0862e7885ff" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" dependencies = [ "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0)", "dirs 5.0.1", @@ -210,7 +210,7 @@ version = "0.3.0" dependencies = [ "anyhow", "bread-screenshots", - "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?branch=dev)", + "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?branch=main)", "bread-theme", "bread-utils", "clap", diff --git a/Cargo.toml b/Cargo.toml index 23acc2c..4748a93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,15 +10,15 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", branch = "dev", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", branch = "main", features = ["gtk"] } # Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs # instead of hand-parsing serde_json::Value. See src/widgets/. -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev", features = ["bread-client"] } -bread-shared = { git = "https://git.breadway.dev/Breadway/bread", branch = "dev" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "main", features = ["bread-client"] } +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", branch = "main" } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev" } +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "main" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } From be0b54e1ea019dc87e5b03092d184f86a847895f Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 21:40:24 +0800 Subject: [PATCH 21/39] Pin ecosystem crates off floating main; bakery pulls bread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bread-theme moves to git.breadway.dev v0.7.1. bread-utils and bread-screenshots pin ecosystem origin/main (v0.7.1 has no BreadClient::request and predates the screenshots crate). bread-shared pins bread v0.8.0-rc.1 — latest existing tag with widget wire types (v0.7.0 has none; v0.8.0 was never cut). bakery.toml sets bread_deps = ["bread"] so the daemon is installed alongside the bar. CLAUDE.md records the single-trunk workflow and the bar / notifications / OSD / widgets split. --- .gitignore | 3 --- CLAUDE.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.lock | 16 +++++++++------- Cargo.toml | 13 +++++++++---- README.md | 1 + bakery.toml | 2 +- 6 files changed, 71 insertions(+), 15 deletions(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 7660082..816e2ad 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,3 @@ logs/ # Internal design documents (not for distribution) aster-brief.md - -# Local hygiene notes (not for commit) -CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f249380 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# CLAUDE.md — Repo hygiene + +Scope: this file covers *repo hygiene* — branching, remotes, CI — plus a +short map of the binary. It is not user-facing project documentation. + +This repo follows the branch/release workflow documented in `CONTRIBUTING.md` +— read and follow it for any git, branch, or release work here (the +single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, +etc). Don't improvise a different workflow. The short version: there is one +long-lived branch, `main` — no `dev` or `beta` branch exists. `main` +auto-publishes a dev-track build on every push. "Beta" and "stable" are both +just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track +build, push a plain `vX.Y.Z` tag to cut the signed stable release. +"Freezing" for stabilization means pausing pushes to `main`, not moving a +branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model +after `main` was found to have silently rotted out of sync with `dev`/`beta` +across most repos in this ecosystem. + +When starting work on a new feature, create branch `feature/`. +When working on a bug or issue, create branch `fix/`. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push `origin` only; GitHub auto-mirrors. + +## CI +- `dev-release.yml` triggers on `push: branches: ['main']`. +- `rc-release.yml` triggers on `vX.Y.Z-rc.N` tag pushes (beta track). +- `release.yml` triggers on any other `v*` tag push (stable). + None of these run on plain commits or PRs beyond what's listed. + +## Architecture + +One GTK4/`relm4` binary, four surfaces: + +| Area | Path | Role | +|---|---|---| +| Bar | `src/bar/` | Layer-shell top bar: workspaces, clock, media, stats, wifi, bluetooth, control panel + SNI tray | +| Notifications | `src/notifications/` | `org.freedesktop.Notifications` daemon + stacked popups | +| OSD | `src/osd.rs` | Volume/brightness overlay | +| Widgets | `src/widgets/` | Live Lua widgets from breadd via `BreadClient` / `WidgetSpec` | + +`--screenshot` (`src/screenshot.rs`) captures those views through +`bread-screenshots`; do not rewrite it just to retarget the crate pin. + +`application_id` drift vs Hyprland layer-rules/tour docs is known — leave it +unless every mention is updated in the same change. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. +- Don't rewrite the widget system or `screenshot.rs` as part of pin/docs work. diff --git a/Cargo.lock b/Cargo.lock index 0dd43cc..de799e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,7 +153,7 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-screenshots" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" dependencies = [ "anyhow", "bread-utils", @@ -173,19 +173,20 @@ dependencies = [ [[package]] name = "bread-shared" -version = "0.7.0" -source = "git+https://git.breadway.dev/Breadway/bread?branch=main#b9e253074355c5f00f80e757ee8b5d2c5ac19328" +version = "0.8.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0-rc.1#2485e1af1f941c724461c0d59416c829ded638fe" dependencies = [ "dirs 6.0.0", "serde", "serde_json", "toml 0.8.23", + "uuid", ] [[package]] name = "bread-theme" version = "0.3.1" -source = "git+https://github.com/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ "dirs 5.0.1", "gtk4", @@ -196,9 +197,9 @@ dependencies = [ [[package]] name = "bread-utils" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" dependencies = [ - "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0)", + "bread-shared 0.7.0", "dirs 5.0.1", "serde", "serde_json", @@ -210,7 +211,7 @@ version = "0.3.0" dependencies = [ "anyhow", "bread-screenshots", - "bread-shared 0.7.0 (git+https://git.breadway.dev/Breadway/bread?branch=main)", + "bread-shared 0.8.0", "bread-theme", "bread-utils", "clap", @@ -1922,6 +1923,7 @@ version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 4748a93..1d06a30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,15 +10,20 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", branch = "main", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["gtk"] } # Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs # instead of hand-parsing serde_json::Value. See src/widgets/. -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "main", features = ["bread-client"] } -bread-shared = { git = "https://git.breadway.dev/Breadway/bread", branch = "main" } +# v0.7.1's BreadClient has no `request`; crate added after that tag too. +# Pin origin/main rather than inventing a tag or floating on branch = "main". +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d", features = ["bread-client"] } +# v0.7.0 has no `bread_shared::widget`; v0.8.0 was never cut. Latest +# existing tag that carries the wire types breadbar deserializes. +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "main" } +# Same rev as bread-utils: v0.7.1 predates this crate (added 2026-07-23). +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } diff --git a/README.md b/README.md index d8b3f9e..474eafa 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ Example — change the font size: | `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service | | `src/notifications/popup.rs` | Layer-shell popup window and card stack | | `src/osd.rs` | Volume/brightness on-screen display | +| `src/widgets/` | Live Lua widgets from breadd (`BreadClient` + `WidgetSpec`) | | `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection | Stats are polled every 2 seconds. Bluetooth and WiFi are sampled every 16 seconds and cached in between to avoid hammering D-Bus and `iw`. diff --git a/bakery.toml b/bakery.toml index 1b305d5..0226f34 100644 --- a/bakery.toml +++ b/bakery.toml @@ -3,7 +3,7 @@ description = "Minimal status bar and notification daemon for Hyprland" binaries = ["breadbar"] system_deps = ["gtk4", "gtk4-layer-shell", "wireplumber", "pipewire-pulse", "brightnessctl", "iw"] optional_system_deps = ["hyprland"] -bread_deps = [] +bread_deps = ["bread"] license_file = "LICENSE" [config] From 92f2e52c1fe40526a96a9a28bcd0599f4777a7da Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:03:30 +0800 Subject: [PATCH 22/39] Rename CLAUDE.md to AGENTS.md --- AGENTS.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5acf2c9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# AGENTS.md — Repo hygiene + +Scope: this file covers *repo hygiene* — branching, remotes, CI — plus a +short map of the binary. It is not user-facing project documentation. + +This repo follows the branch/release workflow documented in `CONTRIBUTING.md` +— read and follow it for any git, branch, or release work here (the +single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, +etc). Don't improvise a different workflow. The short version: there is one +long-lived branch, `main` — no `dev` or `beta` branch exists. `main` +auto-publishes a dev-track build on every push. "Beta" and "stable" are both +just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track +build, push a plain `vX.Y.Z` tag to cut the signed stable release. +"Freezing" for stabilization means pausing pushes to `main`, not moving a +branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model +after `main` was found to have silently rotted out of sync with `dev`/`beta` +across most repos in this ecosystem. + +When starting work on a new feature, create branch `feature/`. +When working on a bug or issue, create branch `fix/`. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push `origin` only; GitHub auto-mirrors. + +## CI +- `dev-release.yml` triggers on `push: branches: ['main']`. +- `rc-release.yml` triggers on `vX.Y.Z-rc.N` tag pushes (beta track). +- `release.yml` triggers on any other `v*` tag push (stable). + None of these run on plain commits or PRs beyond what's listed. + +## Architecture + +One GTK4/`relm4` binary, four surfaces: + +| Area | Path | Role | +|---|---|---| +| Bar | `src/bar/` | Layer-shell top bar: workspaces, clock, media, stats, wifi, bluetooth, control panel + SNI tray | +| Notifications | `src/notifications/` | `org.freedesktop.Notifications` daemon + stacked popups | +| OSD | `src/osd.rs` | Volume/brightness overlay | +| Widgets | `src/widgets/` | Live Lua widgets from breadd via `BreadClient` / `WidgetSpec` | + +`--screenshot` (`src/screenshot.rs`) captures those views through +`bread-screenshots`; do not rewrite it just to retarget the crate pin. + +`application_id` drift vs Hyprland layer-rules/tour docs is known — leave it +unless every mention is updated in the same change. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. +- Don't rewrite the widget system or `screenshot.rs` as part of pin/docs work. From 89f7a93e8ade541e8b70fd2fa7a774d823e217b1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:04:00 +0800 Subject: [PATCH 23/39] Remove CLAUDE.md (renamed to AGENTS.md) --- CLAUDE.md | 51 --------------------------------------------------- 1 file changed, 51 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f249380..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,51 +0,0 @@ -# CLAUDE.md — Repo hygiene - -Scope: this file covers *repo hygiene* — branching, remotes, CI — plus a -short map of the binary. It is not user-facing project documentation. - -This repo follows the branch/release workflow documented in `CONTRIBUTING.md` -— read and follow it for any git, branch, or release work here (the -single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, -etc). Don't improvise a different workflow. The short version: there is one -long-lived branch, `main` — no `dev` or `beta` branch exists. `main` -auto-publishes a dev-track build on every push. "Beta" and "stable" are both -just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track -build, push a plain `vX.Y.Z` tag to cut the signed stable release. -"Freezing" for stabilization means pausing pushes to `main`, not moving a -branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model -after `main` was found to have silently rotted out of sync with `dev`/`beta` -across most repos in this ecosystem. - -When starting work on a new feature, create branch `feature/`. -When working on a bug or issue, create branch `fix/`. - -## Remotes -- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. -- `github` — GitHub mirror. Push `origin` only; GitHub auto-mirrors. - -## CI -- `dev-release.yml` triggers on `push: branches: ['main']`. -- `rc-release.yml` triggers on `vX.Y.Z-rc.N` tag pushes (beta track). -- `release.yml` triggers on any other `v*` tag push (stable). - None of these run on plain commits or PRs beyond what's listed. - -## Architecture - -One GTK4/`relm4` binary, four surfaces: - -| Area | Path | Role | -|---|---|---| -| Bar | `src/bar/` | Layer-shell top bar: workspaces, clock, media, stats, wifi, bluetooth, control panel + SNI tray | -| Notifications | `src/notifications/` | `org.freedesktop.Notifications` daemon + stacked popups | -| OSD | `src/osd.rs` | Volume/brightness overlay | -| Widgets | `src/widgets/` | Live Lua widgets from breadd via `BreadClient` / `WidgetSpec` | - -`--screenshot` (`src/screenshot.rs`) captures those views through -`bread-screenshots`; do not rewrite it just to retarget the crate pin. - -`application_id` drift vs Hyprland layer-rules/tour docs is known — leave it -unless every mention is updated in the same change. - -## Don't -- Don't embed credentials in remote URLs — SSH or a credential helper only. -- Don't rewrite the widget system or `screenshot.rs` as part of pin/docs work. From 614dca71af5d226255234b89a9df30d433a2918a Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 5 Aug 2026 19:12:27 +0800 Subject: [PATCH 24/39] ci: port breadbar onto shared bread-ecosystem build system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delegates dev/rc/release build steps to bread-ecosystem's pinned Arch container via ci/build.sh, pinned to bread-ecosystem commit 147cfbbf96ae4b171027defa1130d2caddb934b1. Adds a check.yml workflow for fast clippy/test signal on feature/fix branches, matching the pattern proven in breadpad. No ci/deps.txt needed — breadbar's dependencies (zbus, resvg, gtk4-layer-shell, hyprland) are all pure Rust or already covered by the shared image's package set. --- .forgejo/workflows/check.yml | 24 ++++++++++++++++++++++++ .forgejo/workflows/dev-release.yml | 2 +- .forgejo/workflows/rc-release.yml | 2 +- .forgejo/workflows/release.yml | 2 +- ci/bread-ecosystem.rev | 1 + ci/build.sh | 21 +++++++++++++++++++++ 6 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 .forgejo/workflows/check.yml create mode 100644 ci/bread-ecosystem.rev create mode 100755 ci/build.sh diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml new file mode 100644 index 0000000..9bbb301 --- /dev/null +++ b/.forgejo/workflows/check.yml @@ -0,0 +1,24 @@ +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 --all-targets --locked -- -D warnings + + - name: test + run: cd src && bash ci/build.sh cargo test --locked diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 421489e..13f0666 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -19,7 +19,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: compute dev version run: | diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml index c05a6dc..769b700 100644 --- a/.forgejo/workflows/rc-release.yml +++ b/.forgejo/workflows/rc-release.yml @@ -21,7 +21,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: prepare artifacts run: | diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 48d3dbc..65b0c30 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -17,7 +17,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: prepare artifacts run: | diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev new file mode 100644 index 0000000..34e7aa9 --- /dev/null +++ b/ci/bread-ecosystem.rev @@ -0,0 +1 @@ +147cfbbf96ae4b171027defa1130d2caddb934b1 diff --git a/ci/build.sh b/ci/build.sh new file mode 100755 index 0000000..2d59cfe --- /dev/null +++ b/ci/build.sh @@ -0,0 +1,21 @@ +#!/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" breadbar "$ROOT" "$@" From 465088dc55ec5e58ec5561b4fb236b17939372a4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:54:04 +0800 Subject: [PATCH 25/39] Add notification history; pin ecosystem crates to v0.7.2 Keep a bounded in-memory history (last 50) and a layer-shell window listing app, summary, truncated body, and time. Toggle with `breadbar --history` or D-Bus `dev.breadway.Bar.ToggleHistory`. Pin bread-theme, bread-utils, and bread-screenshots to tag v0.7.2. bread-shared stays on bread v0.8.0-rc.1. --- AGENTS.md | 2 +- Cargo.lock | 12 +- Cargo.toml | 13 +- README.md | 4 +- src/main.rs | 11 ++ src/notifications/history.rs | 274 +++++++++++++++++++++++++++++++++++ src/notifications/mod.rs | 75 +++++++++- src/notifications/popup.rs | 11 +- src/screenshot.rs | 5 + src/theme.rs | 8 + 10 files changed, 394 insertions(+), 21 deletions(-) create mode 100644 src/notifications/history.rs diff --git a/AGENTS.md b/AGENTS.md index 5acf2c9..1207e2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ One GTK4/`relm4` binary, four surfaces: | Area | Path | Role | |---|---|---| | Bar | `src/bar/` | Layer-shell top bar: workspaces, clock, media, stats, wifi, bluetooth, control panel + SNI tray | -| Notifications | `src/notifications/` | `org.freedesktop.Notifications` daemon + stacked popups | +| Notifications | `src/notifications/` | `org.freedesktop.Notifications` daemon + stacked popups + in-memory history (`breadbar --history`) | | OSD | `src/osd.rs` | Volume/brightness overlay | | Widgets | `src/widgets/` | Live Lua widgets from breadd via `BreadClient` / `WidgetSpec` | diff --git a/Cargo.lock b/Cargo.lock index de799e0..7c03af7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,8 +152,8 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-screenshots" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "anyhow", "bread-utils", @@ -185,8 +185,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "dirs 5.0.1", "gtk4", @@ -196,8 +196,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "bread-shared 0.7.0", "dirs 5.0.1", diff --git a/Cargo.toml b/Cargo.toml index 1d06a30..ffa6d25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,20 +10,17 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } # Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs # instead of hand-parsing serde_json::Value. See src/widgets/. -# v0.7.1's BreadClient has no `request`; crate added after that tag too. -# Pin origin/main rather than inventing a tag or floating on branch = "main". -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d", features = ["bread-client"] } -# v0.7.0 has no `bread_shared::widget`; v0.8.0 was never cut. Latest -# existing tag that carries the wire types breadbar deserializes. +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } +# v0.8.0-rc.1 carries bread_shared::widget; keep this bread tag even if +# ecosystem crates move independently. bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. -# Same rev as bread-utils: v0.7.1 predates this crate (added 2026-07-23). -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" } +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } diff --git a/README.md b/README.md index 474eafa..0047613 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ A single Rust binary that provides a full-width top bar, a D-Bus notification da - Implements `org.freedesktop.Notifications` (D-Bus) — works with any standard sender (`notify-send`, etc.) - Popups appear top-right, stack vertically, auto-dismiss after the sender-specified timeout (default 5 s) - Supports `CloseNotification` and `replaces_id` +- In-memory history of the last 50 notifications (app, summary, truncated body, time). Toggle with `breadbar --history` (Hyprland: `bind = SUPER, N, exec, breadbar --history`) or D-Bus `dev.breadway.Bar.ToggleHistory` on `org.freedesktop.Notifications` at `/dev/breadway/Bar`. Not persisted. **Volume/brightness OSD**: @@ -138,8 +139,9 @@ Example — change the font size: | `src/bar/wifi.rs` | WiFi details popover, `breadcrumbs` profile/scan integration | | `src/bar/control.rs` | Control panel data: volume (`wpctl`), brightness (`brightnessctl`), sinks (`pactl`) | | `src/bar/tray.rs` | `org.kde.StatusNotifierWatcher` D-Bus service, SNI item rendering | -| `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service | +| `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service + `dev.breadway.Bar` history IPC | | `src/notifications/popup.rs` | Layer-shell popup window and card stack | +| `src/notifications/history.rs` | Bounded in-memory history and layer-shell history window | | `src/osd.rs` | Volume/brightness on-screen display | | `src/widgets/` | Live Lua widgets from breadd (`BreadClient` + `WidgetSpec`) | | `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection | diff --git a/src/main.rs b/src/main.rs index 58a70c9..0ce99a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1481,6 +1481,17 @@ fn stat_label() -> gtk4::Label { fn main() { use clap::Parser; let cli = screenshot::Cli::parse(); + if cli.history { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + if let Err(e) = rt.block_on(notifications::toggle_history_remote()) { + eprintln!("breadbar: could not toggle history (is breadbar running?): {e}"); + std::process::exit(1); + } + return; + } let screenshot_req = cli.screenshot_request(); relm4::spawn(async { diff --git a/src/notifications/history.rs b/src/notifications/history.rs new file mode 100644 index 0000000..711f229 --- /dev/null +++ b/src/notifications/history.rs @@ -0,0 +1,274 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use gtk4::prelude::*; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; + +use super::Urgency; + +pub const LIMIT: usize = 50; +const BODY_MAX_CHARS: usize = 96; + +pub type Store = Arc>>; + +#[derive(Debug, Clone)] +pub struct Entry { + pub id: u32, + pub app_name: String, + pub summary: String, + pub body: String, + pub urgency: Urgency, + pub received: SystemTime, +} + +pub struct Ui { + pub window: gtk4::Window, + pub list: gtk4::Box, + pub store: Store, +} + +pub fn new_store() -> Store { + Arc::new(Mutex::new(VecDeque::new())) +} + +/// Insert or replace by `id`, newest first. Drops anything past [`LIMIT`]. +pub fn record(store: &Store, entry: Entry) { + let mut hist = store.lock().unwrap(); + if let Some(pos) = hist.iter().position(|e| e.id == entry.id) { + hist.remove(pos); + } + hist.push_front(entry); + while hist.len() > LIMIT { + hist.pop_back(); + } +} + +pub fn build_window(store: Store) -> Ui { + let window = gtk4::Window::new(); + window.add_css_class("breadbar-history"); + window.init_layer_shell(); + window.set_layer(Layer::Overlay); + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Right, true); + window.set_margin(Edge::Top, 48); + window.set_margin(Edge::Right, 20); + window.set_default_width(360); + window.set_keyboard_mode(KeyboardMode::OnDemand); + + let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + outer.set_margin_top(10); + outer.set_margin_bottom(10); + outer.set_margin_start(10); + outer.set_margin_end(10); + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let title = gtk4::Label::new(Some("Notifications")); + title.add_css_class("history-title"); + title.set_xalign(0.0); + title.set_hexpand(true); + header.append(&title); + + let close_btn = gtk4::Button::with_label("Close"); + close_btn.add_css_class("flat"); + close_btn.add_css_class("history-close"); + let win_close = window.clone(); + close_btn.connect_clicked(move |_| { + win_close.set_visible(false); + }); + header.append(&close_btn); + outer.append(&header); + + let list = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + let scroll = gtk4::ScrolledWindow::new(); + scroll.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); + scroll.set_propagate_natural_height(true); + scroll.set_max_content_height(480); + scroll.set_min_content_width(320); + scroll.set_child(Some(&list)); + outer.append(&scroll); + + window.set_child(Some(&outer)); + + let win_esc = window.clone(); + let keys = gtk4::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, _| { + if key == gtk4::gdk::Key::Escape { + win_esc.set_visible(false); + gtk4::glib::Propagation::Stop + } else { + gtk4::glib::Propagation::Proceed + } + }); + window.add_controller(keys); + + window.connect_close_request(|w| { + w.set_visible(false); + gtk4::glib::Propagation::Stop + }); + + Ui { + window, + list, + store, + } +} + +pub fn toggle(ui: &Ui) { + if ui.window.is_visible() { + ui.window.set_visible(false); + } else { + rebuild(&ui.list, &ui.store); + ui.window.set_visible(true); + } +} + +pub fn refresh_if_visible(ui: &Ui) { + if ui.window.is_visible() { + rebuild(&ui.list, &ui.store); + } +} + +pub fn rebuild(list: >k4::Box, store: &Store) { + while let Some(child) = list.first_child() { + list.remove(&child); + } + + let entries: Vec = store.lock().unwrap().iter().cloned().collect(); + if entries.is_empty() { + let empty = gtk4::Label::new(Some("No notifications yet")); + empty.add_css_class("history-empty"); + empty.set_xalign(0.0); + list.append(&empty); + return; + } + + for entry in entries { + list.append(&make_row(&entry)); + } +} + +fn make_row(entry: &Entry) -> gtk4::Box { + let card = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + card.add_css_class("notification-card"); + card.add_css_class("history-card"); + if let Some(class) = entry.urgency.css_class() { + card.add_css_class(class); + } + + let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let show_app = !entry.app_name.is_empty() + && !entry.app_name.eq_ignore_ascii_case(&entry.summary); + if show_app { + let app = gtk4::Label::new(Some(&entry.app_name)); + app.add_css_class("notification-app"); + app.set_xalign(0.0); + app.set_hexpand(true); + app.set_ellipsize(gtk4::pango::EllipsizeMode::End); + top.append(&app); + } else { + let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + spacer.set_hexpand(true); + top.append(&spacer); + } + let time = gtk4::Label::new(Some(&format_time(entry.received))); + time.add_css_class("history-time"); + time.set_xalign(1.0); + top.append(&time); + card.append(&top); + + if !entry.summary.is_empty() { + let summary = gtk4::Label::new(Some(&entry.summary)); + summary.add_css_class("notification-summary"); + summary.set_xalign(0.0); + summary.set_wrap(true); + summary.set_wrap_mode(gtk4::pango::WrapMode::WordChar); + card.append(&summary); + } + + let body = collapse_ws(&entry.body); + if !body.is_empty() { + let body_lbl = gtk4::Label::new(Some(&truncate(&body, BODY_MAX_CHARS))); + body_lbl.add_css_class("notification-body"); + body_lbl.add_css_class("history-body"); + body_lbl.set_xalign(0.0); + body_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); + body_lbl.set_max_width_chars(48); + card.append(&body_lbl); + } + + card +} + +fn format_time(received: SystemTime) -> String { + let Ok(dur) = received.duration_since(SystemTime::UNIX_EPOCH) else { + return "--:--".into(); + }; + let Ok(dt) = gtk4::glib::DateTime::from_unix_local(dur.as_secs() as i64) else { + return "--:--".into(); + }; + dt.format("%H:%M") + .map(|s| s.to_string()) + .unwrap_or_else(|_| "--:--".into()) +} + +fn collapse_ws(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +fn truncate(s: &str, max_chars: usize) -> String { + let mut chars = s.chars(); + let taken: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{taken}…") + } else { + taken + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(id: u32, summary: &str) -> Entry { + Entry { + id, + app_name: "app".into(), + summary: summary.into(), + body: String::new(), + urgency: Urgency::Normal, + received: SystemTime::UNIX_EPOCH, + } + } + + #[test] + fn record_is_newest_first_and_bounded() { + let store = new_store(); + for i in 0..(LIMIT as u32 + 5) { + record(&store, entry(i, &format!("n{i}"))); + } + let hist = store.lock().unwrap(); + assert_eq!(hist.len(), LIMIT); + assert_eq!(hist.front().unwrap().id, LIMIT as u32 + 4); + assert_eq!(hist.back().unwrap().id, 5); + } + + #[test] + fn record_replaces_same_id_and_moves_to_front() { + let store = new_store(); + record(&store, entry(1, "old")); + record(&store, entry(2, "other")); + record(&store, entry(1, "new")); + let hist = store.lock().unwrap(); + assert_eq!(hist.len(), 2); + assert_eq!(hist[0].id, 1); + assert_eq!(hist[0].summary, "new"); + assert_eq!(hist[1].id, 2); + } + + #[test] + fn truncate_adds_ellipsis_past_limit() { + assert_eq!(truncate("hello", 10), "hello"); + assert_eq!(truncate("hello world", 5), "hello…"); + } +} diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index 0c25c0d..ccc8125 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -1,9 +1,10 @@ +pub mod history; pub mod popup; use std::collections::HashMap; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Mutex; -use std::time::Duration; +use std::time::{Duration, SystemTime}; use tokio::sync::mpsc; use zbus::zvariant::OwnedValue; @@ -37,6 +38,7 @@ pub enum NotifEvent { expire: Expire, }, Close(u32), + ToggleHistory, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -93,6 +95,34 @@ struct NotifServer { /// (app_name, synchronous-hint tag) -> id, for senders relying on /// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`. sync_tags: Mutex>, + history: history::Store, +} + +/// Private breadbar control surface on the same connection as +/// `org.freedesktop.Notifications`. `breadbar --history` is a one-shot +/// client of `ToggleHistory` — there is no other IPC. +struct BarService { + tx: mpsc::Sender, +} + +#[zbus::interface(name = "dev.breadway.Bar")] +impl BarService { + async fn toggle_history(&self) { + let _ = self.tx.send(NotifEvent::ToggleHistory).await; + } +} + +const BAR_DEST: &str = "org.freedesktop.Notifications"; +const BAR_PATH: &str = "/dev/breadway/Bar"; +const BAR_IFACE: &str = "dev.breadway.Bar"; + +/// Ask a running breadbar to toggle the history window. Used by +/// `breadbar --history`; does not start a second bar. +pub async fn toggle_history_remote() -> zbus::Result<()> { + let conn = zbus::Connection::session().await?; + conn.call_method(Some(BAR_DEST), BAR_PATH, Some(BAR_IFACE), "ToggleHistory", &()) + .await?; + Ok(()) } #[zbus::interface(name = "org.freedesktop.Notifications")] @@ -140,6 +170,18 @@ impl NotifServer { let urgency = Urgency::from_hint(hints.get("urgency")); let expire = compute_expire(expire_timeout, urgency == Urgency::Critical); + history::record( + &self.history, + history::Entry { + id, + app_name: app_name.to_string(), + summary: summary.to_string(), + body: body.to_string(), + urgency, + received: SystemTime::now(), + }, + ); + let _ = self .tx .send(NotifEvent::Show { @@ -217,18 +259,22 @@ pub fn spawn(sample: Option) -> gtk4::Window { let _ = tx.try_send(kind.sample_event()); let window_for_loop = window.clone(); relm4::spawn_local(async move { - popup::run(window_for_loop, cards_box, rx, None).await; + popup::run(window_for_loop, cards_box, rx, None, None).await; }); } None => { let (conn_tx, conn_rx) = tokio::sync::oneshot::channel(); + let store = history::new_store(); + let history_ui = history::build_window(store.clone()); relm4::spawn(async move { let server = NotifServer { - tx, + tx: tx.clone(), next_id: AtomicU32::new(1), sync_tags: Mutex::new(HashMap::new()), + history: store, }; + let bar = BarService { tx }; // Builder failures here would only occur with invalid static strings — safe to unwrap. let conn = zbus::connection::Builder::session() .unwrap() @@ -236,6 +282,8 @@ pub fn spawn(sample: Option) -> gtk4::Window { .unwrap() .serve_at("/org/freedesktop/Notifications", server) .unwrap() + .serve_at(BAR_PATH, bar) + .unwrap() .build() .await .expect("failed to claim org.freedesktop.Notifications on D-Bus session bus"); @@ -249,7 +297,8 @@ pub fn spawn(sample: Option) -> gtk4::Window { let window_for_loop = window.clone(); relm4::spawn_local(async move { if let Ok(conn) = conn_rx.await { - popup::run(window_for_loop, cards_box, rx, Some(conn)).await; + popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui)) + .await; } }); } @@ -303,6 +352,7 @@ mod tests { tx, next_id: AtomicU32::new(1), sync_tags: Mutex::new(HashMap::new()), + history: history::new_store(), }, rx, ) @@ -394,4 +444,21 @@ mod tests { .await; assert_ne!(first, second); } + + #[tokio::test] + async fn notify_records_history_newest_first() { + let (server, _rx) = test_server(); + server + .notify("app-a", 0, "", "first", "body-a", vec![], HashMap::new(), -1) + .await; + server + .notify("app-b", 0, "", "second", "body-b", vec![], HashMap::new(), -1) + .await; + let hist = server.history.lock().unwrap(); + assert_eq!(hist.len(), 2); + assert_eq!(hist[0].summary, "second"); + assert_eq!(hist[0].app_name, "app-b"); + assert_eq!(hist[0].body, "body-b"); + assert_eq!(hist[1].summary, "first"); + } } diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 91adec0..9ec29af 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -4,7 +4,7 @@ use gtk4::prelude::*; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use tokio::sync::mpsc::Receiver; -use super::{Expire, NotifEvent, Urgency}; +use super::{history, Expire, NotifEvent, Urgency}; type Cards = Rc>>; // Bumped every time an id gets a (re)placed card — an auto-dismiss timer @@ -49,6 +49,7 @@ pub async fn run( cards_box: gtk4::Box, mut rx: Receiver, conn: Option, + history_ui: Option, ) { let cards: Cards = Rc::new(RefCell::new(HashMap::new())); let generations: Generations = Rc::new(RefCell::new(HashMap::new())); @@ -71,6 +72,9 @@ pub async fn run( cards_box.prepend(&card); cards.borrow_mut().insert(id, card.clone()); window.set_visible(true); + if let Some(ui) = &history_ui { + history::refresh_if_visible(ui); + } let my_generation = { let mut gens = generations.borrow_mut(); @@ -105,6 +109,11 @@ pub async fn run( emit_closed(&conn, id, close_reason::CLOSE_NOTIFICATION_CALL).await; } } + NotifEvent::ToggleHistory => { + if let Some(ui) = &history_ui { + history::toggle(ui); + } + } } } } diff --git a/src/screenshot.rs b/src/screenshot.rs index 8ccdd87..ecf75b3 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -74,6 +74,11 @@ pub struct Cli { /// Capture canvas height — see `width`. #[arg(long, default_value_t = 1080)] pub height: u32, + + /// Toggle the in-memory notification history on a running breadbar, then + /// exit. Keybind-friendly; does not start a second instance. + #[arg(long)] + pub history: bool, } pub struct ScreenshotRequest { diff --git a/src/theme.rs b/src/theme.rs index 10c2415..0e7186f 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -36,12 +36,20 @@ fn load_css() -> String { .bt-icon {{ margin-right: 14px; }}\ separator.bar-sep {{ min-height: 14px; margin: 0 8px 0 0; background: alpha({on_bg}, 0.14); }}\ window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; }}\ + window.breadbar-history {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg};\ + border-radius: {radius}; }}\ .notification-card {{ background: {surface}; color: {on_surface}; border-radius: {radius};\ padding: {pad}; margin-bottom: 8px; border-left: 3px solid transparent; }}\ .notification-card.urgency-critical {{ border-left-color: {critical}; }}\ .notification-card.urgency-normal {{ border-left-color: {accent}; }}\ .notification-summary {{ font-weight: bold; }}\ .notification-app {{ opacity: 0.6; }}\ + .history-title {{ font-weight: bold; font-size: 13px; }}\ + .history-close {{ padding: 2px 8px; }}\ + .history-empty {{ opacity: 0.5; padding: 8px 0; }}\ + .history-time {{ opacity: 0.5; font-size: 11px; }}\ + .history-body {{ opacity: 0.75; }}\ + .history-card {{ margin-bottom: 6px; }}\ window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: {radius_pill}; }}\ .osd-icon {{ opacity: 0.85; margin-right: 8px; }}\ .osd-icon-muted {{ opacity: 0.35; }}\ From 9691485bd64d9493009063278687a685e7fc899c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:05:47 +0800 Subject: [PATCH 26/39] Bump version to v0.3.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c03af7..1a1122a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -207,7 +207,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "bread-screenshots", diff --git a/Cargo.toml b/Cargo.toml index ffa6d25..1648851 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.3.0" +version = "0.3.1" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" From 4fddad510fa43155e1fe27c2c2dcb429d4616a0d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:11:28 +0800 Subject: [PATCH 27/39] Persist notification history to XDG state --- README.md | 4 +- src/notifications/history.rs | 180 ++++++++++++++++++++++++++++++++++- src/notifications/mod.rs | 12 ++- 3 files changed, 191 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0047613..3addfa1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ A single Rust binary that provides a full-width top bar, a D-Bus notification da - Implements `org.freedesktop.Notifications` (D-Bus) — works with any standard sender (`notify-send`, etc.) - Popups appear top-right, stack vertically, auto-dismiss after the sender-specified timeout (default 5 s) - Supports `CloseNotification` and `replaces_id` -- In-memory history of the last 50 notifications (app, summary, truncated body, time). Toggle with `breadbar --history` (Hyprland: `bind = SUPER, N, exec, breadbar --history`) or D-Bus `dev.breadway.Bar.ToggleHistory` on `org.freedesktop.Notifications` at `/dev/breadway/Bar`. Not persisted. +- History of the last 50 notifications (app, summary, truncated body, time). Loaded from and saved to `$XDG_STATE_HOME/breadbar/history.json` (typically `~/.local/state/breadbar/history.json`). Toggle with `breadbar --history` (Hyprland: `bind = SUPER, N, exec, breadbar --history`) or D-Bus `dev.breadway.Bar.ToggleHistory` on `org.freedesktop.Notifications` at `/dev/breadway/Bar`. **Volume/brightness OSD**: @@ -141,7 +141,7 @@ Example — change the font size: | `src/bar/tray.rs` | `org.kde.StatusNotifierWatcher` D-Bus service, SNI item rendering | | `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service + `dev.breadway.Bar` history IPC | | `src/notifications/popup.rs` | Layer-shell popup window and card stack | -| `src/notifications/history.rs` | Bounded in-memory history and layer-shell history window | +| `src/notifications/history.rs` | Bounded history (last 50, persisted under XDG state) and layer-shell history window | | `src/osd.rs` | Volume/brightness on-screen display | | `src/widgets/` | Live Lua widgets from breadd (`BreadClient` + `WidgetSpec`) | | `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection | diff --git a/src/notifications/history.rs b/src/notifications/history.rs index 711f229..0e158cc 100644 --- a/src/notifications/history.rs +++ b/src/notifications/history.rs @@ -1,9 +1,12 @@ use std::collections::VecDeque; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; use gtk4::prelude::*; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; +use serde::{Deserialize, Serialize}; use super::Urgency; @@ -32,6 +35,30 @@ pub fn new_store() -> Store { Arc::new(Mutex::new(VecDeque::new())) } +/// Load the last [`LIMIT`] entries from `$XDG_STATE_HOME/breadbar/history.json` +/// (or `~/.local/state/breadbar/history.json`). Missing or corrupt files +/// yield an empty store — never fail startup. +pub fn load_store() -> Store { + let store = new_store(); + if let Some(path) = history_path() { + load_into(&store, &path); + } + store +} + +/// Next D-Bus notification id so persisted rows are not replaced on restart. +pub fn next_id(store: &Store) -> u32 { + store + .lock() + .unwrap() + .iter() + .map(|e| e.id) + .max() + .unwrap_or(0) + .saturating_add(1) + .max(1) +} + /// Insert or replace by `id`, newest first. Drops anything past [`LIMIT`]. pub fn record(store: &Store, entry: Entry) { let mut hist = store.lock().unwrap(); @@ -44,6 +71,106 @@ pub fn record(store: &Store, entry: Entry) { } } +/// Best-effort write of the in-memory store (already bounded) to the +/// XDG state file. Failures are silent — history stays in memory. +pub fn persist(store: &Store) { + if let Some(path) = history_path() { + let _ = persist_to(store, &path); + } +} + +fn history_path() -> Option { + Some(state_dir()?.join("history.json")) +} + +fn state_dir() -> Option { + if let Ok(xdg) = std::env::var("XDG_STATE_HOME") { + if !xdg.is_empty() { + return Some(PathBuf::from(xdg).join("breadbar")); + } + } + let home = std::env::var_os("HOME")?; + Some(PathBuf::from(home).join(".local/state/breadbar")) +} + +#[derive(Serialize, Deserialize)] +struct PersistedEntry { + id: u32, + app_name: String, + summary: String, + body: String, + urgency: String, + received_unix: u64, +} + +fn urgency_name(u: Urgency) -> &'static str { + match u { + Urgency::Low => "low", + Urgency::Normal => "normal", + Urgency::Critical => "critical", + } +} + +fn urgency_from_name(s: &str) -> Urgency { + match s { + "low" => Urgency::Low, + "critical" => Urgency::Critical, + _ => Urgency::Normal, + } +} + +fn to_persisted(entry: &Entry) -> PersistedEntry { + let received_unix = entry + .received + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + PersistedEntry { + id: entry.id, + app_name: entry.app_name.clone(), + summary: entry.summary.clone(), + body: entry.body.clone(), + urgency: urgency_name(entry.urgency).into(), + received_unix, + } +} + +fn from_persisted(entry: PersistedEntry) -> Entry { + Entry { + id: entry.id, + app_name: entry.app_name, + summary: entry.summary, + body: entry.body, + urgency: urgency_from_name(&entry.urgency), + received: SystemTime::UNIX_EPOCH + Duration::from_secs(entry.received_unix), + } +} + +fn persist_to(store: &Store, path: &Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let payload: Vec = store.lock().unwrap().iter().map(to_persisted).collect(); + let bytes = serde_json::to_vec(&payload).map_err(std::io::Error::other)?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, bytes)?; + fs::rename(&tmp, path) +} + +fn load_into(store: &Store, path: &Path) { + let Ok(bytes) = fs::read(path) else { + return; + }; + let Ok(parsed) = serde_json::from_slice::>(&bytes) else { + return; + }; + let mut hist = store.lock().unwrap(); + hist.clear(); + for entry in parsed.into_iter().take(LIMIT) { + hist.push_back(from_persisted(entry)); + } +} + pub fn build_window(store: Store) -> Ui { let window = gtk4::Window::new(); window.add_css_class("breadbar-history"); @@ -271,4 +398,55 @@ mod tests { assert_eq!(truncate("hello", 10), "hello"); assert_eq!(truncate("hello world", 5), "hello…"); } + + #[test] + fn persist_roundtrip_keeps_newest_first_and_bound() { + let dir = std::env::temp_dir().join(format!( + "breadbar-history-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("history.json"); + let store = new_store(); + for i in 0..(LIMIT as u32 + 3) { + record(&store, entry(i, &format!("n{i}"))); + } + persist_to(&store, &path).unwrap(); + + let loaded = new_store(); + load_into(&loaded, &path); + assert_eq!(next_id(&loaded), LIMIT as u32 + 3); + let hist = loaded.lock().unwrap(); + assert_eq!(hist.len(), LIMIT); + assert_eq!(hist.front().unwrap().id, LIMIT as u32 + 2); + assert_eq!( + hist.front().unwrap().summary, + format!("n{}", LIMIT as u32 + 2) + ); + drop(hist); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn load_into_ignores_corrupt_file() { + let dir = std::env::temp_dir().join(format!( + "breadbar-history-bad-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("history.json"); + fs::write(&path, "not-json").unwrap(); + let store = new_store(); + load_into(&store, &path); + assert!(store.lock().unwrap().is_empty()); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index ccc8125..eccc2e7 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -96,6 +96,8 @@ struct NotifServer { /// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`. sync_tags: Mutex>, history: history::Store, + /// Unit tests leave this off so `Notify` does not write `$XDG_STATE_HOME`. + persist_history: bool, } /// Private breadbar control surface on the same connection as @@ -181,6 +183,9 @@ impl NotifServer { received: SystemTime::now(), }, ); + if self.persist_history { + history::persist(&self.history); + } let _ = self .tx @@ -264,15 +269,17 @@ pub fn spawn(sample: Option) -> gtk4::Window { } None => { let (conn_tx, conn_rx) = tokio::sync::oneshot::channel(); - let store = history::new_store(); + let store = history::load_store(); + let next_id = history::next_id(&store); let history_ui = history::build_window(store.clone()); relm4::spawn(async move { let server = NotifServer { tx: tx.clone(), - next_id: AtomicU32::new(1), + next_id: AtomicU32::new(next_id), sync_tags: Mutex::new(HashMap::new()), history: store, + persist_history: true, }; let bar = BarService { tx }; // Builder failures here would only occur with invalid static strings — safe to unwrap. @@ -353,6 +360,7 @@ mod tests { next_id: AtomicU32::new(1), sync_tags: Mutex::new(HashMap::new()), history: history::new_store(), + persist_history: false, }, rx, ) From 110f0cd3df6878f7b105fafe4983ff24b7262c83 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:26:06 +0800 Subject: [PATCH 28/39] Add notification actions and inline reply Parse FDO action pairs onto popup buttons, emit ActionInvoked on click (default action on the body), and show an inline-reply field when senders request it. Closing still emits NotificationClosed. History persist is unchanged. --- src/notifications/mod.rs | 211 +++++++++++++++++++++++++++++++-- src/notifications/popup.rs | 231 ++++++++++++++++++++++++++++++++++--- src/theme.rs | 4 + 3 files changed, 422 insertions(+), 24 deletions(-) diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index eccc2e7..4668798 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -17,6 +17,28 @@ use zbus::zvariant::OwnedValue; /// warning) — it just piles up a new card next to it forever. const SYNCHRONOUS_HINT: &str = "x-canonical-private-synchronous"; +/// Spec + GNOME/KDE reserved action id for an inline reply field. Hidden +/// from the button row; submitting the field emits `NotificationReplied` +/// (and `ActionInvoked` with this key). See `popup::emit_replied`. +pub const INLINE_REPLY_KEY: &str = "inline-reply"; + +/// KDE placeholder hint. Presence (or an `inline-reply` action) is enough +/// to show the reply field — Discord/Telegram use the action, Plasma often +/// only the hint. +const KDE_REPLY_PLACEHOLDER: &str = "x-kde-reply-placeholder"; + +/// Advertised `GetCapabilities` strings. `body` is the original set; +/// `actions` / `inline-reply` are this change; `body-markup` is the usual +/// companion so senders can ship ``/`` instead of stripping tags. +const CAPABILITIES: &[&str] = &["body", "body-markup", "actions", "inline-reply"]; + +/// One `(id, localized label)` pair from the Notify `actions` array. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Action { + pub key: String, + pub label: String, +} + /// How long a shown notification should stay up before auto-dismissing. /// Distinct from `Option` mainly for readability at call sites — /// `Never` covers both the spec's `expire_timeout == 0` ("never expire") @@ -36,6 +58,9 @@ pub enum NotifEvent { body: String, urgency: Urgency, expire: Expire, + actions: Vec, + /// Placeholder for the inline-reply field, if one should be shown. + inline_reply: Option, }, Close(u32), ToggleHistory, @@ -68,6 +93,36 @@ impl Urgency { } } +/// Spec: `actions` is a flat list of pairs `(id, localized label)`. An +/// unpaired trailing id is ignored. Empty keys are dropped. +fn parse_actions(raw: &[String]) -> Vec { + raw.chunks_exact(2) + .filter(|c| !c[0].is_empty()) + .map(|c| Action { + key: c[0].clone(), + label: c[1].clone(), + }) + .collect() +} + +/// Show an inline reply field when the sender asked for `inline-reply` or +/// sent the KDE placeholder hint. Placeholder text prefers the hint. +fn inline_reply_placeholder( + actions: &[Action], + hints: &HashMap, +) -> Option { + let from_hint = hints + .get(KDE_REPLY_PLACEHOLDER) + .and_then(|v| String::try_from(v.clone()).ok()) + .filter(|s| !s.is_empty()); + let has_action = actions.iter().any(|a| a.key == INLINE_REPLY_KEY); + if has_action || from_hint.is_some() { + Some(from_hint.unwrap_or_else(|| "Reply".into())) + } else { + None + } +} + /// Maps a `Notify` call's `expire_timeout` (plus whether the `urgency` hint /// was critical) to our internal `Expire`, per the freedesktop notification /// spec: `0` always means never expire; a negative value means "server @@ -122,8 +177,14 @@ const BAR_IFACE: &str = "dev.breadway.Bar"; /// `breadbar --history`; does not start a second bar. pub async fn toggle_history_remote() -> zbus::Result<()> { let conn = zbus::Connection::session().await?; - conn.call_method(Some(BAR_DEST), BAR_PATH, Some(BAR_IFACE), "ToggleHistory", &()) - .await?; + conn.call_method( + Some(BAR_DEST), + BAR_PATH, + Some(BAR_IFACE), + "ToggleHistory", + &(), + ) + .await?; Ok(()) } @@ -138,7 +199,7 @@ impl NotifServer { _app_icon: &str, summary: &str, body: &str, - _actions: Vec, + actions: Vec, hints: std::collections::HashMap, expire_timeout: i32, ) -> u32 { @@ -171,6 +232,8 @@ impl NotifServer { // when the sender left expire_timeout at the server-default (-1). let urgency = Urgency::from_hint(hints.get("urgency")); let expire = compute_expire(expire_timeout, urgency == Urgency::Critical); + let actions = parse_actions(&actions); + let inline_reply = inline_reply_placeholder(&actions, &hints); history::record( &self.history, @@ -196,6 +259,8 @@ impl NotifServer { body: body.to_string(), urgency, expire, + actions, + inline_reply, }) .await; id @@ -206,7 +271,7 @@ impl NotifServer { } fn get_capabilities(&self) -> Vec { - vec!["body".to_string()] + CAPABILITIES.iter().map(|s| (*s).to_string()).collect() } fn get_server_information(&self) -> (String, String, String, String) { @@ -241,6 +306,8 @@ impl SampleKind { body: "This is what a notification card looks like.".into(), urgency, expire: Expire::Never, + actions: vec![], + inline_reply: None, } } } @@ -304,8 +371,7 @@ pub fn spawn(sample: Option) -> gtk4::Window { let window_for_loop = window.clone(); relm4::spawn_local(async move { if let Ok(conn) = conn_rx.await { - popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui)) - .await; + popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui)).await; } }); } @@ -457,10 +523,28 @@ mod tests { async fn notify_records_history_newest_first() { let (server, _rx) = test_server(); server - .notify("app-a", 0, "", "first", "body-a", vec![], HashMap::new(), -1) + .notify( + "app-a", + 0, + "", + "first", + "body-a", + vec![], + HashMap::new(), + -1, + ) .await; server - .notify("app-b", 0, "", "second", "body-b", vec![], HashMap::new(), -1) + .notify( + "app-b", + 0, + "", + "second", + "body-b", + vec![], + HashMap::new(), + -1, + ) .await; let hist = server.history.lock().unwrap(); assert_eq!(hist.len(), 2); @@ -469,4 +553,115 @@ mod tests { assert_eq!(hist[0].body, "body-b"); assert_eq!(hist[1].summary, "first"); } + + #[test] + fn parse_actions_pairs_and_drops_trailing_id() { + let parsed = parse_actions(&[ + "default".into(), + "Open".into(), + "snooze".into(), + "Snooze".into(), + "orphan".into(), + ]); + assert_eq!( + parsed, + vec![ + Action { + key: "default".into(), + label: "Open".into(), + }, + Action { + key: "snooze".into(), + label: "Snooze".into(), + }, + ] + ); + } + + #[test] + fn parse_actions_skips_empty_keys() { + assert!(parse_actions(&["", "Nope"].map(String::from)).is_empty()); + } + + #[test] + fn inline_reply_from_action_or_kde_hint() { + let reply_action = vec![Action { + key: INLINE_REPLY_KEY.into(), + label: "Reply".into(), + }]; + assert_eq!( + inline_reply_placeholder(&reply_action, &HashMap::new()).as_deref(), + Some("Reply") + ); + assert!(inline_reply_placeholder(&[], &HashMap::new()).is_none()); + + let mut hints = HashMap::new(); + hints.insert( + KDE_REPLY_PLACEHOLDER.to_string(), + OwnedValue::try_from(zbus::zvariant::Value::from("Write a reply…")).unwrap(), + ); + assert_eq!( + inline_reply_placeholder(&[], &hints).as_deref(), + Some("Write a reply…") + ); + // Hint wins over the generic default when both are present. + assert_eq!( + inline_reply_placeholder(&reply_action, &hints).as_deref(), + Some("Write a reply…") + ); + } + + #[test] + fn get_capabilities_includes_actions_and_inline_reply() { + let (server, _rx) = test_server(); + let caps = server.get_capabilities(); + for wanted in ["body", "body-markup", "actions", "inline-reply"] { + assert!( + caps.iter().any(|c| c == wanted), + "missing capability {wanted}" + ); + } + } + + #[tokio::test] + async fn notify_forwards_actions_and_inline_reply() { + let (server, mut rx) = test_server(); + server + .notify( + "chat", + 0, + "", + "Alice", + "hello", + vec![ + "default".into(), + "Open".into(), + INLINE_REPLY_KEY.into(), + "Reply".into(), + ], + HashMap::new(), + -1, + ) + .await; + match rx.recv().await.expect("Show event") { + NotifEvent::Show { + actions, + inline_reply, + summary, + .. + } => { + assert_eq!(summary, "Alice"); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].key, "default"); + assert_eq!(actions[1].key, INLINE_REPLY_KEY); + assert_eq!(inline_reply.as_deref(), Some("Reply")); + } + _ => panic!("expected Show, got a different event"), + } + // History persist path is unchanged: actions are UI-only, not stored. + let hist = server.history.lock().unwrap(); + assert_eq!(hist.len(), 1); + assert_eq!(hist[0].summary, "Alice"); + assert_eq!(hist[0].body, "hello"); + } } diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 9ec29af..f221524 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -1,10 +1,10 @@ use std::{cell::RefCell, collections::HashMap, rc::Rc}; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, Layer, LayerShell}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use tokio::sync::mpsc::Receiver; -use super::{history, Expire, NotifEvent, Urgency}; +use super::{history, Action, Expire, NotifEvent, Urgency, INLINE_REPLY_KEY}; type Cards = Rc>>; // Bumped every time an id gets a (re)placed card — an auto-dismiss timer @@ -18,7 +18,6 @@ type Generations = Rc>>; /// NotificationClosed reason codes per the freedesktop spec. mod close_reason { pub const EXPIRED: u32 = 1; - #[allow(dead_code)] // no in-app dismiss button exists yet (see make_card) pub const DISMISSED_BY_USER: u32 = 2; pub const CLOSE_NOTIFICATION_CALL: u32 = 3; } @@ -63,12 +62,26 @@ pub async fn run( body, urgency, expire, + actions, + inline_reply, } => { // Replace existing card with same id (replaces_id case) if let Some(old) = cards.borrow_mut().remove(&id) { cards_box.remove(&old); } - let card = make_card(&app_name, &summary, &body, urgency); + let card = make_card(CardSpec { + id, + app_name: &app_name, + summary: &summary, + body: &body, + urgency, + actions: &actions, + inline_reply: inline_reply.as_deref(), + conn: conn.clone(), + cards: cards.clone(), + cards_box: cards_box.clone(), + window: window.clone(), + }); cards_box.prepend(&card); cards.borrow_mut().insert(id, card.clone()); window.set_visible(true); @@ -96,8 +109,7 @@ pub async fn run( gtk4::glib::timeout_future(duration).await; let still_current = generations_clone.borrow().get(&id) == Some(&my_generation); - if still_current - && dismiss(&cards_box_clone, &win_clone, &cards_clone, id) + if still_current && dismiss(&cards_box_clone, &win_clone, &cards_clone, id) { emit_closed(&conn_clone, id, close_reason::EXPIRED).await; } @@ -164,39 +176,226 @@ fn create_window() -> gtk4::Window { window.set_margin(Edge::Top, 20); window.set_margin(Edge::Right, 20); window.set_default_width(320); + // OnDemand so an inline-reply GtkEntry can take keys without the popup + // stealing every keystroke the rest of the time. + window.set_keyboard_mode(KeyboardMode::OnDemand); window } -fn make_card(app_name: &str, summary: &str, body: &str, urgency: Urgency) -> gtk4::Box { +struct CardSpec<'a> { + id: u32, + app_name: &'a str, + summary: &'a str, + body: &'a str, + urgency: Urgency, + actions: &'a [Action], + inline_reply: Option<&'a str>, + conn: Option, + cards: Cards, + cards_box: gtk4::Box, + window: gtk4::Window, +} + +fn make_card(spec: CardSpec<'_>) -> gtk4::Box { let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4); card.add_css_class("notification-card"); - if let Some(class) = urgency.css_class() { + if let Some(class) = spec.urgency.css_class() { card.add_css_class(class); } + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + // Senders often set the title/summary to their own app name (e.g. a bare // "Spotify" notification) — showing app_name above an identical summary // is pure repetition, so skip the app label in that case. - if !app_name.is_empty() && !app_name.eq_ignore_ascii_case(summary) { - let lbl = gtk4::Label::new(Some(app_name)); + if !spec.app_name.is_empty() && !spec.app_name.eq_ignore_ascii_case(spec.summary) { + let lbl = gtk4::Label::new(Some(spec.app_name)); lbl.add_css_class("notification-app"); lbl.set_xalign(0.0); - card.append(&lbl); + content.append(&lbl); } - let summary_lbl = gtk4::Label::new(Some(summary)); + let summary_lbl = gtk4::Label::new(Some(spec.summary)); summary_lbl.add_css_class("notification-summary"); summary_lbl.set_xalign(0.0); summary_lbl.set_wrap(true); - card.append(&summary_lbl); + content.append(&summary_lbl); - if !body.is_empty() { - let body_lbl = gtk4::Label::new(Some(body)); + if !spec.body.is_empty() { + let body_lbl = gtk4::Label::new(None); body_lbl.add_css_class("notification-body"); body_lbl.set_xalign(0.0); body_lbl.set_wrap(true); - card.append(&body_lbl); + apply_body_text(&body_lbl, spec.body); + content.append(&body_lbl); + } + + if spec.actions.iter().any(|a| a.key == "default") { + content.add_css_class("notification-default"); + let gesture = gtk4::GestureClick::new(); + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + gesture.connect_released(move |_, _, _, _| { + invoke_action(invoke.clone(), "default"); + }); + content.add_controller(gesture); + } + + card.append(&content); + + let visible: Vec<&Action> = spec + .actions + .iter() + .filter(|a| a.key != "default" && a.key != INLINE_REPLY_KEY) + .collect(); + if !visible.is_empty() { + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + row.add_css_class("notification-actions"); + row.set_halign(gtk4::Align::End); + for action in visible { + let btn = gtk4::Button::with_label(&action.label); + btn.add_css_class("notification-action"); + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + let key = action.key.clone(); + btn.connect_clicked(move |_| { + invoke_action(invoke.clone(), &key); + }); + row.append(&btn); + } + card.append(&row); + } + + if let Some(placeholder) = spec.inline_reply { + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + row.add_css_class("notification-reply"); + + let entry = gtk4::Entry::new(); + entry.add_css_class("notification-reply-entry"); + entry.set_placeholder_text(Some(placeholder)); + entry.set_hexpand(true); + + let send_label = spec + .actions + .iter() + .find(|a| a.key == INLINE_REPLY_KEY) + .map(|a| a.label.as_str()) + .filter(|l| !l.is_empty()) + .unwrap_or("Send"); + let send = gtk4::Button::with_label(send_label); + send.add_css_class("notification-action"); + + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + let entry_for_btn = entry.clone(); + let invoke_btn = invoke.clone(); + send.connect_clicked(move |_| { + submit_reply(&entry_for_btn, invoke_btn.clone()); + }); + entry.connect_activate(move |e| { + submit_reply(e, invoke.clone()); + }); + + row.append(&entry); + row.append(&send); + card.append(&row); } card } + +/// FDO `body-markup` is a small Pango-ish subset (``, ``, ``, +/// ``). Invalid markup falls back to plain text so a bad sender +/// doesn't blank the card. +fn apply_body_text(label: >k4::Label, body: &str) { + if body.contains('<') && gtk4::pango::parse_markup(body, '\0').is_ok() { + label.set_markup(body); + return; + } + label.set_text(body); +} + +#[derive(Clone)] +struct Invoke { + conn: Option, + cards: Cards, + cards_box: gtk4::Box, + window: gtk4::Window, + id: u32, +} + +fn invoke_action(invoke: Invoke, key: &str) { + let key = key.to_string(); + relm4::spawn_local(async move { + emit_action(&invoke.conn, invoke.id, &key).await; + if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { + emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; + } + }); +} + +fn submit_reply(entry: >k4::Entry, invoke: Invoke) { + let text = entry.text().to_string(); + if text.trim().is_empty() { + return; + } + relm4::spawn_local(async move { + emit_replied(&invoke.conn, invoke.id, &text).await; + emit_action(&invoke.conn, invoke.id, INLINE_REPLY_KEY).await; + if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { + emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; + } + }); +} + +async fn emit_action(conn: &Option, id: u32, action_key: &str) { + let Some(conn) = conn else { return }; + let result = conn + .emit_signal( + None::<&str>, + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "ActionInvoked", + &(id, action_key), + ) + .await; + if let Err(e) = result { + eprintln!("breadbar: failed to emit ActionInvoked for {id}: {e}"); + } +} + +/// GNOME/KDE (and clients such as Discord/Telegram) listen for this +/// non-spec signal on `org.freedesktop.Notifications` when the user +/// submits an inline reply. Signature: `NotificationReplied(u32 id, s text)`. +/// We also emit `ActionInvoked(id, "inline-reply")` so senders that only +/// watch the spec signal still see the send. +async fn emit_replied(conn: &Option, id: u32, text: &str) { + let Some(conn) = conn else { return }; + let result = conn + .emit_signal( + None::<&str>, + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "NotificationReplied", + &(id, text), + ) + .await; + if let Err(e) = result { + eprintln!("breadbar: failed to emit NotificationReplied for {id}: {e}"); + } +} diff --git a/src/theme.rs b/src/theme.rs index 0e7186f..11820bf 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -44,6 +44,10 @@ fn load_css() -> String { .notification-card.urgency-normal {{ border-left-color: {accent}; }}\ .notification-summary {{ font-weight: bold; }}\ .notification-app {{ opacity: 0.6; }}\ + .notification-actions {{ margin-top: 6px; }}\ + .notification-action {{ padding: 2px 8px; font-size: 11px; }}\ + .notification-reply {{ margin-top: 6px; }}\ + .notification-reply-entry {{ min-width: 0; }}\ .history-title {{ font-weight: bold; font-size: 13px; }}\ .history-close {{ padding: 2px 8px; }}\ .history-empty {{ opacity: 0.5; padding: 8px 0; }}\ From 62c6dd5ea34c40d0a72cf2af6b661c3d4d737fcb Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:26:07 +0800 Subject: [PATCH 29/39] Adopt bread_utils::screenshot_cli for --screenshot flags Replace the local settle delay, canvas defaults, and pair-validation error path with bread-utils v0.7.2. Clap parsing stays in-tree. --- src/screenshot.rs | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/screenshot.rs b/src/screenshot.rs index ecf75b3..d3848eb 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -14,16 +14,12 @@ //! windows (`notifications::spawn`/`osd::spawn`, built and primed with //! sample data by `main.rs` before `dispatch` runs — see [`Handles`]). +use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH, SETTLE_DELAY}; use clap::Parser; use gtk4::prelude::*; use std::path::PathBuf; use std::time::Duration; -/// Extra settle time after `map` for the first frame to actually paint -/// before grim runs — `map` fires once the surface exists, not once -/// anything has been drawn into it. -const SETTLE_DELAY: Duration = Duration::from_millis(300); - /// Settle time for views whose content depends on `bar::stats::spawn_poller`'s /// 2-second background loop (control-panel's CPU/RAM/PWR/GPU/network labels, /// gated on popover visibility) or a similar live-data popover load @@ -38,7 +34,7 @@ const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200); /// popover at all) — presumably the parent widget's own allocation isn't /// settled yet at that exact point. Giving the initial layout pass a beat to /// finish first is what makes it actually render. -const PRE_POPUP_DELAY: Duration = Duration::from_millis(300); +const PRE_POPUP_DELAY: Duration = SETTLE_DELAY; const KNOWN_VIEWS: &[&str] = &[ "bar", @@ -68,11 +64,11 @@ pub struct Cli { /// Capture canvas width — matches the isolated compositor's output width /// (`bread-capture --isolate-width`) so the geometry passed to `grim` /// doesn't depend on querying anything at capture time. - #[arg(long, default_value_t = 1920)] + #[arg(long, default_value_t = DEFAULT_WIDTH)] pub width: u32, /// Capture canvas height — see `width`. - #[arg(long, default_value_t = 1080)] + #[arg(long, default_value_t = DEFAULT_HEIGHT)] pub height: u32, /// Toggle the in-memory notification history on a running breadbar, then @@ -89,16 +85,20 @@ pub struct ScreenshotRequest { } impl Cli { - /// `None` for a normal run. Exits the process with an error if - /// `--screenshot` was given without `--output`, before any GTK/relm4 + /// `None` for a normal run. Exits the process with an error if the + /// `--screenshot` / `--output` pair is incomplete, before any GTK/relm4 /// setup happens. pub fn screenshot_request(&self) -> Option { - let view = self.screenshot.clone()?; - let Some(output) = self.output.clone() else { - eprintln!("breadbar: --screenshot requires --output"); + if let Err(e) = validate_pair(self.screenshot.as_deref(), self.output.as_deref()) { + eprintln!("breadbar: {e}"); std::process::exit(1); - }; - Some(ScreenshotRequest { view, output, width: self.width, height: self.height }) + } + Some(ScreenshotRequest { + view: self.screenshot.clone()?, + output: self.output.clone()?, + width: self.width, + height: self.height, + }) } } From ae1fee3591d8d8971c5266b4bf7163f5025776d5 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:50:22 +0800 Subject: [PATCH 30/39] CI: refuse unsigned bakery index on stable tag releases --- .forgejo/workflows/release.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 65b0c30..7814150 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -17,7 +17,16 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && bash ci/build.sh cargo build --release --locked + run: | + set -euo pipefail + if [ ! -f src/ci/build.sh ]; then + echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper" + exit 1 + fi + cd src && bash ci/build.sh cargo build --release --locked || { + echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked." + exit 1 + } - name: prepare artifacts run: | @@ -34,8 +43,14 @@ jobs: ln -sfn "${VERSION}" "/srv/breadway-dl/breadbar/latest" - name: regenerate index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} run: | set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)" + exit 1 + fi rm -rf /tmp/bread-ecosystem-ci git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh From 1806c6f912475578fa89953a1175b1c146fb9257 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:23:31 +0800 Subject: [PATCH 31/39] Bind each bar to its output palette and finish the island chrome One layer-shell window per Hyprland output now loads that output's bread-theme palette. Notifications, history, and OSD follow the monitor they appear on. Pin bread-theme to v0.7.4. --- Cargo.lock | 187 +++-- Cargo.toml | 2 +- src/bar/clock.rs | 18 +- src/bar/control.rs | 1 + src/bar/stats.rs | 49 +- src/bar/wifi.rs | 111 ++- src/bar/workspaces.rs | 315 +++++++- src/main.rs | 1354 ++++++++++++++++++++++------------ src/notifications/history.rs | 10 +- src/notifications/popup.rs | 6 +- src/osd.rs | 7 +- src/screenshot.rs | 51 +- src/theme.rs | 286 +++++-- src/widgets/render.rs | 9 +- 14 files changed, 1652 insertions(+), 754 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a1122a..3bc508e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,9 +123,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -185,8 +185,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" dependencies = [ "dirs 5.0.1", "gtk4", @@ -265,7 +265,7 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -286,9 +286,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.4" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -296,9 +296,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -564,9 +564,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -579,9 +579,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -589,15 +589,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -606,9 +606,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -625,32 +625,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -685,7 +685,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -718,7 +718,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -771,7 +771,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", "windows-sys 0.61.2", ] @@ -835,7 +835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -846,7 +846,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -867,7 +867,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -898,7 +898,7 @@ dependencies = [ "graphene-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -924,9 +924,9 @@ dependencies = [ [[package]] name = "gtk4-layer-shell" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" dependencies = [ "bitflags", "gdk4", @@ -939,15 +939,15 @@ dependencies = [ [[package]] name = "gtk4-layer-shell-sys" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" dependencies = [ "gdk4-sys", "glib-sys", "gtk4-sys", "libc", - "system-deps", + "system-deps 8.0.0", ] [[package]] @@ -978,7 +978,7 @@ dependencies = [ "gsk4-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1058,9 +1058,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -1093,9 +1093,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -1214,7 +1214,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1243,9 +1243,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "png" @@ -1321,7 +1321,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1624,6 +1624,19 @@ dependencies = [ "version-compare", ] +[[package]] +name = "system-deps" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83779a5c956bcb6ba627a4ecf0a9d7625db47d7537e0892d97f712ac995648a3" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 1.1.4+spec-1.1.0", + "version-compare", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -1654,11 +1667,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -1674,9 +1687,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -1728,13 +1741,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1919,9 +1932,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -1943,9 +1956,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -1956,9 +1969,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1966,9 +1979,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -1979,9 +1992,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -2087,9 +2100,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xmlwriter" @@ -2099,9 +2112,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "zbus" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-recursion", @@ -2129,14 +2142,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zbus_names", "zvariant", "zvariant_utils", @@ -2153,6 +2166,15 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zmij" version = "1.0.23" @@ -2161,40 +2183,41 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" dependencies = [ "endi", "enumflags2", "serde", "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.5.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.119", + "syn 3.0.3", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index 1648851..1e24f17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } # Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs diff --git a/src/bar/clock.rs b/src/bar/clock.rs index 4501fde..45e25e6 100644 --- a/src/bar/clock.rs +++ b/src/bar/clock.rs @@ -1,11 +1,21 @@ use crate::{App, AppInput}; use relm4::ComponentSender; +pub fn now() -> gtk4::glib::DateTime { + gtk4::glib::DateTime::now_local().expect("local time") +} + +pub fn time() -> String { + let dt = now(); + format!("{:02}:{:02}", dt.hour(), dt.minute()) +} + +pub fn date() -> String { + now().format("%a %d/%m").expect("date format").to_string() +} + pub fn current() -> String { - let dt = gtk4::glib::DateTime::now_local().expect("local time"); - let date = dt.format("%a %d/%m").expect("date format"); - let time = format!("{:02}:{:02}", dt.hour(), dt.minute()); - format!("{} {}", date, time) + format!("{} {}", date(), time()) } pub fn spawn_ticker(sender: ComponentSender) { diff --git a/src/bar/control.rs b/src/bar/control.rs index ed99568..f480057 100644 --- a/src/bar/control.rs +++ b/src/bar/control.rs @@ -121,6 +121,7 @@ pub fn spawn_set_brightness(v: f64) { }); } +#[allow(dead_code)] pub fn spawn_set_sink(name: String) { relm4::spawn(async move { let _ = tokio::process::Command::new("pactl") diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 764d758..892f792 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -25,6 +25,14 @@ pub const WIFI_MEDIUM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), " pub const WIFI_WEAK: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Weak.svg")); pub const WIFI_OFF: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Disconnect.svg")); +/// Adwaita symbolic names — these are drawn for 16px status bars, not our +/// hand-cropped Lucide arcs. +pub const WIFI_ICON_EXCELLENT: &str = "network-wireless-signal-excellent-symbolic"; +pub const WIFI_ICON_GOOD: &str = "network-wireless-signal-good-symbolic"; +pub const WIFI_ICON_OK: &str = "network-wireless-signal-ok-symbolic"; +pub const WIFI_ICON_WEAK: &str = "network-wireless-signal-weak-symbolic"; +pub const WIFI_ICON_OFF: &str = "network-wireless-offline-symbolic"; + pub const BAT_HIGH: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 3 Bars.svg")); pub const BAT_MID: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 2 Bars.svg")); pub const BAT_LOW: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 1 Bar.svg")); @@ -65,6 +73,7 @@ pub struct Stats { pub gpu_temp: Option, pub net_rx_kbs: f32, pub net_tx_kbs: f32, + pub volume_pct: u8, } struct CpuSnapshot { @@ -76,7 +85,7 @@ static PREV_CPU: OnceLock> = OnceLock::new(); static BAT_PATH: OnceLock> = OnceLock::new(); static AC_PATH: OnceLock> = OnceLock::new(); static WIFI_CACHE: LazyLock> = - LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_OFF))); + LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_ICON_OFF))); static WIFI_TICK: AtomicU8 = AtomicU8::new(0); fn read_cpu() -> f32 { @@ -271,7 +280,7 @@ fn wifi_iface() -> Option<&'static str> { async fn read_wifi() -> (String, &'static str) { let Some(iface) = wifi_iface() else { - return ("—".into(), WIFI_OFF); + return ("—".into(), WIFI_ICON_OFF); }; let link_out = tokio::process::Command::new("iw") @@ -281,7 +290,7 @@ async fn read_wifi() -> (String, &'static str) { .ok(); let link_stdout = match link_out { Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(), - _ => return ("—".into(), WIFI_OFF), + _ => return ("—".into(), WIFI_ICON_OFF), }; let mut ssid = None; @@ -296,13 +305,14 @@ async fn read_wifi() -> (String, &'static str) { } let Some(ssid) = ssid else { - return ("—".into(), WIFI_OFF); + return ("—".into(), WIFI_ICON_OFF); }; let icon = match rssi { - Some(r) if r >= -55 => WIFI_STRONG, - Some(r) if r >= -70 => WIFI_MEDIUM, - _ => WIFI_WEAK, + Some(r) if r >= -55 => WIFI_ICON_EXCELLENT, + Some(r) if r >= -70 => WIFI_ICON_GOOD, + Some(r) if r >= -80 => WIFI_ICON_OK, + _ => WIFI_ICON_WEAK, }; (ssid, icon) @@ -413,7 +423,8 @@ pub async fn poll() -> Stats { let power_watts = read_power(); let power = power_watts.map_or_else(|| "—W".into(), |w| format!("{w:.1}W")); let pct = read_battery(); - let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}%")); + // Demo bar prints the bare number ("83"), not "83%". + let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}")); let bat_icon = pct.map_or(BAT_MID, bat_level_icon); let ac_connected = read_ac(); // BT and WiFi both refresh every 8 cycles (~16 s); cache in between. @@ -442,6 +453,7 @@ pub async fn poll() -> Stats { let gpu_usage = read_gpu_usage(); let gpu_temp = read_gpu_temp(); let (net_rx_kbs, net_tx_kbs) = read_net_throughput(); + let volume_pct = read_volume_pct(); Stats { cpu: format!("{cpu:.0}%"), cpu_pct: cpu, @@ -465,9 +477,30 @@ pub async fn poll() -> Stats { gpu_temp, net_rx_kbs, net_tx_kbs, + volume_pct, } } +/// `wpctl get-volume` prints `Volume: 0.44 [MUTED]`. Scale to a 0–150 percent +/// for the bar chip. Missing pipewire / wpctl degrades to 0 rather than +/// blocking the rest of the poll. +fn read_volume_pct() -> u8 { + let out = std::process::Command::new("wpctl") + .args(["get-volume", "@DEFAULT_AUDIO_SINK@"]) + .output() + .ok(); + let Some(o) = out.filter(|o| o.status.success()) else { + return 0; + }; + String::from_utf8_lossy(&o.stdout) + .trim() + .strip_prefix("Volume:") + .and_then(|s| s.split_whitespace().next()) + .and_then(|s| s.parse::().ok()) + .map(|v| (v * 100.0).round().clamp(0.0, 150.0) as u8) + .unwrap_or(0) +} + pub fn spawn_poller(sender: ComponentSender) { relm4::spawn(async move { loop { diff --git a/src/bar/wifi.rs b/src/bar/wifi.rs index 6531ece..cf5e272 100644 --- a/src/bar/wifi.rs +++ b/src/bar/wifi.rs @@ -24,6 +24,8 @@ pub struct ScanEntry { pub struct WifiPopoverData { pub profiles: Vec<(String, bool)>, // (name, is_active) pub scan: Vec, + /// False while nmcli is still listing APs — profiles must still be usable. + pub scan_ready: bool, } async fn fetch_status() -> Option { @@ -73,31 +75,57 @@ async fn fetch_profile_list() -> Vec<(String, bool)> { .collect() } +async fn saved_ssids() -> std::collections::HashSet { + let out = tokio::process::Command::new("nmcli") + .args(["-t", "-f", "NAME,TYPE", "connection", "show"]) + .output() + .await; + let Ok(o) = out else { + return std::collections::HashSet::new(); + }; + String::from_utf8_lossy(&o.stdout) + .lines() + .filter_map(|line| { + let (name, ty) = line.rsplit_once(':')?; + if ty == "802-11-wireless" || ty == "wifi" { + Some(name.to_string()) + } else { + None + } + }) + .collect() +} + +/// Cached AP list (no rescan). Fast enough to paint next to profiles. async fn fetch_scan() -> Vec { - let Ok(Ok(out)) = tokio::time::timeout( - Duration::from_secs(10), - tokio::process::Command::new("breadcrumbs") - .args(["scan-list", "--json"]) + let out = tokio::time::timeout( + Duration::from_secs(4), + tokio::process::Command::new("nmcli") + .args(["-t", "-f", "SSID,SIGNAL,IN-USE", "device", "wifi", "list"]) .output(), ) - .await - else { + .await; + let Ok(Ok(o)) = out else { return vec![]; }; - let arr: Vec = - serde_json::from_slice(&out.stdout).unwrap_or_default(); - arr.into_iter() - .filter_map(|v| { - let ssid = v["ssid"].as_str()?.to_string(); - if ssid.is_empty() { + let saved = saved_ssids().await; + let mut seen = std::collections::HashSet::new(); + String::from_utf8_lossy(&o.stdout) + .lines() + .filter_map(|line| { + let mut parts = line.rsplitn(3, ':'); + let _in_use = parts.next()?; + let signal = parts.next()?.parse::().ok().unwrap_or(0); + let ssid = parts.next()?.replace("\\:", ":"); + if ssid.is_empty() || ssid == "--" || !seen.insert(ssid.clone()) { return None; } - let signal = v["signal"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - let saved = v["saved"].as_bool().unwrap_or(false); - Some(ScanEntry { ssid, signal, saved }) + let saved = saved.contains(&ssid); + Some(ScanEntry { + ssid, + signal, + saved, + }) }) .collect() } @@ -114,11 +142,32 @@ pub fn spawn_status_poller(sender: ComponentSender) { }); } -/// Called when the popover opens — loads profiles + scan in parallel. +/// Profiles first (so you can switch Home/Away immediately), then the +/// cached AP list. A background rescan refreshes the list if it finds more. pub fn spawn_popover_load(sender: ComponentSender) { relm4::spawn(async move { - let (profiles, scan) = tokio::join!(fetch_profile_list(), fetch_scan()); - sender.input(AppInput::WifiPopoverData(WifiPopoverData { profiles, scan })); + let profiles = fetch_profile_list().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles: profiles.clone(), + scan: vec![], + scan_ready: false, + })); + let scan = fetch_scan().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles: profiles.clone(), + scan: scan.clone(), + scan_ready: true, + })); + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "rescan"]) + .output() + .await; + let scan = fetch_scan().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles, + scan, + scan_ready: true, + })); }); } @@ -132,29 +181,27 @@ pub fn spawn_profile_set(name: String) { }); } -/// Fire-and-forget: connect to a specific saved SSID via `breadcrumbs join`. +/// Fire-and-forget: connect to a known SSID via NetworkManager. pub fn spawn_join(ssid: String) { relm4::spawn(async move { - let _ = tokio::process::Command::new("breadcrumbs") - .args(["join", &ssid]) + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "connect", &ssid]) .output() .await; }); } -/// Fire-and-forget: save a new network with its password, then join it. +/// Save in breadcrumbs (if the CLI still accepts `add`) and connect with nmcli. pub fn spawn_add_and_join(ssid: String, password: String) { relm4::spawn(async move { - let added = tokio::process::Command::new("breadcrumbs") + let _ = tokio::process::Command::new("breadcrumbs") .args(["add", &ssid, &password]) .output() .await; - if matches!(added, Ok(o) if o.status.success()) { - let _ = tokio::process::Command::new("breadcrumbs") - .args(["join", &ssid]) - .output() - .await; - } + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "connect", &ssid, "password", &password]) + .output() + .await; }); } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 67942f8..1181f02 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -1,7 +1,12 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Instant; + use futures_lite::StreamExt; +use gtk4::glib::ControlFlow; use gtk4::prelude::*; use hyprland::{ - data::{Workspace, Workspaces}, + data::{Monitors, Workspaces}, event_listener::{Event, EventStream}, prelude::*, shared::WorkspaceId, @@ -10,16 +15,62 @@ use relm4::ComponentSender; use crate::AppInput; -/// Fetches the current workspace list + active workspace and pushes both to -/// the app — used both for the initial state and to re-sync after the event -/// stream reconnects (state may have changed while we were disconnected). +/// Stock Hyprland accepts `hyprctl dispatch workspace N`. Lua-config +/// Hyprland (BOS) rewrites that as `hl.dispatch(workspace N)`, which is +/// a syntax error — the working form is `hl.dsp.focus({workspace=N})`. +async fn switch_workspace(id: hyprland::shared::WorkspaceId) { + let arg = id.to_string(); + let stock = tokio::process::Command::new("hyprctl") + .args(["dispatch", "workspace", &arg]) + .output() + .await; + if let Ok(o) = &stock { + let err = String::from_utf8_lossy(&o.stderr); + let out = String::from_utf8_lossy(&o.stdout); + if o.status.success() && !err.contains("hl.dispatch") && !out.contains("hl.dispatch") { + return; + } + } + let expr = format!("hl.dispatch(hl.dsp.focus({{workspace={arg}}}))"); + let lua = tokio::process::Command::new("hyprctl") + .args(["eval", &expr]) + .output() + .await; + match lua { + Ok(o) if o.status.success() => {} + Ok(o) => eprintln!( + "breadbar: workspace {arg}: {}", + String::from_utf8_lossy(&o.stderr) + ), + Err(e) => eprintln!("breadbar: workspace {arg}: {e}"), + } +} + +/// Stretch to the old→new span, then snap onto the destination — CSS +/// transitions cannot widen a pill across two buttons, so the trail's +/// Fixed allocation is interpolated on the frame clock instead. +const STRETCH_MS: f64 = 220.0; +const SNAP_MS: f64 = 380.0; + +/// Full workspace + per-monitor active snapshot. Each bar filters this to +/// its own output so a second display does not inherit the laptop's set. async fn sync_state(sender: &ComponentSender) { - if let Ok(ws) = Workspaces::get_async().await { - sender.input(AppInput::WorkspaceList(ws.to_vec())); - } - if let Ok(active) = Workspace::get_active_async().await { - sender.input(AppInput::ActiveWorkspace(active.id)); + let workspaces = Workspaces::get_async() + .await + .map(|w| w.to_vec()) + .unwrap_or_default(); + let mut actives = std::collections::HashMap::new(); + if let Ok(mons) = Monitors::get_async().await { + for m in mons { + if !m.disabled { + actives.insert(m.name, m.active_workspace.id); + } + } } + sender.input(AppInput::WorkspaceSync { + workspaces, + actives, + }); } pub fn spawn_watcher(sender: ComponentSender) { @@ -41,13 +92,21 @@ pub fn spawn_watcher(sender: ComponentSender) { while let Some(Ok(event)) = stream.next().await { backoff = std::time::Duration::from_millis(500); match event { - Event::WorkspaceChanged(data) => { - sender.input(AppInput::ActiveWorkspace(data.id)); + Event::WorkspaceChanged(_) + | Event::WorkspaceAdded(_) + | Event::WorkspaceDeleted(_) => { + sync_state(&sender).await; } - Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => { - if let Ok(ws) = Workspaces::get_async().await { - sender.input(AppInput::WorkspaceList(ws.to_vec())); - } + Event::MonitorAdded(data) => { + sender.input(AppInput::MonitorAdded(data.name)); + sync_state(&sender).await; + } + Event::MonitorRemoved(name) => { + sender.input(AppInput::MonitorRemoved(name)); + sync_state(&sender).await; + } + Event::ActiveWindowChanged(_) => { + sender.input(AppInput::DismissPanels); } _ => {} } @@ -65,17 +124,233 @@ pub fn spawn_watcher(sender: ComponentSender) { }); } -pub fn make_button(id: WorkspaceId, name: &str, active: WorkspaceId) -> gtk4::Button { +pub fn make_button( + id: WorkspaceId, + name: &str, + active: WorkspaceId, + occupied: bool, +) -> gtk4::Button { let btn = gtk4::Button::with_label(name); btn.add_css_class("workspace-btn"); + if occupied { + btn.add_css_class("occupied"); + } if id == active { btn.add_css_class("active"); } + btn.set_valign(gtk4::Align::Center); + btn.set_vexpand(false); + btn.set_size_request(-1, crate::CHIP_HEIGHT); btn.connect_clicked(move |_| { - use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial}; - let _ = Dispatch::call(DispatchType::Workspace(WorkspaceIdentifierWithSpecial::Id( - id, - ))); + relm4::spawn(async move { + switch_workspace(id).await; + }); }); btn } + +#[derive(Clone, Copy)] +struct Geom { + x: f64, + y: f64, + w: f64, + h: f64, +} + +struct TrailInner { + tick: Option, + geom: Geom, +} + +/// Overlay + Fixed pill sitting *behind* the workspace buttons. The +/// Overlay's measured size comes from the button row; the pill is the +/// main child so it paints underneath and never steals clicks. +pub struct WorkspaceTrail { + pub overlay: gtk4::Overlay, + pub buttons: gtk4::Box, + host: gtk4::Fixed, + pill: gtk4::Box, + inner: Rc>, +} + +impl WorkspaceTrail { + pub fn new() -> Self { + let overlay = gtk4::Overlay::new(); + overlay.add_css_class("workspace-overlay"); + overlay.set_valign(gtk4::Align::Center); + overlay.set_vexpand(false); + + let host = gtk4::Fixed::new(); + host.set_can_target(false); + + let pill = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + pill.add_css_class("workspace-trail"); + pill.set_can_target(false); + pill.set_visible(false); + host.put(&pill, 0.0, 0.0); + + let buttons = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); + buttons.set_halign(gtk4::Align::Fill); + buttons.set_valign(gtk4::Align::Center); + buttons.set_vexpand(false); + + overlay.set_child(Some(&host)); + overlay.add_overlay(&buttons); + overlay.set_measure_overlay(&buttons, true); + + let inner = Rc::new(RefCell::new(TrailInner { + tick: None, + geom: Geom { + x: 0.0, + y: 0.0, + w: 0.0, + h: 0.0, + }, + })); + + Self { + overlay, + buttons, + host, + pill, + inner, + } + } + + pub fn cancel(&self) { + if let Some(id) = self.inner.borrow_mut().tick.take() { + id.remove(); + } + } + + pub fn clear(&self) { + self.cancel(); + self.pill.set_visible(false); + self.inner.borrow_mut().geom.w = 0.0; + } + + pub fn place(&self, btn: >k4::Button) { + self.cancel(); + if let Some(g) = button_geom(btn, &self.overlay) { + self.apply(&g); + return; + } + // First map: the button exists but has no allocation yet. + let pill = self.pill.clone(); + let host = self.host.clone(); + let btn = btn.clone(); + let inner = self.inner.clone(); + let id = self.overlay.add_tick_callback(move |ov, _| { + let Some(g) = button_geom(&btn, ov) else { + return ControlFlow::Continue; + }; + apply_geom(&host, &pill, &inner, &g); + inner.borrow_mut().tick = None; + ControlFlow::Break + }); + self.inner.borrow_mut().tick = Some(id); + } + + pub fn stretch(&self, from: Option<>k4::Button>, to: >k4::Button) { + let from_g = self.from_geom(from); + if let Some(to_g) = button_geom(to, &self.overlay) { + self.stretch_geom(from_g, to_g); + return; + } + // Destination just appeared (empty workspace becoming active) and + // has no allocation yet. Wait one layout pass, then stretch from + // the last pill — don't snap via place(). + self.cancel(); + let overlay = self.overlay.clone(); + let pill = self.pill.clone(); + let host = self.host.clone(); + let inner = self.inner.clone(); + let btn = to.clone(); + let id = self.overlay.add_tick_callback(move |ov, _| { + let Some(to_g) = button_geom(&btn, ov) else { + return ControlFlow::Continue; + }; + inner.borrow_mut().tick = None; + stretch_geom_on(&overlay, &host, &pill, &inner, from_g, to_g); + ControlFlow::Break + }); + self.inner.borrow_mut().tick = Some(id); + } + + fn from_geom(&self, from: Option<>k4::Button>) -> Option { + let st = self.inner.borrow(); + if self.pill.is_visible() && st.geom.w > 0.5 { + return Some(st.geom); + } + drop(st); + from.and_then(|b| button_geom(b, &self.overlay)) + } + + fn stretch_geom(&self, from_g: Option, to_g: Geom) { + stretch_geom_on( + &self.overlay, + &self.host, + &self.pill, + &self.inner, + from_g, + to_g, + ); + } + + fn apply(&self, g: &Geom) { + apply_geom(&self.host, &self.pill, &self.inner, g); + } +} + +fn button_geom(btn: >k4::Button, overlay: >k4::Overlay) -> Option { + let r = btn.compute_bounds(overlay)?; + let w = f64::from(r.width()); + let h = f64::from(r.height()); + if w < 1.0 || h < 1.0 { + return None; + } + Some(Geom { + x: f64::from(r.x()), + y: f64::from(r.y()), + w, + h, + }) +} + +fn apply_geom(host: >k4::Fixed, pill: >k4::Box, inner: &Rc>, g: &Geom) { + inner.borrow_mut().geom = Geom { + x: g.x, + y: g.y, + w: g.w, + h: g.h, + }; + pill.set_size_request(g.w.max(1.0).round() as i32, g.h.max(1.0).round() as i32); + host.move_(pill, g.x, g.y); + pill.set_visible(true); +} + +fn lerp(a: f64, b: f64, t: f64) -> f64 { + a + (b - a) * t +} + +fn lerp_geom(a: &Geom, b: &Geom, t: f64) -> Geom { + Geom { + x: lerp(a.x, b.x, t), + y: lerp(a.y, b.y, t), + w: lerp(a.w, b.w, t), + h: lerp(a.h, b.h, t), + } +} + +fn ease(t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Approximates the demo's cubic-bezier(.22, 1.4, .36, 1) snap. +fn ease_overshoot(t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + let c = 1.4; + let t1 = t - 1.0; + 1.0 + t1 * t1 * ((c + 1.0) * t1 + c) +} diff --git a/src/main.rs b/src/main.rs index 0ce99a2..ad09dc3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,39 +7,56 @@ macro_rules! asset { mod bar; mod notifications; mod osd; +mod panel; mod screenshot; mod theme; mod widgets; -/// Thresholds above which the bar's CPU/RAM/power-draw readouts appear at -/// all — see `AppInput::StatsUpdate`. Below these, the bar stays quiet. -const CPU_ATTENTION_THRESHOLD: f32 = 70.0; -const MEM_ATTENTION_THRESHOLD: f32 = 80.0; -const POWER_ATTENTION_THRESHOLD: f32 = 30.0; +/// Floating island bar: widget height, layer-shell inset, reserved zone. +/// Exclusive zone is height + top margin so tiled clients sit below the gap. +pub const BAR_HEIGHT: i32 = 44; +pub const BAR_MARGIN_TOP: i32 = 12; +pub const BAR_MARGIN_SIDES: i32 = 16; +/// Chip / workspace-pill height. Must stay smaller than `BAR_HEIGHT` so +/// hover/active highlights hug the glyphs instead of filling the island. +pub const CHIP_HEIGHT: i32 = 32; +pub const ICON_PX: i32 = 24; use gtk4::prelude::*; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use hyprland::data::Workspace; use hyprland::shared::WorkspaceId; use relm4::prelude::*; +use relm4::{Component, ComponentController, Controller}; use std::cell::Cell; use std::rc::Rc; +pub struct BarInit { + pub screenshot: Option, + pub monitor: Option, + pub primary: bool, +} + pub struct App { + monitor: String, + primary: bool, + satellites: Vec<(String, Controller)>, + // ── Workspaces ──────────────────────────────────────────────────────── workspaces: Vec, active_ws: WorkspaceId, workspace_box: gtk4::Box, + workspace_trail: bar::workspaces::WorkspaceTrail, button_map: std::collections::HashMap, // ── Clock ───────────────────────────────────────────────────────────── time_str: String, - clock_lbl: gtk4::Label, + clock_digits: Vec, + date_lbl: gtk4::Label, // ── Stats bar ───────────────────────────────────────────────────────── - // The system-stats trio (CPU/RAM/power draw) only shows up when a value - // crosses a "you probably want to know about this" threshold — otherwise - // the bar stays quiet. See AppInput::StatsUpdate. + // Island chrome matches the Liquid Motion demo: volume / wifi / battery + // / hamburger. CPU/RAM/power live in the control panel, not on the bar. system_stats_box: gtk4::Box, system_sep: gtk4::Separator, cpu_pair: gtk4::Box, @@ -48,6 +65,7 @@ pub struct App { cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, + vol_lbl: gtk4::Label, bat_lbl: gtk4::Label, bat_img: gtk4::Image, bat_textures: std::collections::HashMap, @@ -56,7 +74,6 @@ pub struct App { bt_textures: std::collections::HashMap, wifi_lbl: gtk4::Label, wifi_img: gtk4::Image, - wifi_textures: std::collections::HashMap, // ── WiFi popover ────────────────────────────────────────────────────── wifi_pane: gtk4::Box, @@ -77,19 +94,9 @@ pub struct App { media_paused_at: Option, // ── Control panel ───────────────────────────────────────────────────── - control_popover: gtk4::Popover, panel_vol_slider: gtk4::Scale, panel_bright_slider: gtk4::Scale, panel_loading: Rc>, - panel_sink_store: gtk4::StringList, - panel_sink_dropdown: gtk4::DropDown, - panel_sink_signal: Option, - panel_sinks: Vec, - panel_cpu_lbl: gtk4::Label, - panel_mem_lbl: gtk4::Label, - panel_pwr_lbl: gtk4::Label, - panel_gpu_lbl: gtk4::Label, - panel_net_lbl: gtk4::Label, // ── Tray ────────────────────────────────────────────────────────────── tray_section: gtk4::Box, @@ -101,16 +108,21 @@ pub struct App { // One container per WidgetPlacement (see bread_shared::widget), fully // rebuilt on every AppInput::WidgetsUpdate — see widgets::client's // module doc for why that's simpler than incremental patching here. - widget_containers: - std::collections::HashMap, + widget_containers: std::collections::HashMap, widget_tray_section: gtk4::Box, widget_tray_sep: gtk4::Separator, + + panels: panel::PanelSet, } #[derive(Debug)] pub enum AppInput { - WorkspaceList(Vec), - ActiveWorkspace(WorkspaceId), + WorkspaceSync { + workspaces: Vec, + actives: std::collections::HashMap, + }, + MonitorAdded(String), + MonitorRemoved(String), ClockTick, StatsUpdate(bar::stats::Stats), TrayUpdate(bar::tray::TrayUpdate), @@ -121,11 +133,13 @@ pub enum AppInput { MediaUpdate(bar::media::MediaState), ControlPanelData(bar::control::ControlPanelData), WidgetsUpdate(Vec), + ReconcileMonitors, + DismissPanels, } #[relm4::component(pub)] impl SimpleComponent for App { - type Init = Option; + type Init = BarInit; type Input = AppInput; type Output = (); @@ -133,7 +147,7 @@ impl SimpleComponent for App { gtk::ApplicationWindow { add_css_class: "breadbar", set_title: Some("breadbar"), - set_default_height: 32, + set_default_height: BAR_HEIGHT, #[name = "center_box"] gtk::CenterBox { @@ -142,25 +156,53 @@ impl SimpleComponent for App { } fn init( - screenshot_req: Self::Init, + init: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { + let screenshot_req = init.screenshot; + let monitor_name = init + .monitor + .clone() + .or_else(primary_hypr_monitor) + .unwrap_or_else(|| "eDP-1".into()); + root.init_layer_shell(); root.set_namespace(Some("breadbar")); root.set_layer(Layer::Top); root.set_anchor(Edge::Top, true); root.set_anchor(Edge::Left, true); root.set_anchor(Edge::Right, true); - root.set_exclusive_zone(32); + root.set_margin(Edge::Top, BAR_MARGIN_TOP); + root.set_margin(Edge::Left, BAR_MARGIN_SIDES); + root.set_margin(Edge::Right, BAR_MARGIN_SIDES); + root.set_exclusive_zone(BAR_HEIGHT + BAR_MARGIN_TOP); + eprintln!( + "breadbar: init monitor={monitor_name} primary={}", + init.primary + ); + if screenshot_req.is_none() && !bind_layer_monitor(&root, &monitor_name) { + // Unbound satellites must not map on the compositor default + // (that stacks a second exclusive-zone bar on the laptop). + root.set_exclusive_zone(-1); + if !init.primary { + root.set_visible(false); + } + } // ── Workspace row (left) ──────────────────────────────────────── // Built imperatively (not via the view! macro) so a widget // container can sit as a plain sibling of workspace_box — see - // WidgetPlacement::RightOfWorkspaces below. - let workspace_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + // WidgetPlacement::RightOfWorkspaces below. The Overlay trail + // lives behind the buttons; rebuild_buttons only touches the + // button box, never the trail host. + let workspace_trail = bar::workspaces::WorkspaceTrail::new(); + let workspace_box = workspace_trail.buttons.clone(); let workspace_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - workspace_row.append(&workspace_box); + workspace_row.set_margin_start(8); + workspace_row.set_valign(gtk4::Align::Center); + workspace_row.set_vexpand(false); + workspace_row.append(&workspace_trail.overlay); // ── Lua-declared widget containers ────────────────────────────── // One per WidgetPlacement; positioned into the layout below as each @@ -182,8 +224,7 @@ impl SimpleComponent for App { // ── SVG icon sets ──────────────────────────────────────────────── use bar::stats::{ - AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, WIFI_MEDIUM, - WIFI_OFF, WIFI_STRONG, WIFI_WEAK, + AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, ICON_VOLUME, }; let bat_textures: std::collections::HashMap = [BAT_HIGH, BAT_MID, BAT_LOW] @@ -195,26 +236,26 @@ impl SimpleComponent for App { .into_iter() .map(|p| (p.as_ptr() as usize, svg_texture(p))) .collect(); - let wifi_textures: std::collections::HashMap = - [WIFI_STRONG, WIFI_MEDIUM, WIFI_WEAK, WIFI_OFF] - .into_iter() - .map(|p| (p.as_ptr() as usize, svg_texture(p))) - .collect(); - // ── Stat labels ────────────────────────────────────────────────── let cpu_lbl = stat_label(); let mem_lbl = stat_label(); let pwr_lbl = stat_label(); + let vol_lbl = stat_label(); let bat_lbl = stat_label(); + let vol_img = svg_image(ICON_VOLUME); + vol_img.add_css_class("stat-icon"); let bat_img = gtk4::Image::from_paintable(Some( bat_textures.get(&(BAT_MID.as_ptr() as usize)).unwrap(), )); - let ac_img = gtk4::Image::from_paintable(Some(&svg_texture(AC_POWER))); + prepare_icon(&bat_img, ICON_PX); + let ac_img = svg_image(AC_POWER); ac_img.set_visible(false); let bt_img = gtk4::Image::from_paintable(Some( bt_textures.get(&(BT_OFF.as_ptr() as usize)).unwrap(), )); + prepare_icon(&bt_img, ICON_PX); + bt_img.set_visible(false); // ── WiFi pair + popover ────────────────────────────────────────── let wifi_lbl = gtk4::Label::new(None); @@ -223,9 +264,11 @@ impl SimpleComponent for App { wifi_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); wifi_lbl.set_max_width_chars(28); wifi_lbl.set_xalign(0.0); - let wifi_img = - gtk4::Image::from_paintable(Some(&svg_texture(asset!("WiFi Connecting.svg")))); - + // SSID lives in the popover + tooltip — a 20-char network name + // crowding the tray is the opposite of a glass workbench bar. + wifi_lbl.set_visible(false); + let wifi_img = gtk4::Image::from_icon_name(bar::stats::WIFI_ICON_EXCELLENT); + prepare_icon(&wifi_img, ICON_PX); wifi_img.add_css_class("stat-icon"); // Content pane only — this becomes a tab inside the merged @@ -239,10 +282,18 @@ impl SimpleComponent for App { // ── Media widget (center) ──────────────────────────────────────── let media_widget = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); media_widget.add_css_class("media-widget"); + bar_chip(&media_widget); media_widget.set_visible(false); - let media_indicator = gtk4::Label::new(Some("▶")); - media_indicator.add_css_class("media-indicator"); + let media_eq = gtk4::Box::new(gtk4::Orientation::Horizontal, 3); + media_eq.add_css_class("media-eq"); + media_eq.set_valign(gtk4::Align::Center); + for _ in 0..4 { + let bar = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + bar.add_css_class("media-eq-bar"); + bar.set_valign(gtk4::Align::End); + media_eq.append(&bar); + } let media_track_lbl = gtk4::Label::new(None); media_track_lbl.add_css_class("media-track-lbl"); @@ -250,7 +301,7 @@ impl SimpleComponent for App { media_track_lbl.set_max_width_chars(42); media_track_lbl.set_xalign(0.0); - media_widget.append(&media_indicator); + media_widget.append(&media_eq); media_widget.append(&media_track_lbl); // Media controls popover @@ -262,14 +313,12 @@ impl SimpleComponent for App { media_controls_box.set_margin_end(4); let prev_btn = gtk4::Button::new(); - prev_btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture( - asset!("Previous.svg"), - ))))); + prev_btn.set_child(Some(&svg_image(asset!("Previous.svg")))); prev_btn.add_css_class("flat"); prev_btn.add_css_class("media-btn"); prev_btn.connect_clicked(|_| bar::media::spawn_cmd("previous")); - let media_play_icon = gtk4::Image::from_paintable(Some(&svg_texture(asset!("Pause.svg")))); + let media_play_icon = svg_image(asset!("Pause.svg")); let media_play_btn = gtk4::Button::new(); media_play_btn.set_child(Some(&media_play_icon)); media_play_btn.add_css_class("flat"); @@ -278,9 +327,7 @@ impl SimpleComponent for App { media_play_btn.connect_clicked(|_| bar::media::spawn_cmd("play-pause")); let next_btn = gtk4::Button::new(); - next_btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture( - asset!("Next.svg"), - ))))); + next_btn.set_child(Some(&svg_image(asset!("Next.svg")))); next_btn.add_css_class("flat"); next_btn.add_css_class("media-btn"); next_btn.connect_clicked(|_| bar::media::spawn_cmd("next")); @@ -289,37 +336,47 @@ impl SimpleComponent for App { media_controls_box.append(&media_play_btn); media_controls_box.append(&next_btn); - let media_popover = gtk4::Popover::new(); - media_popover.add_css_class("media-popover"); - media_popover.set_child(Some(&media_controls_box)); - media_popover.set_parent(&media_widget); - - let mpop = media_popover.clone(); - let mgesture = gtk4::GestureClick::new(); - mgesture.connect_released(move |_, _, _, _| { - if mpop.is_visible() { mpop.popdown(); } else { mpop.popup(); } - }); - media_widget.add_controller(mgesture); - - // Clock label - let clock_lbl = gtk4::Label::new(Some(&bar::clock::current())); - clock_lbl.add_css_class("clock-label"); + // Clock: time is the hero, date sits beside it quieter. + // Per-glyph labels so a minute rollover can flip only the digits + // that changed — same motion as the Liquid Motion demo. + let clock_time = bar::clock::time(); + let clock_digits = make_clock_digits(&clock_time); + let clock_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + clock_box.add_css_class("clock-box"); + clock_box.add_css_class("clock-label"); + clock_box.set_valign(gtk4::Align::Center); + clock_box.set_vexpand(false); + // Varela Round's em box sits optically high in the 44px island. + clock_box.set_margin_top(3); + for digit in &clock_digits { + clock_box.append(digit); + } + let date_lbl = gtk4::Label::new(Some(&bar::clock::date())); + date_lbl.add_css_class("date-label"); + date_lbl.set_visible(false); // Center area: [media_widget · widgets · clock · widgets] - let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); center_area.add_css_class("center-area"); + center_area.set_valign(gtk4::Align::Center); + center_area.set_vexpand(false); center_area.append(&media_widget); center_area.append(&widget_left_of_clock); - center_area.append(&clock_lbl); + center_area.append(&clock_box); center_area.append(&widget_right_of_clock); // ── Stats box (right side) ─────────────────────────────────────── - let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + // Demo order: [vol 64] [wifi] [bat 83] [☰] + let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); stats_box.add_css_class("stats-box"); + stats_box.set_margin_end(2); + stats_box.set_valign(gtk4::Align::Center); + stats_box.set_vexpand(false); stats_box.append(&widget_left_of_stats); - // CPU/RAM/power draw: hidden by default (see StatsUpdate), so this - // whole sub-group — plus its separator — collapses away when quiet. + // CPU/RAM/power draw stay built (control panel + screenshots still + // read the labels) but never mount on the island — the demo bar + // does not show them. let cpu_pair = stat_pair(asset!("CPU.svg"), &cpu_lbl); let mem_pair = stat_pair(asset!("RAM Usage.svg"), &mem_lbl); let pwr_pair = stat_pair(asset!("Power Draw.svg"), &pwr_lbl); @@ -328,22 +385,26 @@ impl SimpleComponent for App { system_stats_box.append(&mem_pair); system_stats_box.append(&pwr_pair); system_stats_box.set_visible(false); - stats_box.append(&system_stats_box); let system_sep = gtk4::Separator::new(gtk4::Orientation::Vertical); system_sep.add_css_class("bar-sep"); system_sep.set_visible(false); - stats_box.append(&system_sep); + + let vol_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + vol_box.add_css_class("stat-pair"); + bar_chip(&vol_box); + vol_lbl.add_css_class("stat-label"); + vol_box.append(&vol_img); + vol_box.append(&vol_lbl); + stats_box.append(&vol_box); let bat_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); bat_box.add_css_class("stat-pair"); + bar_chip(&bat_box); bat_img.add_css_class("stat-icon"); bat_lbl.add_css_class("stat-label"); ac_img.add_css_class("stat-icon"); - ac_img.set_margin_start(6); bat_box.append(&bat_img); bat_box.append(&bat_lbl); - bat_box.append(&ac_img); - stats_box.append(&bat_box); bt_img.add_css_class("bt-icon"); @@ -362,41 +423,38 @@ impl SimpleComponent for App { // Boxes with manual visibility toggling, which left stale width // behind on reopen. // - // hhomogeneous/vhomogeneous are ON (GTK's default), even though that - // means the popover is always sized to the *larger* of the two panes - // (visible empty space under the shorter one) — the alternative, - // sizing to just the active pane, means the popup has to resize - // itself in place when you switch tabs while it's open. Under - // gtk4-layer-shell that in-place resize doesn't reliably reach the - // compositor as a proper xdg_popup reposition; switching from the - // shorter tab to the taller one after a close/reopen cycle made the - // whole popover silently vanish instead of growing. A constant - // footprint sidesteps the resize entirely. + // Scrollport is a fixed 300px so tab-switch / first scan cannot + // resize the xdg_popup (that vanish-on-grow bug). Nearby networks + // live in the scroll, not a clipped 240px well. let content_stack = gtk4::Stack::new(); content_stack.set_hhomogeneous(true); - content_stack.set_vhomogeneous(true); - // Reserve enough room up front for the tallest realistic content - // (WiFi tab with a handful of nearby networks). Homogeneous sizing - // alone still lets the *first* real data load (Scanning… → populated - // list) trigger a live resize while the popup is mapped, which hits - // the same reposition fragility as the tab-switch case — claiming - // the space before anything is shown avoids that resize too. - content_stack.set_size_request(220, 420); + content_stack.set_vhomogeneous(false); + content_stack.set_transition_type(gtk4::StackTransitionType::Crossfade); + content_stack.set_transition_duration(220); content_stack.add_named(&wifi_pane, Some("wifi")); content_stack.add_named(&bt_pane, Some("bluetooth")); content_stack.set_visible_child_name("wifi"); - let wifi_tab_btn = gtk4::ToggleButton::with_label("Wi-Fi"); - wifi_tab_btn.add_css_class("popover-tab"); + // Fixed scrollport so nearby networks stay reachable without + // resizing the xdg_popup (that vanish-on-grow bug). + let content_scroll = gtk4::ScrolledWindow::new(); + content_scroll.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); + content_scroll.set_propagate_natural_width(true); + content_scroll.set_min_content_height(300); + content_scroll.set_max_content_height(300); + content_scroll.set_child(Some(&content_stack)); + + let wifi_tab_btn = popover_tab("Wi-Fi"); wifi_tab_btn.set_active(true); - let bt_tab_btn = gtk4::ToggleButton::with_label("Bluetooth"); - bt_tab_btn.add_css_class("popover-tab"); + let bt_tab_btn = popover_tab("Bluetooth"); bt_tab_btn.set_group(Some(&wifi_tab_btn)); - let tab_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + let tab_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); tab_row.add_css_class("popover-tab-row"); + tab_row.set_homogeneous(true); tab_row.append(&wifi_tab_btn); tab_row.append(&bt_tab_btn); + let wifi_caret = popover_caret(); let stack_for_wifi = content_stack.clone(); wifi_tab_btn.connect_toggled(move |btn| { @@ -414,170 +472,53 @@ impl SimpleComponent for App { let connectivity_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); connectivity_inner.add_css_class("wifi-popover-inner"); connectivity_inner.append(&tab_row); - connectivity_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - connectivity_inner.append(&content_stack); - - let connectivity_popover = gtk4::Popover::new(); - connectivity_popover.add_css_class("wifi-popover"); - connectivity_popover.set_child(Some(&connectivity_inner)); + connectivity_inner.append(&wifi_caret); + connectivity_inner.append(&content_scroll); let connectivity_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); connectivity_pair.add_css_class("stat-pair"); connectivity_pair.add_css_class("wifi-pair"); - connectivity_pair.append(&bt_img); + connectivity_pair.add_css_class("icon-only"); + bar_chip(&connectivity_pair); + wifi_img.set_halign(gtk4::Align::Center); + wifi_img.set_hexpand(false); connectivity_pair.append(&wifi_img); - connectivity_pair.append(&wifi_lbl); - // Anchored to wifi_lbl specifically, not the connectivity_pair row — - // a Popover parented to a multi-child Box via set_parent() balloons - // that Box's own allocation after a popup→popdown→popup cycle (its - // width roughly quadrupled in testing, shoving every bar item to its - // left further left). Anchoring to a single leaf widget instead - // sidesteps whatever GTK4/gtk4-layer-shell interaction causes that; - // the click target below still covers the whole row regardless. - connectivity_popover.set_parent(&wifi_lbl); stats_box.append(&connectivity_pair); - - let cpop = connectivity_popover.clone(); - let gesture = gtk4::GestureClick::new(); - gesture.connect_released(move |_, _, _, _| { - if cpop.is_visible() { cpop.popdown(); } else { cpop.popup(); } - }); - connectivity_pair.add_controller(gesture); - - let sender_conn = sender.clone(); - connectivity_popover.connect_show(move |_| { - bar::wifi::spawn_popover_load(sender_conn.clone()); - bar::bluetooth::spawn_popover_load(sender_conn.clone()); - }); + stats_box.append(&bat_box); // ── Control panel popover ──────────────────────────────────────── + // Liquid Motion chrome: CONTROL / vol / bl / lock·sleep·off. + // SNI tray + Lua tray widgets still mount here, but only as a + // headerless icon row when something actually registers. let panel_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); panel_inner.add_css_class("control-panel-inner"); - // Volume row - let vol_row = build_slider_row(bar::stats::ICON_VOLUME, 0.0, 1.5, 0.02); + let panel_header = gtk4::Label::new(Some("CONTROL")); + panel_header.add_css_class("control-panel-header"); + panel_header.set_xalign(0.0); + panel_inner.append(&panel_header); + panel_inner.append(&popover_caret()); + + let vol_row = build_slider_row("vol", 0.0, 1.5, 0.02); let panel_vol_slider = vol_row.1.clone(); panel_inner.append(&vol_row.0); - // Brightness row - let bright_row = build_slider_row(bar::stats::ICON_BRIGHTNESS, 0.0, 1.0, 0.02); + let bright_row = build_slider_row("bl", 0.0, 1.0, 0.02); let panel_bright_slider = bright_row.1.clone(); panel_inner.append(&bright_row.0); - panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - - // Stats section - let stats_section = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - stats_section.add_css_class("control-panel-stats"); - - let panel_cpu_lbl = gtk4::Label::new(Some("CPU —")); - panel_cpu_lbl.add_css_class("control-panel-stat"); - panel_cpu_lbl.set_xalign(0.0); - - let panel_mem_lbl = gtk4::Label::new(Some("RAM —")); - panel_mem_lbl.add_css_class("control-panel-stat"); - panel_mem_lbl.set_xalign(0.0); - - let panel_pwr_lbl = gtk4::Label::new(Some("PWR —")); - panel_pwr_lbl.add_css_class("control-panel-stat"); - panel_pwr_lbl.set_xalign(0.0); - - let panel_gpu_lbl = gtk4::Label::new(Some("GPU —")); - panel_gpu_lbl.add_css_class("control-panel-stat"); - panel_gpu_lbl.set_xalign(0.0); - - let panel_net_lbl = gtk4::Label::new(Some("↓ — ↑ —")); - panel_net_lbl.add_css_class("control-panel-stat"); - panel_net_lbl.set_xalign(0.0); - - stats_section.append(&panel_cpu_lbl); - stats_section.append(&panel_mem_lbl); - stats_section.append(&panel_pwr_lbl); - stats_section.append(&panel_gpu_lbl); - stats_section.append(&panel_net_lbl); - panel_inner.append(&stats_section); - - panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - - // Audio output section - let sink_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - sink_section.add_css_class("control-panel-section"); - let sink_header = gtk4::Label::new(Some("Audio Output")); - sink_header.add_css_class("control-panel-section-header"); - sink_header.set_xalign(0.0); - - let panel_sink_store = gtk4::StringList::new(&[]); - let panel_sink_dropdown = gtk4::DropDown::new( - Some(panel_sink_store.clone().upcast::()), - Option::::None, - ); - panel_sink_dropdown.add_css_class("control-panel-sink-dropdown"); - panel_sink_dropdown.set_hexpand(true); - - sink_section.append(&sink_header); - sink_section.append(&panel_sink_dropdown); - panel_inner.append(&sink_section); - - panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - - // Tray section - let tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - tray_section.add_css_class("control-panel-section"); - let tray_header = gtk4::Label::new(Some("Apps")); - tray_header.add_css_class("control-panel-section-header"); - tray_header.set_xalign(0.0); - let tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - tray_box.add_css_class("tray-box"); - tray_section.append(&tray_header); - tray_section.append(&tray_box); - // Collapsed (along with its separator) until an SNI app actually - // registers — an empty "Apps" section heading is dead weight. - tray_section.set_visible(false); - panel_inner.append(&tray_section); - - let tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); - tray_sep.set_visible(false); - panel_inner.append(&tray_sep); - - // Widgets section — Lua-declared widgets with placement = "tray". - // Same collapse-when-empty idiom as the Apps section above. - let widget_tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - widget_tray_section.add_css_class("control-panel-section"); - let widget_tray_header = gtk4::Label::new(Some("Widgets")); - widget_tray_header.add_css_class("control-panel-section-header"); - widget_tray_header.set_xalign(0.0); - let widget_tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - widget_tray_box.add_css_class("tray-box"); - widget_tray_section.append(&widget_tray_header); - widget_tray_section.append(&widget_tray_box); - widget_tray_section.set_visible(false); - panel_inner.append(&widget_tray_section); - - let widget_tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); - widget_tray_sep.set_visible(false); - panel_inner.append(&widget_tray_sep); - - // Power section - let power_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - power_section.add_css_class("control-panel-section"); - let power_header = gtk4::Label::new(Some("Power")); - power_header.add_css_class("control-panel-section-header"); - power_header.set_xalign(0.0); - power_section.append(&power_header); - - let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); power_row.add_css_class("power-row"); - for (icon_svg, cmd) in [ + power_row.set_halign(gtk4::Align::Center); + for (label, cmd) in [ // breadlock is the ecosystem's own screen locker — hyprlock is // the thing it was built to replace; the bar shouldn't still // be pointing at it. - (bar::stats::ICON_LOCK, vec!["breadlock"]), - (bar::stats::ICON_SLEEP, vec!["systemctl", "suspend"]), - (bar::stats::ICON_RESTART, vec!["systemctl", "reboot"]), - (bar::stats::ICON_SHUTDOWN, vec!["systemctl", "poweroff"]), + ("lock", vec!["breadlock"]), + ("sleep", vec!["systemctl", "suspend"]), + ("off", vec!["systemctl", "poweroff"]), ] { - let btn = gtk4::Button::new(); - btn.set_child(Some(>k4::Image::from_paintable(Some(&svg_texture(icon_svg))))); + let btn = gtk4::Button::with_label(label); btn.add_css_class("flat"); btn.add_css_class("power-btn"); btn.connect_clicked(move |_| { @@ -590,49 +531,121 @@ impl SimpleComponent for App { }); power_row.append(&btn); } - power_section.append(&power_row); - panel_inner.append(&power_section); + panel_inner.append(&power_row); - let control_popover = gtk4::Popover::new(); - control_popover.add_css_class("control-panel"); - control_popover.set_child(Some(&panel_inner)); + // SNI / Lua tray sit under the demo chrome so a single icon cannot + // split the sliders from the power chips. + let tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + tray_section.add_css_class("control-panel-section"); + let tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + tray_box.add_css_class("tray-box"); + tray_box.set_halign(gtk4::Align::Center); + tray_section.append(&tray_box); + tray_section.set_visible(false); + panel_inner.append(&tray_section); + let tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); + tray_sep.set_visible(false); - // Hamburger button + let widget_tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + widget_tray_section.add_css_class("control-panel-section"); + let widget_tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_tray_box.add_css_class("tray-box"); + widget_tray_box.set_halign(gtk4::Align::Center); + widget_tray_section.append(&widget_tray_box); + widget_tray_section.set_visible(false); + panel_inner.append(&widget_tray_section); + let widget_tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); + widget_tray_sep.set_visible(false); + + // Hamburger button — same chip chrome as volume / wifi / battery. let hamburger_btn = gtk4::Button::with_label("☰"); hamburger_btn.add_css_class("flat"); hamburger_btn.add_css_class("control-panel-btn"); - - control_popover.set_parent(&hamburger_btn); - - let cpop = control_popover.clone(); - hamburger_btn.connect_clicked(move |_| { - if cpop.is_visible() { cpop.popdown(); } else { cpop.popup(); } - }); - - let sender_cp = sender.clone(); - control_popover.connect_show(move |_| { - bar::control::spawn_load(sender_cp.clone()); - }); + hamburger_btn.add_css_class("stat-pair"); + hamburger_btn.add_css_class("icon-only"); + bar_chip(&hamburger_btn); // Slider signals — use Rc> to suppress feedback during data load let panel_loading = Rc::new(Cell::new(false)); let loading_v = panel_loading.clone(); + let vol_lbl_live = vol_lbl.clone(); panel_vol_slider.connect_value_changed(move |s| { - if loading_v.get() { return; } + vol_lbl_live.set_label(&format!("{:.0}", s.value() * 100.0)); + if loading_v.get() { + return; + } bar::control::spawn_set_volume(s.value()); }); let loading_b = panel_loading.clone(); panel_bright_slider.connect_value_changed(move |s| { - if loading_b.get() { return; } + if loading_b.get() { + return; + } bar::control::spawn_set_brightness(s.value()); }); stats_box.append(&hamburger_btn); + // Standalone layer windows — below the island, slid in by Hyprland. + let panels = panel::PanelSet::new( + &monitor_name, + &connectivity_inner, + &panel_inner, + &media_controls_box, + ); + + let sender_conn = sender.clone(); + panels.connectivity.connect_map(move |_| { + bar::wifi::spawn_popover_load(sender_conn.clone()); + bar::bluetooth::spawn_popover_load(sender_conn.clone()); + }); + let sender_cp = sender.clone(); + panels.control.connect_map(move |_| { + bar::control::spawn_load(sender_cp.clone()); + }); + + { + let panels = panels.clone(); + let win = panels.connectivity.clone(); + let gesture = gtk4::GestureClick::new(); + gesture.connect_released(move |_, _, _, _| { + panels.toggle(&win); + }); + connectivity_pair.add_controller(gesture); + } + { + let panels = panels.clone(); + let win = panels.control.clone(); + hamburger_btn.connect_clicked(move |_| { + panels.toggle(&win); + }); + } + { + let panels = panels.clone(); + let win = panels.control.clone(); + let vol_gesture = gtk4::GestureClick::new(); + vol_gesture.connect_released(move |_, _, _, _| { + panels.toggle(&win); + }); + vol_box.add_controller(vol_gesture); + } + { + let panels = panels.clone(); + let win = panels.media.clone(); + let mgesture = gtk4::GestureClick::new(); + mgesture.connect_released(move |_, _, _, _| { + panels.toggle(&win); + }); + media_widget.add_controller(mgesture); + } + let widget_containers = std::collections::HashMap::from([ - (WidgetPlacement::RightOfWorkspaces, widget_right_of_workspaces), + ( + WidgetPlacement::RightOfWorkspaces, + widget_right_of_workspaces, + ), (WidgetPlacement::LeftOfClock, widget_left_of_clock), (WidgetPlacement::RightOfClock, widget_right_of_clock), (WidgetPlacement::LeftOfStats, widget_left_of_stats), @@ -648,21 +661,37 @@ impl SimpleComponent for App { // Captured before these move into `model` (or are otherwise dropped // as bare locals, never stored on `App` at all) — needed by the // screenshot dispatch just before this function returns. - let control_popover_for_screenshot = control_popover.clone(); - let connectivity_popover_for_screenshot = connectivity_popover.clone(); + let control_panel_for_screenshot = panels.control.clone(); + let connectivity_panel_for_screenshot = panels.connectivity.clone(); let wifi_tab_btn_for_screenshot = wifi_tab_btn.clone(); let bt_tab_btn_for_screenshot = bt_tab_btn.clone(); - let media_popover_for_screenshot = media_popover.clone(); + let media_panel_for_screenshot = panels.media.clone(); let media_widget_for_screenshot = media_widget.clone(); let media_track_lbl_for_screenshot = media_track_lbl.clone(); + // Never launch sibling App windows from inside this init — RelmApp + // is still in GApplication activate, and a same-type launch here + // creates a second primary on the laptop. Idle reconcile after. + let satellites = Vec::new(); + if init.primary && screenshot_req.is_none() { + let later = sender.clone(); + gtk4::glib::idle_add_local_once(move || { + later.input(AppInput::ReconcileMonitors); + }); + } + let model = App { + monitor: monitor_name, + primary: init.primary, + satellites, workspaces: vec![], active_ws: 1, workspace_box, + workspace_trail, button_map: std::collections::HashMap::new(), time_str: bar::clock::current(), - clock_lbl, + clock_digits, + date_lbl, system_stats_box, system_sep, cpu_pair, @@ -671,6 +700,7 @@ impl SimpleComponent for App { cpu_lbl, mem_lbl, pwr_lbl, + vol_lbl, bat_lbl, bat_img, bat_textures, @@ -679,7 +709,6 @@ impl SimpleComponent for App { bt_textures, wifi_lbl, wifi_img, - wifi_textures, wifi_pane, crumbs_status: None, wifi_popover_data: None, @@ -692,19 +721,9 @@ impl SimpleComponent for App { media_play_icon, media_last: None, media_paused_at: None, - control_popover, panel_vol_slider, panel_bright_slider, panel_loading, - panel_sink_store, - panel_sink_dropdown, - panel_sink_signal: None, - panel_sinks: vec![], - panel_cpu_lbl, - panel_mem_lbl, - panel_pwr_lbl, - panel_gpu_lbl, - panel_net_lbl, tray_section, tray_sep, tray_box, @@ -712,16 +731,20 @@ impl SimpleComponent for App { widget_containers, widget_tray_section, widget_tray_sep, + panels, }; theme::apply(); + theme::bind_output(&root, &model.monitor); bar::workspaces::spawn_watcher(sender.clone()); bar::clock::spawn_ticker(sender.clone()); bar::stats::spawn_poller(sender.clone()); - bar::tray::spawn_watcher(sender.clone()); bar::wifi::spawn_status_poller(sender.clone()); bar::media::spawn_poller(sender.clone()); - widgets::client::spawn(sender.clone()); + if init.primary { + bar::tray::spawn_watcher(sender.clone()); + widgets::client::spawn(sender.clone()); + } // Screenshot mode primes these with sample content instead of the // real D-Bus/pactl/backlight sources — see notifications::SampleKind @@ -731,28 +754,37 @@ impl SimpleComponent for App { "notification-critical" => Some(notifications::SampleKind::Critical), _ => None, }); - let notification_window = notifications::spawn(notif_sample); + let notification_window = if init.primary { + Some(notifications::spawn(notif_sample)) + } else { + None + }; let osd_sample = screenshot_req.as_ref().and_then(|r| match r.view.as_str() { "osd-volume" => Some(osd::SampleKind::Volume), "osd-brightness" => Some(osd::SampleKind::Brightness), _ => None, }); - let osd_window = osd::spawn(osd_sample); + let osd_window = if init.primary { + Some(osd::spawn(osd_sample)) + } else { + None + }; if let Some(req) = screenshot_req { - let notification_window = matches!(req.view.as_str(), "notification" | "notification-critical") - .then_some(notification_window); - let osd_window = matches!(req.view.as_str(), "osd-volume" | "osd-brightness") - .then_some(osd_window); + let notification_window = notification_window.filter(|_| { + matches!(req.view.as_str(), "notification" | "notification-critical") + }); + let osd_window = + osd_window.filter(|_| matches!(req.view.as_str(), "osd-volume" | "osd-brightness")); screenshot::dispatch( &root, req, screenshot::Handles { - control_popover: control_popover_for_screenshot, - connectivity_popover: connectivity_popover_for_screenshot, + control_panel: control_panel_for_screenshot, + connectivity_panel: connectivity_panel_for_screenshot, wifi_tab_btn: wifi_tab_btn_for_screenshot, bt_tab_btn: bt_tab_btn_for_screenshot, - media_popover: media_popover_for_screenshot, + media_panel: media_panel_for_screenshot, media_widget: media_widget_for_screenshot, media_track_lbl: media_track_lbl_for_screenshot, notification_window, @@ -766,48 +798,88 @@ impl SimpleComponent for App { fn update(&mut self, msg: Self::Input, sender: ComponentSender) { match msg { - AppInput::WorkspaceList(list) => { - let mut sorted = list; + AppInput::WorkspaceSync { + workspaces, + actives, + } => { + let mut sorted = workspaces; sorted.sort_by_key(|w| w.id); + let new_active = actives + .get(&self.monitor) + .copied() + .unwrap_or(self.active_ws); + // Workspace also carries last-window title/address, which + // change constantly. Only the visible row (id/name/monitor/ + // occupied) should rebuild the pills — otherwise a title + // flicker on switch cancels the trail mid-stretch. + let rows_changed = visible_ws_rows(&sorted, &self.monitor, new_active) + != visible_ws_rows(&self.workspaces, &self.monitor, self.active_ws); + let active_changed = new_active != self.active_ws; self.workspaces = sorted; - self.rebuild_buttons(); + if self.primary { + self.reconcile_satellites(); + } + if rows_changed { + self.active_ws = new_active; + self.rebuild_buttons(active_changed); + } else if active_changed { + let from = self.button_map.get(&self.active_ws).cloned(); + if let Some(old) = &from { + old.remove_css_class("active"); + } + self.active_ws = new_active; + if let Some(btn) = self.button_map.get(&self.active_ws).cloned() { + btn.add_css_class("active"); + self.workspace_trail.stretch(from.as_ref(), &btn); + } + } } - AppInput::ActiveWorkspace(id) => { - if let Some(old) = self.button_map.get(&self.active_ws) { - old.remove_css_class("active"); + AppInput::MonitorAdded(name) => { + if !self.primary || name == self.monitor { + return; } - self.active_ws = id; - if let Some(btn) = self.button_map.get(&self.active_ws) { - btn.add_css_class("active"); + if self.satellites.iter().any(|(n, _)| n == &name) { + return; } + if let Some(ctrl) = spawn_satellite(&name) { + self.satellites.push((name, ctrl)); + } + } + AppInput::MonitorRemoved(name) => { + drop_satellite(&mut self.satellites, &name); } AppInput::ClockTick => { self.time_str = bar::clock::current(); - self.clock_lbl.set_label(&self.time_str); + flip_clock_digits(&self.clock_digits, &bar::clock::time()); + self.date_lbl.set_label(&bar::clock::date()); } AppInput::StatsUpdate(stats) => { self.cpu_lbl.set_label(&stats.cpu); self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); - // Bar information diet: CPU/RAM/power draw only surface once - // they're actually worth knowing about, individually, so a - // hot CPU doesn't drag an idle RAM/power reading along with it. - let cpu_hot = stats.cpu_pct > CPU_ATTENTION_THRESHOLD; - let mem_hot = stats.mem_pct > MEM_ATTENTION_THRESHOLD; - let pwr_hot = stats.power_watts > POWER_ATTENTION_THRESHOLD; - self.cpu_pair.set_visible(cpu_hot); - self.mem_pair.set_visible(mem_hot); - self.pwr_pair.set_visible(pwr_hot); - let any_hot = cpu_hot || mem_hot || pwr_hot; - self.system_stats_box.set_visible(any_hot); - self.system_sep.set_visible(any_hot); + // Island bar never shows the system-stats trio — they live + // in the control panel. Keep the widgets hidden so a later + // re-parent cannot accidentally flash them. + self.cpu_pair.set_visible(false); + self.mem_pair.set_visible(false); + self.pwr_pair.set_visible(false); + self.system_stats_box.set_visible(false); + self.system_sep.set_visible(false); - self.bat_lbl.set_label(&stats.bat); + tick_label(&self.vol_lbl, &stats.volume_pct.to_string()); + self.vol_lbl.set_tooltip_text(Some(&format!("volume {}%", stats.volume_pct))); + tick_label(&self.bat_lbl, &stats.bat); if let Some(tex) = self.bat_textures.get(&(stats.bat_icon.as_ptr() as usize)) { self.bat_img.set_paintable(Some(tex)); } - self.ac_img.set_visible(stats.ac_connected); + let bat_tip = if stats.ac_connected { + format!("{}% · charging", stats.bat) + } else { + format!("{}%", stats.bat) + }; + self.bat_img.set_tooltip_text(Some(&bat_tip)); + self.ac_img.set_visible(false); if let Some(tex) = self.bt_textures.get(&(stats.bt_icon.as_ptr() as usize)) { self.bt_img.set_paintable(Some(tex)); } @@ -822,41 +894,12 @@ impl SimpleComponent for App { .map(|s| s.internet && !s.captive_portal) .unwrap_or(true); let icon = if !internet_ok && stats.wifi_ssid != "—" { - bar::stats::WIFI_OFF + bar::stats::WIFI_ICON_OFF } else { stats.wifi_icon }; - if let Some(tex) = self.wifi_textures.get(&(icon.as_ptr() as usize)) { - self.wifi_img.set_paintable(Some(tex)); - } + self.wifi_img.set_icon_name(Some(icon)); - // Live-update control panel stats while open - if self.control_popover.is_visible() { - let cpu_str = match (stats.cpu_temp, stats.cpu.as_str()) { - (Some(t), pct) => format!("CPU {pct} {t:.0}°C"), - (None, pct) => format!("CPU {pct}"), - }; - self.panel_cpu_lbl.set_label(&cpu_str); - - self.panel_mem_lbl - .set_label(&format!("RAM {:.0}% {}", stats.mem_pct, stats.mem)); - self.panel_pwr_lbl - .set_label(&format!("PWR {}", stats.power)); - - let gpu_str = match (stats.gpu_usage, stats.gpu_temp) { - (Some(u), Some(t)) => format!("GPU {u}% {t:.0}°C"), - (Some(u), None) => format!("GPU {u}%"), - (None, Some(t)) => format!("GPU {t:.0}°C"), - (None, None) => "GPU —".to_string(), - }; - self.panel_gpu_lbl.set_label(&gpu_str); - - self.panel_net_lbl.set_label(&format!( - "↓ {} ↑ {}", - fmt_speed(stats.net_rx_kbs), - fmt_speed(stats.net_tx_kbs), - )); - } } AppInput::TrayUpdate(bar::tray::TrayUpdate::Add { id, icon, title }) => { if self.tray_items.contains_key(&id) { @@ -917,7 +960,14 @@ impl SimpleComponent for App { } else { asset!("Play.svg") }; - self.media_play_icon.set_paintable(Some(&svg_texture(icon_svg))); + self.media_play_icon + .set_paintable(Some(&svg_texture(icon_svg))); + prepare_icon(&self.media_play_icon, ICON_PX); + if state.playing { + self.media_widget.add_css_class("playing"); + } else { + self.media_widget.remove_css_class("playing"); + } if state.playing { self.media_paused_at = None; @@ -929,19 +979,20 @@ impl SimpleComponent for App { .media_paused_at .map_or(true, |t| t.elapsed().as_secs() < 30 * 60); self.media_last = Some(state); - self.media_widget.set_visible(within_linger); + reveal_media(&self.media_widget, within_linger); } else { // Player gone — honour linger from last pause + self.media_widget.remove_css_class("playing"); if let Some(paused_at) = self.media_paused_at { if paused_at.elapsed().as_secs() < 30 * 60 { - self.media_widget.set_visible(true); + reveal_media(&self.media_widget, true); } else { - self.media_widget.set_visible(false); + reveal_media(&self.media_widget, false); self.media_last = None; self.media_paused_at = None; } } else { - self.media_widget.set_visible(false); + reveal_media(&self.media_widget, false); self.media_last = None; } } @@ -952,36 +1003,18 @@ impl SimpleComponent for App { self.panel_vol_slider.set_value(data.volume); self.panel_bright_slider.set_value(data.brightness); self.panel_loading.set(false); - - // Rebuild sink dropdown — disconnect, repopulate, reconnect - if let Some(id) = self.panel_sink_signal.take() { - self.panel_sink_dropdown.disconnect(id); - } - // Clear store - let n = self.panel_sink_store.n_items(); - for i in (0..n).rev() { - self.panel_sink_store.remove(i); - } - for sink in &data.sinks { - self.panel_sink_store.append(&sink.description); - } - if let Some(idx) = data.sinks.iter().position(|s| s.is_default) { - self.panel_sink_dropdown.set_selected(idx as u32); - } - self.panel_sinks = data.sinks; - - let sinks = self.panel_sinks.clone(); - let id = self.panel_sink_dropdown.connect_selected_notify(move |dd| { - let idx = dd.selected() as usize; - if let Some(sink) = sinks.get(idx) { - bar::control::spawn_set_sink(sink.name.clone()); - } - }); - self.panel_sink_signal = Some(id); } AppInput::WidgetsUpdate(specs) => { self.reconcile_widgets(specs); } + AppInput::ReconcileMonitors => { + if self.primary { + self.reconcile_satellites(); + } + } + AppInput::DismissPanels => { + self.panels.hide_all(); + } } } } @@ -1030,34 +1063,84 @@ impl App { container.set_visible(container.first_child().is_some()); } - let has_tray_widgets = specs.iter().any(|s| { - s.visible && s.placement == bread_shared::widget::WidgetPlacement::Tray - }); + let has_tray_widgets = specs + .iter() + .any(|s| s.visible && s.placement == bread_shared::widget::WidgetPlacement::Tray); self.widget_tray_section.set_visible(has_tray_widgets); self.widget_tray_sep.set_visible(has_tray_widgets); } + fn reconcile_satellites(&mut self) { + let live: Vec = hypr_monitor_names() + .into_iter() + .filter(|n| n != &self.monitor) + .collect(); + let stale: Vec = self + .satellites + .iter() + .filter_map(|(n, _)| { + if live.contains(n) { + None + } else { + Some(n.clone()) + } + }) + .collect(); + for name in stale { + drop_satellite(&mut self.satellites, &name); + } + for name in live { + if self.satellites.iter().any(|(n, _)| n == &name) { + continue; + } + if let Some(ctrl) = spawn_satellite(&name) { + self.satellites.push((name, ctrl)); + } + } + } + fn apply_wifi_label(&self) { let label = match &self.wifi_profile { Some(p) => format!("{p} · {}", self.current_ssid), None => self.current_ssid.clone(), }; self.wifi_lbl.set_label(&label); + self.wifi_img.set_tooltip_text(Some(&label)); } - fn rebuild_buttons(&mut self) { + fn rebuild_buttons(&mut self, animate: bool) { + self.workspace_trail.cancel(); + let prev: std::collections::HashSet = + self.button_map.keys().copied().collect(); while let Some(child) = self.workspace_box.first_child() { self.workspace_box.remove(&child); } self.button_map.clear(); for ws in &self.workspaces { - let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws); + if ws.monitor != self.monitor { + continue; + } + // Persistent empty Hyprland workspaces stay off the bar unless + // this output is actually looking at them. + if ws.windows == 0 && ws.id != self.active_ws { + continue; + } + let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0); + if !prev.contains(&ws.id) { + play_once(&btn, "ws-in", 360); + } self.workspace_box.append(&btn); self.button_map.insert(ws.id, btn); } + match self.button_map.get(&self.active_ws).cloned() { + Some(btn) if animate => self.workspace_trail.stretch(None, &btn), + Some(btn) => self.workspace_trail.place(&btn), + None => self.workspace_trail.clear(), + } } fn rebuild_wifi_popover(&mut self, sender: &ComponentSender) { + let panels = self.panels.clone(); while let Some(child) = self.wifi_pane.first_child() { self.wifi_pane.remove(&child); } @@ -1089,7 +1172,11 @@ impl App { parts.push("internet ✗"); } if st.tailscale_required { - parts.push(if st.tailscale_ok { "tailscale ✓" } else { "tailscale ✗" }); + parts.push(if st.tailscale_ok { + "tailscale ✓" + } else { + "tailscale ✗" + }); } let status_lbl = gtk4::Label::new(Some(&parts.join(" "))); status_lbl.add_css_class("wifi-popover-status"); @@ -1101,59 +1188,36 @@ impl App { .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); } - let Some(data) = &self.wifi_popover_data else { + let nh = gtk4::Label::new(Some("NETWORKS")); + nh.add_css_class("wifi-popover-section"); + nh.set_xalign(0.0); + nh.set_margin_top(2); + nh.set_margin_bottom(4); + self.wifi_pane.append(&nh); + + let data = self.wifi_popover_data.as_ref(); + let scan_ready = data.map(|d| d.scan_ready).unwrap_or(false); + let scan = data.map(|d| d.scan.as_slice()).unwrap_or(&[]); + let profiles = data + .map(|d| unique_profiles(&d.profiles)) + .unwrap_or_default(); + + if !scan_ready { let lbl = gtk4::Label::new(Some("Scanning…")); lbl.add_css_class("wifi-popover-loading"); - self.wifi_pane.append(&lbl); - return; - }; - - let ph = gtk4::Label::new(Some("Profiles")); - ph.add_css_class("wifi-popover-section"); - ph.set_xalign(0.0); - ph.set_margin_top(6); - ph.set_margin_bottom(2); - self.wifi_pane.append(&ph); - - for (name, active) in &data.profiles { - let row = gtk4::Button::new(); - row.add_css_class("flat"); - row.add_css_class("wifi-popover-row"); - if *active { - row.add_css_class("wifi-popover-row-active"); - } - let lbl = gtk4::Label::new(Some(&format!( - "{}{}", - if *active { "● " } else { " " }, - name - ))); lbl.set_xalign(0.0); - row.set_child(Some(&lbl)); - - let name_clone = name.clone(); - let sender_clone = sender.clone(); - row.connect_clicked(move |btn| { - sender_clone.input(AppInput::SetProfile(name_clone.clone())); - bar::wifi::spawn_profile_set(name_clone.clone()); - close_parent_popover(btn); - }); - self.wifi_pane.append(&row); - } - - if !data.scan.is_empty() { - self.wifi_pane - .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - let nh = gtk4::Label::new(Some("Nearby")); - nh.add_css_class("wifi-popover-section"); - nh.set_xalign(0.0); - nh.set_margin_top(6); - nh.set_margin_bottom(2); - self.wifi_pane.append(&nh); - - for entry in &data.scan { + self.wifi_pane.append(&lbl); + } else if scan.is_empty() { + let lbl = gtk4::Label::new(Some("No networks found")); + lbl.add_css_class("wifi-popover-loading"); + lbl.set_xalign(0.0); + self.wifi_pane.append(&lbl); + } else { + for (i, entry) in scan.iter().enumerate() { let row = gtk4::Button::new(); row.add_css_class("flat"); row.add_css_class("wifi-popover-row"); + stagger_row(&row, i); if !entry.saved { row.add_css_class("wifi-popover-row-unsaved"); } @@ -1162,31 +1226,61 @@ impl App { row.add_css_class("wifi-popover-row-active"); } - let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - let icon_svg = wifi_icon_for_signal(entry.signal); - if let Some(tex) = self.wifi_textures.get(&(icon_svg.as_ptr() as usize)) { - let img = gtk4::Image::from_paintable(Some(tex)); - img.add_css_class("stat-icon"); - row_box.append(&img); - } - let lbl = gtk4::Label::new(Some(&format!( - "{}{}", - if is_current { "● " } else { " " }, - entry.ssid, - ))); + let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let img = gtk4::Image::from_icon_name(wifi_icon_for_signal(entry.signal)); + prepare_icon(&img, 18); + img.add_css_class("stat-icon"); + row_box.append(&img); + let lbl = gtk4::Label::new(Some(&entry.ssid)); lbl.set_xalign(0.0); + lbl.set_hexpand(true); + lbl.set_valign(gtk4::Align::Center); row_box.append(&lbl); row.set_child(Some(&row_box)); let ssid_clone = entry.ssid.clone(); let saved = entry.saved; + let panels = panels.clone(); row.connect_clicked(move |btn| { if saved { bar::wifi::spawn_join(ssid_clone.clone()); } else { show_add_network_dialog(btn, ssid_clone.clone(), |_| {}); } - close_parent_popover(btn); + panels.hide_all(); + }); + self.wifi_pane.append(&row); + } + } + + if !profiles.is_empty() { + let ph = gtk4::Label::new(Some("PROFILES")); + ph.add_css_class("wifi-popover-section"); + ph.set_xalign(0.0); + ph.set_margin_top(10); + ph.set_margin_bottom(4); + self.wifi_pane.append(&ph); + + for (i, (name, active)) in profiles.into_iter().enumerate() { + let row = gtk4::Button::new(); + row.add_css_class("flat"); + row.add_css_class("wifi-popover-row"); + stagger_row(&row, i); + if active { + row.add_css_class("wifi-popover-row-active"); + } + let lbl = gtk4::Label::new(Some(&name)); + lbl.set_xalign(0.0); + lbl.set_valign(gtk4::Align::Center); + row.set_child(Some(&lbl)); + + let name_clone = name.clone(); + let sender_clone = sender.clone(); + let panels = panels.clone(); + row.connect_clicked(move |_| { + sender_clone.input(AppInput::SetProfile(name_clone.clone())); + bar::wifi::spawn_profile_set(name_clone.clone()); + panels.hide_all(); }); self.wifi_pane.append(&row); } @@ -1213,6 +1307,7 @@ impl App { toggle_lbl.set_hexpand(true); toggle_lbl.set_xalign(0.0); let toggle_switch = gtk4::Switch::new(); + toggle_switch.add_css_class("bt-switch"); toggle_switch.set_active(data.powered); toggle_switch.set_valign(gtk4::Align::Center); toggle_switch.connect_state_set(|_, on| { @@ -1234,26 +1329,24 @@ impl App { lbl.add_css_class("wifi-popover-loading"); self.bt_pane.append(&lbl); } else { - let dh = gtk4::Label::new(Some("Paired")); + let dh = gtk4::Label::new(Some("PAIRED")); dh.add_css_class("wifi-popover-section"); dh.set_xalign(0.0); dh.set_margin_top(2); - dh.set_margin_bottom(2); + dh.set_margin_bottom(4); self.bt_pane.append(&dh); - for dev in &data.devices { + for (i, dev) in data.devices.iter().enumerate() { let row = gtk4::Button::new(); row.add_css_class("flat"); row.add_css_class("wifi-popover-row"); + stagger_row(&row, i); if dev.connected { row.add_css_class("wifi-popover-row-active"); } - let lbl = gtk4::Label::new(Some(&format!( - "{}{}", - if dev.connected { "● " } else { " " }, - dev.name, - ))); + let lbl = gtk4::Label::new(Some(&dev.name)); lbl.set_xalign(0.0); + lbl.set_valign(gtk4::Align::Center); row.set_child(Some(&lbl)); let address = dev.address.clone(); @@ -1275,7 +1368,7 @@ impl App { settings_row.add_css_class("flat"); settings_row.add_css_class("wifi-popover-row"); let settings_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - let settings_icon = gtk4::Image::from_paintable(Some(&svg_texture(bar::stats::ICON_BT_SETTINGS))); + let settings_icon = svg_image(bar::stats::ICON_BT_SETTINGS); settings_icon.add_css_class("stat-icon"); settings_box.append(&settings_icon); settings_box.append(>k4::Label::new(Some("Bluetooth settings"))); @@ -1291,65 +1384,60 @@ impl App { // ── Helpers ─────────────────────────────────────────────────────────────────── -fn build_slider_row(icon_svg: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { +fn build_slider_row(label: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); row.add_css_class("control-panel-row"); - row.set_margin_top(2); - row.set_margin_bottom(2); - // Rendered larger than the standard 16px stat/section icons: these are - // the two things in the panel you actually drag, so they should read as - // primary controls rather than blend in with passive readouts. - let icon = gtk4::Image::from_paintable(Some(&svg_texture_sized(icon_svg, 20))); - icon.add_css_class("control-panel-row-icon"); + let lbl = gtk4::Label::new(Some(label)); + lbl.add_css_class("control-panel-row-label"); + lbl.set_xalign(0.0); + lbl.set_width_chars(3); let slider = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, min, max, step); slider.set_draw_value(false); slider.set_hexpand(true); - slider.set_width_request(180); + slider.set_width_request(160); slider.add_css_class("control-panel-slider"); - row.append(&icon); + row.append(&lbl); row.append(&slider); (row, slider) } -fn fmt_speed(kbs: f32) -> String { - if kbs >= 1024.0 { - format!("{:.1} MB/s", kbs / 1024.0) - } else { - format!("{:.0} KB/s", kbs) - } -} - fn wifi_icon_for_signal(pct: u8) -> &'static str { - use bar::stats::{WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG, WIFI_WEAK}; + use bar::stats::{ + WIFI_ICON_EXCELLENT, WIFI_ICON_GOOD, WIFI_ICON_OFF, WIFI_ICON_OK, WIFI_ICON_WEAK, + }; match pct { - 75..=100 => WIFI_STRONG, - 50..=74 => WIFI_MEDIUM, - 25..=49 => WIFI_WEAK, - _ => WIFI_OFF, + 75..=100 => WIFI_ICON_EXCELLENT, + 50..=74 => WIFI_ICON_GOOD, + 25..=49 => WIFI_ICON_OK, + 1..=24 => WIFI_ICON_WEAK, + _ => WIFI_ICON_OFF, } } -fn close_parent_popover(widget: >k4::Button) { - if let Some(w) = widget.ancestor(gtk4::Popover::static_type()) { - if let Ok(p) = w.downcast::() { - p.popdown(); - } - } -} + /// Small modal prompting for a password, then saves + joins the network via /// `breadcrumbs add` + `breadcrumbs join`. `on_build` runs on the freshly /// built dialog *before* it's presented — screenshot mode's only hook point, /// since `connect_map` registered any later would miss a map that already /// happened. The real call site passes a no-op. -fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_build: impl FnOnce(>k4::Window)) { +fn show_add_network_dialog( + anchor: &impl IsA, + ssid: String, + on_build: impl FnOnce(>k4::Window), +) { let dialog = gtk4::Window::new(); dialog.set_title(Some(&format!("Add “{ssid}”"))); dialog.set_resizable(false); dialog.add_css_class("wifi-add-dialog"); + if let Some(output) = bread_theme::gtk::output_for_widget(anchor) { + theme::bind_output(&dialog, &output); + } else { + theme::bind_auto(&dialog); + } // A bare gtk4::Window with no titlebar set falls back to GTK's own // minimal CSD: a flat bar with plain system-font title text and no // rounding — which is what actually made this look like a stray window @@ -1433,33 +1521,180 @@ fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_bui fn stat_pair(icon_svg: &str, label: >k4::Label) -> gtk4::Box { let pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); pair.add_css_class("stat-pair"); - let img = gtk4::Image::from_paintable(Some(&svg_texture(icon_svg))); + bar_chip(&pair); + let img = svg_image(icon_svg); img.add_css_class("stat-icon"); pair.append(&img); pair.append(label); pair } -pub(crate) fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { - svg_texture_sized(svg_src, 16) +/// Identity of the pills this bar actually draws. Ignores last-window +/// title/address so a tab title change cannot rebuild the row. +fn visible_ws_rows( + workspaces: &[Workspace], + monitor: &str, + active: WorkspaceId, +) -> Vec<(WorkspaceId, String, bool)> { + workspaces + .iter() + .filter(|w| w.monitor == monitor && (w.windows > 0 || w.id == active)) + .map(|w| (w.id, w.name.clone(), w.windows > 0)) + .collect() } -/// Same as `svg_texture` but rendered at an explicit pixel size — used to give -/// primary/interactive icons (e.g. sliders you actually drag) more visual -/// weight than passive informational ones, which otherwise all read as the -/// same flat 16px stroke glyph once emoji stopped providing accidental variety. +fn bar_chip(widget: &impl IsA) { + widget.set_valign(gtk4::Align::Center); + widget.set_vexpand(false); +} + +fn popover_caret() -> gtk4::Box { + let caret = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + caret.add_css_class("popover-caret"); + caret.set_hexpand(true); + caret +} + +fn stagger_row(widget: &impl IsA, i: usize) { + widget.add_css_class("row-in"); + widget.add_css_class(&format!("stagger-{}", i.min(11))); +} + +fn play_once(widget: &impl IsA, class: &str, ms: u64) { + widget.remove_css_class(class); + widget.add_css_class(class); + let w = widget.as_ref().clone(); + let class = class.to_string(); + gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(ms), move || { + w.remove_css_class(&class); + }); +} + +fn make_clock_digits(time: &str) -> Vec { + time.chars() + .map(|ch| { + let lbl = gtk4::Label::new(Some(&ch.to_string())); + lbl.add_css_class("clock-digit"); + if ch == ':' { + lbl.add_css_class("clock-colon"); + } + lbl.set_valign(gtk4::Align::Center); + lbl.set_vexpand(false); + lbl.set_yalign(0.5); + lbl + }) + .collect() +} + +fn flip_clock_digits(digits: &[gtk4::Label], time: &str) { + let chars: Vec = time.chars().collect(); + for (i, lbl) in digits.iter().enumerate() { + let next = chars.get(i).copied().unwrap_or(' '); + let next_s = next.to_string(); + if lbl.label().as_str() == next_s { + continue; + } + lbl.set_label(&next_s); + if next != ':' { + play_once(lbl, "flip", 450); + } + } +} + +fn tick_label(lbl: >k4::Label, text: &str) { + if lbl.label().as_str() == text { + return; + } + lbl.set_label(text); + play_once(lbl, "tick", 360); +} + +fn reveal_media(widget: >k4::Box, show: bool) { + if show && !widget.is_visible() { + play_once(widget, "media-in", 420); + } + widget.set_visible(show); +} + +fn popover_tab(label: &str) -> gtk4::ToggleButton { + let btn = gtk4::ToggleButton::with_label(label); + btn.add_css_class("popover-tab"); + btn.set_hexpand(true); + btn.set_valign(gtk4::Align::Center); + btn.set_vexpand(false); + btn.set_size_request(-1, CHIP_HEIGHT); + if let Some(child) = btn.child() { + child.set_halign(gtk4::Align::Center); + child.set_valign(gtk4::Align::Center); + child.set_hexpand(true); + child.set_vexpand(false); + if let Ok(lbl) = child.downcast::() { + lbl.set_xalign(0.5); + lbl.set_yalign(0.5); + } + } + btn +} + +/// breadcrumbs can list the same profile twice under different case +/// (`Home` / `home`). Keep the active spelling when there is one. +fn unique_profiles(profiles: &[(String, bool)]) -> Vec<(String, bool)> { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for (name, active) in profiles { + if *active { + seen.insert(name.to_ascii_lowercase()); + out.push((name.clone(), true)); + } + } + for (name, active) in profiles { + if seen.insert(name.to_ascii_lowercase()) { + out.push((name.clone(), *active)); + } + } + out +} + +pub(crate) fn prepare_icon(img: >k4::Image, px: i32) { + img.set_pixel_size(px); + img.set_valign(gtk4::Align::Center); + img.set_vexpand(false); +} + +pub(crate) fn svg_image(svg_src: &str) -> gtk4::Image { + svg_image_sized(svg_src, ICON_PX as u32) +} + +pub(crate) fn svg_image_sized(svg_src: &str, px: u32) -> gtk4::Image { + let img = gtk4::Image::from_paintable(Some(&svg_texture_sized(svg_src, px))); + prepare_icon(&img, px as i32); + img +} + +pub(crate) fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { + svg_texture_sized(svg_src, ICON_PX as u32) +} + +/// Rasterise at 2× the display size so Lucide strokes stay sharp when GTK +/// displays the texture at `px` via `Image::set_pixel_size`. pub(crate) fn svg_texture_sized(svg_src: &str, px: u32) -> gtk4::gdk::Texture { use resvg::{tiny_skia, usvg}; + let raster = px.saturating_mul(2).max(1); let fg = theme::fg_color(); - let dim = format!(r#"width="{px}" height="{px}""#); + let dim = format!(r#"width="{raster}" height="{raster}""#); let svg = svg_src .replace("currentColor", &fg) + .replace(r#"stroke-width="2""#, r#"stroke-width="2.35""#) .replace(r#"width="24" height="24""#, &dim); let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("parse svg"); let size = tree.size().to_int_size(); let (w, h) = (size.width(), size.height()); let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap"); - resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut()); + resvg::render( + &tree, + tiny_skia::Transform::identity(), + &mut pixmap.as_mut(), + ); let bytes = gtk4::glib::Bytes::from_owned(pixmap.take()); gtk4::gdk::MemoryTexture::new( w as i32, @@ -1471,6 +1706,8 @@ pub(crate) fn svg_texture_sized(svg_src: &str, px: u32) -> gtk4::gdk::Texture { .upcast() } + + fn stat_label() -> gtk4::Label { let lbl = gtk4::Label::new(None); lbl.add_css_class("stat-label"); @@ -1515,5 +1752,138 @@ fn main() { if screenshot_req.is_some() { app.allow_multiple_instances(true); } - app.run::(screenshot_req); + app.run::(BarInit { + screenshot: screenshot_req, + monitor: None, + primary: true, + }); +} + +/// Live outputs only. `hyprctl monitors all` (and the hyprland crate's +/// `Monitors::get`) keep ghost connectors after a rename — `DVI-I-1` stayed +/// at 0×0 with `disabled=false` after the panel became `DVI-I-2`, and a +/// geometry fallback then stacked a second bar on the laptop. +#[derive(Debug, Clone, serde::Deserialize)] +struct HyprMon { + name: String, + x: i32, + y: i32, + #[serde(default)] + focused: bool, + #[serde(default)] + disabled: bool, +} + +fn hypr_monitors_live() -> Vec { + let output = match std::process::Command::new("hyprctl") + .args(["monitors", "-j"]) + .output() + { + Ok(o) if o.status.success() => o.stdout, + _ => return Vec::new(), + }; + serde_json::from_slice::>(&output) + .unwrap_or_default() + .into_iter() + .filter(|m| !m.disabled) + .collect() +} + +fn primary_hypr_monitor() -> Option { + let mons = hypr_monitors_live(); + mons.iter() + .find(|m| m.focused) + .or_else(|| mons.first()) + .map(|m| m.name.clone()) +} + +fn hypr_monitor_names() -> Vec { + hypr_monitors_live() + .into_iter() + .map(|m| m.name) + .collect() +} + +fn hypr_monitor_origin(name: &str) -> Option<(i32, i32)> { + hypr_monitors_live() + .into_iter() + .find(|m| m.name == name) + .map(|m| (m.x, m.y)) +} + +/// Hyprland connector names and GDK connector names can disagree after a +/// hotplug (`DVI-I-1` vs `DVI-I-2`). Match the connector first, then the +/// output's origin — transform swaps width/height so size is not reliable. +/// Never steal a GDK output whose connector is already a live Hyprland name. +fn gdk_monitor_for_hypr(name: &str) -> Option { + use gtk4::gdk::prelude::MonitorExt; + use gtk4::gio::prelude::ListModelExt; + let display = gtk4::gdk::Display::default()?; + let list = display.monitors(); + for i in 0..list.n_items() { + let Some(mon) = list.item(i).and_downcast::() else { + continue; + }; + if mon.connector().as_deref() == Some(name) { + return Some(mon); + } + } + let (hx, hy) = hypr_monitor_origin(name)?; + let live = hypr_monitor_names(); + for i in 0..list.n_items() { + let Some(mon) = list.item(i).and_downcast::() else { + continue; + }; + let g = mon.geometry(); + if g.x() != hx || g.y() != hy { + continue; + } + if let Some(conn) = mon.connector() { + if live.iter().any(|n| n != name && n == conn.as_str()) { + return None; + } + } + return Some(mon); + } + None +} + +pub(crate) fn bind_layer_monitor(window: &impl LayerShell, name: &str) -> bool { + match gdk_monitor_for_hypr(name) { + Some(mon) => { + window.set_monitor(Some(&mon)); + true + } + None => { + eprintln!("breadbar: no GDK monitor for {name}"); + false + } + } +} + +fn spawn_satellite(name: &str) -> Option> { + if gdk_monitor_for_hypr(name).is_none() { + eprintln!("breadbar: skip bar on {name}: no matching GDK output"); + return None; + } + let ctrl = App::builder() + .launch(BarInit { + screenshot: None, + monitor: Some(name.to_string()), + primary: false, + }) + .detach(); + ctrl.widget().present(); + Some(ctrl) +} + +fn drop_satellite(satellites: &mut Vec<(String, Controller)>, name: &str) { + satellites.retain(|(n, ctrl)| { + if n == name { + ctrl.widget().set_visible(false); + false + } else { + true + } + }); } diff --git a/src/notifications/history.rs b/src/notifications/history.rs index 0e158cc..61adc0f 100644 --- a/src/notifications/history.rs +++ b/src/notifications/history.rs @@ -175,13 +175,15 @@ pub fn build_window(store: Store) -> Ui { let window = gtk4::Window::new(); window.add_css_class("breadbar-history"); window.init_layer_shell(); + window.set_namespace(Some("breadbar-notif")); window.set_layer(Layer::Overlay); window.set_anchor(Edge::Top, true); window.set_anchor(Edge::Right, true); - window.set_margin(Edge::Top, 48); - window.set_margin(Edge::Right, 20); + window.set_margin(Edge::Top, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8); + window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES); window.set_default_width(360); window.set_keyboard_mode(KeyboardMode::OnDemand); + crate::theme::bind_auto(&window); let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 8); outer.set_margin_top(10); @@ -284,8 +286,8 @@ fn make_row(entry: &Entry) -> gtk4::Box { } let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - let show_app = !entry.app_name.is_empty() - && !entry.app_name.eq_ignore_ascii_case(&entry.summary); + let show_app = + !entry.app_name.is_empty() && !entry.app_name.eq_ignore_ascii_case(&entry.summary); if show_app { let app = gtk4::Label::new(Some(&entry.app_name)); app.add_css_class("notification-app"); diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index f221524..5753b65 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -170,15 +170,17 @@ fn create_window() -> gtk4::Window { let window = gtk4::Window::new(); window.add_css_class("breadbar-notification"); window.init_layer_shell(); + window.set_namespace(Some("breadbar-notif")); window.set_layer(Layer::Overlay); window.set_anchor(Edge::Top, true); window.set_anchor(Edge::Right, true); - window.set_margin(Edge::Top, 20); - window.set_margin(Edge::Right, 20); + window.set_margin(Edge::Top, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8); + window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES); window.set_default_width(320); // OnDemand so an inline-reply GtkEntry can take keys without the popup // stealing every keystroke the rest of the time. window.set_keyboard_mode(KeyboardMode::OnDemand); + crate::theme::bind_auto(&window); window } diff --git a/src/osd.rs b/src/osd.rs index 5deb1d4..d438292 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -163,9 +163,7 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver) { container.set_margin_end(14); window.set_child(Some(&container)); - let icon = gtk4::Image::from_paintable(Some(&crate::svg_texture( - crate::bar::stats::ICON_VOLUME, - ))); + let icon = crate::svg_image(crate::bar::stats::ICON_VOLUME); icon.add_css_class("osd-icon"); container.append(&icon); @@ -184,6 +182,7 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver) { }; icon.set_paintable(Some(&crate::svg_texture(icon_svg))); + crate::prepare_icon(&icon, crate::ICON_PX); if muted { icon.add_css_class("osd-icon-muted"); } else { @@ -209,9 +208,11 @@ fn create_window() -> gtk4::Window { let window = gtk4::Window::new(); window.add_css_class("breadbar-osd"); window.init_layer_shell(); + window.set_namespace(Some("breadbar-osd")); window.set_layer(Layer::Overlay); window.set_anchor(Edge::Bottom, true); window.set_margin(Edge::Bottom, 80); window.set_default_width(180); + crate::theme::bind_auto(&window); window } diff --git a/src/screenshot.rs b/src/screenshot.rs index d3848eb..05e9af6 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -20,11 +20,9 @@ use gtk4::prelude::*; use std::path::PathBuf; use std::time::Duration; -/// Settle time for views whose content depends on `bar::stats::spawn_poller`'s -/// 2-second background loop (control-panel's CPU/RAM/PWR/GPU/network labels, -/// gated on popover visibility) or a similar live-data popover load -/// (connectivity's wifi/bluetooth scan) — capturing any sooner leaves -/// placeholder dashes/"Scanning…" instead of real content. +/// Settle time for views whose content depends on a live-data popover load +/// (connectivity's wifi/bluetooth scan, control-panel sliders) — capturing +/// any sooner leaves placeholder dashes/"Scanning…" instead of real content. const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200); /// Delay between the bar's own `map` and calling `popover.popup()`. Calling @@ -107,11 +105,11 @@ impl Cli { /// outlive `init()` (never stored on `App`), so they have to be cloned out /// before dispatch time same as `control_popover` always was. pub struct Handles { - pub control_popover: gtk4::Popover, - pub connectivity_popover: gtk4::Popover, + pub control_panel: gtk4::Window, + pub connectivity_panel: gtk4::Window, pub wifi_tab_btn: gtk4::ToggleButton, pub bt_tab_btn: gtk4::ToggleButton, - pub media_popover: gtk4::Popover, + pub media_panel: gtk4::Window, pub media_widget: gtk4::Box, pub media_track_lbl: gtk4::Label, /// Already built and primed with sample content by `main.rs` (via @@ -122,10 +120,10 @@ pub struct Handles { pub osd_window: Option, } -/// The bar's fixed height — matches `root.set_exclusive_zone(32)` / -/// `set_default_height: 32` in `main.rs`. Unlike the other views' full -/// canvas, this never varies with `--width`/`--height`. -const BAR_HEIGHT: i32 = 32; +/// Capture height for the `bar` view: layer-shell top margin + widget +/// height (the exclusive zone). Unlike the other views' full canvas, +/// this never varies with `--width`/`--height`. +const BAR_HEIGHT: i32 = crate::BAR_HEIGHT + crate::BAR_MARGIN_TOP; pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) { let output = req.output; @@ -141,15 +139,15 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: }); } "control-panel" => { - open_popover_on_root_map(root, handles.control_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.control_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); } "connectivity-wifi" => { handles.wifi_tab_btn.set_active(true); - open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); } "connectivity-bluetooth" => { handles.bt_tab_btn.set_active(true); - open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height); } "media-popover" => { // Real media state only shows the widget/text when something's @@ -157,8 +155,9 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: // run has nothing playing, so fake enough of it directly on the // widgets to get a representative capture. handles.media_widget.set_visible(true); + handles.media_widget.add_css_class("playing"); handles.media_track_lbl.set_text("Sample Track — Sample Artist"); - open_popover_on_root_map(root, handles.media_popover, SETTLE_DELAY, output, width, height); + open_panel_on_root_map(root, handles.media_panel, SETTLE_DELAY, output, width, height); } "notification" | "notification-critical" => { let Some(window) = handles.notification_window else { @@ -196,27 +195,25 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: } } -/// Shared shape for every popover view: force it open shortly after the bar -/// maps (autohide disabled — a programmatic `popup()` has no real input -/// event serial to grab the Wayland seat with), then capture the whole -/// canvas after `settle` once the popover itself maps. -fn open_popover_on_root_map( +/// Shared shape for panel views: present the standalone layer window after +/// the bar maps, then capture the canvas once the panel itself maps. +fn open_panel_on_root_map( root: >k4::ApplicationWindow, - popover: gtk4::Popover, + panel: gtk4::Window, settle: Duration, output: PathBuf, width: i32, height: i32, ) { - let popover_to_open = popover.clone(); + let panel_to_open = panel.clone(); root.connect_map(move |_| { - popover_to_open.set_autohide(false); - let popover_to_open = popover_to_open.clone(); + let panel_to_open = panel_to_open.clone(); gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { - popover_to_open.popup(); + panel_to_open.set_visible(true); + panel_to_open.present(); }); }); - popover.connect_map(move |_| { + panel.connect_map(move |_| { let output = output.clone(); gtk4::glib::timeout_add_local_once(settle, move || { finish(bread_screenshots::capture_region(0, 0, width, height, &output)); diff --git a/src/theme.rs b/src/theme.rs index 11820bf..50f34f8 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,4 +1,5 @@ -use bread_theme::{gtk as bgtk, hex_to_rgba, ink_on, load_palette}; +use bread_theme::{gtk as bgtk, ink_on, load_palette, load_palette_for, Palette}; +use gtk4::prelude::IsA; use gtk4::CssProvider; use std::cell::RefCell; @@ -7,7 +8,6 @@ thread_local! { } fn load_css() -> String { - let p = load_palette(); // breadbar-specific rules only — fonts, base colours, and generic widgets // come from the shared ecosystem stylesheet (applied first in `apply()`). // Colour is set on each surface (bar, active workspace pill, notification @@ -15,37 +15,78 @@ fn load_css() -> String { // pywal hands a given slot. `on_*` are luminance-picked ink (black/white) for // that background — the pywal hues themselves are untouched. // - // Shared tokens: one radius and one padding rhythm reused across every - // popover/card/OSD surface so they read as one design system rather than - // four different ones. `radius_pill` is only for the tiny transient OSD. - let radius = "10px"; - let radius_sm = "0px"; - let radius_pill = "20px"; - let pad = "10px"; + // Glass workbench: 16px island on the bar, 12px cards/popovers, pill OSD. + // Hyprland `layerrule = blur, breadbar` frosts the translucent fills — + // the CSS just leaves alpha. Colours are bread-theme tokens so pywal + // accents (`@accent`) flow through on SIGHUP / `bread-theme reload`. + let radius = "12px"; + let radius_bar = "16px"; + let radius_sm = "9px"; + let radius_pill = "999px"; + let pad = "12px"; format!( - "window.breadbar {{ background-color: {bg_rgba}; color: {on_bg}; border-radius: 0; }}\ - .workspace-btn {{ background: transparent; opacity: 0.45; color: {on_bg};\ - border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\ - min-width: 20px; margin: 5px 2px; padding: 2px 9px; }}\ - .workspace-btn:hover {{ opacity: 0.8; }}\ - .workspace-btn.active {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\ - .stats-box {{ margin-right: 8px; }}\ - .stat-pair {{ margin-right: 14px; }}\ - .stat-icon {{ margin-right: 2px; }}\ - .bt-icon {{ margin-right: 14px; }}\ - separator.bar-sep {{ min-height: 14px; margin: 0 8px 0 0; background: alpha({on_bg}, 0.14); }}\ - window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; }}\ - window.breadbar-history {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg};\ - border-radius: {radius}; }}\ - .notification-card {{ background: {surface}; color: {on_surface}; border-radius: {radius};\ - padding: {pad}; margin-bottom: 8px; border-left: 3px solid transparent; }}\ - .notification-card.urgency-critical {{ border-left-color: {critical}; }}\ - .notification-card.urgency-normal {{ border-left-color: {accent}; }}\ + "@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\ + @keyframes osd-in {{ from {{ opacity: 0; margin-bottom: -8px; }} }}\ + @keyframes media-eq {{ to {{ min-height: 14px; }} }}\ + @keyframes pop-in {{ from {{ opacity: 0; margin-top: -10px; }} to {{ opacity: 1; margin-top: 0; }} }}\ + @keyframes pop-out {{ from {{ opacity: 1; margin-top: 0; }} to {{ opacity: 0; margin-top: -6px; }} }}\ + @keyframes row-in {{ from {{ opacity: 0; margin-top: 8px; }} to {{ opacity: 1; margin-top: 0; }} }}\ + @keyframes digit-flip {{ from {{ opacity: 0; margin-top: 7px; }} to {{ opacity: 1; margin-top: 0; }} }}\ + @keyframes caret-draw {{ from {{ margin-right: 200px; opacity: 0.2; }} to {{ margin-right: 4px; opacity: 1; }} }}\ + window.breadbar {{ background-color: alpha(@bg, 0.72); color: @on-bg;\ + border-radius: {radius_bar}; border: 1px solid alpha(@on-bg, 0.08); }}\ + window.breadbar > centerbox {{ padding: 0 8px 0 6px; }}\ + window.breadbar button {{ min-height: 0; min-width: 0; }}\ + .workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\ + background-color: @accent; border-radius: 999px; }}\ + .workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\ + border-radius: 999px; border: none; outline: none; box-shadow: none;\ + min-width: 32px; min-height: 32px; margin: 0 2px; padding: 0 12px;\ + font-size: 22px; font-weight: bold;\ + transition: opacity 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\ + background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + .workspace-btn:hover {{ opacity: 0.85; background: alpha(@on-bg, 0.08); }}\ + .workspace-btn.occupied {{ opacity: 0.78; }}\ + .workspace-btn.active {{ background: transparent; color: @on-accent; opacity: 1; }}\ + .workspace-btn.active:hover {{ background: transparent; }}\ + .workspace-btn.ws-in {{ animation: row-in 0.32s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\ + .clock-box {{ padding: 0 4px; }}\ + .clock-label {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\ + min-height: 0; padding: 0; margin-top: 3px; }}\ + .clock-digit {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\ + min-width: 15px; min-height: 0; padding: 0; margin: 0; }}\ + .clock-colon {{ min-width: 10px; opacity: 0.7; }}\ + .clock-digit.flip {{ animation: digit-flip 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .date-label {{ font-size: 14px; opacity: 0.52; letter-spacing: 0.04em; }}\ + .stat-label {{ font-size: 14px; letter-spacing: 0.02em; opacity: 0.92; }}\ + .stat-label.tick {{ animation: digit-flip 0.35s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .stats-box {{ margin-right: 0; }}\ + .stat-pair {{ margin: 0; border-radius: 10px; padding: 5px 9px; min-height: 0;\ + transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\ + opacity 0.18s ease; }}\ + .stat-pair:hover {{ background: alpha(@on-bg, 0.12); }}\ + .stat-pair:active {{ background: alpha(@on-bg, 0.18); }}\ + .stat-pair.icon-only {{ padding: 6px; border-radius: 999px; }}\ + .stat-icon {{ margin-right: 6px; }}\ + .stat-pair.icon-only .stat-icon {{ margin-right: 0; }}\ + .bt-icon {{ margin-right: 8px; }} + separator.bar-sep {{ min-height: 12px; min-width: 1px; margin: 0 10px 0 2px;\ + background: alpha(@on-bg, 0.10); }}\ + window.breadbar-notification {{ background-color: transparent; color: @on-bg; }}\ + window.breadbar-history {{ background-color: alpha(@bg, 0.70); color: @on-bg;\ + border-radius: {radius}; border: 1px solid alpha(@on-bg, 0.10);\ + animation: pop-in 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .notification-card {{ background: alpha(@bg, 0.70); color: @on-bg; border-radius: {radius};\ + padding: {pad}; margin-bottom: 8px; border: 1px solid alpha(@on-bg, 0.10);\ + border-left: 3px solid transparent;\ + animation: notif-in 0.45s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\ + .notification-card.urgency-critical {{ border-left-color: @red; }}\ + .notification-card.urgency-normal {{ border-left-color: @accent; }}\ .notification-summary {{ font-weight: bold; }}\ - .notification-app {{ opacity: 0.6; }}\ + .notification-app {{ opacity: 0.55; font-size: 11px; letter-spacing: 0.04em; }}\ .notification-actions {{ margin-top: 6px; }}\ - .notification-action {{ padding: 2px 8px; font-size: 11px; }}\ + .notification-action {{ padding: 2px 8px; font-size: 11px; border-radius: {radius_sm}; }}\ .notification-reply {{ margin-top: 6px; }}\ .notification-reply-entry {{ min-width: 0; }}\ .history-title {{ font-weight: bold; font-size: 13px; }}\ @@ -54,59 +95,126 @@ fn load_css() -> String { .history-time {{ opacity: 0.5; font-size: 11px; }}\ .history-body {{ opacity: 0.75; }}\ .history-card {{ margin-bottom: 6px; }}\ - window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: {radius_pill}; }}\ + window.breadbar-osd {{ background-color: alpha(@bg, 0.70); color: @on-bg;\ + border-radius: {radius_pill}; border: 1px solid alpha(@on-bg, 0.10);\ + animation: osd-in 0.4s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\ .osd-icon {{ opacity: 0.85; margin-right: 8px; }}\ .osd-icon-muted {{ opacity: 0.35; }}\ progressbar.osd-bar {{ min-height: 6px; }}\ - progressbar.osd-bar trough {{ background-image: none; background-color: {trough}; border-radius: 3px; min-height: 6px; }}\ - progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 3px; min-height: 6px; }}\ - .wifi-pair {{ border-radius: {radius_sm}; padding: 0 2px; }}\ - .wifi-pair:hover {{ background: alpha({on_bg}, 0.12); }}\ - .wifi-popover-inner {{ min-width: 200px; padding: {pad}; }}\ - .popover-tab-row {{ margin-bottom: {pad}; }}\ - .popover-tab {{ background: transparent; color: {on_bg}; border: none; box-shadow: none;\ - outline: none; border-radius: {radius_sm}; padding: 4px 10px; font-size: 11px;\ - font-weight: bold; opacity: 0.55; }}\ + progressbar.osd-bar trough {{ background-image: none; background-color: alpha(@accent, 0.25);\ + border-radius: 3px; min-height: 6px; }}\ + progressbar.osd-bar trough progress {{ background-image: none; background-color: @accent;\ + border-radius: 3px; min-height: 6px; }}\ + .wifi-pair {{ padding: 6px; }}\ + window.breadbar-panel {{ background-color: alpha(@bg, 0.72); color: @on-bg;\ + border-radius: 14px; border: 1px solid alpha(@on-bg, 0.12); }}\ + window.breadbar-dismiss, .breadbar-dismiss-hit {{\ + background-color: alpha(#000000, 0.02); }}\ + .popover-caret {{ min-height: 2px; margin: 2px 4px 10px; border-radius: 2px;\ + background-color: @accent;\ + background-image: linear-gradient(90deg, @accent, @teal);\ + animation: caret-draw 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .wifi-popover-inner {{ min-width: 228px; padding: {pad}; }}\ + window.wifi-popover button {{ min-height: 0; min-width: 0; }}\ + .popover-tab-row {{ background: alpha(@on-bg, 0.06); border-radius: 10px;\ + padding: 3px; margin-bottom: 10px; }}\ + .popover-tab {{ background: transparent; color: @on-bg; border: none; box-shadow: none;\ + outline: none; border-radius: 999px; padding: 0 14px; min-height: 32px;\ + font-size: 17px; font-weight: bold; opacity: 0.55;\ + transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\ + opacity 0.22s ease, color 0.22s ease; }}\ .popover-tab:hover {{ opacity: 0.8; }}\ - .popover-tab:checked {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\ - .wifi-popover-ssid {{ font-weight: bold; font-size: 13px; }}\ - .wifi-popover-ip {{ opacity: 0.6; font-size: 11px; }}\ - .wifi-popover-status {{ font-size: 11px; margin-top: 2px; }}\ - .wifi-popover-section {{ font-size: 10px; font-weight: bold; opacity: 0.5; letter-spacing: 0.08em; }}\ + .popover-tab:checked {{ background: alpha(@accent, 0.22); color: @accent; opacity: 1; }}\ + .popover-tab label {{ padding: 0; margin: 0; }}\ + .wifi-popover-ssid {{ font-weight: bold; font-size: 18px; }}\ + .wifi-popover-ip {{ opacity: 0.6; font-size: 16px; }}\ + .wifi-popover-status {{ font-size: 16px; margin-top: 2px; }}\ + .wifi-popover-section {{ font-size: 13px; font-weight: bold; opacity: 0.45;\ + letter-spacing: 0.12em; }}\ .wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\ - border-radius: {radius_sm}; padding: 4px 6px; }}\ - .wifi-popover-row:hover {{ background: alpha({on_bg}, 0.08); }}\ - .wifi-popover-row-active {{ color: {accent}; }}\ + outline: none; border-radius: 10px; padding: 0 12px; min-height: 42px;\ + transition: background-color 0.18s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + .wifi-popover-row label {{ font-size: 18px; }}\ + .wifi-popover-row:hover {{ background: alpha(@on-bg, 0.08); }}\ + .wifi-popover-row-active {{ background: alpha(@accent, 0.14); color: @accent; }}\ + .wifi-popover-row-active:hover {{ background: alpha(@accent, 0.20); }}\ + .row-in {{ animation: row-in 0.32s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .stagger-0 {{ animation-delay: 0ms; }} .stagger-1 {{ animation-delay: 28ms; }}\ + .stagger-2 {{ animation-delay: 56ms; }} .stagger-3 {{ animation-delay: 84ms; }}\ + .stagger-4 {{ animation-delay: 112ms; }} .stagger-5 {{ animation-delay: 140ms; }}\ + .stagger-6 {{ animation-delay: 168ms; }} .stagger-7 {{ animation-delay: 196ms; }}\ + .stagger-8 {{ animation-delay: 224ms; }} .stagger-9 {{ animation-delay: 252ms; }}\ + .stagger-10 {{ animation-delay: 280ms; }} .stagger-11 {{ animation-delay: 308ms; }}\ .wifi-popover-row-unsaved {{ opacity: 0.4; }}\ .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ - window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px;\ - border-radius: {radius}; }}\ - window.wifi-add-dialog headerbar {{ background-color: {bg_rgba}; color: {on_bg};\ + switch.bt-switch, switch.bt-switch:hover, switch.bt-switch:checked,\ + switch.bt-switch:checked:hover {{ min-width: 42px; min-height: 24px; padding: 2px;\ + border: none; outline: none; box-shadow: none; background-image: none;\ + border-radius: 99px; }}\ + switch.bt-switch {{ background-color: alpha(@on-bg, 0.14);\ + transition: background-color 0.25s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + switch.bt-switch:checked {{ background-color: @accent; }}\ + switch.bt-switch slider {{ min-width: 20px; min-height: 20px; margin: 0;\ + border-radius: 99px; border: none; outline: none; box-shadow: none;\ + background-image: none; background-color: @on-bg; }}\ + window.wifi-add-dialog {{ background-color: alpha(@bg, 0.70); color: @on-bg; min-width: 240px;\ + border-radius: {radius}; border: 1px solid alpha(@on-bg, 0.10);\ + animation: pop-in 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + window.wifi-add-dialog headerbar {{ background-color: alpha(@bg, 0.70); color: @on-bg;\ border-top-left-radius: {radius}; border-top-right-radius: {radius};\ - border-bottom: 1px solid alpha({on_bg}, 0.08); box-shadow: none; }}\ + border-bottom: 1px solid alpha(@on-bg, 0.10); box-shadow: none; }}\ .confirm-button {{ background-color: @accent; color: @on-accent; }}\ .confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\ - .media-widget {{ border-radius: {radius_sm}; padding: 0 6px; }}\ - .media-widget:hover {{ background: alpha({on_bg}, 0.10); }}\ - .media-indicator {{ font-size: 11px; opacity: 0.7; margin-right: 2px; }}\ - .media-track-lbl {{ font-size: 12px; }}\ - .media-controls {{ padding: 2px; }}\ - .media-btn {{ min-width: 32px; padding: 4px 8px; }}\ - .control-panel-btn {{ padding: 0 6px; margin-left: 6px; border-radius: {radius_sm}; }}\ + .media-widget {{ border-radius: 10px; padding: 4px 8px; min-height: 0;\ + transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + .media-widget:hover {{ background: alpha(@on-bg, 0.08); }}\ + .media-widget.media-in {{ animation: row-in 0.4s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .media-eq {{ min-height: 14px; margin-right: 4px; }}\ + .media-eq-bar {{ min-width: 3px; min-height: 5px; background-color: @accent;\ + border-radius: 2px; }}\ + .media-widget.playing .media-eq-bar {{\ + animation: media-eq 0.85s ease-in-out infinite alternate; }}\ + .media-widget.playing .media-eq-bar:nth-child(2) {{ animation-delay: 0.1s; min-height: 11px; }}\ + .media-widget.playing .media-eq-bar:nth-child(3) {{ animation-delay: 0.22s; min-height: 7px; }}\ + .media-widget.playing .media-eq-bar:nth-child(4) {{ animation-delay: 0.06s; min-height: 13px; }}\ + .media-track-lbl {{ font-size: 17px; }}\ + .media-controls {{ padding: 4px; }}\ + .media-btn {{ min-width: 32px; padding: 4px 8px; border-radius: {radius_sm};\ + transition: background-color 0.18s ease; }}\ + .media-btn:hover {{ background: alpha(@on-bg, 0.10); }}\ + .control-panel-btn {{ padding: 5px 8px; margin: 0; border-radius: 10px;\ + opacity: 0.92; font-size: 18px; line-height: 1; min-width: 0; min-height: 0;\ + background: transparent; border: none; outline: none; box-shadow: none;\ + transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\ + opacity 0.18s ease; }}\ + .control-panel-btn:hover {{ opacity: 1; background: alpha(@on-bg, 0.10); }}\ + .control-panel-btn:active {{ background: alpha(@on-bg, 0.16); }}\ .control-panel {{ }}\ .control-panel-inner {{ min-width: 220px; padding: {pad}; }}\ - .control-panel-row {{ margin: 4px 0; }}\ - .control-panel-row-icon {{ opacity: 1; margin-right: 4px; }}\ - .control-panel-slider {{ margin: 0; }}\ - .control-panel-stats {{ margin: {pad} 0; }}\ - .control-panel-stat {{ font-size: 12px; opacity: 0.85; margin: 1px 0; }}\ - .control-panel-section {{ margin: {pad} 0; }}\ - .control-panel-section-header {{ font-size: 10px; font-weight: bold; opacity: 0.5;\ - letter-spacing: 0.08em; margin-bottom: 4px; }}\ - .control-panel-sink-dropdown {{ }}\ - .power-row {{ margin-top: 2px; }}\ - .power-btn {{ min-width: 40px; padding: 8px; border-radius: {radius_sm}; }}\ - separator {{ margin: 4px 0; }}\ + .control-panel-header {{ font-size: 12px; font-weight: bold; letter-spacing: 0.12em;\ + opacity: 0.45; margin-bottom: 8px; }}\ + .control-panel-row {{ margin: 8px 0; }}\ + .control-panel-row-label {{ font-size: 16px; opacity: 0.78; }}\ + .control-panel-slider {{ margin: 0; padding: 0; min-height: 18px; }}\ + scale.control-panel-slider trough {{ min-height: 6px; border-radius: 99px;\ + background-image: none; background-color: alpha(@on-bg, 0.12);\ + border: none; outline: none; box-shadow: none; }}\ + scale.control-panel-slider highlight {{ min-height: 6px; border-radius: 99px;\ + background-image: none; background-color: @accent; }}\ + scale.control-panel-slider slider {{ min-width: 0; min-height: 0; margin: 0;\ + padding: 0; opacity: 0; background: transparent; border: none;\ + outline: none; box-shadow: none; }}\ + .control-panel-section {{ margin: 8px 0 0; }}\ + .power-row {{ margin-top: 8px; }}\ + .power-btn {{ min-width: 0; min-height: 0; padding: 8px 10px; border-radius: 8px;\ + background: alpha(@on-bg, 0.08); font-size: 13px; border: none;\ + outline: none; box-shadow: none;\ + transition: background-color 0.2s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + .power-btn:hover {{ background: alpha(@on-bg, 0.14); }}\ + .power-btn:active {{ background: alpha(@accent, 0.22); }}\ + .notification-action {{ transition: background-color 0.18s ease; }}\ + .tray-btn {{ transition: opacity 0.2s ease, background-color 0.2s ease; }}\ + separator {{ margin: 4px 0; background: alpha(@on-bg, 0.10); }}\ /* Lua-declared widgets (see Documentation.md's Widgets §style): the\ slot rule below is what the four inline `.bread-widget-slot`\ containers in main.rs rely on for the same 12px stat-pair rhythm\ @@ -157,14 +265,11 @@ fn load_css() -> String { .bread-padding-xs {{ padding: 4px; }}\ .bread-padding-sm {{ padding: 8px; }}\ .bread-padding-md {{ padding: 12px; }}", - bg_plain = p.background, - bg_rgba = hex_to_rgba(&p.background, 0.92), - surface = p.color0, - accent = p.color4, - critical = p.color1, - on_bg = ink_on(&p.background), - on_surface = ink_on(&p.color0), - trough = hex_to_rgba(&p.color4, 0.25), + radius = radius, + radius_bar = radius_bar, + radius_sm = radius_sm, + radius_pill = radius_pill, + pad = pad, ) } @@ -175,6 +280,31 @@ pub fn fg_color() -> String { ink_on(&load_palette().background).to_string() } +/// Ink colour for the given Hyprland output's wallpaper palette. +#[allow(dead_code)] +pub fn fg_color_for(output: &str) -> String { + ink_on(&load_palette_for(output).background).to_string() +} + +/// Bind this window (and its popover children) to `output`'s palette. +/// +/// App CSS still uses `@accent` / `@on-bg` tokens; `bind_window_with_app_css` +/// resolves them against that output. Display-level [`apply`] stays as the +/// SIGHUP / single-output fallback. +pub fn bind_output(widget: &impl IsA, output: &str) { + bgtk::bind_window_with_app_css(widget, output, load_css_for); +} + +/// Bind a satellite window (notification, history, OSD, wifi dialog) to +/// whichever output it is actually rendered on. +pub fn bind_auto(window: &impl IsA) { + bgtk::bind_window_auto_with_app_css(window, load_css_for); +} + +fn load_css_for(_palette: &Palette) -> String { + load_css() +} + /// Apply (or reload) the theme CSS. Safe to call from `glib::MainContext::invoke`. pub fn apply() { // Shared ecosystem base (fonts, palette, generic widgets) — applied first diff --git a/src/widgets/render.rs b/src/widgets/render.rs index 53de47b..785470b 100644 --- a/src/widgets/render.rs +++ b/src/widgets/render.rs @@ -24,7 +24,8 @@ const DEFAULT_LABEL_MAX_WIDTH_CHARS: i32 = 32; fn bundled_icon(name: &str) -> Option<&'static str> { use crate::bar::stats::{ AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_OFF, BT_ON, ICON_BRIGHTNESS, ICON_LOCK, - ICON_VOLUME, WIFI_OFF, WIFI_STRONG, + ICON_RESTART, ICON_SHUTDOWN, ICON_SLEEP, ICON_VOLUME, WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG, + WIFI_WEAK, }; Some(match name { "ac-power" => AC_POWER, @@ -34,8 +35,13 @@ fn bundled_icon(name: &str) -> Option<&'static str> { "bluetooth-on" => BT_ON, "bluetooth-off" => BT_OFF, "wifi-strong" => WIFI_STRONG, + "wifi-medium" => WIFI_MEDIUM, + "wifi-weak" => WIFI_WEAK, "wifi-off" => WIFI_OFF, "lock" => ICON_LOCK, + "sleep" => ICON_SLEEP, + "restart" => ICON_RESTART, + "shutdown" => ICON_SHUTDOWN, "volume" => ICON_VOLUME, "brightness" => ICON_BRIGHTNESS, _ => return None, @@ -171,6 +177,7 @@ pub fn build_node(node: &WidgetNode, widget_id: &str) -> gtk4::Widget { let px = size.unwrap_or(16).max(1) as u32; let texture = icon_texture(widget_id, name.as_deref(), path.as_deref(), px); let image = gtk4::Image::from_paintable(texture.as_ref()); + crate::prepare_icon(&image, px as i32); image.upcast() } WidgetNode::Progress { value, .. } => { From 96d666b3cb6755453afd4a42bf6b60903ffb6825 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:45:59 +0800 Subject: [PATCH 32/39] Fix CI: ship panel.rs and the workspace trail helper mod panel and stretch_geom_on were referenced on main but not committed, so --locked release builds failed. --- src/bar/workspaces.rs | 163 +++++++++++++++++++++++++++++++----------- src/main.rs | 58 +++++++++++---- src/panel.rs | 154 +++++++++++++++++++++++++++++++++++++++ src/theme.rs | 5 +- 4 files changed, 325 insertions(+), 55 deletions(-) create mode 100644 src/panel.rs diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 1181f02..fd83c79 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -230,49 +230,68 @@ impl WorkspaceTrail { } pub fn place(&self, btn: >k4::Button) { - self.cancel(); - if let Some(g) = button_geom(btn, &self.overlay) { - self.apply(&g); - return; - } - // First map: the button exists but has no allocation yet. let pill = self.pill.clone(); let host = self.host.clone(); - let btn = btn.clone(); let inner = self.inner.clone(); - let id = self.overlay.add_tick_callback(move |ov, _| { - let Some(g) = button_geom(&btn, ov) else { - return ControlFlow::Continue; - }; + self.when_stable(btn, move |g| { apply_geom(&host, &pill, &inner, &g); - inner.borrow_mut().tick = None; - ControlFlow::Break }); - self.inner.borrow_mut().tick = Some(id); } pub fn stretch(&self, from: Option<>k4::Button>, to: >k4::Button) { let from_g = self.from_geom(from); - if let Some(to_g) = button_geom(to, &self.overlay) { - self.stretch_geom(from_g, to_g); - return; - } - // Destination just appeared (empty workspace becoming active) and - // has no allocation yet. Wait one layout pass, then stretch from - // the last pill — don't snap via place(). - self.cancel(); let overlay = self.overlay.clone(); let pill = self.pill.clone(); let host = self.host.clone(); let inner = self.inner.clone(); - let btn = to.clone(); + let dest = to.clone(); + self.when_stable(to, move |to_g| { + stretch_geom_on(&overlay, &host, &pill, &inner, from_g, to_g, Some(dest)); + }); + } + + /// New empty-workspace buttons first allocate at CSS `min-width` (32px) + /// and only then grow to the padded label. Two stable frames of that + /// placeholder is not enough — empty→empty used to shrink the pill to it. + fn when_stable(&self, btn: >k4::Button, then: impl FnOnce(Geom) + 'static) { + self.cancel(); + let btn = btn.clone(); + let inner = self.inner.clone(); + let last = std::cell::Cell::new(None::); + let same = std::cell::Cell::new(0u8); + let frames = std::cell::Cell::new(0u8); + let then = std::cell::Cell::new(Some(then)); let id = self.overlay.add_tick_callback(move |ov, _| { - let Some(to_g) = button_geom(&btn, ov) else { - return ControlFlow::Continue; + frames.set(frames.get().saturating_add(1)); + let n = frames.get(); + let Some(g) = button_geom(&btn, ov) else { + last.set(None); + same.set(0); + return if n > 24 { + inner.borrow_mut().tick = None; + ControlFlow::Break + } else { + ControlFlow::Continue + }; }; - inner.borrow_mut().tick = None; - stretch_geom_on(&overlay, &host, &pill, &inner, from_g, to_g); - ControlFlow::Break + // Still sitting on the 32px min-width slot, or smaller than the + // button's natural request — keep waiting for the real layout. + if still_placeholder(&btn, &g) && n < 20 { + last.set(None); + same.set(0); + return ControlFlow::Continue; + } + let stable = last.get().is_some_and(|p| geom_close(&p, &g)); + last.set(Some(g)); + same.set(if stable { same.get().saturating_add(1) } else { 0 }); + if same.get() >= 2 || n > 22 { + inner.borrow_mut().tick = None; + if let Some(f) = then.take() { + f(g); + } + return ControlFlow::Break; + } + ControlFlow::Continue }); self.inner.borrow_mut().tick = Some(id); } @@ -286,20 +305,68 @@ impl WorkspaceTrail { from.and_then(|b| button_geom(b, &self.overlay)) } - fn stretch_geom(&self, from_g: Option, to_g: Geom) { - stretch_geom_on( - &self.overlay, - &self.host, - &self.pill, - &self.inner, - from_g, - to_g, - ); +} + +fn stretch_geom_on( + overlay: >k4::Overlay, + host: >k4::Fixed, + pill: >k4::Box, + inner: &Rc>, + from_g: Option, + to_g: Geom, + dest: Option, +) { + let Some(from_g) = from_g else { + apply_geom(host, pill, inner, &to_g); + return; + }; + if (from_g.x - to_g.x).abs() < 0.5 && (from_g.w - to_g.w).abs() < 0.5 { + apply_geom(host, pill, inner, &to_g); + return; } - fn apply(&self, g: &Geom) { - apply_geom(&self.host, &self.pill, &self.inner, g); + let span_x = from_g.x.min(to_g.x); + let span_w = (from_g.x + from_g.w).max(to_g.x + to_g.w) - span_x; + let mid = Geom { + x: span_x, + y: to_g.y, + w: span_w, + h: to_g.h, + }; + + if let Some(id) = inner.borrow_mut().tick.take() { + id.remove(); } + let started = Instant::now(); + let pill = pill.clone(); + let host = host.clone(); + let inner_tick = inner.clone(); + let dest = dest.clone(); + let ov = overlay.clone(); + let id = overlay.add_tick_callback(move |_, _| { + let elapsed = started.elapsed().as_secs_f64() * 1000.0; + let (g, done) = if elapsed < STRETCH_MS { + let t = ease(elapsed / STRETCH_MS); + (lerp_geom(&from_g, &mid, t), false) + } else if elapsed < STRETCH_MS + SNAP_MS { + let t = ease_overshoot((elapsed - STRETCH_MS) / SNAP_MS); + (lerp_geom(&mid, &to_g, t), false) + } else { + let end = dest + .as_ref() + .and_then(|b| button_geom(b, &ov)) + .unwrap_or(to_g); + (end, true) + }; + apply_geom(&host, &pill, &inner_tick, &g); + if done { + inner_tick.borrow_mut().tick = None; + ControlFlow::Break + } else { + ControlFlow::Continue + } + }); + inner.borrow_mut().tick = Some(id); } fn button_geom(btn: >k4::Button, overlay: >k4::Overlay) -> Option { @@ -324,11 +391,27 @@ fn apply_geom(host: >k4::Fixed, pill: >k4::Box, inner: &Rc bool { + let (min_w, nat_w, _, _) = btn.measure(gtk4::Orientation::Horizontal, -1); + g.w <= f64::from(min_w) + 1.0 || g.w + 0.5 < f64::from(nat_w) +} + +fn geom_close(a: &Geom, b: &Geom) -> bool { + (a.x - b.x).abs() < 0.5 + && (a.y - b.y).abs() < 0.5 + && (a.w - b.w).abs() < 0.5 + && (a.h - b.h).abs() < 0.5 +} + fn lerp(a: f64, b: f64, t: f64) -> f64 { a + (b - a) * t } diff --git a/src/main.rs b/src/main.rs index ad09dc3..21c4646 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,9 +62,11 @@ pub struct App { cpu_pair: gtk4::Box, mem_pair: gtk4::Box, pwr_pair: gtk4::Box, + gpu_pair: gtk4::Box, cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, + gpu_lbl: gtk4::Label, vol_lbl: gtk4::Label, bat_lbl: gtk4::Label, bat_img: gtk4::Image, @@ -240,6 +242,7 @@ impl SimpleComponent for App { let cpu_lbl = stat_label(); let mem_lbl = stat_label(); let pwr_lbl = stat_label(); + let gpu_lbl = stat_label(); let vol_lbl = stat_label(); let bat_lbl = stat_label(); @@ -380,11 +383,22 @@ impl SimpleComponent for App { let cpu_pair = stat_pair(asset!("CPU.svg"), &cpu_lbl); let mem_pair = stat_pair(asset!("RAM Usage.svg"), &mem_lbl); let pwr_pair = stat_pair(asset!("Power Draw.svg"), &pwr_lbl); - let system_stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - system_stats_box.append(&cpu_pair); - system_stats_box.append(&mem_pair); - system_stats_box.append(&pwr_pair); - system_stats_box.set_visible(false); + let gpu_pair = stat_pair(asset!("GPU.svg"), &gpu_lbl); + for pair in [&cpu_pair, &mem_pair, &pwr_pair, &gpu_pair] { + pair.add_css_class("sys-stat"); + pair.set_hexpand(true); + } + gpu_pair.set_visible(false); + let system_stats_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + system_stats_box.add_css_class("sys-grid"); + let sys_row1 = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + sys_row1.append(&cpu_pair); + sys_row1.append(&mem_pair); + let sys_row2 = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + sys_row2.append(&gpu_pair); + sys_row2.append(&pwr_pair); + system_stats_box.append(&sys_row1); + system_stats_box.append(&sys_row2); let system_sep = gtk4::Separator::new(gtk4::Orientation::Vertical); system_sep.add_css_class("bar-sep"); system_sep.set_visible(false); @@ -507,6 +521,13 @@ impl SimpleComponent for App { let panel_bright_slider = bright_row.1.clone(); panel_inner.append(&bright_row.0); + let sys_header = gtk4::Label::new(Some("SYSTEM")); + sys_header.add_css_class("control-panel-header"); + sys_header.set_xalign(0.0); + sys_header.set_margin_top(10); + panel_inner.append(&sys_header); + panel_inner.append(&system_stats_box); + let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); power_row.add_css_class("power-row"); power_row.set_halign(gtk4::Align::Center); @@ -697,9 +718,11 @@ impl SimpleComponent for App { cpu_pair, mem_pair, pwr_pair, + gpu_pair, cpu_lbl, mem_lbl, pwr_lbl, + gpu_lbl, vol_lbl, bat_lbl, bat_img, @@ -854,17 +877,24 @@ impl SimpleComponent for App { self.date_lbl.set_label(&bar::clock::date()); } AppInput::StatsUpdate(stats) => { - self.cpu_lbl.set_label(&stats.cpu); + let cpu = match stats.cpu_temp { + Some(t) => format!("{} · {:.0}°", stats.cpu, t), + None => stats.cpu, + }; + self.cpu_lbl.set_label(&cpu); self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); - - // Island bar never shows the system-stats trio — they live - // in the control panel. Keep the widgets hidden so a later - // re-parent cannot accidentally flash them. - self.cpu_pair.set_visible(false); - self.mem_pair.set_visible(false); - self.pwr_pair.set_visible(false); - self.system_stats_box.set_visible(false); + match stats.gpu_usage { + Some(g) => { + let gpu = match stats.gpu_temp { + Some(t) => format!("{g}% · {t:.0}°"), + None => format!("{g}%"), + }; + self.gpu_lbl.set_label(&gpu); + self.gpu_pair.set_visible(true); + } + None => self.gpu_pair.set_visible(false), + } self.system_sep.set_visible(false); tick_label(&self.vol_lbl, &stats.volume_pct.to_string()); diff --git a/src/panel.rs b/src/panel.rs new file mode 100644 index 0000000..c1bcd75 --- /dev/null +++ b/src/panel.rs @@ -0,0 +1,154 @@ +//! Standalone layer-shell panels for wifi / control / media. +//! +//! GTK `Popover` is an xdg_popup child of the island, so it paints over the +//! bar and Hyprland can only fade it. These are their own surfaces, parked +//! *below* the exclusive zone, and Hyprland slides `breadbar-panel` in from +//! the right. + +use gtk4::gdk::Key; +use gtk4::prelude::*; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; + +use crate::{bind_layer_monitor, theme, BAR_HEIGHT, BAR_MARGIN_SIDES, BAR_MARGIN_TOP}; + +const BELOW_BAR: i32 = BAR_MARGIN_TOP + BAR_HEIGHT + 8; + +#[derive(Clone)] +pub struct PanelSet { + pub connectivity: gtk4::Window, + pub control: gtk4::Window, + pub media: gtk4::Window, + dismiss: gtk4::Window, +} + +impl PanelSet { + pub fn new( + monitor: &str, + connectivity_child: &impl IsA, + control_child: &impl IsA, + media_child: &impl IsA, + ) -> Self { + let connectivity = make_panel("wifi-popover", connectivity_child, monitor); + let control = make_panel("control-panel", control_child, monitor); + let media = make_panel("media-popover", media_child, monitor); + let dismiss = make_dismiss(monitor); + + let set = Self { + connectivity, + control, + media, + dismiss, + }; + set.wire_dismiss(); + set.wire_escape(); + set + } + + pub fn toggle(&self, which: >k4::Window) { + if which.is_visible() { + self.hide_all(); + } else { + self.show(which); + } + } + + pub fn show(&self, which: >k4::Window) { + self.hide_panels(); + // Dismiss first so the panel maps above it (same Overlay layer). + self.dismiss.set_visible(true); + self.dismiss.present(); + which.set_visible(true); + which.present(); + } + + pub fn hide_all(&self) { + self.hide_panels(); + self.dismiss.set_visible(false); + } + + fn hide_panels(&self) { + self.connectivity.set_visible(false); + self.control.set_visible(false); + self.media.set_visible(false); + } + + fn wire_dismiss(&self) { + let set = self.clone(); + let click = gtk4::GestureClick::new(); + click.set_button(0); + click.connect_pressed(move |_, _, _, _| { + set.hide_all(); + }); + if let Some(child) = self.dismiss.child() { + child.add_controller(click); + } else { + self.dismiss.add_controller(click); + } + } + + fn wire_escape(&self) { + for win in [&self.connectivity, &self.control, &self.media] { + let set = self.clone(); + let keys = gtk4::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, _| { + if key == Key::Escape { + set.hide_all(); + gtk4::glib::Propagation::Stop + } else { + gtk4::glib::Propagation::Proceed + } + }); + win.add_controller(keys); + } + } +} + +fn make_panel(class: &str, child: &impl IsA, monitor: &str) -> gtk4::Window { + let window = gtk4::Window::new(); + window.add_css_class("breadbar-panel"); + window.add_css_class(class); + window.set_decorated(false); + window.set_resizable(false); + window.init_layer_shell(); + window.set_namespace(Some("breadbar-panel")); + window.set_layer(Layer::Overlay); + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Right, true); + window.set_margin(Edge::Top, BELOW_BAR); + window.set_margin(Edge::Right, BAR_MARGIN_SIDES); + window.set_exclusive_zone(-1); + window.set_keyboard_mode(KeyboardMode::OnDemand); + window.set_child(Some(child)); + bind_layer_monitor(&window, monitor); + theme::bind_output(&window, monitor); + window.set_visible(false); + window +} + +fn make_dismiss(monitor: &str) -> gtk4::Window { + let window = gtk4::Window::new(); + window.add_css_class("breadbar-dismiss"); + window.init_layer_shell(); + window.set_namespace(Some("breadbar-dismiss")); + // Overlay with the panels, but mapped first so they sit above it. + // Top margin keeps the island's chips clickable. + window.set_layer(Layer::Overlay); + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Bottom, true); + window.set_anchor(Edge::Left, true); + window.set_anchor(Edge::Right, true); + window.set_margin(Edge::Top, BAR_MARGIN_TOP + BAR_HEIGHT); + window.set_exclusive_zone(-1); + window.set_keyboard_mode(KeyboardMode::None); + // An empty window never maps a hit region. A filling child + a hair of + // alpha is what actually receives the click-away. + let hit = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + hit.add_css_class("breadbar-dismiss-hit"); + hit.set_hexpand(true); + hit.set_vexpand(true); + window.set_child(Some(&hit)); + bind_layer_monitor(&window, monitor); + theme::bind_output(&window, monitor); + window.set_visible(false); + window +} diff --git a/src/theme.rs b/src/theme.rs index 50f34f8..3a2ecc7 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -190,7 +190,10 @@ fn load_css() -> String { .control-panel-btn:hover {{ opacity: 1; background: alpha(@on-bg, 0.10); }}\ .control-panel-btn:active {{ background: alpha(@on-bg, 0.16); }}\ .control-panel {{ }}\ - .control-panel-inner {{ min-width: 220px; padding: {pad}; }}\ + .control-panel-inner {{ min-width: 248px; padding: {pad}; }}\ + .sys-grid {{ margin: 2px 0 6px; }}\ + .sys-stat {{ padding: 4px 2px; background: transparent; }}\ + .sys-stat:hover {{ background: transparent; }}\ .control-panel-header {{ font-size: 12px; font-weight: bold; letter-spacing: 0.12em;\ opacity: 0.45; margin-bottom: 8px; }}\ .control-panel-row {{ margin: 8px 0; }}\ From 241dfd17a75e486df31fa61b495b8bf3fc68fa0b Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:49:52 +0800 Subject: [PATCH 33/39] Include GPU.svg so the bar release build can compile --- assets/GPU.svg | 1 + 1 file changed, 1 insertion(+) create mode 100644 assets/GPU.svg diff --git a/assets/GPU.svg b/assets/GPU.svg new file mode 100644 index 0000000..03c7f1c --- /dev/null +++ b/assets/GPU.svg @@ -0,0 +1 @@ + From e4c12e9b62cc570d410626abc24c38ac672b74ba Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 14:04:05 +0800 Subject: [PATCH 34/39] Add audio output switching and fix the workspace trail The control panel lists PipeWire sinks and sets the default, moving playing streams so the change is immediate. The workspace pill starts on switch without a layout wait, measures against the Fixed host so it stays centered on the digit, and ignores row-wide bounds that used to stretch it across several chips. --- src/bar/control.rs | 21 +++- src/bar/workspaces.rs | 216 +++++++++++++++++++----------------------- src/main.rs | 51 ++++++++++ src/theme.rs | 12 ++- 4 files changed, 172 insertions(+), 128 deletions(-) diff --git a/src/bar/control.rs b/src/bar/control.rs index f480057..43cb6c3 100644 --- a/src/bar/control.rs +++ b/src/bar/control.rs @@ -121,12 +121,29 @@ pub fn spawn_set_brightness(v: f64) { }); } -#[allow(dead_code)] -pub fn spawn_set_sink(name: String) { +pub fn spawn_set_sink(name: String, sender: ComponentSender) { relm4::spawn(async move { let _ = tokio::process::Command::new("pactl") .args(["set-default-sink", &name]) .output() .await; + // Default sink alone leaves already-playing streams on the old + // device — move them too so the switch is audible immediately. + if let Ok(o) = tokio::process::Command::new("pactl") + .args(["list", "short", "sink-inputs"]) + .output() + .await + { + for line in String::from_utf8_lossy(&o.stdout).lines() { + let Some(id) = line.split_whitespace().next() else { + continue; + }; + let _ = tokio::process::Command::new("pactl") + .args(["move-sink-input", id, &name]) + .output() + .await; + } + } + spawn_load(sender); }); } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index fd83c79..2c05a15 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -139,8 +139,14 @@ pub fn make_button( btn.add_css_class("active"); } btn.set_valign(gtk4::Align::Center); + btn.set_halign(gtk4::Align::Center); btn.set_vexpand(false); + btn.set_hexpand(false); btn.set_size_request(-1, crate::CHIP_HEIGHT); + if let Some(child) = btn.child() { + child.set_halign(gtk4::Align::Center); + child.set_valign(gtk4::Align::Center); + } btn.connect_clicked(move |_| { relm4::spawn(async move { switch_workspace(id).await; @@ -189,7 +195,7 @@ impl WorkspaceTrail { pill.set_visible(false); host.put(&pill, 0.0, 0.0); - let buttons = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); + let buttons = gtk4::Box::new(gtk4::Orientation::Horizontal, 1); buttons.set_halign(gtk4::Align::Fill); buttons.set_valign(gtk4::Align::Center); buttons.set_vexpand(false); @@ -230,150 +236,125 @@ impl WorkspaceTrail { } pub fn place(&self, btn: >k4::Button) { + self.cancel(); + if let Some(g) = button_geom(btn, &self.host) { + apply_geom(&self.host, &self.pill, &self.inner, &inset_pill(g)); + return; + } let pill = self.pill.clone(); let host = self.host.clone(); let inner = self.inner.clone(); - self.when_stable(btn, move |g| { - apply_geom(&host, &pill, &inner, &g); + let btn = btn.clone(); + let id = self.overlay.add_tick_callback(move |_, _| { + let Some(g) = button_geom(&btn, &host) else { + return ControlFlow::Continue; + }; + apply_geom(&host, &pill, &inner, &inset_pill(g)); + inner.borrow_mut().tick = None; + ControlFlow::Break }); + self.inner.borrow_mut().tick = Some(id); } pub fn stretch(&self, from: Option<>k4::Button>, to: >k4::Button) { - let from_g = self.from_geom(from); - let overlay = self.overlay.clone(); + self.cancel(); + let Some(from_g) = self.from_geom(from) else { + self.place(to); + return; + }; + let dest = to.clone(); let pill = self.pill.clone(); let host = self.host.clone(); let inner = self.inner.clone(); - let dest = to.clone(); - self.when_stable(to, move |to_g| { - stretch_geom_on(&overlay, &host, &pill, &inner, from_g, to_g, Some(dest)); - }); - } - - /// New empty-workspace buttons first allocate at CSS `min-width` (32px) - /// and only then grow to the padded label. Two stable frames of that - /// placeholder is not enough — empty→empty used to shrink the pill to it. - fn when_stable(&self, btn: >k4::Button, then: impl FnOnce(Geom) + 'static) { - self.cancel(); - let btn = btn.clone(); - let inner = self.inner.clone(); - let last = std::cell::Cell::new(None::); - let same = std::cell::Cell::new(0u8); - let frames = std::cell::Cell::new(0u8); - let then = std::cell::Cell::new(Some(then)); - let id = self.overlay.add_tick_callback(move |ov, _| { - frames.set(frames.get().saturating_add(1)); - let n = frames.get(); - let Some(g) = button_geom(&btn, ov) else { - last.set(None); - same.set(0); - return if n > 24 { - inner.borrow_mut().tick = None; - ControlFlow::Break - } else { - ControlFlow::Continue - }; - }; - // Still sitting on the 32px min-width slot, or smaller than the - // button's natural request — keep waiting for the real layout. - if still_placeholder(&btn, &g) && n < 20 { - last.set(None); - same.set(0); - return ControlFlow::Continue; - } - let stable = last.get().is_some_and(|p| geom_close(&p, &g)); - last.set(Some(g)); - same.set(if stable { same.get().saturating_add(1) } else { 0 }); - if same.get() >= 2 || n > 22 { - inner.borrow_mut().tick = None; - if let Some(f) = then.take() { - f(g); + let started = Instant::now(); + let id = self.overlay.add_tick_callback(move |_, _| { + let to_g = resolved_dest(&dest, &host, &from_g); + let mid = { + let span_x = from_g.x.min(to_g.x); + let span_w = (from_g.x + from_g.w).max(to_g.x + to_g.w) - span_x; + Geom { + x: span_x, + y: to_g.y, + w: span_w, + h: to_g.h, } - return ControlFlow::Break; + }; + let elapsed = started.elapsed().as_secs_f64() * 1000.0; + let (g, done) = if elapsed < STRETCH_MS { + let t = ease(elapsed / STRETCH_MS); + (lerp_geom(&from_g, &mid, t), false) + } else if elapsed < STRETCH_MS + SNAP_MS { + let t = ease_overshoot((elapsed - STRETCH_MS) / SNAP_MS); + (lerp_geom(&mid, &to_g, t), false) + } else { + (to_g, true) + }; + apply_geom(&host, &pill, &inner, &g); + if done { + inner.borrow_mut().tick = None; + ControlFlow::Break + } else { + ControlFlow::Continue } - ControlFlow::Continue }); self.inner.borrow_mut().tick = Some(id); } fn from_geom(&self, from: Option<>k4::Button>) -> Option { let st = self.inner.borrow(); - if self.pill.is_visible() && st.geom.w > 0.5 { + // A leftover mid-stretch can be as wide as the whole row — never + // treat that as the start of the next animation. + if self.pill.is_visible() && st.geom.w > 0.5 && st.geom.w <= MAX_CHIP_W { return Some(st.geom); } drop(st); - from.and_then(|b| button_geom(b, &self.overlay)) + from.and_then(|b| button_geom(b, &self.host).map(inset_pill)) } - } -fn stretch_geom_on( - overlay: >k4::Overlay, - host: >k4::Fixed, - pill: >k4::Box, - inner: &Rc>, - from_g: Option, - to_g: Geom, - dest: Option, -) { - let Some(from_g) = from_g else { - apply_geom(host, pill, inner, &to_g); - return; - }; - if (from_g.x - to_g.x).abs() < 0.5 && (from_g.w - to_g.w).abs() < 0.5 { - apply_geom(host, pill, inner, &to_g); - return; - } +/// Keep the trail slimmer than the hit target so the fill doesn't look +/// like a second, fatter button. +const PILL_INSET_X: f64 = 5.0; +const PILL_INSET_Y: f64 = 3.0; +/// One workspace chip is a digit + padding. Wider than this is the overlay +/// or the whole button row leaking through `compute_bounds`. +const MAX_CHIP_W: f64 = 72.0; - let span_x = from_g.x.min(to_g.x); - let span_w = (from_g.x + from_g.w).max(to_g.x + to_g.w) - span_x; - let mid = Geom { - x: span_x, - y: to_g.y, - w: span_w, - h: to_g.h, - }; - - if let Some(id) = inner.borrow_mut().tick.take() { - id.remove(); +fn inset_pill(g: Geom) -> Geom { + let w = (g.w - PILL_INSET_X * 2.0).max(10.0); + let h = (g.h - PILL_INSET_Y * 2.0).max(18.0); + Geom { + x: g.x + (g.w - w) * 0.5, + y: g.y + (g.h - h) * 0.5, + w, + h, } - let started = Instant::now(); - let pill = pill.clone(); - let host = host.clone(); - let inner_tick = inner.clone(); - let dest = dest.clone(); - let ov = overlay.clone(); - let id = overlay.add_tick_callback(move |_, _| { - let elapsed = started.elapsed().as_secs_f64() * 1000.0; - let (g, done) = if elapsed < STRETCH_MS { - let t = ease(elapsed / STRETCH_MS); - (lerp_geom(&from_g, &mid, t), false) - } else if elapsed < STRETCH_MS + SNAP_MS { - let t = ease_overshoot((elapsed - STRETCH_MS) / SNAP_MS); - (lerp_geom(&mid, &to_g, t), false) - } else { - let end = dest - .as_ref() - .and_then(|b| button_geom(b, &ov)) - .unwrap_or(to_g); - (end, true) - }; - apply_geom(&host, &pill, &inner_tick, &g); - if done { - inner_tick.borrow_mut().tick = None; - ControlFlow::Break - } else { - ControlFlow::Continue +} + +fn resolved_dest(btn: >k4::Button, host: >k4::Fixed, from: &Geom) -> Geom { + match button_geom(btn, host) { + Some(g) if !still_placeholder(btn, &g) => inset_pill(g), + Some(g) => { + let centered = inset_pill(g); + Geom { + x: centered.x + (centered.w - from.w) * 0.5, + y: centered.y + (centered.h - from.h) * 0.5, + w: from.w, + h: from.h, + } } - }); - inner.borrow_mut().tick = Some(id); + None => *from, + } } -fn button_geom(btn: >k4::Button, overlay: >k4::Overlay) -> Option { - let r = btn.compute_bounds(overlay)?; +/// Position in the Fixed host's space — that's what `host.move_` uses. +/// Measuring against the Overlay instead left the pill a few px left of +/// the digit whenever the host and overlay origins disagreed. +fn button_geom(btn: >k4::Button, host: >k4::Fixed) -> Option { + let r = btn.compute_bounds(host)?; let w = f64::from(r.width()); let h = f64::from(r.height()); - if w < 1.0 || h < 1.0 { + if w < 8.0 || h < 8.0 || w > MAX_CHIP_W { return None; } Some(Geom { @@ -405,13 +386,6 @@ fn still_placeholder(btn: >k4::Button, g: &Geom) -> bool { g.w <= f64::from(min_w) + 1.0 || g.w + 0.5 < f64::from(nat_w) } -fn geom_close(a: &Geom, b: &Geom) -> bool { - (a.x - b.x).abs() < 0.5 - && (a.y - b.y).abs() < 0.5 - && (a.w - b.w).abs() < 0.5 - && (a.h - b.h).abs() < 0.5 -} - fn lerp(a: f64, b: f64, t: f64) -> f64 { a + (b - a) * t } diff --git a/src/main.rs b/src/main.rs index 21c4646..309a8fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,6 +99,8 @@ pub struct App { panel_vol_slider: gtk4::Scale, panel_bright_slider: gtk4::Scale, panel_loading: Rc>, + sink_box: gtk4::Box, + sink_section: gtk4::Box, // ── Tray ────────────────────────────────────────────────────────────── tray_section: gtk4::Box, @@ -517,6 +519,18 @@ impl SimpleComponent for App { let panel_vol_slider = vol_row.1.clone(); panel_inner.append(&vol_row.0); + let sink_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + sink_section.add_css_class("control-panel-section"); + let sink_header = gtk4::Label::new(Some("OUTPUT")); + sink_header.add_css_class("control-panel-header"); + sink_header.set_xalign(0.0); + sink_header.set_margin_top(6); + let sink_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + sink_section.append(&sink_header); + sink_section.append(&sink_box); + sink_section.set_visible(false); + panel_inner.append(&sink_section); + let bright_row = build_slider_row("bl", 0.0, 1.0, 0.02); let panel_bright_slider = bright_row.1.clone(); panel_inner.append(&bright_row.0); @@ -747,6 +761,8 @@ impl SimpleComponent for App { panel_vol_slider, panel_bright_slider, panel_loading, + sink_box, + sink_section, tray_section, tray_sep, tray_box, @@ -1033,6 +1049,7 @@ impl SimpleComponent for App { self.panel_vol_slider.set_value(data.volume); self.panel_bright_slider.set_value(data.brightness); self.panel_loading.set(false); + self.rebuild_sinks(&data.sinks, &sender); } AppInput::WidgetsUpdate(specs) => { self.reconcile_widgets(specs); @@ -1129,6 +1146,40 @@ impl App { } } + fn rebuild_sinks( + &mut self, + sinks: &[bar::control::AudioSink], + sender: &ComponentSender, + ) { + while let Some(child) = self.sink_box.first_child() { + self.sink_box.remove(&child); + } + self.sink_section.set_visible(!sinks.is_empty()); + for (i, sink) in sinks.iter().enumerate() { + let row = gtk4::Button::new(); + row.add_css_class("flat"); + row.add_css_class("wifi-popover-row"); + row.add_css_class("sink-row"); + if sink.is_default { + row.add_css_class("wifi-popover-row-active"); + } + stagger_row(&row, i); + let lbl = gtk4::Label::new(Some(&sink.description)); + lbl.set_xalign(0.0); + lbl.set_hexpand(true); + lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); + lbl.set_max_width_chars(22); + lbl.set_valign(gtk4::Align::Center); + row.set_child(Some(&lbl)); + let name = sink.name.clone(); + let sender = sender.clone(); + row.connect_clicked(move |_| { + bar::control::spawn_set_sink(name.clone(), sender.clone()); + }); + self.sink_box.append(&row); + } + } + fn apply_wifi_label(&self) { let label = match &self.wifi_profile { Some(p) => format!("{p} · {}", self.current_ssid), diff --git a/src/theme.rs b/src/theme.rs index 3a2ecc7..f11b8d2 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -39,10 +39,10 @@ fn load_css() -> String { window.breadbar > centerbox {{ padding: 0 8px 0 6px; }}\ window.breadbar button {{ min-height: 0; min-width: 0; }}\ .workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\ - background-color: @accent; border-radius: 999px; }}\ + background-color: @accent; border-radius: 12px; }}\ .workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\ - border-radius: 999px; border: none; outline: none; box-shadow: none;\ - min-width: 32px; min-height: 32px; margin: 0 2px; padding: 0 12px;\ + border-radius: 12px; border: none; outline: none; box-shadow: none;\ + min-width: 28px; min-height: 28px; margin: 0; padding: 0 7px;\ font-size: 22px; font-weight: bold;\ transition: opacity 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\ background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ @@ -67,9 +67,10 @@ fn load_css() -> String { opacity 0.18s ease; }}\ .stat-pair:hover {{ background: alpha(@on-bg, 0.12); }}\ .stat-pair:active {{ background: alpha(@on-bg, 0.18); }}\ - .stat-pair.icon-only {{ padding: 6px; border-radius: 999px; }}\ + .stat-pair.icon-only {{ padding: 4px; border-radius: 999px;\ + min-width: 32px; min-height: 32px; }}\ .stat-icon {{ margin-right: 6px; }}\ - .stat-pair.icon-only .stat-icon {{ margin-right: 0; }}\ + .stat-pair.icon-only .stat-icon {{ margin: 0; }}\ .bt-icon {{ margin-right: 8px; }} separator.bar-sep {{ min-height: 12px; min-width: 1px; margin: 0 10px 0 2px;\ background: alpha(@on-bg, 0.10); }}\ @@ -208,6 +209,7 @@ fn load_css() -> String { padding: 0; opacity: 0; background: transparent; border: none;\ outline: none; box-shadow: none; }}\ .control-panel-section {{ margin: 8px 0 0; }}\ + .sink-row label {{ font-size: 15px; }}\ .power-row {{ margin-top: 8px; }}\ .power-btn {{ min-width: 0; min-height: 0; padding: 8px 10px; border-radius: 8px;\ background: alpha(@on-bg, 0.08); font-size: 13px; border: none;\ From cd7465a18bb90bf138c648fddd32d56d2b0b1bfb Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 14:09:04 +0800 Subject: [PATCH 35/39] Bump version to v0.3.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3bc508e..d7ff339 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -207,7 +207,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.3.1" +version = "0.3.3" dependencies = [ "anyhow", "bread-screenshots", diff --git a/Cargo.toml b/Cargo.toml index 1e24f17..f5bfbb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.3.1" +version = "0.3.3" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" From 9c4205b1b25904d16a1cf828de3b11c69f49d19e Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 6 Aug 2026 08:47:05 +0800 Subject: [PATCH 36/39] clippy: fix char-comparison and map_or lints (cherry picked from commit 2e393082a744fe85f1ca1f8c6af48ce288e64b6c) --- src/bar/stats.rs | 2 +- src/main.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 892f792..26358e0 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -402,7 +402,7 @@ fn read_crumbs_profile() -> Option { for line in text.lines() { if let Some(rest) = line.trim().strip_prefix("profile") { let val = rest - .trim_start_matches(|c: char| c == ' ' || c == '=') + .trim_start_matches([' ', '=']) .trim_matches('"'); if !val.is_empty() { return Some(val.to_string()); diff --git a/src/main.rs b/src/main.rs index 309a8fd..5210546 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1023,7 +1023,7 @@ impl SimpleComponent for App { let within_linger = self .media_paused_at - .map_or(true, |t| t.elapsed().as_secs() < 30 * 60); + .is_none_or(|t| t.elapsed().as_secs() < 30 * 60); self.media_last = Some(state); reveal_media(&self.media_widget, within_linger); } else { From 700fc3ed16b54aa68b88ca70b260fe738e485f18 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 15:02:51 +0800 Subject: [PATCH 37/39] Unify bread-ecosystem crate pins at v0.7.4 bread-theme was already pinned to v0.7.4 while bread-utils and bread-screenshots trailed at v0.7.2, even though bread-ecosystem locks all workspace packages together as of a9754d9. Bump the two lagging pins so all three crates from that repo resolve to the same tag/commit. (cherry picked from commit f0de82aafb547134e0edc9dac8d72c01586db57c) --- Cargo.lock | 8 ++++---- Cargo.toml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d7ff339..14765ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,8 +152,8 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-screenshots" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#a9754d90ed32efcc26765abd01c9f441bfb01b1e" dependencies = [ "anyhow", "bread-utils", @@ -196,8 +196,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#a9754d90ed32efcc26765abd01c9f441bfb01b1e" dependencies = [ "bread-shared 0.7.0", "dirs 5.0.1", diff --git a/Cargo.toml b/Cargo.toml index f5bfbb6..1825aa8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,12 +15,12 @@ bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs # instead of hand-parsing serde_json::Value. See src/widgets/. -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["bread-client"] } # v0.8.0-rc.1 carries bread_shared::widget; keep this bread tag even if # ecosystem crates move independently. bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } From 3049a20be3a24fdd4fe2ee68935acdc74e1e8793 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 15:07:51 +0800 Subject: [PATCH 38/39] gitignore: exclude graphify-out local tool cache --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 816e2ad..4f90dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ logs/ # Internal design documents (not for distribution) aster-brief.md + +# graphify knowledge-graph output (local tool cache, not for commit) +graphify-out/ From 297207a7aad1e85fb8633983f150119f93a5239c Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 13:11:20 +0800 Subject: [PATCH 39/39] notifications: stop toast popups from stealing focus or blocking clicks Toasts never grab keyboard focus and pass every pointer event through to whatever's underneath, via an empty layer-shell input region. --- src/notifications/popup.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 5753b65..9c767b6 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -177,9 +177,16 @@ fn create_window() -> gtk4::Window { window.set_margin(Edge::Top, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8); window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES); window.set_default_width(320); - // OnDemand so an inline-reply GtkEntry can take keys without the popup - // stealing every keystroke the rest of the time. - window.set_keyboard_mode(KeyboardMode::OnDemand); + // Toasts are purely informational for now: never grab keyboard focus... + window.set_keyboard_mode(KeyboardMode::None); + // ...and click through entirely — an empty input region means every + // pointer event passes straight to whatever's underneath instead of + // hitting the toast. + window.connect_map(|win| { + if let Some(surface) = win.surface() { + surface.set_input_region(Some(>k4::cairo::Region::create())); + } + }); crate::theme::bind_auto(&window); window }