diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml deleted file mode 100644 index 9bbb301..0000000 --- a/.forgejo/workflows/check.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: check - -# Fast-fail lint/test on short-lived work branches, before it ever reaches -# main and triggers a dev-track release build. -on: - push: - branches: ['feature/**', 'fix/**'] - -jobs: - check: - runs-on: [self-hosted, hestia] - steps: - - name: checkout - run: | - set -euo pipefail - rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: clippy - run: cd src && bash ci/build.sh cargo clippy --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 deleted file mode 100644 index 13f0666..0000000 --- a/.forgejo/workflows/dev-release.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: dev release - -# Publishes a dev-track build on every push to `main` (the trunk -# branch — there is no separate `dev` branch). See bread-ecosystem's -# docs/release-channels.md for the release-track policy this is part of. -on: - push: - branches: ['main'] - -jobs: - build: - runs-on: [self-hosted, hestia] - steps: - - name: checkout - run: | - set -euo pipefail - rm -rf src && mkdir src - git clone --branch main --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: build - run: cd src && bash ci/build.sh cargo build --release --locked - - - name: compute dev version - run: | - set -euo pipefail - cd src - # Base the dev version off the latest published stable tag, - # not Cargo.toml — Cargo.toml can go stale relative to the last - # real release (seen in practice: breadbox/breadpad/breadcrumbs/ - # breadpaper), which would make a dev build sort as OLDER than - # what's already installed and bakery would correctly refuse it. - LATEST_TAG="$(git ls-remote --tags --refs \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" - if [ -n "${LATEST_TAG}" ]; then - CUR="${LATEST_TAG}" - else - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" - fi - IFS='.' read -r MA MI PA <<< "${CUR}" - SHA="$(git rev-parse --short HEAD)" - TS="$(date -u +%Y%m%d%H%M%S)" - echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV" - - - name: prepare artifacts - run: | - set -euo pipefail - PKG_DIR="/srv/breadway-dl/dev/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/LICENSE "${PKG_DIR}/" - 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-* 2>/dev/null || true - # mktemp: a fixed clone path races when multiple repos' dev/beta - # workflows run close together on the same self-hosted runner. - ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" - git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" - TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" - rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..26a9b37 --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -0,0 +1,19 @@ +name: Mirror to GitHub + +on: + push: + branches: ['**'] + tags: ['**'] + +jobs: + mirror: + runs-on: [self-hosted, hestia] + steps: + - name: Mirror to GitHub + run: | + set -euo pipefail + git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git + cd repo.git + git push --prune \ + "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadbar.git" \ + '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml new file mode 100644 index 0000000..2895a76 --- /dev/null +++ b/.forgejo/workflows/package.yml @@ -0,0 +1,40 @@ +name: Build and publish package + +on: + push: + tags: ['v*'] + +jobs: + package: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + steps: + # Note: no actions/checkout — the archlinux image has no Node, which JS + # actions require. Everything runs as shell steps and clones manually. + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell 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/rc-release.yml b/.forgejo/workflows/rc-release.yml deleted file mode 100644 index 769b700..0000000 --- a/.forgejo/workflows/rc-release.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: beta (rc) release - -# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag -# pushed to `main` — there is no separate `beta` branch; "freezing" is -# just pausing pushes to main while an RC gets tested. See -# bread-ecosystem's docs/release-channels.md for the release-track policy. -on: - push: - tags: ['v*'] - -jobs: - build: - if: ${{ contains(github.ref_name, '-rc.') }} - runs-on: [self-hosted, hestia] - steps: - - name: checkout - run: | - set -euo pipefail - rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: build - run: cd src && bash ci/build.sh cargo build --release --locked - - - name: prepare artifacts - run: | - set -euo pipefail - VERSION="${GITHUB_REF_NAME#v}" - PKG_DIR="/srv/breadway-dl/beta/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/LICENSE "${PKG_DIR}/" - cp src/bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadbar/latest" - - # No GitHub Release upload — beta, like dev, is only distributed via - # dl.breadway.dev/beta/. - - name: regenerate beta index.json - env: - MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} - run: | - set -euo pipefail - if [ -z "${MINISIGN_SEC_KEY:-}" ]; then - echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)" - exit 1 - fi - rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true - # mktemp: a fixed clone path races when multiple repos' dev/beta - # workflows run close together on the same self-hosted runner. - ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" - git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" - TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" - rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 7814150..8124ec6 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,7 +6,6 @@ on: jobs: build: - if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout @@ -17,16 +16,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: | - set -euo pipefail - if [ ! -f src/ci/build.sh ]; then - echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper" - exit 1 - fi - cd src && bash ci/build.sh cargo build --release --locked || { - echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked." - exit 1 - } + run: cd src && cargo build --release --locked - name: prepare artifacts run: | @@ -38,19 +28,12 @@ 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" - name: regenerate index.json - env: - MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} run: | set -euo pipefail - if [ -z "${MINISIGN_SEC_KEY:-}" ]; then - echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)" - exit 1 - fi rm -rf /tmp/bread-ecosystem-ci git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh diff --git a/.gitignore b/.gitignore index af5d71a..816e2ad 100644 --- a/.gitignore +++ b/.gitignore @@ -35,9 +35,3 @@ logs/ # Internal design documents (not for distribution) aster-brief.md - -# graphify knowledge-graph output (local tool cache, not for commit) -graphify-out/ - -# Local-only source overrides (see .cargo/config.toml). -.cargo/ diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 1207e2f..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,51 +0,0 @@ -# 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 + in-memory history (`breadbar --history`) | -| 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/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index e171853..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,84 +0,0 @@ -# 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 - -There is one long-lived branch: **`main`**. All day-to-day work lands here. -Every push to `main` automatically builds and publishes a **dev-track** -build (see Tracks below) — a real install you can test before cutting -anything more formal. - -New work — features and bug fixes alike — goes on a short-lived branch: - -``` -feature/ -fix/ -``` - -Branch off `main`, open a PR/push back into `main` when ready. Short-lived -branches get deleted on merge — they never accumulate the kind of drift a -second long-lived branch does. - -## The release cycle - -There's no separate `beta` or release branch — "stable" and "beta" are both -just **tags** on `main`, not branches that need to be kept in sync: - -1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push - auto-publishes a dev build — install it with `bakery track set dev` and - `bakery update --all`, then fix anything broken with another push. -2. When you want to stabilize before a real release, tag a release - candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to - both remotes). That tag alone triggers a beta-track build — - "freezing" is just pausing pushes to `main` while you test it, not a - branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes. -3. Once an RC has gone without issues, tag the real release: - `git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the - signed stable release build. - -## Tracks, from a user's perspective - -``` -bakery track show # what you're currently on (defaults to stable) -bakery track set dev # or beta, or stable -bakery update --all # pull the latest build on your current track -``` - -| Track | What it is | Published from | -|--------|-----------|-----------------| -| `stable` | The last tagged release | a `vX.Y.Z` tag | -| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag | -| `dev` | Bleeding edge | `main`, on every push | - -Dev versions are auto-computed (`X.Y.Z-dev.+`) from the -latest published stable tag, so they always sort as newer than what you -have installed — no manual version bumping needed. Beta versions are just -the RC tag itself (already valid semver, already sorts below the real -release it's a candidate for). - -## Local development - -```sh -cargo build --release -cargo test --release -``` - -## CI - -- `dev-release.yml` — triggered on push to `main`. -- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push. -- `release.yml` — triggered on any other `v*` tag push, cuts the actual - stable release. - -All CI runs on a self-hosted runner; nothing runs automatically on plain -commits or PRs beyond the track builds above. See -[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md) -for the full policy, including how a new product gets wired onto these tracks. - -## Questions - -Open an issue on this repo's Forgejo tracker. diff --git a/Cargo.lock b/Cargo.lock index 23b75b3..a277271 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,61 +8,11 @@ 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" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arrayref" @@ -72,9 +22,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.8" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "async-broadcast" @@ -96,7 +46,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -118,18 +68,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "async-trait" -version = "0.1.92" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] @@ -146,78 +96,32 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.13.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] -name = "bread-launcher" -version = "0.7.5" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8" -dependencies = [ - "bread-utils", - "gtk4", - "serde_json", -] - -[[package]] -name = "bread-screenshots" -version = "0.7.5" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8" -dependencies = [ - "anyhow", - "bread-utils", - "tracing", -] - -[[package]] -name = "bread-shared" -version = "0.8.0" -source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0#cdd5de8f58e437b3fc6d9b9087eb7b3d0fd09704" -dependencies = [ - "dirs 6.0.0", - "serde", - "serde_json", - "toml 0.8.23", - "uuid", -] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" -version = "0.7.5" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8" +version = "0.2.3" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780" dependencies = [ - "anyhow", - "dirs 5.0.1", + "dirs", "gtk4", "serde", "serde_json", - "toml 0.8.23", - "tracing", -] - -[[package]] -name = "bread-utils" -version = "0.7.5" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8" -dependencies = [ - "bread-shared", - "dirs 5.0.1", - "serde", - "serde_json", ] [[package]] name = "breadbar" -version = "0.3.3" +version = "0.2.4" dependencies = [ - "anyhow", - "bread-launcher", - "bread-screenshots", - "bread-shared", "bread-theme", - "bread-utils", - "clap", "futures-lite", "gtk4", "gtk4-layer-shell", @@ -238,23 +142,23 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.2" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "bytes" -version = "1.12.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cairo-rs" -version = "0.22.9" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df683f1d30b457964673a5541a4603208ef95ab6873d2a55871895cf310f3b56" +checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cairo-sys-rs", "glib", "libc", @@ -262,20 +166,20 @@ dependencies = [ [[package]] name = "cairo-sys-rs" -version = "0.22.9" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee548131103ad8f698c6725669647b9c757b3b66992dd6a026037596417bee21" +checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", - "system-deps 9.0.0", + "system-deps", ] [[package]] name = "cfg-expr" -version = "0.20.9" +version = "0.20.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" dependencies = [ "smallvec", "target-lexicon", @@ -288,51 +192,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "clap" -version = "4.6.6" +name = "concurrent-queue" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ - "clap_builder", - "clap_derive", + "crossbeam-utils", ] -[[package]] -name = "clap_builder" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" -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.4", -] - -[[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" @@ -344,13 +211,19 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "data-url" version = "0.3.2" @@ -376,7 +249,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.119", + "syn", "unicode-xid", ] @@ -386,16 +259,7 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "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", + "dirs-sys", ] [[package]] @@ -406,27 +270,15 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users 0.4.6", + "redox_users", "windows-sys 0.48.0", ] -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.61.2", -] - [[package]] name = "either" -version = "1.18.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "endi" @@ -452,7 +304,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -482,10 +334,11 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.2" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ + "concurrent-queue", "parking", "pin-project-lite", ] @@ -502,11 +355,11 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.5.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.3.4", ] [[package]] @@ -530,13 +383,12 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.10" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", - "miniz_oxide 0.9.1", - "zlib-rs", + "miniz_oxide", ] [[package]] @@ -557,6 +409,12 @@ dependencies = [ "spin", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "fragile" version = "2.1.0" @@ -568,9 +426,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -583,9 +441,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -593,15 +451,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -610,9 +468,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -629,32 +487,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] name = "futures-sink" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -681,22 +539,22 @@ dependencies = [ [[package]] name = "gdk-pixbuf-sys" -version = "0.22.9" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5209294ba3775f9b650bf78bfadeba4332089f2329b3e2f6ce0a4957a45bff" +checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" dependencies = [ "gio-sys", "glib-sys", "gobject-sys", "libc", - "system-deps 9.0.0", + "system-deps", ] [[package]] name = "gdk4" -version = "0.11.4" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39" +checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" dependencies = [ "cairo-rs", "gdk-pixbuf", @@ -710,9 +568,9 @@ dependencies = [ [[package]] name = "gdk4-sys" -version = "0.11.4" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13" +checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -722,7 +580,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -738,22 +596,36 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", + "wasip2", "wasm-bindgen", ] [[package]] -name = "gio" -version = "0.22.9" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b399f4650bb52c051f3fdf49a234e8a27ab5725c8cd774556c55eee64b2c0350" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" dependencies = [ "futures-channel", "futures-core", @@ -768,14 +640,14 @@ dependencies = [ [[package]] name = "gio-sys" -version = "0.22.9" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28739f914c15b87a9856000bed9cbd5b3a8afbca5e982d9a299e1601422cb5" +checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 9.0.0", + "system-deps", "windows-sys 0.61.2", ] @@ -801,11 +673,11 @@ dependencies = [ [[package]] name = "glib" -version = "0.22.9" +version = "0.22.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18b9b8d350db41f690ec1b87109f202782748bbd8ab2f2ede17cb40d29e2838c" +checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" dependencies = [ - "bitflags", + "bitflags 2.13.0", "futures-channel", "futures-core", "futures-executor", @@ -822,63 +694,65 @@ dependencies = [ [[package]] name = "glib-macros" -version = "0.22.9" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9597c68fa7bdf4154a3079642cd21c4dccac0b0bdd2897d82861523bf91e7b9" +checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] name = "glib-sys" -version = "0.22.9" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99b38907e67e40dec9b60f858bcada952598719cb512a13eb13d6cdcd16f8295" +checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" dependencies = [ "libc", - "system-deps 9.0.0", + "system-deps", ] [[package]] name = "gobject-sys" -version = "0.22.9" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07595c9ba696bd9819cb6a8df39f08078f3dd679508fe052dd558492c2053834" +checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", - "system-deps 9.0.0", + "system-deps", ] [[package]] name = "graphene-rs" -version = "0.22.8" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff" +checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" dependencies = [ "glib", "graphene-sys", + "libc", ] [[package]] name = "graphene-sys" -version = "0.22.9" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97842c7543828f98aa5583aa54fcda28aa84fa2b0f8c556142177f7bbb638057" +checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" dependencies = [ "glib-sys", "libc", - "system-deps 9.0.0", + "pkg-config", + "system-deps", ] [[package]] name = "gsk4" -version = "0.11.4" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff" +checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" dependencies = [ "cairo-rs", "gdk4", @@ -891,9 +765,9 @@ dependencies = [ [[package]] name = "gsk4-sys" -version = "0.11.4" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088" +checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" dependencies = [ "cairo-sys-rs", "gdk4-sys", @@ -902,14 +776,14 @@ dependencies = [ "graphene-sys", "libc", "pango-sys", - "system-deps 7.0.8", + "system-deps", ] [[package]] name = "gtk4" -version = "0.11.4" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9" +checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" dependencies = [ "cairo-rs", "field-offset", @@ -928,11 +802,11 @@ dependencies = [ [[package]] name = "gtk4-layer-shell" -version = "0.8.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" +checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" dependencies = [ - "bitflags", + "bitflags 2.13.0", "gdk4", "glib", "glib-sys", @@ -943,34 +817,34 @@ dependencies = [ [[package]] name = "gtk4-layer-shell-sys" -version = "0.6.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" +checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" dependencies = [ "gdk4-sys", "glib-sys", "gtk4-sys", "libc", - "system-deps 8.0.0", + "system-deps", ] [[package]] name = "gtk4-macros" -version = "0.11.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e" +checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "gtk4-sys" -version = "0.11.4" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01" +checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -982,7 +856,16 @@ dependencies = [ "gsk4-sys", "libc", "pango-sys", - "system-deps 7.0.8", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", ] [[package]] @@ -1029,31 +912,33 @@ checksum = "31157e6ccefbad4b0cd7e549db6696691a70c11b108f26bf6bf76eef26af8c10" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "imagesize" -version = "0.14.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" [[package]] name = "indexmap" -version = "2.14.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", + "serde", + "serde_core", ] -[[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" @@ -1062,12 +947,13 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", + "once_cell", "wasm-bindgen", ] @@ -1079,27 +965,32 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kurbo" -version = "0.13.1" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" dependencies = [ "arrayvec", "euclid", - "polycool", "smallvec", ] [[package]] -name = "libc" -version = "0.2.189" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.21" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -1121,15 +1012,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.34" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "memchr" -version = "2.8.3" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memoffset" @@ -1150,21 +1041,11 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "miniz_oxide" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -1186,12 +1067,6 @@ 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" @@ -1210,25 +1085,26 @@ dependencies = [ [[package]] name = "pango" -version = "0.22.9" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b2022dbbd82e1c42bd950a6f1df9b98c796e725dce5ec275e03cbf9772e4efa" +checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" dependencies = [ "gio", "glib", + "libc", "pango-sys", ] [[package]] name = "pango-sys" -version = "0.22.9" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca02d64761b74d56cdd4b437db6d73dc95e6c0d40f27e1c707952c0f09052948" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 9.0.0", + "system-deps", ] [[package]] @@ -1257,30 +1133,31 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "png" -version = "0.18.1" +version = "0.17.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" dependencies = [ - "bitflags", + "bitflags 1.3.2", "crc32fast", "fdeflate", "flate2", - "miniz_oxide 0.8.9", + "miniz_oxide", ] [[package]] -name = "polycool" -version = "0.4.0" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "arrayvec", + "proc-macro2", + "syn", ] [[package]] @@ -1289,27 +1166,33 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", + "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.107" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.47" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" 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" @@ -1324,18 +1207,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 1.0.69", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.20", + "thiserror", ] [[package]] @@ -1369,14 +1241,14 @@ checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "resvg" -version = "0.47.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +checksum = "4a325d5e8d1cebddd070b13f44cec8071594ab67d1012797c121f27a669b7958" dependencies = [ "log", "pico-args", @@ -1397,12 +1269,9 @@ dependencies = [ [[package]] name = "roxmltree" -version = "0.21.1" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" -dependencies = [ - "memchr", -] +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rustc_version" @@ -1419,7 +1288,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -1428,9 +1297,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "scopeguard" @@ -1446,9 +1315,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -1456,29 +1325,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -1489,22 +1358,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.21" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", + "syn", ] [[package]] @@ -1528,9 +1388,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.10" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simplecss" @@ -1555,15 +1415,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.5" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1571,9 +1431,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.9" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" dependencies = [ "lock_api", ] @@ -1587,17 +1447,11 @@ 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" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" dependencies = [ "kurbo", "siphasher", @@ -1605,20 +1459,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.119" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1634,33 +1477,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 1.1.4+spec-1.1.0", - "version-compare", -] - -[[package]] -name = "system-deps" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83779a5c956bcb6ba627a4ecf0a9d7625db47d7537e0892d97f712ac995648a3" -dependencies = [ - "cfg-expr", - "heck", - "pkg-config", - "toml 1.1.4+spec-1.1.0", - "version-compare", -] - -[[package]] -name = "system-deps" -version = "9.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0dae2cbca1f0ff93795713cfe0a4c02f760e3c0b8ae2ab4ec4045808ff429f" -dependencies = [ - "cfg-expr", - "heck", - "pkg-config", - "toml 1.1.4+spec-1.1.0", + "toml", "version-compare", ] @@ -1677,7 +1494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1689,16 +1506,7 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl", ] [[package]] @@ -1709,25 +1517,14 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", + "syn", ] [[package]] name = "tiny-skia" -version = "0.12.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" dependencies = [ "arrayref", "arrayvec", @@ -1740,9 +1537,9 @@ dependencies = [ [[package]] name = "tiny-skia-path" -version = "0.12.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" dependencies = [ "arrayref", "bytemuck", @@ -1751,9 +1548,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -1768,49 +1565,28 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.2" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] name = "toml" -version = "0.8.23" +version = "1.1.2+spec-1.1.0" 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.4+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", + "serde_spanned", + "toml_datetime", "toml_parser", "toml_writer", - "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", + "winnow", ] [[package]] @@ -1824,50 +1600,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", - "winnow 1.0.4", + "winnow", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.4", + "winnow", ] -[[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" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tracing" @@ -1888,7 +1644,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -1931,9 +1687,9 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "usvg" -version = "0.47.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +checksum = "7447e703d7223b067607655e625e0dbca80822880248937da65966194c4864e6" dependencies = [ "base64", "data-url", @@ -1951,19 +1707,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.26.0" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ - "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -1982,10 +1731,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasm-bindgen" -version = "0.2.127" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -1996,9 +1763,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2006,26 +1773,60 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -2109,27 +1910,112 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] [[package]] -name = "winnow" -version = "1.0.4" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "memchr", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] name = "xml-rs" -version = "0.8.29" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "xmlwriter" @@ -2139,9 +2025,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "zbus" -version = "5.19.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" dependencies = [ "async-broadcast", "async-recursion", @@ -2161,7 +2047,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.4", + "winnow", "zbus_macros", "zbus_names", "zvariant", @@ -2169,14 +2055,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.19.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.4", + "syn", "zbus_names", "zvariant", "zvariant_utils", @@ -2184,73 +2070,57 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.4" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow 1.0.4", + "winnow", "zvariant", ] -[[package]] -name = "zcheapstr" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" -dependencies = [ - "serde", -] - -[[package]] -name = "zlib-rs" -version = "0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" - [[package]] name = "zmij" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zvariant" -version = "5.15.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" dependencies = [ "endi", "enumflags2", "serde", - "winnow 1.0.4", - "zcheapstr", + "winnow", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.4", + "syn", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "4.2.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.4", - "winnow 1.0.4", + "syn", + "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 21f8fa6..de80fcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,31 +1,16 @@ [package] name = "breadbar" -version = "0.3.3" +version = "0.2.4" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" -authors = ["Breadway "] -repository = "https://git.breadway.dev/Breadway/breadbar" +authors = ["Breadway "] +repository = "https://github.com/Breadway/breadbar" keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["gtk"] } -# Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) -# for talking to breadd's IPC socket, and bread-shared purely for the -# WidgetSpec/WidgetNode wire types so we deserialize into real structs -# instead of hand-parsing serde_json::Value. See src/widgets/. -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["bread-client"] } -# bread_shared::widget wire types; match the bread-shared tag bread-utils -# pins (v0.8.0) so only one copy of bread-shared is linked. -bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0" } -# Capture primitives for `--screenshot` mode — see src/screenshot.rs. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5" } -# Headless app-launcher core + GTK4 results-list widget (plan §3/§7): theme -# 04/spotlight embeds the SAME `ResultsList` breadbox's overlay wraps, via -# `bar::launcher_results` — one implementation, two hosts. `gtk` feature for -# the results widget itself. -bread-launcher = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } @@ -35,11 +20,9 @@ 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 } +resvg = { version = "0.44", default-features = false } [profile.release] lto = "thin" diff --git a/README.md b/README.md index 3addfa1..7e58eff 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,13 @@ A single Rust binary that provides a full-width top bar, a D-Bus notification da - Live CPU%, GPU%, and network throughput (download/upload) - Audio output selector (lists PulseAudio sinks via `pactl`, switching takes effect immediately) - System tray (SNI): apps that register with `org.kde.StatusNotifierWatcher` appear as icon buttons -- Power buttons: lock (`breadlock`), suspend, reboot, poweroff +- Power buttons: lock (`hyprlock`), suspend, reboot, poweroff **Notification daemon**: - 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` -- 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**: @@ -139,11 +138,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 + `dev.breadway.Bar` history IPC | +| `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service | | `src/notifications/popup.rs` | Layer-shell popup window and card stack | -| `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 | 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/assets/Bluetooth Settings.svg b/assets/Bluetooth Settings.svg deleted file mode 100644 index c176fff..0000000 --- a/assets/Bluetooth Settings.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/Brightness.svg b/assets/Brightness.svg deleted file mode 100644 index 8afaa73..0000000 --- a/assets/Brightness.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/GPU.svg b/assets/GPU.svg deleted file mode 100644 index 03c7f1c..0000000 --- a/assets/GPU.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/Lock.svg b/assets/Lock.svg deleted file mode 100644 index 0be852f..0000000 --- a/assets/Lock.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/Next.svg b/assets/Next.svg deleted file mode 100644 index 44d5519..0000000 --- a/assets/Next.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/Pause.svg b/assets/Pause.svg deleted file mode 100644 index 3ff4ce2..0000000 --- a/assets/Pause.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/Play.svg b/assets/Play.svg deleted file mode 100644 index 2821df0..0000000 --- a/assets/Play.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/Previous.svg b/assets/Previous.svg deleted file mode 100644 index 23ce806..0000000 --- a/assets/Previous.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/Restart.svg b/assets/Restart.svg deleted file mode 100644 index b8b9c76..0000000 --- a/assets/Restart.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/Shutdown.svg b/assets/Shutdown.svg deleted file mode 100644 index 787d1a3..0000000 --- a/assets/Shutdown.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/Sleep.svg b/assets/Sleep.svg deleted file mode 100644 index a49d306..0000000 --- a/assets/Sleep.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/Volume.svg b/assets/Volume.svg deleted file mode 100644 index 3ffce7f..0000000 --- a/assets/Volume.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons-needed.txt b/assets/icons-needed.txt new file mode 100644 index 0000000..e228054 --- /dev/null +++ b/assets/icons-needed.txt @@ -0,0 +1,48 @@ +SVG icons needed for breadbar +============================== +24×24 viewBox. Use `currentColor` for all fill/stroke so icons recolour +automatically with the bar theme. Drop finished files in this directory. + + +Control panel — slider row icons +--------------------------------- +Volume.svg + Speaker / soundwave icon for the volume slider row. + Currently placeholder: 🔊 emoji label. + Usage: control panel, left of volume slider. + +Brightness.svg + Sun / light bulb icon for the brightness slider row. + Currently placeholder: ☀ emoji label. + Usage: control panel, left of brightness slider. + + +Power section buttons +----------------------- +Lock.svg + Padlock icon — triggers hyprlock (lock screen). + Currently placeholder: 🔒 + +Sleep.svg + Crescent moon or Zzz icon — triggers systemctl suspend. + Currently placeholder: 💤 + +Restart.svg + Circular arrow icon — triggers systemctl reboot. + Currently placeholder: 🔄 + +Shutdown.svg + Power symbol (⏻) icon — triggers systemctl poweroff. + Currently placeholder: ⏻ + + +How to wire up icons once SVGs are ready +----------------------------------------- +Each power button and slider row icon is currently a gtk4::Label with an emoji. +To replace with an SVG: + + 1. Add the SVG to this directory. + 2. In main.rs, replace the emoji Label with: + gtk4::Image::from_paintable(Some(&svg_texture(asset!("Icon Name.svg")))) + 3. For slider rows, replace the icon_lbl in build_slider_row() calls, + or add an overload that takes an image widget instead of a string. diff --git a/bakery.toml b/bakery.toml index 0226f34..bd1153f 100644 --- a/bakery.toml +++ b/bakery.toml @@ -3,8 +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"] -license_file = "LICENSE" +bread_deps = [] [config] dir = "~/.config/breadbar" diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev deleted file mode 100644 index 34e7aa9..0000000 --- a/ci/bread-ecosystem.rev +++ /dev/null @@ -1 +0,0 @@ -147cfbbf96ae4b171027defa1130d2caddb934b1 diff --git a/ci/build.sh b/ci/build.sh deleted file mode 100755 index 2d59cfe..0000000 --- a/ci/build.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -# Delegates to bread-ecosystem's shared CI build image/script, pinned to -# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI -# files now affect every product's release pipeline, so bumping the pin -# is a deliberate act instead of silent drift (see the bread-theme test -# that broke here for exactly that reason, before it was pinned by rev). -# -# Usage: ci/build.sh cargo build --release --locked -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")" - -CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}" -if [ ! -d "$CACHE_DIR" ]; then - rm -rf /tmp/bread-ecosystem-ci-* - git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR" - git -C "$CACHE_DIR" checkout --quiet "$REV" -fi - -bash "${CACHE_DIR}/ci/build.sh" breadbar "$ROOT" "$@" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..bb55a53 --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,36 @@ +# Maintainer: Breadway + +pkgname=breadbar +pkgver=0.2.0 +pkgrel=1 +pkgdesc="Minimal status bar and notification daemon for Hyprland" +arch=('x86_64') +url="https://github.com/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" +} diff --git a/src/bar/bluetooth.rs b/src/bar/bluetooth.rs deleted file mode 100644 index 422b298..0000000 --- a/src/bar/bluetooth.rs +++ /dev/null @@ -1,123 +0,0 @@ -use crate::{App, AppInput}; -use relm4::ComponentSender; -use std::fs; - -#[derive(Debug, Clone)] -pub struct BtDevice { - pub address: String, - pub name: String, - pub connected: bool, - pub paired: bool, -} - -#[derive(Debug, Clone)] -pub struct BtPopoverData { - pub powered: bool, - pub devices: Vec, -} - -/// Same rfkill scan `bar::stats` uses for the bar icon — kept independent -/// (rather than shared) since it's a two-line read and pulling in a shared -/// helper isn't worth the coupling. -fn powered() -> bool { - fs::read_dir("/sys/class/rfkill") - .into_iter() - .flatten() - .filter_map(|e| e.ok()) - .any(|e| { - let p = e.path(); - fs::read_to_string(p.join("type")) - .map(|t| t.trim() == "bluetooth") - .unwrap_or(false) - && fs::read_to_string(p.join("state")) - .map(|s| s.trim() == "1") - .unwrap_or(false) - }) -} - -async fn fetch_devices() -> Vec { - try_fetch_devices().await.unwrap_or_default() -} - -async fn try_fetch_devices() -> Option> { - let conn = zbus::Connection::system().await.ok()?; - let mgr = zbus::fdo::ObjectManagerProxy::builder(&conn) - .destination("org.bluez") - .ok()? - .path("/") - .ok()? - .build() - .await - .ok()?; - let objects = mgr.get_managed_objects().await.ok()?; - - let mut devices: Vec = objects - .values() - .filter_map(|ifaces| ifaces.get("org.bluez.Device1")) - .filter_map(|props| { - let paired = props - .get("Paired") - .and_then(|v| bool::try_from(v.clone()).ok()) - .unwrap_or(false); - if !paired { - return None; - } - let address = props - .get("Address") - .and_then(|v| String::try_from(v.clone()).ok())?; - let name = props - .get("Alias") - .or_else(|| props.get("Name")) - .and_then(|v| String::try_from(v.clone()).ok()) - .unwrap_or_else(|| address.clone()); - let connected = props - .get("Connected") - .and_then(|v| bool::try_from(v.clone()).ok()) - .unwrap_or(false); - Some(BtDevice { address, name, connected, paired }) - }) - .collect(); - - devices.sort_by(|a, b| b.connected.cmp(&a.connected).then(a.name.cmp(&b.name))); - Some(devices) -} - -pub fn spawn_popover_load(sender: ComponentSender) { - relm4::spawn(async move { - let devices = fetch_devices().await; - sender.input(AppInput::BtPopoverData(BtPopoverData { - powered: powered(), - devices, - })); - }); -} - -/// Fire-and-forget: toggle the adapter's rfkill soft-block. -pub fn spawn_set_powered(on: bool) { - relm4::spawn(async move { - let _ = tokio::process::Command::new("rfkill") - .args([if on { "unblock" } else { "block" }, "bluetooth"]) - .output() - .await; - }); -} - -/// Fire-and-forget: connect a paired device by address via `bluetoothctl`. -pub fn spawn_connect(address: String) { - relm4::spawn(async move { - let _ = tokio::process::Command::new("bluetoothctl") - .args(["connect", &address]) - .output() - .await; - }); -} - -/// Fire-and-forget: disconnect a device by address via `bluetoothctl`. -pub fn spawn_disconnect(address: String) { - relm4::spawn(async move { - let _ = tokio::process::Command::new("bluetoothctl") - .args(["disconnect", &address]) - .output() - .await; - }); -} diff --git a/src/bar/clock.rs b/src/bar/clock.rs index f7d897b..4501fde 100644 --- a/src/bar/clock.rs +++ b/src/bar/clock.rs @@ -1,34 +1,11 @@ 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 { - format!("{} {}", date(), time()) -} - -/// `modules.clock.format` rendered against GLib's own `DateTime::format` -/// (a strftime subset — `%H`, `%M`, `%a`, `%d`, `%m`, ... all work). Falls -/// back to [`time`]'s hardcoded "HH:MM" on a malformed format string rather -/// than propagating an error — a broken theme's clock format must degrade, -/// not crash the bar, same as every other "malformed theme" fallback in -/// this system. -pub fn formatted(format: &str) -> String { - now() - .format(format) - .map(|s| s.to_string()) - .unwrap_or_else(|_| time()) + 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) } pub fn spawn_ticker(sender: ComponentSender) { diff --git a/src/bar/control.rs b/src/bar/control.rs index 43cb6c3..ed99568 100644 --- a/src/bar/control.rs +++ b/src/bar/control.rs @@ -121,29 +121,11 @@ pub fn spawn_set_brightness(v: f64) { }); } -pub fn spawn_set_sink(name: String, sender: ComponentSender) { +pub fn spawn_set_sink(name: String) { relm4::spawn(async move { let _ = tokio::process::Command::new("pactl") .args(["set-default-sink", &name]) .output() .await; - // Default sink alone leaves already-playing streams on the old - // device — move them too so the switch is audible immediately. - if let Ok(o) = tokio::process::Command::new("pactl") - .args(["list", "short", "sink-inputs"]) - .output() - .await - { - for line in String::from_utf8_lossy(&o.stdout).lines() { - let Some(id) = line.split_whitespace().next() else { - continue; - }; - let _ = tokio::process::Command::new("pactl") - .args(["move-sink-input", id, &name]) - .output() - .await; - } - } - spawn_load(sender); }); } diff --git a/src/bar/mod.rs b/src/bar/mod.rs index 0d20edc..2563f23 100644 --- a/src/bar/mod.rs +++ b/src/bar/mod.rs @@ -1,8 +1,6 @@ -pub mod bluetooth; pub mod clock; pub mod control; pub mod media; -pub mod slots; pub mod stats; pub mod tray; pub mod wifi; diff --git a/src/bar/slots.rs b/src/bar/slots.rs deleted file mode 100644 index 87bf915..0000000 --- a/src/bar/slots.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Module registry for the theme manifest's `[bar.slots]` (plan Phase 3a), -//! extended in Phase 3b to also route `widget:` entries. -//! -//! Each bar module (`workspaces`, `media`, `clock`, `volume`, `wifi`, -//! `battery`, `control`, …) is still built exactly where it always was in -//! `main.rs` — this registry only decouples the ORDER in which those -//! already-constructed widgets get appended into the left/centre/right -//! containers from the fixed source-code order they were built in. main.rs -//! registers each widget by its manifest module name once construction is -//! done, then walks `ShellTheme::slots()` to append them in the theme's -//! order instead of a hardcoded sequence. -//! -//! A slot entry may also be `widget:`, where `` is either a -//! `WidgetPlacement` alias (`right_of_workspaces`, `left_of_clock`, -//! `right_of_clock`, `left_of_stats`, `tray`) or a Lua module name (see -//! `bread_shared::widget::WidgetSpec::module`) — these route through -//! `for_each_in_slot`'s `on_widget` callback rather than this registry, -//! since their containers are Lua-widget slots created on demand by the -//! caller, not modules registered here. `WidgetPlacement` itself is a wire -//! type from `bread-shared` and is never referenced in this file. - -use gtk4::prelude::*; -use std::collections::HashMap; - -/// Maps a `[bar.slots]` module name to its already-built widget. -#[derive(Default)] -pub struct ModuleRegistry(HashMap<&'static str, gtk4::Widget>); - -impl ModuleRegistry { - pub fn new() -> Self { - Self::default() - } - - /// Registers `widget` under `name` (a `[bar.slots]` module name, e.g. - /// `"workspaces"` or `"clock"`). - pub fn register(&mut self, name: &'static str, widget: &impl IsA) { - self.0.insert(name, widget.clone().upcast()); - } - - /// Walks every entry named in `names` (a manifest slot list, in theme - /// order). A `widget:` entry calls `on_widget(key)`, letting the - /// caller create-or-fetch that Lua widget container and append it at - /// this exact position. Anything else is looked up as a registered - /// module name and passed to `on_module`; a name with no registered - /// widget is logged and skipped — an unrecognized or unmapped module in - /// a theme manifest must never crash the bar. - pub fn for_each_in_slot( - &self, - names: &[String], - mut on_module: impl FnMut(&str, >k4::Widget), - mut on_widget: impl FnMut(&str), - ) { - for name in names { - if let Some(key) = name.strip_prefix("widget:") { - on_widget(key); - continue; - } - match self.0.get(name.as_str()) { - Some(widget) => on_module(name, widget), - None => eprintln!("breadbar: [bar.slots] names unknown module '{name}' — skipping"), - } - } - } -} - -/// Returns the widget container keyed `key` in `containers`, creating it -/// (a plain horizontal box, styled like every other Lua widget slot) on -/// first use. Called from `for_each_in_slot`'s `on_widget` callback so a -/// `widget:` slot entry gets a container the first time a theme -/// places one there, regardless of whether `key` is a `WidgetPlacement` -/// alias or a Lua module name — `reconcile_widgets` (main.rs) is what -/// gives that distinction meaning when it routes specs into these -/// containers. -pub fn widget_slot_container( - containers: &mut HashMap, - key: &str, -) -> gtk4::Box { - containers - .entry(key.to_string()) - .or_insert_with(|| { - let b = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - b.add_css_class("bread-widget-slot"); - b - }) - .clone() -} diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 26358e0..3991324 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -25,14 +25,6 @@ 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")); @@ -45,22 +37,11 @@ pub const BT_CONNECTED: &str = include_str!(concat!( "/assets/Bluetooth Connected.svg" )); -pub const ICON_VOLUME: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Volume.svg")); -pub const ICON_BRIGHTNESS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Brightness.svg")); -pub const ICON_LOCK: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Lock.svg")); -pub const ICON_SLEEP: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Sleep.svg")); -pub const ICON_RESTART: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Restart.svg")); -pub const ICON_SHUTDOWN: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Shutdown.svg")); -pub const ICON_BT_SETTINGS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Bluetooth Settings.svg")); - #[derive(Debug)] pub struct Stats { pub cpu: String, - pub cpu_pct: f32, pub mem: String, - pub mem_pct: f32, pub power: String, - pub power_watts: f32, pub bat: String, pub bat_icon: &'static str, pub ac_connected: bool, @@ -73,7 +54,6 @@ pub struct Stats { pub gpu_temp: Option, pub net_rx_kbs: f32, pub net_tx_kbs: f32, - pub volume_pct: u8, } struct CpuSnapshot { @@ -85,7 +65,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_ICON_OFF))); + LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_OFF))); static WIFI_TICK: AtomicU8 = AtomicU8::new(0); fn read_cpu() -> f32 { @@ -119,8 +99,7 @@ fn read_cpu() -> f32 { (dtotal - didle) as f32 / dtotal as f32 * 100.0 } -/// Returns (used_kb, total_kb). -fn read_ram() -> (u64, u64) { +fn read_ram() -> u64 { let text = fs::read_to_string("/proc/meminfo").unwrap_or_default(); let mut total = 0u64; let mut avail = 0u64; @@ -142,7 +121,7 @@ fn read_ram() -> (u64, u64) { break; } } - (total.saturating_sub(avail), total) + total.saturating_sub(avail) } fn bat_path() -> Option<&'static PathBuf> { @@ -280,7 +259,7 @@ fn wifi_iface() -> Option<&'static str> { async fn read_wifi() -> (String, &'static str) { let Some(iface) = wifi_iface() else { - return ("—".into(), WIFI_ICON_OFF); + return ("—".into(), WIFI_OFF); }; let link_out = tokio::process::Command::new("iw") @@ -290,7 +269,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_ICON_OFF), + _ => return ("—".into(), WIFI_OFF), }; let mut ssid = None; @@ -305,14 +284,13 @@ async fn read_wifi() -> (String, &'static str) { } let Some(ssid) = ssid else { - return ("—".into(), WIFI_ICON_OFF); + return ("—".into(), WIFI_OFF); }; let icon = match rssi { - 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, + Some(r) if r >= -55 => WIFI_STRONG, + Some(r) if r >= -70 => WIFI_MEDIUM, + _ => WIFI_WEAK, }; (ssid, icon) @@ -402,7 +380,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([' ', '=']) + .trim_start_matches(|c: char| c == ' ' || c == '=') .trim_matches('"'); if !val.is_empty() { return Some(val.to_string()); @@ -414,17 +392,10 @@ fn read_crumbs_profile() -> Option { pub async fn poll() -> Stats { let cpu = read_cpu(); - let (mem, mem_total) = read_ram(); - let mem_pct = if mem_total > 0 { - mem as f32 / mem_total as f32 * 100.0 - } else { - 0.0 - }; - let power_watts = read_power(); - let power = power_watts.map_or_else(|| "—W".into(), |w| format!("{w:.1}W")); + let mem = read_ram(); + let power = read_power().map_or_else(|| "—W".into(), |w| format!("{w:.1}W")); let pct = read_battery(); - // Demo bar prints the bare number ("83"), not "83%". - let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}")); + 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. @@ -453,18 +424,14 @@ 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, mem: if mem >= 1024 * 1024 { format!("{:.1}G", mem as f32 / (1024.0 * 1024.0)) } else { format!("{}M", mem / 1024) }, - mem_pct, power, - power_watts: power_watts.unwrap_or(0.0), bat, bat_icon, ac_connected, @@ -477,30 +444,9 @@ 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 cf5e272..6531ece 100644 --- a/src/bar/wifi.rs +++ b/src/bar/wifi.rs @@ -24,8 +24,6 @@ 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 { @@ -75,57 +73,31 @@ 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 out = tokio::time::timeout( - Duration::from_secs(4), - tokio::process::Command::new("nmcli") - .args(["-t", "-f", "SSID,SIGNAL,IN-USE", "device", "wifi", "list"]) + let Ok(Ok(out)) = tokio::time::timeout( + Duration::from_secs(10), + tokio::process::Command::new("breadcrumbs") + .args(["scan-list", "--json"]) .output(), ) - .await; - let Ok(Ok(o)) = out else { + .await + else { return vec![]; }; - 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()) { + 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() { return None; } - let saved = saved.contains(&ssid); - Some(ScanEntry { - ssid, - signal, - saved, - }) + 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 }) }) .collect() } @@ -142,32 +114,11 @@ pub fn spawn_status_poller(sender: ComponentSender) { }); } -/// Profiles first (so you can switch Home/Away immediately), then the -/// cached AP list. A background rescan refreshes the list if it finds more. +/// Called when the popover opens — loads profiles + scan in parallel. pub fn spawn_popover_load(sender: ComponentSender) { relm4::spawn(async move { - 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, - })); + let (profiles, scan) = tokio::join!(fetch_profile_list(), fetch_scan()); + sender.input(AppInput::WifiPopoverData(WifiPopoverData { profiles, scan })); }); } @@ -181,27 +132,29 @@ pub fn spawn_profile_set(name: String) { }); } -/// Fire-and-forget: connect to a known SSID via NetworkManager. +/// Fire-and-forget: connect to a specific saved SSID via `breadcrumbs join`. pub fn spawn_join(ssid: String) { relm4::spawn(async move { - let _ = tokio::process::Command::new("nmcli") - .args(["device", "wifi", "connect", &ssid]) + let _ = tokio::process::Command::new("breadcrumbs") + .args(["join", &ssid]) .output() .await; }); } -/// Save in breadcrumbs (if the CLI still accepts `add`) and connect with nmcli. +/// Fire-and-forget: save a new network with its password, then join it. pub fn spawn_add_and_join(ssid: String, password: String) { relm4::spawn(async move { - let _ = tokio::process::Command::new("breadcrumbs") + let added = tokio::process::Command::new("breadcrumbs") .args(["add", &ssid, &password]) .output() .await; - let _ = tokio::process::Command::new("nmcli") - .args(["device", "wifi", "connect", &ssid, "password", &password]) - .output() - .await; + if matches!(added, Ok(o) if o.status.success()) { + let _ = tokio::process::Command::new("breadcrumbs") + .args(["join", &ssid]) + .output() + .await; + } }); } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 26e4cdc..f3e2209 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -1,12 +1,7 @@ -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::{Monitors, Workspaces}, + data::{Workspace, Workspaces}, event_listener::{Event, EventStream}, prelude::*, shared::WorkspaceId, @@ -15,665 +10,43 @@ use relm4::ComponentSender; use crate::AppInput; -/// 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) { - 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) { relm4::spawn(async move { - sync_state(&sender).await; + 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)); + } - // Hyprland's IPC event socket can drop out from under us — a - // Hyprland restart/reload, or just a transient hiccup — at which - // point `stream.next()` yields `None` (or an `Err`, also excluded - // by this `while let Some(Ok(..))` pattern). That used to just fall - // through and end this whole task permanently, freezing every - // workspace button for the rest of the bar's life. Reconnect with a - // capped exponential backoff instead of giving up. - let mut backoff = std::time::Duration::from_millis(500); - const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30); - - loop { - let mut stream = EventStream::new(); - while let Some(Ok(event)) = stream.next().await { - backoff = std::time::Duration::from_millis(500); - match event { - Event::WorkspaceChanged(_) - | Event::WorkspaceAdded(_) - | Event::WorkspaceDeleted(_) => { - sync_state(&sender).await; - } - 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); - } - _ => {} + let mut stream = EventStream::new(); + while let Some(Ok(event)) = stream.next().await { + match event { + Event::WorkspaceChanged(data) => { + sender.input(AppInput::ActiveWorkspace(data.id)); } + Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => { + if let Ok(ws) = Workspaces::get_async().await { + sender.input(AppInput::WorkspaceList(ws.to_vec())); + } + } + _ => {} } - - eprintln!( - "breadbar: Hyprland event stream ended (restart/reload/IPC hiccup); \ - reconnecting in {:?}", - backoff - ); - tokio::time::sleep(backoff).await; - backoff = (backoff * 2).min(MAX_BACKOFF); - sync_state(&sender).await; } }); } -pub fn make_button( - id: WorkspaceId, - name: &str, - active: WorkspaceId, - occupied: bool, -) -> gtk4::Button { +pub fn make_button(id: WorkspaceId, name: &str, active: WorkspaceId) -> gtk4::Button { let btn = gtk4::Button::with_label(name); btn.add_css_class("workspace-btn"); - if occupied { - btn.add_css_class("occupied"); - } if id == active { btn.add_css_class("active"); } - btn.set_valign(gtk4::Align::Center); - btn.set_halign(gtk4::Align::Center); - btn.set_vexpand(false); - btn.set_hexpand(false); - // `crate::theme::approved_chip_height`, not `tokens().chip_height()`: - // the latter is the stale pre-demo `breadbar::CHIP_HEIGHT` token (32 - // for this Trail/Pill style's theme) and, as a hard `set_size_request` - // minimum, would out-rank the CSS `min-height` the demo actually wants - // (26px Trail / 22px Pill) — see that function's doc comment. - let style = crate::theme::shell_theme().modules().workspaces.style; - btn.set_size_request(-1, crate::theme::approved_chip_height(style) as i32); - if let Some(child) = btn.child() { - child.set_halign(gtk4::Align::Center); - child.set_valign(gtk4::Align::Center); - } btn.connect_clicked(move |_| { - relm4::spawn(async move { - switch_workspace(id).await; - }); + use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial}; + let _ = Dispatch::call(DispatchType::Workspace(WorkspaceIdentifierWithSpecial::Id( + id, + ))); }); btn } - -/// `style = "dots"` (theme 04/spotlight): a label-less pill whose WIDTH -/// encodes `windows` (0/1/2/3-or-more open). Distinct from [`make_button`] -/// (Trail/Pill) rather than a variant of it because dots carry no text at -/// all (`04-spotlight.html`'s `.dots button` has no label); reusing -/// `Button::with_label("")` would still measure/lay out an empty label box -/// that a genuinely childless button doesn't. Width is a hard -/// `set_size_request` snap, not animated — GTK CSS min-width transitions -/// don't participate in a directly-set size request the way an opacity/ -/// background-color transition does, and the plan only calls out the -/// capsule's own expand/collapse as worth the `anim::spring_to` treatment. -/// -/// `_dot_widths` (the manifest's `modules.workspaces.dot_widths`) is -/// accepted but deliberately unused — see `APPROVED_DOT_WIDTHS` below, -/// which overrides it with the approved Option B numbers the manifest's -/// own value predates. Kept in the signature rather than dropped so the -/// call site still documents where a real per-theme width would flow from -/// once `theme.toml` catches up. -pub fn make_dot_button( - id: WorkspaceId, - active: WorkspaceId, - windows: i32, - _dot_widths: bread_theme::shell::DotWidths, -) -> gtk4::Button { - let btn = gtk4::Button::new(); - btn.add_css_class("workspace-dot"); - if windows > 0 { - btn.add_css_class("occupied"); - } - if id == active { - btn.add_css_class("active"); - } - btn.set_valign(gtk4::Align::Center); - btn.set_halign(gtk4::Align::Center); - btn.set_vexpand(false); - btn.set_hexpand(false); - // Height is a deliberate departure from `04-spotlight.html`'s own 6px - // (see the demo's `.dots button { height: 6px }`): reported as "too - // small and hard to click", and also genuinely hard to *see* on a real - // display, not just hard to hit. Option B (approved): 10px tall — up - // from an earlier 9px pass that undershot the approved number by 1px. - // Keeps the dots reading as slim pills rather than growing into little - // chips (which would fight the capsule's minimal, text-first look), - // while being clearly perceptible against the 36px-tall bar. - const DOT_HEIGHT: i32 = 10; - // Option B widths (approved): 8/13/17/22px for 0/1/2/3-or-more open - // windows — `[8, 13, 17, 22]`, not the `dot_widths` parameter's own - // manifest value. `theme.toml`'s `modules.workspaces.dot_widths = - // [6, 10, 14, 18]` predates this pass and was never updated to match; - // hardcoded here (ignoring the passed-in `dot_widths`) rather than - // edited upstream, since bread-ecosystem is a sibling agent's repo - // this pass. Flagged in the task report — `dot_widths` should become - // `[8, 13, 17, 22]` in `assets/shell/spotlight/theme.toml`. - const APPROVED_DOT_WIDTHS: bread_theme::shell::DotWidths = [8, 13, 17, 22]; - btn.set_size_request( - APPROVED_DOT_WIDTHS[dot_width_index(windows)], - DOT_HEIGHT, - ); - btn.connect_clicked(move |_| { - relm4::spawn(async move { - switch_workspace(id).await; - }); - }); - btn -} - -/// Maps an open-window count to a [`bread_theme::shell::DotWidths`] index: -/// 0/1/2 pass through, 3-or-more all collapse onto index 3 (the demo's own -/// `.dots button[data-n="3"]` never has a "4" variant). Pulled out of -/// [`make_dot_button`] as its own pure function purely so it's testable -/// without a GTK display — the isolated screenshot harness -/// (`bread-capture`) has no Hyprland IPC, so it can never exercise a -/// nonzero window count, and this is what stands in for that visual proof -/// (see the task notes on that gap). -fn dot_width_index(windows: i32) -> usize { - (windows.max(0) as usize).min(3) -} - -#[cfg(test)] -mod dot_width_tests { - use super::dot_width_index; - - #[test] - fn zero_and_one_and_two_pass_through() { - assert_eq!(dot_width_index(0), 0); - assert_eq!(dot_width_index(1), 1); - assert_eq!(dot_width_index(2), 2); - } - - #[test] - fn three_or_more_all_collapse_onto_index_three() { - assert_eq!(dot_width_index(3), 3); - assert_eq!(dot_width_index(4), 3); - assert_eq!(dot_width_index(50), 3); - } - - #[test] - fn negative_windows_clamps_to_zero_rather_than_panicking() { - // Hyprland's `windows` count is unsigned (u16) in practice, but - // `make_dot_button` takes a plain i32 — a negative value must - // never underflow the `dot_widths` index and panic. - assert_eq!(dot_width_index(-1), 0); - assert_eq!(dot_width_index(i32::MIN), 0); - } -} - -#[derive(Clone, Copy)] -struct Geom { - x: f64, - y: f64, - w: f64, - h: f64, -} - -struct TrailInner { - tick: Option, - geom: Geom, - /// Last width/height measured from a button that was actually allocated. - /// A row rebuild (switching to an empty workspace makes Hyprland create - /// and destroy it) can leave every button unallocated for a frame, and - /// without a remembered size the trail had nothing safe to animate from - /// and fell back to an instant `place()` — which is what made a switch - /// snap instead of move. - natural: Option<(f64, f64)>, -} - -/// Overlay + Fixed pill sitting *behind* the workspace buttons. The -/// Overlay's measured size comes from the button row; the pill is the -/// main child so it paints underneath and never steals clicks. -pub struct WorkspaceTrail { - pub overlay: gtk4::Overlay, - pub buttons: gtk4::Box, - host: gtk4::Fixed, - pill: gtk4::Box, - inner: Rc>, -} - -impl WorkspaceTrail { - pub fn new() -> Self { - let overlay = gtk4::Overlay::new(); - overlay.add_css_class("workspace-overlay"); - overlay.set_valign(gtk4::Align::Center); - overlay.set_vexpand(false); - - let host = gtk4::Fixed::new(); - host.set_can_target(false); - - let pill = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - pill.add_css_class("workspace-trail"); - pill.set_can_target(false); - pill.set_visible(false); - host.put(&pill, 0.0, 0.0); - - let buttons = gtk4::Box::new(gtk4::Orientation::Horizontal, 1); - buttons.set_halign(gtk4::Align::Fill); - buttons.set_valign(gtk4::Align::Center); - buttons.set_vexpand(false); - - overlay.set_child(Some(&host)); - overlay.add_overlay(&buttons); - overlay.set_measure_overlay(&buttons, true); - - let inner = Rc::new(RefCell::new(TrailInner { - tick: None, - natural: None, - geom: Geom { - x: 0.0, - y: 0.0, - w: 0.0, - h: 0.0, - }, - })); - - Self { - overlay, - buttons, - host, - pill, - inner, - } - } - - pub fn cancel(&self) { - if let Some(id) = self.inner.borrow_mut().tick.take() { - id.remove(); - } - } - - pub fn clear(&self) { - self.cancel(); - self.pill.set_visible(false); - self.inner.borrow_mut().geom.w = 0.0; - } - - pub fn place(&self, btn: >k4::Button) { - self.cancel(); - if let Some(g) = button_geom(btn, &self.host) { - apply_geom(&self.host, &self.pill, &self.inner, &inset_pill(g)); - return; - } - let pill = self.pill.clone(); - let host = self.host.clone(); - let inner = self.inner.clone(); - let btn = btn.clone(); - let id = self.overlay.add_tick_callback(move |_, _| { - let Some(g) = button_geom(&btn, &host) else { - return ControlFlow::Continue; - }; - apply_geom(&host, &pill, &inner, &inset_pill(g)); - inner.borrow_mut().tick = None; - ControlFlow::Break - }); - self.inner.borrow_mut().tick = Some(id); - } - - pub fn stretch(&self, from: Option<>k4::Button>, to: >k4::Button) { - self.cancel(); - let Some(from_g) = self.from_geom(from, to) else { - self.place(to); - return; - }; - let dest = to.clone(); - let pill = self.pill.clone(); - let host = self.host.clone(); - let inner = self.inner.clone(); - let started = Instant::now(); - let id = self.overlay.add_tick_callback(move |_, _| { - let to_g = resolved_dest(&dest, &host, &from_g); - let mid = { - let span_x = from_g.x.min(to_g.x); - let span_w = (from_g.x + from_g.w).max(to_g.x + to_g.w) - span_x; - Geom { - x: span_x, - y: to_g.y, - w: span_w, - h: to_g.h, - } - }; - let elapsed = started.elapsed().as_secs_f64() * 1000.0; - let (g, done) = if elapsed < STRETCH_MS { - let t = ease(elapsed / STRETCH_MS); - (lerp_geom(&from_g, &mid, t), false) - } else if elapsed < STRETCH_MS + SNAP_MS { - let t = ease_overshoot((elapsed - STRETCH_MS) / SNAP_MS); - (lerp_geom(&mid, &to_g, t), false) - } else { - (to_g, true) - }; - // Squash-and-stretch (ANIMATION WORK #1): compress the pill's - // height a few px while it's mid-flight and let it spring back - // — slightly taller than normal, then settling — as it lands, - // so the trail reads as having weight instead of sliding like a - // rigid box. A pure post-process on top of the already-correct - // x/w/y trajectory above, applied only to the frame actually - // painted (`g`) — never fed back into `TrailInner::natural` - // (`from_geom`'s own width/height memory for the *next* - // animation), which stays driven solely by real button geometry - // as before. Skipped on the final `done` frame so the resting - // geometry is exactly `to_g`, unperturbed. - let g = if done { g } else { squash_geom(g, squash_factor(elapsed)) }; - apply_geom(&host, &pill, &inner, &g); - if done { - inner.borrow_mut().tick = None; - ControlFlow::Break - } else { - ControlFlow::Continue - } - }); - self.inner.borrow_mut().tick = Some(id); - } - - // `from` is a noun here (the source button we animate away from), not a - // conversion — `&self` is correct. - #[allow(clippy::wrong_self_convention)] - fn from_geom(&self, from: Option<>k4::Button>, to: >k4::Button) -> Option { - let st = self.inner.borrow(); - let live = if self.pill.is_visible() && st.geom.w > 0.5 { - Some(st.geom) - } else { - None - }; - let cached = st.natural; - drop(st); - - // Width must always come from a button that is CURRENTLY IN THE ROW. - // Switching to an empty workspace makes Hyprland create and destroy it, - // which rebuilds the button row mid-animation and can leave `from` - // detached — `button_geom` then returns None. Falling back to the live - // geometry there handed the wide mid-stretch span straight back in, - // which is exactly the accumulation this function exists to prevent - // (reproduced by spamming between workspace 1 and an empty 6). The - // destination button is always live, so it is the correct fallback. - let natural = from - .and_then(|b| button_geom(b, &self.host).map(inset_pill)) - .or_else(|| button_geom(to, &self.host).map(inset_pill)); - - // Continuity without accumulation. - // - // Interrupting an in-flight stretch should carry the pill's CURRENT - // POSITION into the next animation, so a rapid sequence of switches - // reads as one continuous movement. It must not carry the current - // WIDTH: mid-stretch the pill deliberately spans both the old and new - // buttons, and `ease_overshoot` (c = 1.4) pushes it wider still past - // the target. Feeding that span back in as the next `from` made each - // interrupted switch start wider than the last, so spamming workspace - // switches grew the pill until it hit MAX_CHIP_W — which only capped - // the runaway, it never stopped the compounding. - // - // Taking position from the live geometry and width from the source - // button's natural size keeps the motion continuous while making width - // a pure function of which button we started from. - if let Some(n) = natural { - self.inner.borrow_mut().natural = Some((n.w, n.h)); - } - // Only x comes from the live geometry. That is what makes an - // interrupted switch continue from where the pill currently is instead - // of jumping back. y/w/h always come from a real button: taking y from - // a mid-animation or post-rebuild geometry is what made the pill sit - // low, and taking w from it is what let the width compound. - let size = natural.map(|n| (n.w, n.h)).or(cached); - match (live, natural, size) { - (Some(live), _, Some((w, h))) => Some(Geom { - x: live.x, - y: natural.map(|n| n.y).unwrap_or(live.y), - w, - h, - }), - (None, Some(natural), _) => Some(natural), - _ => None, - } - } -} - -/// Keep the trail slimmer than the hit target so the fill doesn't look -/// like a second, fatter button. -const PILL_INSET_X: f64 = 5.0; -const PILL_INSET_Y: f64 = 3.0; -/// One workspace chip is a digit + padding. Wider than this is the overlay -/// or the whole button row leaking through `compute_bounds`. -const MAX_CHIP_W: f64 = 72.0; - -fn inset_pill(g: Geom) -> Geom { - let w = (g.w - PILL_INSET_X * 2.0).max(10.0); - let h = (g.h - PILL_INSET_Y * 2.0).max(18.0); - Geom { - x: g.x + (g.w - w) * 0.5, - y: g.y + (g.h - h) * 0.5, - w, - h, - } -} - -fn resolved_dest(btn: >k4::Button, host: >k4::Fixed, from: &Geom) -> Geom { - match button_geom(btn, host) { - Some(g) if !still_placeholder(btn, &g) => inset_pill(g), - Some(g) => { - let centered = inset_pill(g); - Geom { - x: centered.x + (centered.w - from.w) * 0.5, - y: centered.y + (centered.h - from.h) * 0.5, - w: from.w, - h: from.h, - } - } - None => *from, - } -} - -/// Position in the Fixed host's space — that's what `host.move_` uses. -/// Measuring against the Overlay instead left the pill a few px left of -/// the digit whenever the host and overlay origins disagreed. -fn button_geom(btn: >k4::Button, host: >k4::Fixed) -> Option { - let r = btn.compute_bounds(host)?; - let w = f64::from(r.width()); - let h = f64::from(r.height()); - if w < 8.0 || h < 8.0 || w > MAX_CHIP_W { - return None; - } - Some(Geom { - x: f64::from(r.x()), - y: f64::from(r.y()), - w, - h, - }) -} - -fn apply_geom(host: >k4::Fixed, pill: >k4::Box, inner: &Rc>, g: &Geom) { - inner.borrow_mut().geom = Geom { - x: g.x, - y: g.y, - w: g.w, - h: g.h, - }; - let w = g.w.max(1.0).round() as i32; - let h = g.h.max(1.0).round() as i32; - // Clearing first lets GTK shrink; size-request is a minimum. - pill.set_size_request(-1, -1); - pill.set_size_request(w, h); - host.move_(pill, g.x, g.y); - pill.set_visible(true); -} - -fn still_placeholder(btn: >k4::Button, g: &Geom) -> bool { - let (min_w, nat_w, _, _) = btn.measure(gtk4::Orientation::Horizontal, -1); - g.w <= f64::from(min_w) + 1.0 || g.w + 0.5 < f64::from(nat_w) -} - -fn lerp(a: f64, b: f64, t: f64) -> f64 { - a + (b - a) * t -} - -fn lerp_geom(a: &Geom, b: &Geom, t: f64) -> Geom { - Geom { - x: lerp(a.x, b.x, t), - y: lerp(a.y, b.y, t), - w: lerp(a.w, b.w, t), - h: lerp(a.h, b.h, t), - } -} - -fn ease(t: f64) -> f64 { - let t = t.clamp(0.0, 1.0); - t * t * (3.0 - 2.0 * t) -} - -/// Approximates the demo's cubic-bezier(.22, 1.4, .36, 1) snap. -fn ease_overshoot(t: f64) -> f64 { - let t = t.clamp(0.0, 1.0); - let c = 1.4; - let t1 = t - 1.0; - 1.0 + t1 * t1 * ((c + 1.0) * t1 + c) -} - -/// Lowest fraction of the resting height the pill compresses to, at the -/// peak of the stretch phase (fastest travel). -const SQUASH_MIN: f64 = 0.80; - -/// The squash-and-stretch height multiplier for a given point in -/// `stretch`'s own STRETCH_MS-then-SNAP_MS timeline: eases DOWN to -/// `SQUASH_MIN` across the stretch phase (using the same `ease` curve the -/// width stretch already uses), then eases back UP to 1.0 across the snap -/// phase using `ease_overshoot` — which legitimately overshoots past 1.0 -/// partway through, so the pill also plumps up slightly taller than its -/// resting height right before settling, the same "spring" read the width -/// snap already has. Past both phases (a caller-side `elapsed` this large -/// only happens if something calls this after `stretch`'s own `done` cutoff) -/// this is exactly 1.0, i.e. a no-op. -fn squash_factor(elapsed: f64) -> f64 { - if elapsed < STRETCH_MS { - lerp(1.0, SQUASH_MIN, ease(elapsed / STRETCH_MS)) - } else if elapsed < STRETCH_MS + SNAP_MS { - lerp(SQUASH_MIN, 1.0, ease_overshoot((elapsed - STRETCH_MS) / SNAP_MS)) - } else { - 1.0 - } -} - -/// Applies a height multiplier to `g`, keeping it vertically centred on -/// `g`'s own centre (so the squash reads as compression, not a pill that -/// sinks or rises) and leaving x/w untouched. -fn squash_geom(g: Geom, factor: f64) -> Geom { - let h = g.h * factor; - Geom { - x: g.x, - y: g.y + (g.h - h) * 0.5, - w: g.w, - h, - } -} - -#[cfg(test)] -mod squash_tests { - use super::*; - - #[test] - fn factor_starts_and_ends_at_one() { - assert!((squash_factor(0.0) - 1.0).abs() < 1e-9); - assert!((squash_factor(STRETCH_MS + SNAP_MS) - 1.0).abs() < 1e-9); - assert_eq!(squash_factor(STRETCH_MS + SNAP_MS + 500.0), 1.0); - } - - #[test] - fn factor_dips_below_one_mid_stretch() { - let mid_stretch = STRETCH_MS * 0.5; - assert!(squash_factor(mid_stretch) < 1.0); - assert!(squash_factor(mid_stretch) >= SQUASH_MIN); - } - - #[test] - fn squash_geom_keeps_the_vertical_centre_fixed() { - let g = Geom { - x: 10.0, - y: 20.0, - w: 30.0, - h: 26.0, - }; - let centre = g.y + g.h * 0.5; - let squashed = squash_geom(g, 0.8); - assert!((squashed.h - 20.8).abs() < 1e-9); - assert!((squashed.y + squashed.h * 0.5 - centre).abs() < 1e-9); - assert_eq!(squashed.x, g.x); - assert_eq!(squashed.w, g.w); - } - - #[test] - fn squash_geom_factor_one_is_identity() { - let g = Geom { - x: 1.0, - y: 2.0, - w: 3.0, - h: 4.0, - }; - let out = squash_geom(g, 1.0); - assert_eq!(out.y, g.y); - assert_eq!(out.h, g.h); - } -} diff --git a/src/launcher_command.rs b/src/launcher_command.rs deleted file mode 100644 index 798f5ec..0000000 --- a/src/launcher_command.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Subscribes to `bread.command.box.open` and focuses/opens the capsule — -//! only under `[launcher] mode = "embedded"` (spotlight, THEME_SYSTEM_PLAN.md -//! §7 phase 6c). See `breadbox/EVENTS.md` for the command's existing -//! contract: it's honored today only while `breadbox listen` is running, and -//! is a silent no-op at the bus with no subscriber. breadbar becomes a -//! SECOND subscriber of the exact same verb here — under an embedded theme, -//! breadbox's own `main` (see its doc comment on `dispatch_embedded_open`) -//! redirects a direct launch to this same event instead of mapping its own -//! overlay window, specifically so this module can pick it up. `breadbox -//! listen`'s own handling of the same event is separately made a no-op -//! under an embedded theme (see its `handle_open`) — there is exactly one -//! real handler for this event at a time, whichever theme is active. -//! -//! Same connection pattern as `widgets::client` (this crate's other -//! `BreadClient::subscribe` user): a fire-and-forget connect, a background -//! subscription thread with its own reconnect/backoff, and the handle is -//! leaked rather than threaded through `App` — there's no natural point to -//! stop it before the process exits. - -use crate::{App, AppInput}; -use bread_theme::shell::LauncherMode; -use bread_utils::bread_client::BreadClient; -use relm4::ComponentSender; - -/// Starts the subscription iff the active shell theme's launcher is -/// `Embedded`. A no-op call under every other theme — never connects to -/// breadd at all, matching the "effectively a no-op under every other -/// theme" pattern the rest of the capsule wiring already follows (main.rs's -/// `open_fn`/`close_fn` doc comment). -pub fn spawn(sender: ComponentSender) { - if crate::theme::shell_theme().launcher().mode != LauncherMode::Embedded { - return; - } - let client = BreadClient::connect(crate::widgets::client::APP_ID); - // Two verbs, deliberately. - // - // `bread.box.open_requested` is what breadbox actually emits when the - // active theme is embedded: an app may only publish inside its own - // `bread..*` namespace, so breadbox (app id `box`) cannot emit a - // `bread.command.*` event at all — bread-client refuses it outright. - // - // `bread.command.box.open` is kept because it is the addressed-TO-an-app - // command form, which is what an external trigger (the `bread` CLI, a - // keybind, another app) would legitimately send. Honouring both means the - // capsule opens whether it was asked directly or told by breadbox. - for verb in ["bread.box.open_requested", "bread.command.box.open"] { - let sender = sender.clone(); - let subscription = client.subscribe(verb, move |_event| { - sender.input(AppInput::OpenLauncher); - }); - std::mem::forget(subscription); - } -} diff --git a/src/main.rs b/src/main.rs index fe1ae21..2767846 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,255 +5,34 @@ macro_rules! asset { } mod bar; -mod launcher_command; mod notifications; mod osd; -mod panel; -mod screenshot; -mod surface; mod theme; -mod widgets; -use bread_launcher::gtk::ResultsList; -use bread_theme::shell::{ClockStyle, Exclusive, Keyboard, Width, WorkspaceStyle}; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; +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::cell::RefCell; use std::rc::Rc; -/// The launched-app event's publisher id and event name — deliberately -/// matching breadbox's OWN constants (`breadbox/src/main.rs`: `APP_ID = -/// "box"`, `LAUNCHED_EVENT = "bread.box.launched"`), NOT breadbar's own -/// `widgets::client::APP_ID` ("bar"). Theme 04/spotlight's capsule IS the -/// launcher wearing a different shell (plan §7), sharing breadbox's cache -/// and history via `bread_launcher::LAUNCHER_APP` — it publishes under that -/// same launcher identity too, so anything downstream listening for "an app -/// was launched via the launcher" sees one event stream regardless of which -/// surface launched it. -const LAUNCHER_APP_ID: &str = "box"; -const LAUNCHER_LAUNCHED_EVENT: &str = "bread.box.launched"; - -/// Diagnostic trace for the capsule's open/close/focus wiring, gated -/// behind an env var so it costs nothing by default. Three previous fixes -/// for the "opens itself at startup with no focus" bug all failed because -/// nothing could observe which path actually fired on a real desktop — -/// this makes that observable: `BREADBAR_CAPSULE_DEBUG=1 breadbar` prints -/// every call to `open_fn`/`close_fn` and every signal that could lead to -/// one, with enough context (source, open state) to tell a legitimate -/// user-driven open from an accidental one after the fact. -fn capsule_debug_enabled() -> bool { - std::env::var_os("BREADBAR_CAPSULE_DEBUG").is_some() -} - -macro_rules! capsule_trace { - ($($arg:tt)*) => { - if capsule_debug_enabled() { - eprintln!("[capsule] {}", format!($($arg)*)); - } - }; -} - -/// The drawer's own content-height ceiling (`04-spotlight.html`: `.searching -/// .results { max-height: 420px }`) — see [`drawer_target_height`]. Also -/// used to size the click-away scrim's dead zone (see `open_fn` in `init`): -/// the scrim's clickable region never reaches higher than the capsule row -/// plus this much, so it can never overlap a real result row regardless of -/// how tall the drawer currently is. -const DRAWER_MAX_HEIGHT_PX: i32 = 420; - -/// `bar.window.exclusive` resolved to a layer-shell exclusive-zone pixel -/// value (axis 2, daylight). `Exclusive::Auto` reserves `height` plus the -/// margin on whichever edge the bar is actually anchored to — `margin.top` -/// for every theme through spotlight (all top-anchored), `margin.bottom` -/// for daylight's bottom-anchored dock. Before daylight this always read -/// `margin.top` unconditionally, which happened to be correct for every -/// existing theme (a bottom-anchored bar had never shipped, so -/// `margin.bottom` was always 0 and never observed) but reserved 12px too -/// little for daylight (`height` alone, not `height + margin.bottom`) — -/// tiled windows would have run up under the last 12px of the floating -/// dock. Extracted to a pure function so this is unit-testable without a -/// live layer-shell surface. -fn exclusive_zone_for(spec: &bread_theme::shell::WindowSpec) -> i32 { - let anchor_margin = if spec.anchors.iter().any(|a| a == "bottom") { - spec.margin.bottom - } else { - spec.margin.top - }; - match spec.exclusive { - Exclusive::Auto => spec.height + anchor_margin, - Exclusive::None => -1, - Exclusive::Px(px) => px, - } -} - -#[cfg(test)] -mod exclusive_zone_tests { - use super::exclusive_zone_for; - use bread_theme::shell::{Exclusive, Margin, Width, WindowSpec}; - - fn spec(anchors: &[&str], height: i32, margin: Margin, exclusive: Exclusive) -> WindowSpec { - WindowSpec { - anchors: anchors.iter().map(|s| s.to_string()).collect(), - width: Width::Fill, - height, - margin, - exclusive, - keyboard: bread_theme::shell::Keyboard::None, - layer: "top".to_string(), - } - } - - #[test] - fn top_anchored_auto_reserves_height_plus_top_margin() { - let s = spec( - &["top", "left", "right"], - 44, - Margin { - top: 12, - left: 16, - right: 16, - bottom: 0, - }, - Exclusive::Auto, - ); - assert_eq!(exclusive_zone_for(&s), 56); - } - - #[test] - fn bottom_anchored_auto_reserves_height_plus_bottom_margin_not_top() { - // The daylight case: margin.top is 0 (nothing reserved there), the - // real floating gap is margin.bottom. - let s = spec( - &["bottom", "left", "right"], - 40, - Margin { - top: 0, - left: 0, - right: 0, - bottom: 12, - }, - Exclusive::Auto, - ); - assert_eq!(exclusive_zone_for(&s), 52); - } - - #[test] - fn none_is_no_reservation_regardless_of_anchor() { - let s = spec( - &["bottom"], - 40, - Margin::default(), - Exclusive::None, - ); - assert_eq!(exclusive_zone_for(&s), -1); - } - - #[test] - fn explicit_px_wins_over_computed_auto_value() { - let s = spec( - &["bottom", "left", "right"], - 40, - Margin { - bottom: 12, - ..Margin::default() - }, - Exclusive::Px(99), - ); - assert_eq!(exclusive_zone_for(&s), 99); - } -} - -pub struct BarInit { - pub screenshot: Option, - pub monitor: Option, - pub primary: bool, -} - pub struct App { - monitor: String, - primary: bool, - satellites: Vec<(String, Controller)>, - // ── Workspaces ──────────────────────────────────────────────────────── workspaces: Vec, active_ws: WorkspaceId, workspace_box: gtk4::Box, - workspace_trail: bar::workspaces::WorkspaceTrail, button_map: std::collections::HashMap, // ── Clock ───────────────────────────────────────────────────────────── time_str: String, - clock_digits: Vec, - date_lbl: gtk4::Label, - // `modules.clock.style = "plain"` (glass-workbench, Phase 5): a plain - // "HH:MM" label with no per-digit flip. Built alongside `clock_digits` - // regardless of the active theme's style so switching styles needs no - // recompile; only one of the two ever lands in a `[bar.slots]` module - // registration (see the "Assemble" section). - clock_plain_lbl: gtk4::Label, - // `modules.clock.placeholder_clock` (spotlight, theme 04): when set, - // `AppInput::ClockTick` writes the time into this entry's placeholder - // text instead of (or alongside) any clock label — see that handler. - launcher_entry: gtk4::Entry, - // Whether the capsule's drawer is currently expanded — read by - // `ClockTick` so a live search in progress never has its placeholder - // text stomped (it wouldn't be visible anyway once there's real text, - // but matches the demo's own `if (!open) q.placeholder = t;` guard). - launcher_open: Rc>, - // `AppInput::OpenLauncher`'s local-route arm needs to actually open the - // capsule, not just move keyboard focus onto `launcher_entry` — focus - // alone stopped opening it when `connect_enter`'s `open_fn()` call was - // removed (see that handler's own comment for why: it was the - // mechanism behind the startup-open bug). This is `init`'s `open_fn`, - // stored so the hotkey/command path can call it directly, the same way - // the click gesture and `connect_changed` do at their own call sites. - launcher_open_fn: Rc, + clock_lbl: gtk4::Label, // ── Stats bar ───────────────────────────────────────────────────────── - // Island chrome matches the Liquid Motion demo: volume / wifi / battery - // / hamburger. CPU/RAM/power live in the control panel, not on the bar. - // Held to keep the control-panel sys-grid widgets alive for the App's - // lifetime; their parent boxes own them, so they're never read back. - #[allow(dead_code)] - system_stats_box: gtk4::Box, - system_sep: gtk4::Separator, - #[allow(dead_code)] - cpu_pair: gtk4::Box, - #[allow(dead_code)] - mem_pair: gtk4::Box, - #[allow(dead_code)] - pwr_pair: gtk4::Box, - gpu_pair: gtk4::Box, cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, - // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench, Phase - // 5): separate instances from `cpu_pair`/`mem_pair` above, which stay - // parented in the control panel's sys-grid — a GTK widget can only have - // one parent, so reusing those here would mean reparenting them out of - // the panel, changing panel behaviour no theme asked to change. Fed by - // the same `AppInput::StatsUpdate` data. - bar_cpu_lbl: gtk4::Label, - bar_ram_lbl: gtk4::Label, - gpu_lbl: gtk4::Label, - // Odometer-style digit chips (plan: "ODOMETER DIGITS FOR NUMERIC - // CHIPS") — reuses the clock's `make_clock_digits`/`flip_clock_digits` - // machinery (see `make_digit_chip`/`flip_digit_chip` below), so the - // volume/battery numbers roll per-digit instead of snapping. `Rc>`, - // not a plain `Vec`, because the label set is also mutated from the - // control panel's volume-slider `connect_value_changed` closure (set up - // in `init`, before `self` exists) as well as from `update`'s - // `StatsUpdate` handler — both need to see and replace the same digit - // set. - vol_lbl: gtk4::Box, - vol_digits: Rc>>, - bat_lbl: gtk4::Box, - bat_digits: Rc>>, + bat_lbl: gtk4::Label, bat_img: gtk4::Image, bat_textures: std::collections::HashMap, ac_img: gtk4::Image, @@ -261,95 +40,57 @@ 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, + wifi_popover_box: gtk4::Box, crumbs_status: Option, wifi_popover_data: Option, wifi_profile: Option, current_ssid: String, - // ── Bluetooth popover ──────────────────────────────────────────────── - bt_pane: gtk4::Box, - bt_popover_data: Option, - // ── Media ───────────────────────────────────────────────────────────── media_widget: gtk4::Box, media_track_lbl: gtk4::Label, - media_play_icon: gtk4::Image, + media_play_btn: gtk4::Button, media_last: Option, media_paused_at: Option, // ── Control panel ───────────────────────────────────────────────────── + control_popover: gtk4::Popover, panel_vol_slider: gtk4::Scale, panel_bright_slider: gtk4::Scale, panel_loading: Rc>, - sink_box: gtk4::Box, - sink_section: gtk4::Box, + panel_sink_store: gtk4::StringList, + panel_sink_dropdown: gtk4::DropDown, + panel_sink_signal: Option, + panel_sinks: Vec, + panel_cpu_lbl: gtk4::Label, + panel_gpu_lbl: gtk4::Label, + panel_net_lbl: gtk4::Label, // ── Tray ────────────────────────────────────────────────────────────── - tray_section: gtk4::Box, - tray_sep: gtk4::Separator, tray_box: gtk4::Box, tray_items: std::collections::HashMap, - - // ── Lua-declared widgets ───────────────────────────────────────────── - // One container per `widget:` slot entry (Phase 3b — see - // bar::slots::ModuleRegistry and reconcile_widgets' routing below), - // fully rebuilt on every AppInput::WidgetsUpdate — see widgets::client's - // module doc for why that's simpler than incremental patching here. - // Keyed by the slot entry's key: either a WidgetPlacement alias - // (`right_of_workspaces`, `left_of_clock`, `right_of_clock`, - // `left_of_stats`, `tray`) or a Lua module name. `bread_shared::widget`'s - // `WidgetPlacement` itself never appears here — it's a wire type from - // the bread daemon API and stays untouched. - widget_containers: std::collections::HashMap, - /// Widget ids already reported as undeliverable by `reconcile_widgets`, so - /// the warning fires once per widget instead of once per reconcile. - dropped_widget_warned: std::collections::HashSet, - widget_tray_section: gtk4::Box, - widget_tray_sep: gtk4::Separator, - - panels: panel::PanelSet, } #[derive(Debug)] pub enum AppInput { - WorkspaceSync { - workspaces: Vec, - actives: std::collections::HashMap, - }, - MonitorAdded(String), - MonitorRemoved(String), + WorkspaceList(Vec), + ActiveWorkspace(WorkspaceId), ClockTick, StatsUpdate(bar::stats::Stats), TrayUpdate(bar::tray::TrayUpdate), CrumbsStatus(bar::wifi::CrumbsStatus), WifiPopoverData(bar::wifi::WifiPopoverData), SetProfile(String), - BtPopoverData(bar::bluetooth::BtPopoverData), MediaUpdate(bar::media::MediaState), ControlPanelData(bar::control::ControlPanelData), - WidgetsUpdate(Vec), - ReconcileMonitors, - DismissPanels, - // `bread.command.box.open` (plan §7 phase 6c, `launcher_command` - // module): only ever dispatched when the active theme's launcher is - // `Embedded` — `launcher_command::spawn` never subscribes otherwise. - // Focuses `launcher_entry` AND calls `self.launcher_open_fn()` - // directly (its handler below) — the hotkey/command itself is the - // real-user-input signal, the same status a mouse click into the - // entry has at its own call site. Deliberately does NOT rely on - // `grab_focus()` triggering `EventControllerFocus::connect_enter` to - // open it as a side effect any more; that indirection was the actual - // mechanism behind the startup-open bug (see `connect_enter`'s own - // comment in `init`). - OpenLauncher, } #[relm4::component(pub)] impl SimpleComponent for App { - type Init = BarInit; + type Init = (); type Input = AppInput; type Output = (); @@ -357,230 +98,42 @@ impl SimpleComponent for App { gtk::ApplicationWindow { add_css_class: "breadbar", set_title: Some("breadbar"), - set_default_height: bar_height, + set_default_height: 32, - // Root is a vbox (bar row + drawer), not a bare CenterBox, per - // plan §2/§11: `drawer` is the only structural thing Capsule/ - // theme-04 adds over Island/Edge, and it's a slot below the bar - // row, not a separate layout code path. `drawer_box` starts - // empty and zero-height for every theme that never names a - // module in `[bar.slots].drawer` (liquid-motion, glass- - // workbench) — see main.rs's "Assemble" section and - // `theme.rs`'s `window.breadbar > box > centerbox` selector - // update for why this is a no-op for both. - #[name = "root_vbox"] - gtk::Box { - set_orientation: gtk4::Orientation::Vertical, + #[name = "center_box"] + gtk::CenterBox { + #[wrap(Some)] + set_start_widget = >k::Box { + set_orientation: gtk::Orientation::Horizontal, + set_spacing: 0, - #[name = "center_box"] - gtk::CenterBox { - // Fill the bar's height. Without this the CenterBox takes - // only its natural height and sits at the TOP of the vbox, - // leaving the remainder as dead space along the bottom - // edge — so every valign:Center child centred within a - // short box rather than within the bar, and the whole row - // rode high. Regression from wrapping the bar row in a - // vbox to gain the drawer slot. - set_vexpand: true, - }, - - #[name = "drawer_box"] - gtk::Box { - set_orientation: gtk4::Orientation::Vertical, + #[name = "workspace_box"] + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_spacing: 4, + } }, } } } fn init( - init: Self::Init, + _: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { - let screenshot_req = init.screenshot; - let monitor_name = init - .monitor - .clone() - .or_else(primary_hypr_monitor) - .unwrap_or_else(|| "eDP-1".into()); - - // `bar.window` (plan §2/§6) — window shape is data, not a closed - // layout enum. Read once and reused below for the layer-shell setup - // and (via `bar_height`, captured for the view! macro above) the - // root window's initial GTK height. - let window_spec = theme::shell_theme().window().clone(); - let bar_height = window_spec.height; - root.init_layer_shell(); root.set_namespace(Some("breadbar")); - root.set_layer(if window_spec.layer == "overlay" { - Layer::Overlay - } else { - Layer::Top - }); - for anchor in &window_spec.anchors { - match anchor.as_str() { - "top" => root.set_anchor(Edge::Top, true), - "bottom" => root.set_anchor(Edge::Bottom, true), - "left" => root.set_anchor(Edge::Left, true), - "right" => root.set_anchor(Edge::Right, true), - other => eprintln!( - "breadbar: bar.window.anchors entry \"{other}\" is not top|bottom|left|right, ignoring" - ), - } - } - root.set_margin(Edge::Top, window_spec.margin.top); - root.set_margin(Edge::Left, window_spec.margin.left); - root.set_margin(Edge::Right, window_spec.margin.right); - // `Margin::bottom` (axis 2, daylight): every builtin before daylight - // anchored top and left this at its struct default of 0, which is - // exactly why this call was missing entirely — see that field's own - // doc comment (bread-theme) for the gap this closes. A bottom- - // anchored theme's floating gap off the screen edge IS this margin, - // the same way a top-anchored theme's is `margin.top`. - root.set_margin(Edge::Bottom, window_spec.margin.bottom); - // `Width::Fill` (Island/Edge): unset, exactly as before this - // change — the surface stretches to the anchored left/right edges - // on its own, with no explicit width request needed. `Width::Px` - // (the capsule, anchored top-only): gtk4-layer-shell has nothing to - // stretch it TO, so without this it would size to its natural - // content width instead of the theme's requested 480px — this was - // a schema key declared but never consumed before theme 04 needed - // a real value out of it. - if let Width::Px(px) = window_spec.width { - root.set_default_width(px); - // set_default_width alone is only a preference — a wide child (the - // results list, whose natural width is its longest app name plus - // icon and wm-class) overrides it, so the capsule rendered far - // wider than the theme's 480px and stopped reading as a pill. - // Pinning the request keeps the surface at the theme's width - // regardless of what the app catalog contains. - root.set_size_request(px, -1); - } - // "auto" reserves height + the margin on the ANCHORED edge, so - // tiled clients sit clear of the gap — see WindowSpec::exclusive's - // doc comment (bread-theme). Every theme before daylight anchored - // top, so `margin.top` was the only edge that ever mattered here; - // reading it unconditionally under a bottom-anchored bar reserved - // only `height` (40px), 12px short of the dock's real footprint - // (`height` + `margin.bottom`) — tiled windows would have run up - // into the last 12px of the floating dock. `margin_for_exclusive` - // picks whichever edge's margin actually borders the reserved - // strip, same reasoning `exclusive_zone_for`'s own doc comment - // (tested standalone) spells out. - let exclusive_zone = exclusive_zone_for(&window_spec); - root.set_exclusive_zone(exclusive_zone); - // ANIMATION WORK #3: bar entrance on first map, Liquid Motion - // only. The flush glass-workbench bar has no floating margin to - // slide from (it sits edge-to-edge against the screen), so it - // gets nothing; spotlight's capsule already has its own width/ - // drawer motion in flight during a real search, so stacking a - // margin spring underneath that felt like fighting it rather than - // adding to it — it gets a plain opacity fade instead (the - // `bar-entrance` CSS class, added unconditionally by both arms - // below; only liquid-motion also gets the margin spring). - // - // This animates the SURFACE's own layer-shell top margin via - // `anim::spring_to`, not a CSS property, deliberately: `theme.rs` - // has no `margin-top` transition/keyframe reachable from here - // that wouldn't also require moving `root_vbox`'s or - // `center_box`'s own CSS margin, which — unlike an outer - // layer-shell margin, a compositor-level surface placement, not a - // GTK box-model property at all — WOULD perturb every descendant - // widget's own measured size, including the workspace row's. - // `WorkspaceTrail::place()`'s single-shot initial geometry sample - // is a documented previous crash site for exactly that: `ws-in`'s - // margin-top keyframe on a first-build button raced that sample - // and froze the trail pill low (see the `!is_first_build` guard - // and its comment, a few hundred lines below, for the full - // story). A layer-shell margin change repositions the whole - // surface on screen without touching any widget's own box model, - // so `compute_bounds(host)`'s host-local coordinates — the trail's - // whole coordinate system — never see it move at all, by - // construction rather than by luck. - let theme_id = theme::shell_theme().id().to_string(); - if theme_id == "liquid-motion" { - const BAR_ENTRANCE_DROP: i32 = 18; - let target_top = window_spec.margin.top; - let start_top = (target_top - BAR_ENTRANCE_DROP).max(0); - root.set_margin(Edge::Top, start_top); - root.add_css_class("bar-entrance"); - let played = Rc::new(Cell::new(false)); - root.connect_map(move |win| { - // Guard against replaying on a later remap (e.g. a - // satellite hidden/reshown — see `root.set_visible(false)` - // a few lines below for the unbound-satellite case): this - // is a first-paint entrance, not a reveal animation. - if played.replace(true) { - return; - } - let target = win.clone(); - bread_theme::anim::spring_to(win, start_top, target_top, 420.0, move |v| { - target.set_margin(Edge::Top, v); - }); - }); - } else if theme_id == "spotlight" { - root.add_css_class("bar-entrance"); - } - // breadbar never called `set_keyboard_mode` before Phase 2 — it - // relied on gtk4-layer-shell's own default (`KeyboardMode::None`), - // which is exactly what the builtin manifest's `keyboard = "none"` - // resolves to. Same behaviour, no longer implicit. - root.set_keyboard_mode(match window_spec.keyboard { - Keyboard::None => KeyboardMode::None, - Keyboard::OnDemand => KeyboardMode::OnDemand, - Keyboard::Exclusive => KeyboardMode::Exclusive, - }); - eprintln!( - "breadbar: init monitor={monitor_name} primary={}", - init.primary - ); - if screenshot_req.is_none() && !bind_layer_monitor(&root, &monitor_name) { - // Unbound satellites must not map on the compositor default - // (that stacks a second exclusive-zone bar on the laptop). - root.set_exclusive_zone(-1); - if !init.primary { - root.set_visible(false); - } - } - - // ── Workspace row (left) ──────────────────────────────────────── - // Built imperatively (not via the view! macro) so a widget - // container can sit as a plain sibling of workspace_box — see the - // `widget:*` slot-entry handling in "Assemble" below. The Overlay - // trail lives behind the buttons; rebuild_buttons only touches the - // button box, never the trail host. - let workspace_trail = bar::workspaces::WorkspaceTrail::new(); - let workspace_box = workspace_trail.buttons.clone(); - let workspace_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - // `.bar-segment` (axis 3, daylight): a no-op class under every other - // theme (theme.rs only ever emits a `.bar-segment` CSS rule when - // `bar_border == "segmented"` — see its `segment_css` local) — - // added unconditionally here, same as `center_area`/`stats_box` - // below, so the Rust side never has to branch on the active theme - // id to know which boxes are "the three segments". - workspace_row.add_css_class("bar-segment"); - workspace_row.set_margin_start(8); - workspace_row.set_valign(gtk4::Align::Center); - workspace_row.set_vexpand(false); - // `workspace_trail.overlay` is appended in the "Assemble" section - // below, in the order `[bar.slots].left` names it — not here. - - // ── Lua-declared widget containers ────────────────────────────── - // Phase 3b: a container per `widget:` slot entry is created - // on demand while walking `[bar.slots]` in the "Assemble" section - // below (see `bar::slots::widget_slot_container`), so ANY slot can - // host a Lua widget — not just the four fixed positions Phase 3a - // shipped with. Populated by widgets::client's events.subscribe- - // driven refresh loop, started at the end of init. - - // `tokens.icon_px` (plan §4) — bar-chrome icon pixel size; reused - // below for every `prepare_icon` call in this function. - let icon_px = theme::shell_theme().tokens().icon_px() as i32; + 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); // ── SVG icon sets ──────────────────────────────────────────────── use bar::stats::{ - AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, ICON_VOLUME, + AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, WIFI_MEDIUM, + WIFI_OFF, WIFI_STRONG, WIFI_WEAK, }; let bat_textures: std::collections::HashMap = [BAT_HIGH, BAT_MID, BAT_LOW] @@ -592,29 +145,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 gpu_lbl = stat_label(); - let vol_lbl = digit_chip_box(); - let vol_digits: Rc>> = Rc::new(RefCell::new(Vec::new())); - let bat_lbl = digit_chip_box(); - let bat_digits: Rc>> = Rc::new(RefCell::new(Vec::new())); + let 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(), )); - prepare_icon(&bat_img, icon_px); - let ac_img = svg_image(AC_POWER); + let ac_img = gtk4::Image::from_paintable(Some(&svg_texture(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); @@ -623,36 +173,50 @@ 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); - // 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"); + let wifi_img = + gtk4::Image::from_paintable(Some(&svg_texture(asset!("WiFi Connecting.svg")))); - // Content pane only — this becomes a tab inside the merged - // connectivity popover built alongside the Bluetooth pane below, - // once bt_img exists too. See "Connectivity popover" further down. - let wifi_pane = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + let wifi_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + wifi_pair.add_css_class("stat-pair"); + wifi_pair.add_css_class("wifi-pair"); + wifi_img.add_css_class("stat-icon"); + wifi_pair.append(&wifi_img); + wifi_pair.append(&wifi_lbl); + + let wifi_popover_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + wifi_popover_box.add_css_class("wifi-popover-inner"); + wifi_popover_box.set_margin_top(4); + wifi_popover_box.set_margin_bottom(4); + wifi_popover_box.set_margin_start(4); + wifi_popover_box.set_margin_end(4); let loading_lbl = gtk4::Label::new(Some("Scanning…")); loading_lbl.add_css_class("wifi-popover-loading"); - wifi_pane.append(&loading_lbl); + wifi_popover_box.append(&loading_lbl); + + let wifi_popover = gtk4::Popover::new(); + wifi_popover.add_css_class("wifi-popover"); + wifi_popover.set_child(Some(&wifi_popover_box)); + wifi_popover.set_parent(&wifi_pair); + + let wpop = wifi_popover.clone(); + let gesture = gtk4::GestureClick::new(); + gesture.connect_released(move |_, _, _, _| { + if wpop.is_visible() { wpop.popdown(); } else { wpop.popup(); } + }); + wifi_pair.add_controller(gesture); + + let sender_wp = sender.clone(); + wifi_popover.connect_show(move |_| { + bar::wifi::spawn_popover_load(sender_wp.clone()); + }); // ── 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_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_indicator = gtk4::Label::new(Some("▶")); + media_indicator.add_css_class("media-indicator"); let media_track_lbl = gtk4::Label::new(None); media_track_lbl.add_css_class("media-track-lbl"); @@ -660,7 +224,7 @@ impl SimpleComponent for App { media_track_lbl.set_max_width_chars(42); media_track_lbl.set_xalign(0.0); - media_widget.append(&media_eq); + media_widget.append(&media_indicator); media_widget.append(&media_track_lbl); // Media controls popover @@ -671,22 +235,18 @@ impl SimpleComponent for App { media_controls_box.set_margin_start(4); media_controls_box.set_margin_end(4); - let prev_btn = gtk4::Button::new(); - prev_btn.set_child(Some(&svg_image(asset!("Previous.svg")))); + let prev_btn = gtk4::Button::with_label("⏮"); 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 = svg_image(asset!("Pause.svg")); - let media_play_btn = gtk4::Button::new(); - media_play_btn.set_child(Some(&media_play_icon)); + let media_play_btn = gtk4::Button::with_label("⏸"); media_play_btn.add_css_class("flat"); media_play_btn.add_css_class("media-btn"); media_play_btn.add_css_class("media-play-btn"); media_play_btn.connect_clicked(|_| bar::media::spawn_cmd("play-pause")); - let next_btn = gtk4::Button::new(); - next_btn.set_child(Some(&svg_image(asset!("Next.svg")))); + let next_btn = gtk4::Button::with_label("⏭"); next_btn.add_css_class("flat"); next_btn.add_css_class("media-btn"); next_btn.connect_clicked(|_| bar::media::spawn_cmd("next")); @@ -695,354 +255,147 @@ impl SimpleComponent for App { media_controls_box.append(&media_play_btn); media_controls_box.append(&next_btn); - // Clock: time is the hero, date sits beside it quieter. - // Per-glyph labels so a minute rollover can flip only the digits - // that changed — same motion as the Liquid Motion demo. - let clock_time = bar::clock::time(); - let clock_digits = make_clock_digits(&clock_time); - let clock_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - clock_box.add_css_class("clock-box"); - clock_box.add_css_class("clock-label"); - clock_box.set_valign(gtk4::Align::Center); - clock_box.set_vexpand(false); - // Varela Round's em box sits optically high in the 44px island. - // No compensating margin: this +3 existed to nudge the clock down - // against the mis-centred CenterBox above. With the row now filling - // the bar height and centring properly, the same offset would push - // the clock 3px BELOW everything else. - clock_box.set_margin_top(0); - for digit in &clock_digits { - clock_box.append(digit); - } - let date_lbl = gtk4::Label::new(Some(&bar::clock::date())); - date_lbl.add_css_class("date-label"); - date_lbl.set_visible(false); + let 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); - // `modules.clock.style = "plain"` (glass-workbench): date_lbl above - // plus one plain "HH:MM" label, no per-digit flip markup at all. - // Built unconditionally alongside the flip clock so a theme's style - // choice is just which of the two gets registered into the "clock" - // slot below — see "Assemble". - let clock_plain_lbl = gtk4::Label::new(Some(&bar::clock::time())); - clock_plain_lbl.add_css_class("clock-plain-time"); - clock_plain_lbl.set_valign(gtk4::Align::Center); - clock_plain_lbl.set_vexpand(false); - let clock_plain_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - clock_plain_box.add_css_class("clock-plain"); - clock_plain_box.set_valign(gtk4::Align::Center); - clock_plain_box.set_vexpand(false); - clock_plain_box.append(&date_lbl); - clock_plain_box.append(&clock_plain_lbl); + 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); - // ── Launcher entry + results (theme 04/spotlight, plan §7) ─────── - // Built unconditionally, exactly like `clock_plain_box` above — - // placed in a slot only by a theme that names "launcher_entry"/ - // "launcher_results" (spotlight today; see "Assemble" below). - // `bread_launcher::LAUNCHER_APP` ("breadbox") is the launcher's - // shared identity: this reads/writes the SAME icon cache and - // launch history breadbox's own overlay window does, so the - // capsule and breadbox rank a user's apps identically instead of - // forking into two histories just because a different theme - // happens to be active (see that constant's own doc comment). - let launcher_cfg = theme::shell_theme().launcher().clone(); - let launcher_manifest: std::collections::HashMap = - std::fs::read_to_string(bread_launcher::IconCache::manifest_path( - bread_launcher::LAUNCHER_APP, - )) - .ok() - .and_then(|s| serde_json::from_str::>(&s).ok()) - .unwrap_or_default() - .into_iter() - .map(|(k, v)| (k, std::path::PathBuf::from(v))) - .collect(); - let launcher_history = Rc::new(RefCell::new(bread_launcher::LaunchHistory::load( - bread_launcher::LAUNCHER_APP, - ))); - // No per-workspace priority context here — that's breadbox's own - // `Config`/`Context` format (breadbox-shared), not launcher - // substance, and breadbar has no equivalent concept. The capsule - // sorts by launch history then alphabetically, same fallback - // ordering breadbox itself uses once a workspace has no configured - // priority list at all. - let launcher_entries = bread_launcher::load_sorted_entries( - &launcher_manifest, - &[], - &launcher_history.borrow(), - ); - let launcher_results = ResultsList::new( - &launcher_entries, - launcher_cfg.icon_px, - Rc::clone(&launcher_history), - launcher_cfg.sections, - ); - launcher_results.scroller.add_css_class("bread-drawer-scroller"); + // Clock label + let clock_lbl = gtk4::Label::new(Some(&bar::clock::current())); + clock_lbl.add_css_class("clock-label"); - let launcher_entry = gtk4::Entry::new(); - launcher_entry.add_css_class("launcher-entry"); - launcher_entry.set_has_frame(false); - launcher_entry.set_hexpand(true); - // Starts non-focusable ("the spotlight theme starts with the - // search open"). GTK4 auto-assigns keyboard focus to the first - // can-focus widget in a window as it's first mapped/shown — with - // nothing else focusable in the bar, that was always - // `launcher_entry`, and `focus_ctrl`'s `connect_enter` below - // (added unconditionally) turns "the entry gained focus" straight - // into `open_fn()`, so the capsule opened itself at startup. A - // widget that can't focus is skipped by BOTH that automatic - // selection and an explicit `grab_focus()` call — see GTK's own - // docs for `Widget::grab_focus`: "if widget is not focusable... - // this function does nothing." Every place that later wants real - // focus (the click gesture below, and `AppInput::OpenLauncher`'s - // handler) flips this back to `true` immediately before grabbing. - launcher_entry.set_can_focus(false); - gtk4::prelude::EntryExt::set_alignment(&launcher_entry, 0.5); - // `modules.clock.placeholder_clock` (spotlight): the entry's idle - // placeholder IS the clock — no separate clock module renders at - // all under `style = "none"`. Any other theme gets a plain "Search" - // placeholder (never shown today: no other builtin slots - // "launcher_entry" anywhere), so this still degrades sanely if a - // future/user theme places it without also setting the flag. - let placeholder_clock = theme::shell_theme().modules().clock.placeholder_clock; - launcher_entry.set_placeholder_text(Some(if placeholder_clock { - bar::clock::time() - } else { - "Search".to_string() - }.as_str())); - - // Center area: [media_widget · widgets · clock · widgets] - let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + // Center area: [media_widget · clock] + let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); center_area.add_css_class("center-area"); - center_area.add_css_class("bar-segment"); - center_area.set_valign(gtk4::Align::Center); - center_area.set_vexpand(false); - // `media_widget`/`clock_box` and any `widget:*` entries interleaved - // around them are all appended in "Assemble" below, in the exact - // order `[bar.slots].centre` names them. + center_area.append(&media_widget); + center_area.append(&clock_lbl); // ── Stats box (right side) ─────────────────────────────────────── - // Demo order: [vol 64] [wifi] [bat 83] [☰] - let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); + let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); stats_box.add_css_class("stats-box"); - stats_box.add_css_class("bar-segment"); - stats_box.set_margin_end(2); - stats_box.set_valign(gtk4::Align::Center); - stats_box.set_vexpand(false); - // Also appended in "Assemble" — before the right slot's modules, - // preserving its fixed position (left of the stats modules) - // whatever `[bar.slots].right` contains. - - // CPU/RAM/power draw stay built (control panel + screenshots still - // read the labels) but never mount on the island — the demo bar - // does not show them. - let cpu_pair = stat_pair(asset!("CPU.svg"), &cpu_lbl); - let mem_pair = stat_pair(asset!("RAM Usage.svg"), &mem_lbl); - let pwr_pair = stat_pair(asset!("Power Draw.svg"), &pwr_lbl); - let gpu_pair = stat_pair(asset!("GPU.svg"), &gpu_lbl); - for pair in [&cpu_pair, &mem_pair, &pwr_pair, &gpu_pair] { - pair.add_css_class("sys-stat"); - pair.set_hexpand(true); - } - gpu_pair.set_visible(false); - - // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench, - // Phase 5): separate chip instances from `cpu_pair`/`mem_pair` - // above, which stay parented in the control panel's sys-grid below - // — reusing them here would mean reparenting them out of the panel. - // Same icons, same `.stat-pair` chip styling every other bar chip - // (volume/battery) already uses; fed by the same `StatsUpdate` data. - let bar_cpu_lbl = stat_label(); - let bar_ram_lbl = stat_label(); - let bar_cpu_pair = stat_pair(asset!("CPU.svg"), &bar_cpu_lbl); - let bar_ram_pair = stat_pair(asset!("RAM Usage.svg"), &bar_ram_lbl); - - let system_stats_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - system_stats_box.add_css_class("sys-grid"); - let sys_row1 = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - sys_row1.append(&cpu_pair); - sys_row1.append(&mem_pair); - let sys_row2 = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - sys_row2.append(&gpu_pair); - sys_row2.append(&pwr_pair); - system_stats_box.append(&sys_row1); - system_stats_box.append(&sys_row2); - let system_sep = gtk4::Separator::new(gtk4::Orientation::Vertical); - system_sep.add_css_class("bar-sep"); - system_sep.set_visible(false); - - let vol_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - vol_box.add_css_class("stat-pair"); - bar_chip(&vol_box); - bar_chip(&vol_lbl); - vol_box.append(&vol_img); - vol_box.append(&vol_lbl); - // Appended in "Assemble" below, per `[bar.slots].right`. + stats_box.append(&stat_pair(asset!("CPU.svg"), &cpu_lbl)); + stats_box.append(&stat_pair(asset!("RAM Usage.svg"), &mem_lbl)); + stats_box.append(&stat_pair(asset!("Power Draw.svg"), &pwr_lbl)); 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"); - bar_chip(&bat_lbl); + 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"); - - // Content pane only — same deal as wifi_pane above. - let bt_pane = gtk4::Box::new(gtk4::Orientation::Vertical, 0); - let bt_loading_lbl = gtk4::Label::new(Some("Loading…")); - bt_loading_lbl.add_css_class("wifi-popover-loading"); - bt_pane.append(&bt_loading_lbl); - - // ── Connectivity popover ───────────────────────────────────────── - // WiFi and Bluetooth share one popover with one anatomy (same CSS - // classes throughout the two panes) instead of two near-identical - // popups behind two separate icons. A small tab row switches between - // them; both fetches kick off on open so switching tabs afterward is - // instant. The two panes live in a Stack rather than plain sibling - // Boxes with manual visibility toggling, which left stale width - // behind on reopen. - // - // 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(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"); - - // 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 = popover_tab("Bluetooth"); - bt_tab_btn.set_group(Some(&wifi_tab_btn)); - - 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| { - if btn.is_active() { - stack_for_wifi.set_visible_child_name("wifi"); - } + bt_img.add_css_class("clickable"); + let bt_gesture = gtk4::GestureClick::new(); + bt_gesture.connect_released(|_, _, _, _| { + relm4::spawn(async { + let _ = tokio::process::Command::new("blueman-manager").spawn(); + }); }); - let stack_for_bt = content_stack.clone(); - bt_tab_btn.connect_toggled(move |btn| { - if btn.is_active() { - stack_for_bt.set_visible_child_name("bluetooth"); - } - }); - - 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(&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("icon-only"); - bar_chip(&connectivity_pair); - wifi_img.set_halign(gtk4::Align::Center); - // `.stat-pair.icon-only` forces a 32px min-width on this box, wider - // than the 24px icon's natural size. Without hexpand, a `gtk4::Box` - // packs a non-expanding child at its natural size flush against the - // start edge and leaves the leftover width trailing after it — so - // `halign: Center` had nothing to center within and the glyph sat a - // few pixels left of true center (reported: wifi icon not centered - // on glass-workbench/liquid-motion). `bat_box` never showed this - // because it isn't `icon-only` — no forced min-width wider than its - // (icon + label) content, so there's no leftover space to - // mis-place. hexpand(true) gives the icon a fillable cell spanning - // the full 32px box, which `halign: Center` then centers within, - // matching `bat_box`'s already-centered result. - // - // A bare `set_hexpand(true)` on the image is not enough on its - // own: GTK4 computes a container's *effective* expand by OR-ing in - // its children's hexpand whenever the container's own hexpand - // hasn't been explicitly set, so the flag silently bubbles up - // through `connectivity_pair` into the shared right-hand stats box - // and from there into the centerbox's end slot — which then hands - // that slot most of the bar's remaining width instead of its - // normal packed size. The visible symptom was dramatic, not - // subtle: the whole vol/wifi cluster jumped left to sit against - // the clock, with a huge dead gap before battery/hamburger, in a - // `--screenshot bar` capture. `connectivity_pair.set_hexpand(false)` - // pins this box's own expand explicitly, which stops the - // computation from climbing any further — the child can still - // fill and center within this one box's fixed 32px cell. - wifi_img.set_hexpand(true); - connectivity_pair.set_hexpand(false); - connectivity_pair.append(&wifi_img); - // `connectivity_pair` and `bat_box` are appended in "Assemble" - // below, per `[bar.slots].right`. + bt_img.add_controller(bt_gesture); + stats_box.append(&bt_img); + stats_box.append(&wifi_pair); // ── 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"); - 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); + // Volume row + let vol_row = build_slider_row("🔊", 0.0, 1.5, 0.02); 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); + // Brightness row + let bright_row = build_slider_row("☀", 0.0, 1.0, 0.02); 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); + panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + // 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_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_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); + panel_inner.append(&tray_section); + + panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + + // 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); power_row.add_css_class("power-row"); - 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. - ("lock", vec!["breadlock"]), - ("sleep", vec!["systemctl", "suspend"]), - ("off", vec!["systemctl", "poweroff"]), + ("🔒", vec!["hyprlock"]), + ("💤", vec!["systemctl", "suspend"]), + ("🔄", vec!["systemctl", "reboot"]), + ("⏻", vec!["systemctl", "poweroff"]), ] { let btn = gtk4::Button::with_label(label); btn.add_css_class("flat"); @@ -1057,675 +410,63 @@ impl SimpleComponent for App { }); power_row.append(&btn); } - panel_inner.append(&power_row); + power_section.append(&power_row); + panel_inner.append(&power_section); - // 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); + let control_popover = gtk4::Popover::new(); + control_popover.add_css_class("control-panel"); + control_popover.set_child(Some(&panel_inner)); - 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. + // Hamburger button let hamburger_btn = gtk4::Button::with_label("☰"); hamburger_btn.add_css_class("flat"); hamburger_btn.add_css_class("control-panel-btn"); - hamburger_btn.add_css_class("stat-pair"); - hamburger_btn.add_css_class("icon-only"); - bar_chip(&hamburger_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()); + }); // Slider signals — use Rc> to suppress feedback during data load let panel_loading = Rc::new(Cell::new(false)); let loading_v = panel_loading.clone(); - let vol_lbl_live = vol_lbl.clone(); - let vol_digits_live = vol_digits.clone(); panel_vol_slider.connect_value_changed(move |s| { - // No flip animation here, deliberately: a slider drag fires - // this on every pointer-move tick, and re-playing the - // digit-flip keyframe that fast would read as a flicker, not a - // roll. `set_digit_chip` just swaps the label text/count in - // place; `flip_digit_chip` (used by the `StatsUpdate` poll - // below) is the one that actually animates. - set_digit_chip( - &vol_lbl_live, - &mut vol_digits_live.borrow_mut(), - &format!("{:.0}", s.value() * 100.0), - ); - if loading_v.get() { - return; - } + 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()); }); - // `hamburger_btn` is appended in "Assemble" below, per - // `[bar.slots].right`. - - // Standalone layer windows — below the island, slid in by Hyprland. - let panels = panel::PanelSet::new( - &monitor_name, - &connectivity_inner, - &panel_inner, - &media_controls_box, - ); - - let sender_conn = sender.clone(); - panels.connectivity.connect_map(move |_| { - bar::wifi::spawn_popover_load(sender_conn.clone()); - bar::bluetooth::spawn_popover_load(sender_conn.clone()); - }); - let sender_cp = sender.clone(); - panels.control.connect_map(move |_| { - bar::control::spawn_load(sender_cp.clone()); - }); - - { - let panels = panels.clone(); - let win = panels.connectivity.clone(); - let gesture = gtk4::GestureClick::new(); - gesture.connect_released(move |_, _, _, _| { - panels.toggle(&win); - }); - connectivity_pair.add_controller(gesture); - } - { - let panels = panels.clone(); - let win = panels.control.clone(); - hamburger_btn.connect_clicked(move |_| { - panels.toggle(&win); - }); - } - { - let panels = panels.clone(); - let win = panels.control.clone(); - let vol_gesture = gtk4::GestureClick::new(); - vol_gesture.connect_released(move |_, _, _, _| { - panels.toggle(&win); - }); - vol_box.add_controller(vol_gesture); - } - { - let panels = panels.clone(); - let win = panels.media.clone(); - let mgesture = gtk4::GestureClick::new(); - mgesture.connect_released(move |_, _, _, _| { - panels.toggle(&win); - }); - media_widget.add_controller(mgesture); - } - - // ── Assemble: slot-driven module + widget order (plan §11 Phase 3b) ── - // Every module widget above is already fully built; only the ORDER - // it lands in its container, and which of left/centre/right it - // lands in, comes from the theme manifest's `[bar.slots]` now. A - // `widget:` slot entry gets (or creates) a Lua widget - // container at that exact position — `` is either a - // `WidgetPlacement` alias or a Lua module name; see - // `bar::slots::widget_slot_container` and `reconcile_widgets`' - // routing below. This is how a Lua widget can land in ANY slot, - // not just the four fixed positions Phase 3a shipped with. - let bar_shell_theme = theme::shell_theme(); - let bar_slots = bar_shell_theme.slots(); - let mut bar_modules = bar::slots::ModuleRegistry::new(); - bar_modules.register("workspaces", &workspace_trail.overlay); - bar_modules.register("media", &media_widget); - // `modules.clock.style`: "flip" (default, liquid-motion) registers - // the existing per-digit clock_box unchanged; "plain" (glass- - // workbench) registers clock_plain_box instead and reveals date_lbl - // per `show_date` — clock_box/clock_digits are still fully built in - // that case, just never placed in any slot. "none" (Phase 6+, - // unused today) registers neither. - match bar_shell_theme.modules().clock.style { - ClockStyle::Plain => { - date_lbl.set_visible(bar_shell_theme.modules().clock.show_date); - bar_modules.register("clock", &clock_plain_box); - } - ClockStyle::Flip => bar_modules.register("clock", &clock_box), - ClockStyle::None => {} - } - bar_modules.register("volume", &vol_box); - bar_modules.register("wifi", &connectivity_pair); - bar_modules.register("battery", &bat_box); - bar_modules.register("control", &hamburger_btn); - // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench): - // registered unconditionally, same as every other module — a theme - // that never names "cpu"/"ram" in a slot (liquid-motion) just never - // walks these entries in `for_each_in_slot` below, so they stay - // built but unparented, exactly like `media_widget` does for - // glass-workbench (which omits "media" entirely). - bar_modules.register("cpu", &bar_cpu_pair); - bar_modules.register("ram", &bar_ram_pair); - // Theme 04/spotlight (plan §7): unconditional, same reasoning — - // liquid-motion/glass-workbench never name either in a slot, so - // both stay built but unparented for them. - bar_modules.register("launcher_entry", &launcher_entry); - bar_modules.register("launcher_results", &launcher_results.scroller); - - // `tray` never appears in a bar slot — it stays inside the - // control-panel popover (built above, next to the SNI tray) — but - // it's keyed here so `reconcile_widgets`' routing finds it the same - // way as any slot-driven widget container. - let mut widget_containers: std::collections::HashMap = - std::collections::HashMap::new(); - widget_containers.insert("tray".to_string(), widget_tray_box); - - bar_modules.for_each_in_slot( - &bar_slots.left, - |_, widget| workspace_row.append(widget), - |key| workspace_row.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), - ); - bar_modules.for_each_in_slot( - &bar_slots.centre, - |_, widget| center_area.append(widget), - |key| center_area.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), - ); - bar_modules.for_each_in_slot( - &bar_slots.right, - |_, widget| stats_box.append(widget), - |key| stats_box.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), - ); + stats_box.append(&hamburger_btn); // ── 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)); - // `drawer` slot (plan §2/§7/§11 Phase 6): the only slot list that - // isn't left/centre/right of the CenterBox — appended into the vbox - // row below it instead. Empty for every theme but spotlight, so - // `drawer_box` stays a childless, zero-height box for them (see the - // `window.breadbar > box > centerbox` selector note in theme.rs for - // why the vbox wrapper itself is safe for those two themes too). - widgets.drawer_box.add_css_class("bread-drawer"); - bar_modules.for_each_in_slot( - &bar_slots.drawer, - |_, widget| widgets.drawer_box.append(widget), - |key| { - widgets - .drawer_box - .append(&bar::slots::widget_slot_container(&mut widget_containers, key)) - }, - ); - // Collapsed by default; `Overflow::Hidden` clips the results list - // while its allocated height is below its natural content height, - // same as the demo's `.results { max-height: 0; overflow: hidden }`. - widgets.drawer_box.set_overflow(gtk4::Overflow::Hidden); - widgets.drawer_box.set_size_request(-1, 0); - // set_size_request is a MINIMUM, not a maximum: GTK still allocates a - // visible box its natural height, and this layer-shell surface has no - // fixed height, so the window grew to fit the whole results list and - // the capsule sat open-at-idle showing a stray row. A hidden widget - // requests no size at all, which is what "collapsed" actually needs. - // open_fn/close_fn toggle this back on/off around the height animation. - widgets.drawer_box.set_visible(false); - - // ── Query-mode results (plan §7 phase 6c: `=` calc, `>` cmd, `.` - // url) ─────────────────────────────────────────────────────────── - // A second, small list living alongside `launcher_results.scroller` - // in the same drawer — only one of the two is ever visible at a - // time (see `connect_changed` below). Built unconditionally, same - // as `launcher_results` itself, but only ever APPENDED into - // `drawer_box` for an embedded launcher: every other theme must - // keep `drawer_box` exactly as childless as it already is (see the - // comment above `bar_modules.for_each_in_slot(&bar_slots.drawer, ..)`). - let mode_list = gtk4::ListBox::new(); - mode_list.set_selection_mode(gtk4::SelectionMode::Browse); - mode_list.set_visible(false); - if launcher_cfg.mode == bread_theme::shell::LauncherMode::Embedded { - widgets.drawer_box.append(&mode_list); - } - - // ── Capsule expand/collapse + search wiring (theme 04/spotlight) ── - // Effectively a no-op under every other theme: `launcher_entry` - // never receives focus if it's never in a slot, so `open_fn` is - // simply never invoked. `results.set_query`/select_next`/`select_prev` - // and launching all come straight from `bread-launcher`; only the - // capsule shell (drawer height, entry placeholder/alignment, - // keyboard focus) is this file's own. - let anim: Rc>> = Rc::new(RefCell::new(None)); - // Search-state width (plan §7 phase 6c: `[launcher].search_width`, - // `04-spotlight.html`'s `.searching .capsule { width: 520px }`) — - // a separate animation from the drawer's own height, both driven - // by `bread_theme::anim::spring_to` independently. - let anim_width: Rc>> = Rc::new(RefCell::new(None)); - let launcher_open: Rc> = Rc::new(Cell::new(false)); - // Set around every PROGRAMMATIC `launcher_entry.set_text(...)` call - // (close_fn's reset-to-empty today; any future one belongs here - // too) so `connect_changed` below can tell "the user typed a - // character" from "code changed the buffer" and only ever opens - // the capsule for the former — see that handler's own comment. - // Gates the SIGNAL itself rather than hunting every caller that - // could fire it, so no future programmatic `set_text` can reopen - // the capsule by accident, whether or not one does today. - let programmatic_text_change: Rc> = Rc::new(Cell::new(false)); - // The click-away scrim's dead zone (item B, see `DRAWER_MAX_HEIGHT_PX`'s - // own doc comment) — the capsule row's own theme-default dismiss - // offset (52px: `bar.window.height` + `bar.window.margin.top`) plus - // the drawer's own max content height, so the scrim's clickable - // region can never reach up into a rendered result row. - let capsule_dismiss_margin: i32 = theme::shell_theme() - .surfaces() - .get("breadbar-dismiss") - .map(|s| s.offset.first().copied().unwrap_or(0.0) as i32) - .unwrap_or(52) - + DRAWER_MAX_HEIGHT_PX; - - let open_fn: Rc = Rc::new({ - let drawer_box = widgets.drawer_box.clone(); - let anim = Rc::clone(&anim); - let anim_width = Rc::clone(&anim_width); - let launcher_open = Rc::clone(&launcher_open); - let entry = launcher_entry.clone(); - let root = root.clone(); - let panels = panels.clone(); - let idle_width = launcher_cfg.width; - let search_width = launcher_cfg.search_width; - let monitor_name_for_dismiss = monitor_name.clone(); - move || { - capsule_trace!("open_fn() called, was_open={}", launcher_open.get()); - if !launcher_open.get() { - launcher_open.set(true); - // Reveal before measuring/animating: while hidden the box - // reports no natural height, so the open animation would - // target 0 and nothing would appear. - drawer_box.set_visible(true); - entry.add_css_class("searching"); - gtk4::prelude::EntryExt::set_alignment(&entry, 0.0); - // `.searching` on the root itself (plan §7 phase 6c): - // toggles `[launcher].search_radius` via CSS (see - // theme.rs's `window.breadbar.searching` rule) and - // marks the width animation's starting point below. - root.add_css_class("searching"); - let current_width = root.width(); - let from = if current_width > 0 { current_width } else { idle_width }; - animate_capsule_width(&root, &anim_width, from, search_width); - // Item B: click-away scrim. Shown at a fixed dead-zone - // offset (`capsule_dismiss_margin`), never the live - // drawer height — see that constant's own doc comment - // for why a live-tracking offset would risk swallowing - // clicks meant for a result row. `hole` scopes that - // dead zone to the capsule's own column instead of the - // full screen width ("it only sometimes is dismissed - // when you click somewhere else") — see - // `capsule_dismiss_hole`'s doc comment. Falls back to - // the old full-width dead zone if the live geometry - // query fails for any reason. - let hole = hypr_capsule_center_x(&monitor_name_for_dismiss).and_then( - |center_x| { - let (origin_x, _) = hypr_monitor_origin(&monitor_name_for_dismiss)?; - Some(capsule_dismiss_hole(center_x, origin_x, search_width)) - }, - ); - panels.show_capsule_dismiss(capsule_dismiss_margin, hole); - } - let target = drawer_target_height(&drawer_box); - let current = drawer_box.size_request().1; - animate_drawer_height(&drawer_box, &anim, current, target); - } - }); - let close_fn: Rc = Rc::new({ - let drawer_box = widgets.drawer_box.clone(); - let anim = Rc::clone(&anim); - let anim_width = Rc::clone(&anim_width); - let launcher_open = Rc::clone(&launcher_open); - let entry = launcher_entry.clone(); - let root_for_focus = root.clone(); - let root_for_width = root.clone(); - let panels = panels.clone(); - let idle_width = launcher_cfg.width; - let programmatic_text_change = Rc::clone(&programmatic_text_change); - move || { - capsule_trace!("close_fn() called, was_open={}", launcher_open.get()); - if !launcher_open.get() { - return; - } - launcher_open.set(false); - entry.remove_css_class("searching"); - gtk4::prelude::EntryExt::set_alignment(&entry, 0.5); - // Resetting the buffer here is a PROGRAMMATIC change, not a - // user keystroke — flagged so `connect_changed` below - // doesn't treat "close_fn cleared the query" as "the user - // typed", which would immediately reopen what this - // function is in the middle of closing. - programmatic_text_change.set(true); - entry.set_text(""); - programmatic_text_change.set(false); - if placeholder_clock { - entry.set_placeholder_text(Some(&bar::clock::time())); - } - root_for_width.remove_css_class("searching"); - let current_width = root_for_width.width(); - animate_capsule_width(&root_for_width, &anim_width, current_width, idle_width); - panels.hide_dismiss(); - let current = drawer_box.size_request().1; - animate_drawer_height(&drawer_box, &anim, current, 0); - // `keyboard = "on_demand"` (plan §7) ties the layer-shell - // surface's keyboard grab to GTK's own focus-widget state — - // releasing focus here is what hands the keyboard back. - gtk4::prelude::GtkWindowExt::set_focus(&root_for_focus, None::<>k4::Widget>); - } - }); - - panels.set_on_dismiss({ - let close_fn = Rc::clone(&close_fn); - move || close_fn() - }); - - // Which of the four query modes is currently driving `mode_list` - // (plan §7 phase 6c) — read by `key_ctrl` below to route Up/Down/ - // Return at the right list, and whether that mode's row is even - // selectable (an info-only row, e.g. an empty calc expression, - // never is). `launcher_cfg.modes` gates which prefixes actually - // switch mode: a prefix this theme doesn't list in `modes` falls - // through to a literal Apps query, prefix character and all. - let active_mode: Rc> = - Rc::new(Cell::new(bread_launcher::QueryKind::Apps)); - let mode_selectable: Rc> = Rc::new(Cell::new(false)); - let modes = launcher_cfg.modes.clone(); - - { - let results = launcher_results.clone(); - let mode_list = mode_list.clone(); - let open_fn = Rc::clone(&open_fn); - let active_mode = Rc::clone(&active_mode); - let mode_selectable = Rc::clone(&mode_selectable); - let programmatic_text_change = Rc::clone(&programmatic_text_change); - launcher_entry.connect_changed(move |entry| { - // `changed` fires for EVERY buffer mutation, programmatic - // ones included — `close_fn`'s own `entry.set_text("")` - // among them. Without this guard that reset re-enters this - // handler and its unconditional `open_fn()` below reopens - // the capsule close_fn is still in the middle of closing. - // This is the fix for "changed a moment ago". Real typing - // never sets the flag, so it always reaches `open_fn()`. - if programmatic_text_change.get() { - capsule_trace!("connect_changed: skipped (programmatic set_text)"); - return; - } - let text = entry.text(); - capsule_trace!("connect_changed: user input, text={text:?}"); - let parsed = bread_launcher::parse_query(&text); - let mode_name = match parsed.kind { - bread_launcher::QueryKind::Calc => "calc", - bread_launcher::QueryKind::Cmd => "cmd", - bread_launcher::QueryKind::Url => "url", - bread_launcher::QueryKind::Apps => "apps", - }; - let kind = if modes.iter().any(|m| m == mode_name) { - parsed.kind - } else { - bread_launcher::QueryKind::Apps - }; - active_mode.set(kind); - if kind == bread_launcher::QueryKind::Apps { - mode_list.set_visible(false); - results.scroller.set_visible(true); - mode_selectable.set(false); - results.set_query(text.as_str()); - } else { - results.scroller.set_visible(false); - let selectable = populate_mode_list(&mode_list, &parsed); - mode_selectable.set(selectable); - mode_list.set_visible(true); - } - // Reached only for a real keystroke (the guard above - // already returned for a programmatic reset) — matches the - // reference model's `q.addEventListener("input", () => { - // if (!open) setOpen(true); ... })`: typing is itself a - // real-user-input signal that opens the capsule. - open_fn(); - }); - } - { - // Deliberately does NOT call `open_fn()`. It used to — that - // was the actual mechanism behind "opens itself with no - // focus": ANYTHING that moved keyboard focus onto - // `launcher_entry`, including GTK's own auto-focus-on-map of - // the first focusable widget in a window, opened the capsule - // as a side effect. `set_can_focus(false)` (constructor, - // above) already closes that specific hole, but leaving this - // handler wired to `open_fn` kept the same footgun loaded for - // the next path that calls `grab_focus()` on this entry for - // any reason. Both real-input paths that DO want to open now - // call `open_fn()` themselves at their own call site (the - // click gesture below, `AppInput::OpenLauncher`'s handler) — - // opening is a direct consequence of the user's action, not a - // side effect of a focus-change event that could have come - // from anywhere. What's left here is pure diagnostics: under - // `BREADBAR_CAPSULE_DEBUG=1`, seeing "focus entered" with no - // preceding "open_fn() called" trace is exactly the signature - // a future accidental-open regression would leave. - let focus_ctrl = gtk4::EventControllerFocus::new(); - focus_ctrl.connect_enter(move |_| { - capsule_trace!("focus_ctrl: entry gained keyboard focus"); - }); - launcher_entry.add_controller(focus_ctrl); - } - // "you can't close it using escape unless you are focused on the - // UI" / "it doesn't grab keyboard for typing": both are the same - // root cause as the startup-open bug above, from the other side. - // `launcher_entry.set_can_focus(false)` means nothing (startup or - // otherwise) can silently steal GTK's own notion of focus any - // more — so it now needs a real, explicit grab. This click gesture - // is that grab for the mouse path: a `GestureClick` press is a - // genuine user-originated pointer event delivered through the - // compositor, which is also exactly the kind of interaction - // `KeyboardMode::OnDemand` (gtk4-layer-shell/wlr-layer-shell) is - // documented to react to — the protocol spec (wlr-layer-shell- - // unstable-v1.xml) leaves *when* an on-demand surface gets the - // compositor's keyboard focus as "implementation-defined", but a - // literal click on the surface is the universal, minimum-common- - // denominator trigger every compositor implementation reacts to - // (it's the same interaction wofi/fuzzel/rofi-wayland rely on). - // Flipping `can_focus` back on right before `grab_focus()` mirrors - // `AppInput::OpenLauncher`'s handler below, which needs the exact - // same two-liner for the hotkey/command path. - { - let entry_for_click = launcher_entry.clone(); - let open_fn = Rc::clone(&open_fn); - let click = gtk4::GestureClick::new(); - click.connect_pressed(move |_, _, _, _| { - capsule_trace!("click gesture: pressed"); - entry_for_click.set_can_focus(true); - entry_for_click.grab_focus(); - // Called directly here rather than left to fire as a side - // effect of `grab_focus()` moving keyboard focus (that was - // `focus_ctrl.connect_enter`'s job until this same pass - // removed it) — a real click is itself the user-input - // signal that should open the capsule, not merely a way to - // produce a focus-change event that then does. - open_fn(); - }); - launcher_entry.add_controller(click); - } - { - let results = launcher_results.clone(); - let mode_list = mode_list.clone(); - let close_fn = Rc::clone(&close_fn); - let active_mode = Rc::clone(&active_mode); - let mode_selectable = Rc::clone(&mode_selectable); - let key_ctrl = gtk4::EventControllerKey::new(); - // CAPTURE, not the default BUBBLE. A GtkEntry handles Return in the - // target phase itself — it emits `activate` and returns TRUE, which - // stops propagation before a bubble-phase controller ever runs, so - // Enter silently did nothing and the selected app never launched. - // Capturing puts this controller ahead of the entry's own handling - // for every key it cares about (Return/Up/Down/Escape), and keys it - // doesn't claim still Proceed to the entry for normal text input. - key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); - key_ctrl.connect_key_pressed(move |_, key, _, _| { - use gtk4::gdk::Key; - if active_mode.get() != bread_launcher::QueryKind::Apps { - // Calc/Cmd/Url (plan §7 phase 6c): Up/Down move the - // (possibly single-row) `mode_list` selection; Return - // runs whatever `mode_row_action` finds on the - // selected row — nothing, for a calc result or an - // empty prompt, since those rows carry none. - return match key { - Key::Escape => { - close_fn(); - gtk4::glib::Propagation::Stop - } - Key::Down if mode_selectable.get() => { - listbox_select_next(&mode_list); - gtk4::glib::Propagation::Stop - } - Key::Up if mode_selectable.get() => { - listbox_select_prev(&mode_list); - gtk4::glib::Propagation::Stop - } - Key::Return | Key::KP_Enter => { - if let Some(row) = mode_list.selected_row() { - if let Some(action) = mode_row_action(&row) { - run_mode_action(&action); - close_fn(); - } - } - gtk4::glib::Propagation::Stop - } - _ => gtk4::glib::Propagation::Proceed, - }; - } - match key { - Key::Escape => { - close_fn(); - gtk4::glib::Propagation::Stop - } - Key::Down => { - results.select_next(); - gtk4::glib::Propagation::Stop - } - Key::Up => { - results.select_prev(); - gtk4::glib::Propagation::Stop - } - Key::Return | Key::KP_Enter => { - if let Some(entry) = results.selected_entry() { - results.record_launch(&entry); - bread_launcher::do_launch( - &entry, - LAUNCHER_APP_ID, - LAUNCHER_LAUNCHED_EVENT, - ); - } - close_fn(); - gtk4::glib::Propagation::Stop - } - _ => gtk4::glib::Propagation::Proceed, - } - }); - launcher_entry.add_controller(key_ctrl); - } - // Click on a mode_list row (a `>`-mode command or the `.`-mode - // "open this URL" prompt) acts too, same as a result row's click. - { - let close_fn = Rc::clone(&close_fn); - mode_list.connect_row_activated(move |_, row| { - if let Some(action) = mode_row_action(row) { - run_mode_action(&action); - close_fn(); - } - }); - } - // Row click launches too, same as breadbox's own overlay. - { - let results = launcher_results.clone(); - let close_fn = Rc::clone(&close_fn); - launcher_results.list.connect_row_activated(move |_, row| { - if let Some(entry) = bread_launcher::gtk::row_entry(row) { - results.record_launch(&entry); - bread_launcher::do_launch(&entry, LAUNCHER_APP_ID, LAUNCHER_LAUNCHED_EVENT); - } - close_fn(); - }); - } - - // Captured before these move into `model` (or are otherwise dropped - // as bare locals, never stored on `App` at all) — needed by the - // screenshot dispatch just before this function returns. - let control_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_panel_for_screenshot = panels.media.clone(); - let media_widget_for_screenshot = media_widget.clone(); - let media_track_lbl_for_screenshot = media_track_lbl.clone(); - // Theme 04/spotlight's capsule (plan §6b): `launcher_entry` moves - // into `model` below, `drawer_box` lives only in `widgets` — both - // need a clone out here for the same reason every other - // `_for_screenshot` handle above does. - let launcher_entry_for_screenshot = launcher_entry.clone(); - let drawer_box_for_screenshot = widgets.drawer_box.clone(); - - // Never launch sibling App windows from inside this init — RelmApp - // is still in GApplication activate, and a same-type launch here - // creates a second primary on the laptop. Idle reconcile after. - let satellites = Vec::new(); - if init.primary && screenshot_req.is_none() { - let later = sender.clone(); - gtk4::glib::idle_add_local_once(move || { - later.input(AppInput::ReconcileMonitors); - }); - } - - let model = App { - monitor: monitor_name, - primary: init.primary, - satellites, + let mut model = App { workspaces: vec![], active_ws: 1, - workspace_box, - workspace_trail, + workspace_box: gtk4::Box::new(gtk4::Orientation::Horizontal, 4), button_map: std::collections::HashMap::new(), time_str: bar::clock::current(), - clock_digits, - date_lbl, - clock_plain_lbl, - launcher_entry, - launcher_open, - launcher_open_fn: open_fn, - system_stats_box, - system_sep, - cpu_pair, - mem_pair, - pwr_pair, - gpu_pair, + clock_lbl, cpu_lbl, mem_lbl, pwr_lbl, - bar_cpu_lbl, - bar_ram_lbl, - gpu_lbl, - vol_lbl, - vol_digits, bat_lbl, - bat_digits, bat_img, bat_textures, ac_img, @@ -1733,242 +474,76 @@ impl SimpleComponent for App { bt_textures, wifi_lbl, wifi_img, - wifi_pane, + wifi_textures, + wifi_popover_box, crumbs_status: None, wifi_popover_data: None, wifi_profile: None, current_ssid: "—".to_string(), - bt_pane, - bt_popover_data: None, media_widget, media_track_lbl, - media_play_icon, + media_play_btn, media_last: None, media_paused_at: None, + control_popover, panel_vol_slider, panel_bright_slider, panel_loading, - sink_box, - sink_section, - tray_section, - tray_sep, + panel_sink_store, + panel_sink_dropdown, + panel_sink_signal: None, + panel_sinks: vec![], + panel_cpu_lbl, + panel_gpu_lbl, + panel_net_lbl, tray_box, tray_items: std::collections::HashMap::new(), - widget_containers, - dropped_widget_warned: std::collections::HashSet::new(), - widget_tray_section, - widget_tray_sep, - panels, }; + model.workspace_box = widgets.workspace_box.clone(); 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()); - if init.primary { - bar::tray::spawn_watcher(sender.clone()); - widgets::client::spawn(sender.clone()); - // `bread.command.box.open` (plan §7 phase 6c): one subscriber, - // same reasoning as `widgets::client::spawn` above — a keybind - // should focus ONE capsule, not every satellite monitor's. - // A no-op call under every theme but spotlight (see the - // module's own doc comment). - launcher_command::spawn(sender.clone()); - // Optional (plan §10, Phase 2 item 6): live theme.toml/extra.css - // token reload, the same way a pywal palette change already - // hot-reloads via `apply_app_css`. One watch per process, so - // only the primary instance arms it. - theme::watch_hot_reload(); - } - - // Screenshot mode primes these with sample content instead of the - // real D-Bus/pactl/backlight sources — see notifications::SampleKind - // 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 = 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 = if init.primary { - Some(osd::spawn(osd_sample)) - } else { - None - }; - - if let Some(req) = screenshot_req { - 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_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_panel: media_panel_for_screenshot, - media_widget: media_widget_for_screenshot, - media_track_lbl: media_track_lbl_for_screenshot, - notification_window, - osd_window, - launcher_entry: launcher_entry_for_screenshot, - drawer_box: drawer_box_for_screenshot, - }, - ); - } + notifications::spawn(); + osd::spawn(); ComponentParts { model, widgets } } fn update(&mut self, msg: Self::Input, sender: ComponentSender) { match msg { - AppInput::WorkspaceSync { - workspaces, - actives, - } => { - let mut sorted = workspaces; + AppInput::WorkspaceList(list) => { + let mut sorted = list; 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; - if self.primary { - self.reconcile_satellites(); - } - if rows_changed { - self.active_ws = new_active; - self.rebuild_buttons(active_changed); - } else if active_changed { - let from = self.button_map.get(&self.active_ws).cloned(); - if let Some(old) = &from { - old.remove_css_class("active"); - } - self.active_ws = new_active; - if let Some(btn) = self.button_map.get(&self.active_ws).cloned() { - btn.add_css_class("active"); - // Trail style only: pill/dots never call place()/ - // stretch() at all — the "active" CSS class above is - // the whole of their active-workspace treatment - // (solid accent fill, no trail overlay). - if theme::shell_theme().modules().workspaces.style == WorkspaceStyle::Trail - { - self.workspace_trail.stretch(from.as_ref(), &btn); - } - } - } + self.rebuild_buttons(); } - AppInput::MonitorAdded(name) => { - if !self.primary || name == self.monitor { - return; + AppInput::ActiveWorkspace(id) => { + if let Some(old) = self.button_map.get(&self.active_ws) { + old.remove_css_class("active"); } - if self.satellites.iter().any(|(n, _)| n == &name) { - return; + self.active_ws = id; + if let Some(btn) = self.button_map.get(&self.active_ws) { + btn.add_css_class("active"); } - 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.date_lbl.set_label(&bar::clock::date()); - let clock_module = theme::shell_theme().modules().clock.clone(); - match clock_module.style { - // Plain (glass-workbench): one label, no flip animation - // — `flip_clock_digits` would just be wasted work (and - // a pointless 450ms `play_once` timer) on digits that - // are never on screen. - ClockStyle::Plain => { - self.clock_plain_lbl - .set_label(&bar::clock::formatted(&clock_module.format)); - } - // Flip (default, liquid-motion) and None both keep - // exactly today's per-digit-flip update — None has no - // module in a slot to display it, but there's no reason - // to special-case skipping the (cheap, idempotent) work. - ClockStyle::Flip | ClockStyle::None => { - flip_clock_digits(&self.clock_digits, &bar::clock::time()); - } - } - // `modules.clock.placeholder_clock` (spotlight): the - // capsule's entry IS the clock until focused — matches the - // demo's own `if (!open) q.placeholder = t;` guard so a - // live search in progress never has its placeholder text - // (invisibly, since real text covers it) stomped mid-type. - if clock_module.placeholder_clock && !self.launcher_open.get() { - self.launcher_entry - .set_placeholder_text(Some(&bar::clock::time())); - } + self.clock_lbl.set_label(&self.time_str); } AppInput::StatsUpdate(stats) => { - let cpu = match stats.cpu_temp { - Some(t) => format!("{} · {:.0}°", stats.cpu, t), - None => stats.cpu, - }; - self.cpu_lbl.set_label(&cpu); + self.cpu_lbl.set_label(&stats.cpu); self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); - // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass- - // workbench): same formatted text, separate chip instances - // (see the App struct field docs for why). - self.bar_cpu_lbl.set_label(&cpu); - self.bar_ram_lbl.set_label(&stats.mem); - match stats.gpu_usage { - Some(g) => { - let gpu = match stats.gpu_temp { - Some(t) => format!("{g}% · {t:.0}°"), - None => format!("{g}%"), - }; - self.gpu_lbl.set_label(&gpu); - self.gpu_pair.set_visible(true); - } - None => self.gpu_pair.set_visible(false), - } - self.system_sep.set_visible(false); - - flip_digit_chip( - &self.vol_lbl, - &mut self.vol_digits.borrow_mut(), - &stats.volume_pct.to_string(), - ); - self.vol_lbl.set_tooltip_text(Some(&format!("volume {}%", stats.volume_pct))); - flip_digit_chip(&self.bat_lbl, &mut self.bat_digits.borrow_mut(), &stats.bat); + self.bat_lbl.set_label(&stats.bat); if let Some(tex) = self.bat_textures.get(&(stats.bat_icon.as_ptr() as usize)) { self.bat_img.set_paintable(Some(tex)); } - 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); + self.ac_img.set_visible(stats.ac_connected); if let Some(tex) = self.bt_textures.get(&(stats.bt_icon.as_ptr() as usize)) { self.bt_img.set_paintable(Some(tex)); } @@ -1983,12 +558,36 @@ 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_ICON_OFF + bar::stats::WIFI_OFF } else { stats.wifi_icon }; - self.wifi_img.set_icon_name(Some(icon)); + if let Some(tex) = self.wifi_textures.get(&(icon.as_ptr() as usize)) { + self.wifi_img.set_paintable(Some(tex)); + } + // 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); + + 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) { @@ -2004,34 +603,19 @@ impl SimpleComponent for App { btn.connect_clicked(move |_| bar::tray::spawn_activate(id_click.clone())); self.tray_box.append(&btn); self.tray_items.insert(id, btn); - self.tray_section.set_visible(true); - self.tray_sep.set_visible(true); } AppInput::TrayUpdate(bar::tray::TrayUpdate::Remove { id }) => { if let Some(btn) = self.tray_items.remove(&id) { self.tray_box.remove(&btn); } - let has_items = !self.tray_items.is_empty(); - self.tray_section.set_visible(has_items); - self.tray_sep.set_visible(has_items); } AppInput::CrumbsStatus(status) => { self.crumbs_status = Some(status); - // CrumbsStatus and WifiPopoverData arrive independently (different - // pollers); whichever lands second must still repaint the header — - // otherwise it only shows up if CrumbsStatus happens to win the race. - if self.wifi_popover_data.is_some() { - self.rebuild_wifi_popover(&sender); - } } AppInput::WifiPopoverData(data) => { self.wifi_popover_data = Some(data); self.rebuild_wifi_popover(&sender); } - AppInput::BtPopoverData(data) => { - self.bt_popover_data = Some(data); - self.rebuild_bt_popover(); - } AppInput::SetProfile(name) => { self.wifi_profile = Some(name); self.apply_wifi_label(); @@ -2044,22 +628,8 @@ impl SimpleComponent for App { format!("{} · {}", state.artist, state.title) }; self.media_track_lbl.set_label(&label); - let icon_svg = if state.playing { - asset!("Pause.svg") - } else { - asset!("Play.svg") - }; - self.media_play_icon - .set_paintable(Some(&svg_texture(icon_svg))); - prepare_icon( - &self.media_play_icon, - theme::shell_theme().tokens().icon_px() as i32, - ); - if state.playing { - self.media_widget.add_css_class("playing"); - } else { - self.media_widget.remove_css_class("playing"); - } + self.media_play_btn + .set_label(if state.playing { "⏸" } else { "▶" }); if state.playing { self.media_paused_at = None; @@ -2069,22 +639,21 @@ impl SimpleComponent for App { let within_linger = self .media_paused_at - .is_none_or(|t| t.elapsed().as_secs() < 30 * 60); + .map_or(true, |t| t.elapsed().as_secs() < 30 * 60); self.media_last = Some(state); - reveal_media(&self.media_widget, within_linger); + self.media_widget.set_visible(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 { - reveal_media(&self.media_widget, true); + self.media_widget.set_visible(true); } else { - reveal_media(&self.media_widget, false); + self.media_widget.set_visible(false); self.media_last = None; self.media_paused_at = None; } } else { - reveal_media(&self.media_widget, false); + self.media_widget.set_visible(false); self.media_last = None; } } @@ -2095,320 +664,61 @@ 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); - } - AppInput::ReconcileMonitors => { - if self.primary { - self.reconcile_satellites(); + + // Rebuild sink dropdown — disconnect, repopulate, reconnect + if let Some(id) = self.panel_sink_signal.take() { + self.panel_sink_dropdown.disconnect(id); } - } - AppInput::DismissPanels => { - self.panels.hide_all(); - } - AppInput::OpenLauncher => { - // Only the primary instance subscribes to the open command - // (`launcher_command::spawn`), but `self.monitor` here is - // whichever output was focused ONCE, at this instance's own - // `init()` — baked in at process start, not re-resolved on - // every keybind press. If the user has since moved focus to - // a different monitor, blindly grabbing focus on `self` - // would open the capsule on the wrong screen. Re-resolve - // the focused monitor now and route to whichever instance - // actually owns it. - let satellite_names: Vec<&str> = - self.satellites.iter().map(|(n, _)| n.as_str()).collect(); - let focused = primary_hypr_monitor(); - match resolve_launcher_route(focused.as_deref(), &self.monitor, &satellite_names) - { - LauncherRoute::Satellite(name) => { - // resolve_launcher_route only returns a name present in - // satellite_names, so this lookup cannot miss. - if let Some((_, ctrl)) = self.satellites.iter().find(|(n, _)| *n == name) { - ctrl.sender().emit(AppInput::OpenLauncher); - } - } - LauncherRoute::Local => { - // See `launcher_entry.set_can_focus(false)`'s own - // comment above: a hotkey/command-triggered open - // has no pointer click to flip this back on, so - // this path has to do it itself or `grab_focus()` - // below is a silent no-op. - self.launcher_entry.set_can_focus(true); - self.launcher_entry.grab_focus(); - // ...and then actually OPEN it. Focus alone no longer - // opens the capsule: `connect_enter` used to call - // `open_fn()`, which is precisely what made the capsule - // open itself during window construction, so that path - // was deliberately removed. Without this call the - // keybind focuses the entry and leaves the drawer shut. - (self.launcher_open_fn)(); - } + // 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); } } } } -/// The `widget:` alias `for_each_in_slot` recognizes for each -/// `WidgetPlacement` variant — the fallback a `WidgetSpec` routes through -/// when no `widget:` container claims its module name specifically. -/// Kept in one place since both the builtin manifest's slot lists and -/// `reconcile_widgets`' routing below must agree on these names. -fn placement_alias(placement: bread_shared::widget::WidgetPlacement) -> &'static str { - use bread_shared::widget::WidgetPlacement::*; - match placement { - Tray => "tray", - LeftOfClock => "left_of_clock", - RightOfClock => "right_of_clock", - RightOfWorkspaces => "right_of_workspaces", - LeftOfStats => "left_of_stats", - } -} - impl App { - fn reconcile_widgets(&mut self, specs: Vec) { - for container in self.widget_containers.values() { - while let Some(child) = container.first_child() { - container.remove(&child); - } - } - - // Route each spec to a widget_containers entry: a `widget:` - // slot entry (keyed by WidgetSpec::module) takes priority over the - // spec's placement alias, so a theme can retarget one Lua module's - // widgets without moving every widget that shares its placement. - // A spec whose module AND placement alias both lack a container - // (e.g. a theme's slots omit that placement's widget: entry - // entirely) is logged and dropped rather than silently vanishing — - // WidgetPlacement itself never changes; only which container (if - // any) each spec lands in does. - let mut by_container: std::collections::HashMap< - String, - Vec<&bread_shared::widget::WidgetSpec>, - > = std::collections::HashMap::new(); - for spec in &specs { - let key = if self.widget_containers.contains_key(&spec.module) { - spec.module.clone() - } else { - placement_alias(spec.placement).to_string() - }; - if self.widget_containers.contains_key(&key) { - by_container.entry(key).or_default().push(spec); - } else if self.dropped_widget_warned.insert(spec.id.clone()) { - // Warn ONCE per widget id, not once per reconcile: breadd - // re-pushes every spec on each update (a widget on a timer, - // like a git-branch poller, reconciles continuously), which - // turned a legitimate one-off diagnostic into unbounded log - // spam. The set is only added to, so a spec that starts - // resolving again after a theme switch stays quiet — the - // message is about a theme lacking the slot, and repeating it - // every tick tells the reader nothing new. - eprintln!( - "breadbar: widget '{}' (module '{}', placement {:?}) has no matching \ - [bar.slots] widget: container — dropping", - spec.id, spec.module, spec.placement - ); - } - } - - let mut has_tray_widgets = false; - for (key, mut group) in by_container { - let container = &self.widget_containers[&key]; - group.sort_by_key(|s| s.order); - for spec in group { - if !spec.visible { - continue; - } - if key == "tray" { - has_tray_widgets = true; - } - let node = widgets::build_node(&spec.root, &spec.id); - if let Some(tooltip) = &spec.tooltip { - node.set_tooltip_text(Some(tooltip)); - } - container.append(&node); - } - } - - // The "tray" container has its own section/separator (handled - // below, same as the existing SNI tray items) — an empty inline - // slot has no such wrapper, so it must hide itself to stop - // contributing to its parent box's `spacing` gap. - for (key, container) in &self.widget_containers { - if key == "tray" { - continue; - } - container.set_visible(container.first_child().is_some()); - } - - self.widget_tray_section.set_visible(has_tray_widgets); - self.widget_tray_sep.set_visible(has_tray_widgets); - } - - fn reconcile_satellites(&mut self) { - let live: Vec = hypr_monitor_names() - .into_iter() - .filter(|n| n != &self.monitor) - .collect(); - let stale: Vec = self - .satellites - .iter() - .filter_map(|(n, _)| { - if live.contains(n) { - None - } else { - Some(n.clone()) - } - }) - .collect(); - for name in stale { - drop_satellite(&mut self.satellites, &name); - } - for name in live { - if self.satellites.iter().any(|(n, _)| n == &name) { - continue; - } - if let Some(ctrl) = spawn_satellite(&name) { - self.satellites.push((name, ctrl)); - } - } - } - - fn rebuild_sinks( - &mut self, - sinks: &[bar::control::AudioSink], - sender: &ComponentSender, - ) { - while let Some(child) = self.sink_box.first_child() { - self.sink_box.remove(&child); - } - self.sink_section.set_visible(!sinks.is_empty()); - for (i, sink) in sinks.iter().enumerate() { - let row = gtk4::Button::new(); - row.add_css_class("flat"); - row.add_css_class("wifi-popover-row"); - row.add_css_class("sink-row"); - if sink.is_default { - row.add_css_class("wifi-popover-row-active"); - } - stagger_row(&row, i); - let lbl = gtk4::Label::new(Some(&sink.description)); - lbl.set_xalign(0.0); - lbl.set_hexpand(true); - lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); - lbl.set_max_width_chars(22); - lbl.set_valign(gtk4::Align::Center); - row.set_child(Some(&lbl)); - let name = sink.name.clone(); - let sender = sender.clone(); - row.connect_clicked(move |_| { - bar::control::spawn_set_sink(name.clone(), sender.clone()); - }); - self.sink_box.append(&row); - } - } - fn apply_wifi_label(&self) { let label = match &self.wifi_profile { Some(p) => format!("{p} · {}", self.current_ssid), None => self.current_ssid.clone(), }; self.wifi_lbl.set_label(&label); - self.wifi_img.set_tooltip_text(Some(&label)); } - fn rebuild_buttons(&mut self, animate: bool) { - self.workspace_trail.cancel(); - let prev: std::collections::HashSet = - self.button_map.keys().copied().collect(); - // `button_map` only starts empty once — the very first call this - // App instance ever makes, before any workspace has ever been - // synced from Hyprland. A fully-emptied bar never happens after - // that (the active workspace's own row is always kept), so this - // doubles as a clean "is this the initial paint" signal without a - // dedicated flag — see its one use below. - let is_first_build = prev.is_empty(); + fn rebuild_buttons(&mut self) { while let Some(child) = self.workspace_box.first_child() { self.workspace_box.remove(&child); } self.button_map.clear(); - let modules = theme::shell_theme().modules().clone(); - let ws_style = modules.workspaces.style; - let show_empty = modules.workspaces.show_empty; for ws in &self.workspaces { - if ws.monitor != self.monitor { - continue; - } - let empty = ws.windows == 0 && ws.id != self.active_ws; - if empty { - match ws_style { - // Trail (default, liquid-motion): unconditionally off - // the bar, exactly as before this change — regardless - // of `show_empty`, which liquid-motion's own manifest - // declares `true` but this style has never consumed. - // Changing that now would be a real, undesired - // liquid-motion regression, not a Phase 5 fix. - WorkspaceStyle::Trail => continue, - // Pill/Dots: honour `show_empty` for real — demo 02's - // pills render an unoccupied, non-active workspace at - // reduced opacity via the `.workspace-btn:not(.occupied) - // :not(.active)` CSS rule rather than hiding it. - _ => { - if !show_empty { - continue; - } - } - } - } - let btn = match ws_style { - WorkspaceStyle::Dots => bar::workspaces::make_dot_button( - ws.id, - self.active_ws, - ws.windows as i32, - modules.workspaces.dot_widths, - ), - WorkspaceStyle::Trail | WorkspaceStyle::Pill => { - bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0) - } - }; - // Never on the very first build (bug: "the [Trail] row sits - // ~5px low on first paint and only corrects after the first - // switch"). Root cause: `ws-in`'s `row-in` keyframe animates - // `margin-top` 8px → 0 over 320ms; `WorkspaceTrail::place` - // (called once, synchronously-ish, right after this loop for - // the initial row) samples each button's geometry via a - // single-shot tick callback that can fire while that margin - // is still mid-animation, freezing the trail pill a few px - // low until the next `place`/`stretch` call (the first real - // workspace switch) re-samples the by-then-settled layout. - // The demo itself never animates the initial row in at all - // (`OCC.forEach` builds plainly, only `place(0)` runs) — only - // *subsequently added* workspaces should ever play this. - if !is_first_build && !prev.contains(&ws.id) { - play_once(&btn, "ws-in", 360); - } + let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws); self.workspace_box.append(&btn); self.button_map.insert(ws.id, btn); } - // Trail style only: pill/dots never call place()/stretch()/clear() - // at all — the "active" CSS class `make_button` already applies is - // the whole of their active-workspace treatment. - if ws_style == WorkspaceStyle::Trail { - match self.button_map.get(&self.active_ws).cloned() { - Some(btn) if animate => self.workspace_trail.stretch(None, &btn), - Some(btn) => self.workspace_trail.place(&btn), - None => self.workspace_trail.clear(), - } - } } fn rebuild_wifi_popover(&mut self, sender: &ComponentSender) { - let panels = self.panels.clone(); - while let Some(child) = self.wifi_pane.first_child() { - self.wifi_pane.remove(&child); + while let Some(child) = self.wifi_popover_box.first_child() { + self.wifi_popover_box.remove(&child); } if let Some(st) = &self.crumbs_status { @@ -2438,52 +748,71 @@ 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"); status_lbl.set_xalign(0.0); header.append(&status_lbl); - self.wifi_pane.append(&header); - self.wifi_pane + self.wifi_popover_box.append(&header); + self.wifi_popover_box .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); } - 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 Some(data) = &self.wifi_popover_data else { let lbl = gtk4::Label::new(Some("Scanning…")); lbl.add_css_class("wifi-popover-loading"); + self.wifi_popover_box.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_popover_box.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); - 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() { + 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_popover_box.append(&row); + } + + if !data.scan.is_empty() { + self.wifi_popover_box + .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_popover_box.append(&nh); + + for entry in &data.scan { 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"); } @@ -2492,227 +821,96 @@ impl App { row.add_css_class("wifi-popover-row-active"); } - 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)); + 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, + ))); 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(), |_| {}); + show_add_network_dialog(btn, ssid_clone.clone()); } - panels.hide_all(); + close_parent_popover(btn); }); - self.wifi_pane.append(&row); + self.wifi_popover_box.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); - } - } - } - - fn rebuild_bt_popover(&mut self) { - while let Some(child) = self.bt_pane.first_child() { - self.bt_pane.remove(&child); - } - - let Some(data) = &self.bt_popover_data else { - let lbl = gtk4::Label::new(Some("Loading…")); - lbl.add_css_class("wifi-popover-loading"); - self.bt_pane.append(&lbl); - return; - }; - - // Power toggle row - let toggle_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - toggle_row.set_margin_bottom(4); - let toggle_lbl = gtk4::Label::new(Some("Bluetooth")); - toggle_lbl.add_css_class("wifi-popover-ssid"); - 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| { - bar::bluetooth::spawn_set_powered(on); - gtk4::glib::Propagation::Proceed - }); - toggle_row.append(&toggle_lbl); - toggle_row.append(&toggle_switch); - self.bt_pane.append(&toggle_row); - self.bt_pane - .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - - if !data.powered { - let lbl = gtk4::Label::new(Some("Bluetooth is off")); - lbl.add_css_class("wifi-popover-loading"); - self.bt_pane.append(&lbl); - } else if data.devices.is_empty() { - let lbl = gtk4::Label::new(Some("No paired devices")); - lbl.add_css_class("wifi-popover-loading"); - self.bt_pane.append(&lbl); - } else { - 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(4); - self.bt_pane.append(&dh); - - 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(&dev.name)); - lbl.set_xalign(0.0); - lbl.set_valign(gtk4::Align::Center); - row.set_child(Some(&lbl)); - - let address = dev.address.clone(); - let connected = dev.connected; - row.connect_clicked(move |_| { - if connected { - bar::bluetooth::spawn_disconnect(address.clone()); - } else { - bar::bluetooth::spawn_connect(address.clone()); - } - }); - self.bt_pane.append(&row); - } - } - - self.bt_pane - .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - let settings_row = gtk4::Button::new(); - 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 = 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"))); - settings_row.set_child(Some(&settings_box)); - settings_row.connect_clicked(|_| { - relm4::spawn(async { - let _ = tokio::process::Command::new("blueman-manager").spawn(); - }); - }); - self.bt_pane.append(&settings_row); + self.wifi_popover_box.set_visible(true); } } // ── Helpers ─────────────────────────────────────────────────────────────────── -fn build_slider_row(label: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { +fn build_slider_row(icon: &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); - 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 icon_lbl = gtk4::Label::new(Some(icon)); + icon_lbl.add_css_class("control-panel-row-icon"); + icon_lbl.set_width_chars(2); 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(160); + slider.set_width_request(180); slider.add_css_class("control-panel-slider"); - row.append(&lbl); + row.append(&icon_lbl); row.append(&slider); (row, slider) } -fn wifi_icon_for_signal(pct: u8) -> &'static str { - use bar::stats::{ - WIFI_ICON_EXCELLENT, WIFI_ICON_GOOD, WIFI_ICON_OFF, WIFI_ICON_OK, WIFI_ICON_WEAK, - }; - match pct { - 75..=100 => WIFI_ICON_EXCELLENT, - 50..=74 => WIFI_ICON_GOOD, - 25..=49 => WIFI_ICON_OK, - 1..=24 => WIFI_ICON_WEAK, - _ => WIFI_ICON_OFF, +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}; + match pct { + 75..=100 => WIFI_STRONG, + 50..=74 => WIFI_MEDIUM, + 25..=49 => WIFI_WEAK, + _ => WIFI_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), -) { +/// `breadcrumbs add` + `breadcrumbs join`. +fn show_add_network_dialog(anchor: >k4::Button, ssid: String) { 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 - // 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)); @@ -2738,14 +936,7 @@ fn show_add_network_dialog( btn_row.set_halign(gtk4::Align::End); let cancel_btn = gtk4::Button::with_label("Cancel"); let connect_btn = gtk4::Button::with_label("Connect"); - // 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"); + connect_btn.add_css_class("suggested-action"); btn_row.append(&cancel_btn); btn_row.append(&connect_btn); body.append(&btn_row); @@ -2779,7 +970,6 @@ fn show_add_network_dialog( dialog_for_activate.close(); }); - on_build(&dialog); dialog.present(); entry.grab_focus(); } @@ -2787,513 +977,24 @@ fn show_add_network_dialog( 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"); - bar_chip(&pair); - let img = svg_image(icon_svg); + let img = gtk4::Image::from_paintable(Some(&svg_texture(icon_svg))); img.add_css_class("stat-icon"); pair.append(&img); pair.append(label); pair } -/// 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() -} - -fn bar_chip(widget: &impl IsA) { - widget.set_valign(gtk4::Align::Center); - widget.set_vexpand(false); -} - -fn popover_caret() -> gtk4::Box { - let caret = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - caret.add_css_class("popover-caret"); - caret.set_hexpand(true); - caret -} - -fn stagger_row(widget: &impl IsA, i: usize) { - widget.add_css_class("row-in"); - widget.add_css_class(&format!("stagger-{}", i.min(11))); -} - -fn play_once(widget: &impl IsA, class: &str, ms: u64) { - widget.remove_css_class(class); - widget.add_css_class(class); - let w = widget.as_ref().clone(); - let class = class.to_string(); - gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(ms), move || { - w.remove_css_class(&class); - }); -} - -fn make_clock_digits(time: &str) -> Vec { - time.chars() - .map(|ch| { - let lbl = gtk4::Label::new(Some(&ch.to_string())); - lbl.add_css_class("clock-digit"); - if ch == ':' { - lbl.add_css_class("clock-colon"); - } - lbl.set_valign(gtk4::Align::Center); - lbl.set_vexpand(false); - lbl.set_yalign(0.5); - lbl - }) - .collect() -} - -fn flip_clock_digits(digits: &[gtk4::Label], time: &str) { - let chars: Vec = time.chars().collect(); - for (i, lbl) in digits.iter().enumerate() { - let next = chars.get(i).copied().unwrap_or(' '); - let next_s = next.to_string(); - if lbl.label().as_str() == next_s { - continue; - } - lbl.set_label(&next_s); - if next != ':' { - play_once(lbl, "flip", 450); - } - } -} - -/// Container for a [`make_digit_chip`]/[`flip_digit_chip`]-driven numeric -/// chip (volume, battery) — a plain horizontal box, same shape as -/// `vol_box`/`bat_box`'s existing icon+label chips, that gets one -/// `.stat-digit` label per character instead of a single `gtk4::Label`. -fn digit_chip_box() -> gtk4::Box { - gtk4::Box::new(gtk4::Orientation::Horizontal, 0) -} - -/// One `.stat-digit` label per character of `text` — the `stat-label` -/// styled counterpart of `make_clock_digits`, reused so volume/battery -/// chips can roll per-digit the same way the clock does (plan: "ODOMETER -/// DIGITS FOR NUMERIC CHIPS"). Not the clock's own `.clock-digit` class: -/// that carries the clock's much larger `font-size`, wrong for a bar chip. -fn make_digit_chip(text: &str) -> Vec { - text.chars() - .map(|ch| { - let lbl = gtk4::Label::new(Some(&ch.to_string())); - lbl.add_css_class("stat-label"); - lbl.add_css_class("stat-digit"); - lbl.set_valign(gtk4::Align::Center); - lbl.set_vexpand(false); - lbl.set_yalign(0.5); - lbl - }) - .collect() -} - -/// Tears down `container`'s current digit labels and rebuilds them for -/// `text` from scratch — the shared fallback [`set_digit_chip`] and -/// [`flip_digit_chip`] both take when the character count changes (`9` -> -/// `10`), including the very first call, when `digits` starts empty. No -/// per-position diff makes sense across different lengths, so this never -/// animates. -fn rebuild_digit_chip(container: >k4::Box, digits: &mut Vec, text: &str) { - for lbl in digits.drain(..) { - container.remove(&lbl); - } - *digits = make_digit_chip(text); - for lbl in digits.iter() { - container.append(lbl); - } -} - -/// Replaces `container`'s digit labels with `text`'s, with NO animation — -/// used for the volume slider's live drag feedback (`connect_value_changed` -/// fires on every pointer-move tick; replaying the flip keyframe that fast -/// would read as a flicker). [`flip_digit_chip`] below is the animated -/// counterpart, driven by the `StatsUpdate` poll instead. -fn set_digit_chip(container: >k4::Box, digits: &mut Vec, text: &str) { - let chars: Vec = text.chars().collect(); - if digits.len() != chars.len() { - rebuild_digit_chip(container, digits, text); - return; - } - for (lbl, ch) in digits.iter().zip(chars.iter()) { - let next = ch.to_string(); - if lbl.label().as_str() != next { - lbl.set_label(&next); - } - } -} - -/// The animated counterpart of [`set_digit_chip`]: same rebuild-on-length- -/// change fallback, but on a same-length update it plays the `flip` -/// keyframe (`digit-flip`, the same one the clock uses) only on the -/// characters that actually changed — same convention as -/// `flip_clock_digits`. -fn flip_digit_chip(container: >k4::Box, digits: &mut Vec, text: &str) { - let chars: Vec = text.chars().collect(); - if digits.len() != chars.len() { - rebuild_digit_chip(container, digits, text); - return; - } - for (lbl, ch) in digits.iter().zip(chars.iter()) { - let next = ch.to_string(); - if lbl.label().as_str() == next { - continue; - } - lbl.set_label(&next); - play_once(lbl, "flip", 350); - } -} - -fn reveal_media(widget: >k4::Box, show: bool) { - if show && !widget.is_visible() { - play_once(widget, "media-in", 420); - } - widget.set_visible(show); -} - -/// Drives `drawer_box`'s height from `from` to `to` over 360ms via -/// `bread_theme::anim::spring_to` (plan §7: GTK4 has no CSS height -/// transition on a widget, so the capsule's `.results { max-height: 0 → -/// 420px }` becomes a `set_size_request` interpolation on the frame clock -/// instead). Cancels any run already in flight first — reopening mid-close -/// (or vice versa) must restart from the CURRENT height, not fight a -/// leftover callback still walking toward the old target. -fn animate_drawer_height( - drawer_box: >k4::Box, - anim: &Rc>>, - from: i32, - to: i32, -) { - if let Some(id) = anim.borrow_mut().take() { - id.remove(); - } - let target = drawer_box.clone(); - let id = bread_theme::anim::spring_to(drawer_box, from, to, 360.0, move |h| { - // `spring_ease` deliberately overshoots past t=1.0 — that bounce is the - // point on expand, but on a collapse (from=content height, to=0) the - // same overshoot carries the interpolated value BELOW zero, and - // `set_size_request` hard-asserts `height >= -1` (GTK-CRITICAL, once - // per frame at 60fps). -1 is GTK's "use natural height" sentinel, not a - // valid animation frame, so clamp to 0 rather than -1: a drawer mid- - // collapse wants zero height, never its natural height. - target.set_size_request(-1, h.max(0)); - // Collapse finished: hide the box so it stops claiming natural height. - // set_size_request is only a minimum, so a visible-but-zero-request - // drawer still gets allocated its children's full height and holds the - // capsule open. Hiding here rather than in close_fn keeps the collapse - // animated instead of snapping shut on the first frame. - if to == 0 && h <= 0 { - target.set_visible(false); - } - }); - *anim.borrow_mut() = Some(id); -} - -/// Drives the capsule's own window width from `from` to `to` over 360ms -/// (plan §7 phase 6c: `[launcher].search_width`, `04-spotlight.html`'s -/// `.searching .capsule { width: 520px }`) — the same `spring_to` + -/// `set_size_request` technique `animate_drawer_height` uses for the -/// drawer's height, applied to the root window itself instead of a child -/// box. Unlike a drawer collapse, width never animates toward a negative -/// target (idle/search widths are both positive theme values), so there is -/// no analogous "clamp to 0" concern here. -fn animate_capsule_width( - root: >k4::ApplicationWindow, - anim: &Rc>>, - from: i32, - to: i32, -) { - if let Some(id) = anim.borrow_mut().take() { - id.remove(); - } - let target = root.clone(); - let id = bread_theme::anim::spring_to(root, from, to, 360.0, move |w| { - target.set_size_request(w.max(0), -1); - }); - *anim.borrow_mut() = Some(id); -} - -/// The drawer's natural content height right now, capped at the demo's own -/// 420px (`04-spotlight.html`: `.searching .results { max-height: 420px }`) -/// — `ResultsList`'s scroller already self-caps at 480px -/// (`max_content_height`), shared with breadbox, so this is a tighter, -/// spotlight-specific ceiling on top of that shared one, not a replacement -/// for it. -fn drawer_target_height(drawer_box: >k4::Box) -> i32 { - // Deliberately never measures `drawer_box` itself. `animate_drawer_height`'s - // tick callback calls `drawer_box.set_size_request(-1, h)` on every - // frame, and GTK clamps a widget's own `measure()` result up to at - // least its own explicit size request — so once an animation has run - // even one frame, `drawer_box.measure()` reports that frame's forced - // height (or the spring's overshoot past it), not whatever its - // children actually need next. This bit spotlight's new query-mode - // rows directly: switching from the (tall) app list to a one-row calc - // result measured "437" instead of "~33", because the PREVIOUS - // animation frame had already forced `drawer_box` to 437px. - // - // Summing each currently-visible child's own natural height instead - // sidesteps this entirely — `launcher_results.scroller` and - // `mode_list` never get an explicit size request of their own, so - // their `measure()` always reflects their actual current content. - let mut total = 0; - let mut child = drawer_box.first_child(); - while let Some(c) = child { - if c.is_visible() { - let (_, natural, _, _) = c.measure(gtk4::Orientation::Vertical, -1); - total += natural; - } - child = c.next_sibling(); - } - total.min(DRAWER_MAX_HEIGHT_PX) -} - -// ── Query-mode rows (plan §7 phase 6c) ────────────────────────────────── -// -// `mode_list`'s rows are NOT `bread_launcher::DesktopEntry`-backed -// (`bread_launcher::gtk::row_entry` returns `None` for every one of -// these), so they're built/read here rather than through that crate. - -/// A single-line, non-interactive row — the calc result, or a "nothing -/// typed yet" placeholder. Reuses `.app-name` so it inherits the same -/// `.bread-drawer row` typography `bread-launcher`'s own rows get. -fn mode_info_row(text: &str) -> gtk4::ListBoxRow { - let row = gtk4::ListBoxRow::new(); - row.set_selectable(false); - row.set_activatable(false); - let lbl = gtk4::Label::new(Some(text)); - lbl.add_css_class("app-name"); - lbl.set_xalign(0.0); - row.set_child(Some(&lbl)); - row -} - -/// A single-line, actionable row (a `>`-mode command, or the `.`-mode -/// "open this URL" prompt) — Enter/click spawns `action` once resolved by -/// [`run_mode_action`]. -fn mode_action_row(text: &str, action: ModeAction) -> gtk4::ListBoxRow { - let row = gtk4::ListBoxRow::new(); - let lbl = gtk4::Label::new(Some(text)); - lbl.add_css_class("app-name"); - lbl.set_xalign(0.0); - row.set_child(Some(&lbl)); - unsafe { row.set_data("mode_action", action) }; - row -} - -/// What Enter/click on a [`mode_action_row`] does. Two variants, not one -/// shell-command string, so a `.`-mode URL (arbitrary user-typed text) -/// never passes through `bash -c` at all — only a `>`-mode command's own -/// fixed, trusted `exec` string does. -#[derive(Clone)] -enum ModeAction { - RunShell(&'static str), - OpenUrl(String), -} - -fn mode_row_action(row: >k4::ListBoxRow) -> Option { - unsafe { row.data::("mode_action").map(|p| p.as_ref().clone()) } -} - -/// Pure half of the `.`-mode URL action: adds a scheme when the user typed -/// a bare host (`example.com` -> `https://example.com`), leaves anything -/// that already looks like `scheme://...` untouched. -fn url_open_target(url: &str) -> String { - if url.contains("://") { - url.to_string() - } else { - format!("https://{url}") - } -} - -fn run_mode_action(action: &ModeAction) { - match action { - ModeAction::RunShell(cmd) => { - if let Err(e) = std::process::Command::new("bash") - .args(["-c", cmd]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - { - eprintln!("breadbar: failed to run mode command {cmd:?}: {e}"); - } - } - ModeAction::OpenUrl(url) => { - // No scheme-adding shell involved — `xdg-open` gets the raw - // argument, so nothing in a `.`-mode query is ever parsed as - // shell syntax. - let target = url_open_target(url); - if let Err(e) = std::process::Command::new("xdg-open") - .arg(&target) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - { - eprintln!("breadbar: failed to open url {target:?}: {e}"); - } - } - } -} - -/// Moves `list`'s selection to the next/previous row — unlike -/// `bread_launcher::gtk::ResultsList::select_next`/`select_prev`, `mode_list` -/// never has hidden rows to skip (it's cleared and rebuilt from scratch on -/// every query change), so this is the plain, un-filtered version. -fn listbox_select_next(list: >k4::ListBox) { - let cur = list.selected_row().map(|r| r.index()).unwrap_or(-1); - if let Some(row) = list.row_at_index(cur + 1) { - list.select_row(Some(&row)); - } -} - -fn listbox_select_prev(list: >k4::ListBox) { - let cur = list.selected_row().map(|r| r.index()).unwrap_or(0); - if cur > 0 { - if let Some(row) = list.row_at_index(cur - 1) { - list.select_row(Some(&row)); - } - } -} - -/// Clears `mode_list` and rebuilds it for `parsed` — the calc result, -/// filtered `>`-mode commands, or the `.`-mode "open this URL" prompt. -/// Returns whether anything is now selectable (a real command/URL row, not -/// just an info row) so the caller knows whether Return has anything to do. -fn populate_mode_list(mode_list: >k4::ListBox, parsed: &bread_launcher::ParsedQuery) -> bool { - while let Some(row) = mode_list.row_at_index(0) { - mode_list.remove(&row); - } - match parsed.kind { - bread_launcher::QueryKind::Calc => { - match bread_launcher::eval_calc(&parsed.value) { - Some(result) => mode_list.append(&mode_info_row(&format!("= {result}"))), - None => mode_list.append(&mode_info_row("=")), - } - false - } - bread_launcher::QueryKind::Cmd => { - let matches = bread_launcher::filter_commands( - &parsed.value, - bread_launcher::builtin_commands(), - ); - if matches.is_empty() { - mode_list.append(&mode_info_row("No matching commands")); - false - } else { - for cmd in &matches { - mode_list.append(&mode_action_row(cmd.name, ModeAction::RunShell(cmd.exec))); - } - if let Some(first) = mode_list.row_at_index(0) { - mode_list.select_row(Some(&first)); - } - true - } - } - bread_launcher::QueryKind::Url => { - if parsed.value.is_empty() { - mode_list.append(&mode_info_row(".")); - false - } else { - mode_list.append(&mode_action_row( - &format!("Open {}", parsed.value), - ModeAction::OpenUrl(parsed.value.clone()), - )); - if let Some(first) = mode_list.row_at_index(0) { - mode_list.select_row(Some(&first)); - } - true - } - } - bread_launcher::QueryKind::Apps => false, - } -} - -fn popover_tab(label: &str) -> gtk4::ToggleButton { - let btn = gtk4::ToggleButton::with_label(label); - btn.add_css_class("popover-tab"); - btn.set_hexpand(true); - btn.set_valign(gtk4::Align::Center); - btn.set_vexpand(false); - btn.set_size_request(-1, theme::shell_theme().tokens().chip_height() as i32); - if let Some(child) = btn.child() { - child.set_halign(gtk4::Align::Center); - child.set_valign(gtk4::Align::Center); - child.set_hexpand(true); - child.set_vexpand(false); - if let Ok(lbl) = child.downcast::() { - lbl.set_xalign(0.5); - lbl.set_yalign(0.5); - } - } - btn -} - -/// breadcrumbs can list the same profile twice under different case -/// (`Home` / `home`). Keep the active spelling when there is one. -fn unique_profiles(profiles: &[(String, bool)]) -> Vec<(String, bool)> { - let mut seen = std::collections::HashSet::new(); - let mut out = Vec::new(); - for (name, active) in profiles { - if *active { - seen.insert(name.to_ascii_lowercase()); - out.push((name.clone(), true)); - } - } - for (name, active) in profiles { - if seen.insert(name.to_ascii_lowercase()) { - out.push((name.clone(), *active)); - } - } - out -} - -pub(crate) fn prepare_icon(img: >k4::Image, px: i32) { - img.set_pixel_size(px); - img.set_valign(gtk4::Align::Center); - img.set_vexpand(false); -} - -pub(crate) fn svg_image(svg_src: &str) -> gtk4::Image { - svg_image_sized(svg_src, theme::shell_theme().tokens().icon_px() as u32) -} - -pub(crate) fn svg_image_sized(svg_src: &str, px: u32) -> gtk4::Image { - let img = gtk4::Image::from_paintable(Some(&svg_texture_sized(svg_src, px))); - prepare_icon(&img, px as i32); - img -} - -pub(crate) fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { - svg_texture_sized(svg_src, theme::shell_theme().tokens().icon_px() as u32) -} - -/// Rasterise at 2× the display size so Lucide strokes stay sharp when GTK -/// displays the texture at `px` via `Image::set_pixel_size`. -pub(crate) fn svg_texture_sized(svg_src: &str, px: u32) -> gtk4::gdk::Texture { +fn svg_texture(svg_src: &str) -> 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="{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); + .replace(r#"width="24" height="24""#, r#"width="16" height="16""#); 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, @@ -3305,8 +1006,6 @@ 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"); @@ -3315,21 +1014,6 @@ 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 { use tokio::signal::unix::{signal, SignalKind}; let mut stream = signal(SignalKind::hangup()).expect("SIGHUP handler"); @@ -3339,345 +1023,6 @@ fn main() { } }); - // Best-effort cleanup when killed (Ctrl+C in the terminal, a dev-loop - // `kill`, systemd stop): kill the OSD's `pactl subscribe` children - // before exiting; the exit hook in osd.rs covers normal exits and - // panics. Without either, each breadbar restart orphaned a `pactl - // subscribe` holding its PulseAudio connection open until - // pipewire-pulse's client cap filled up and new clients — settings - // apps among them — were refused ("no devices in settings"). - relm4::spawn(async { - use tokio::signal::unix::{signal, SignalKind}; - let mut term = signal(SignalKind::terminate()).expect("SIGTERM handler"); - let mut intr = signal(SignalKind::interrupt()).expect("SIGINT handler"); - tokio::select! { - _ = term.recv() => {} - _ = intr.recv() => {} - } - crate::osd::kill_watchers(); - std::process::exit(0); - }); - - // `with_args(vec![])` stops relm4 from handing our own --screenshot/ - // --output flags to GLib's option parser (`app.run()`'s default), which - // would otherwise reject them as unrecognized before Cli::parse() above - // 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::(BarInit { - screenshot: screenshot_req, - monitor: None, - primary: true, - }); -} - -/// Live outputs only. `hyprctl monitors all` (and the hyprland crate's -/// `Monitors::get`) keep ghost connectors after a rename — `DVI-I-1` stayed -/// at 0×0 with `disabled=false` after the panel became `DVI-I-2`, and a -/// geometry fallback then stacked a second bar on the laptop. -#[derive(Debug, Clone, serde::Deserialize)] -struct HyprMon { - name: String, - x: i32, - y: i32, - #[serde(default)] - focused: bool, - #[serde(default)] - disabled: bool, -} - -fn hypr_monitors_live() -> Vec { - let output = match std::process::Command::new("hyprctl") - .args(["monitors", "-j"]) - .output() - { - Ok(o) if o.status.success() => o.stdout, - _ => return Vec::new(), - }; - serde_json::from_slice::>(&output) - .unwrap_or_default() - .into_iter() - .filter(|m| !m.disabled) - .collect() -} - -fn primary_hypr_monitor() -> Option { - let mons = hypr_monitors_live(); - mons.iter() - .find(|m| m.focused) - .or_else(|| mons.first()) - .map(|m| m.name.clone()) -} - -/// Where `AppInput::OpenLauncher` should be actually handled: locally (this -/// instance grabs its own capsule's focus), or forwarded to a specific -/// satellite instance. Pure decision logic, split out of the `update` match -/// arm so it's unit-testable without a live `App`/GTK/Hyprland stack. -#[derive(Debug, Clone, PartialEq, Eq)] -enum LauncherRoute { - Local, - Satellite(String), -} - -/// `focused`: the currently-focused Hyprland monitor, re-queried at -/// keybind-fire time (`None` if Hyprland's monitor query failed, e.g. -/// screenshot mode). `own`: this instance's own monitor, fixed at `init()`. -/// `satellites`: names of monitors this (necessarily primary) instance -/// tracks a `Controller` for. -/// -/// Falls back to `Local` whenever forwarding isn't possible or isn't -/// needed, so a caller can always make forward progress: no focused -/// monitor, the focused monitor is this instance's own, or the focused -/// monitor has no tracked satellite yet. -fn resolve_launcher_route(focused: Option<&str>, own: &str, satellites: &[&str]) -> LauncherRoute { - match focused { - Some(name) if name != own && satellites.contains(&name) => { - LauncherRoute::Satellite(name.to_string()) - } - _ => LauncherRoute::Local, - } -} - -fn hypr_monitor_names() -> Vec { - hypr_monitors_live() - .into_iter() - .map(|m| m.name) - .collect() -} - -fn hypr_monitor_origin(name: &str) -> Option<(i32, i32)> { - hypr_monitors_live() - .into_iter() - .find(|m| m.name == name) - .map(|m| (m.x, m.y)) -} - -/// The bar/capsule's own layer-surface geometry for `monitor`, straight -/// from the compositor (`hyprctl layers -j`, ground truth — not derived -/// from anything GTK/gtk4-layer-shell reports client-side, since the -/// wlr-layer-shell protocol never hands a client its own assigned x/y back; -/// only width/height come through `configure`). Matched by `namespace` -/// ("breadbar", set via `root.set_namespace` above), which is unique per -/// output since each monitor gets its own bound `App` instance/window. -/// Returns the surface's horizontal center in Hyprland's global coordinate -/// space. `None` on any parse/lookup failure — callers must fall back to -/// the pre-existing, safe-but-broader dead-zone behaviour rather than -/// guess. -fn hypr_capsule_center_x(monitor: &str) -> Option { - let output = std::process::Command::new("hyprctl") - .args(["layers", "-j"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let root: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; - let levels = root.get(monitor)?.get("levels")?.as_object()?; - for arr in levels.values() { - let Some(items) = arr.as_array() else { - continue; - }; - for item in items { - if item.get("namespace").and_then(|v| v.as_str()) != Some("breadbar") { - continue; - } - let x = item.get("x")?.as_i64()? as i32; - let w = item.get("w")?.as_i64()? as i32; - return Some(x + w / 2); - } - } - None -} - -/// The click-away scrim's capsule-column hole, in coordinates local to the -/// `breadbar-dismiss` surface (see `PanelSet::show_capsule_dismiss`) — -/// pure arithmetic, split out for unit testing. `capsule_center_global` and -/// `monitor_origin_x` are both in Hyprland's global compositor space -/// (`hypr_capsule_center_x`/`hypr_monitor_origin`); `column_width` is the -/// capsule's own *configured* search-state width -/// (`[launcher].search_width`), not a live-queried one — this fires right -/// as `open_fn` starts the width-animation from idle to search width, so a -/// live query at that exact instant would catch it mid-transition. Using -/// the wider, settled target here (like `DRAWER_MAX_HEIGHT_PX` already does -/// for the vertical bound) means the hole is never narrower than the -/// capsule ever actually gets while the scrim is showing. -fn capsule_dismiss_hole( - capsule_center_global: i32, - monitor_origin_x: i32, - column_width: i32, -) -> (i32, i32) { - let local_center = capsule_center_global - monitor_origin_x; - (local_center - column_width / 2, column_width) -} - -/// Hyprland connector names and GDK connector names can disagree after a -/// hotplug (`DVI-I-1` vs `DVI-I-2`). Match the connector first, then the -/// output's origin — transform swaps width/height so size is not reliable. -/// Never steal a GDK output whose connector is already a live Hyprland name. -fn gdk_monitor_for_hypr(name: &str) -> Option { - use gtk4::gdk::prelude::MonitorExt; - use gtk4::gio::prelude::ListModelExt; - let display = gtk4::gdk::Display::default()?; - let list = display.monitors(); - for i in 0..list.n_items() { - let Some(mon) = list.item(i).and_downcast::() else { - continue; - }; - if mon.connector().as_deref() == Some(name) { - return Some(mon); - } - } - let (hx, hy) = hypr_monitor_origin(name)?; - let live = hypr_monitor_names(); - for i in 0..list.n_items() { - let Some(mon) = list.item(i).and_downcast::() else { - continue; - }; - let g = mon.geometry(); - if g.x() != hx || g.y() != hy { - continue; - } - if let Some(conn) = mon.connector() { - if live.iter().any(|n| n != name && n == conn.as_str()) { - return None; - } - } - return Some(mon); - } - None -} - -pub(crate) fn bind_layer_monitor(window: &impl LayerShell, name: &str) -> bool { - match gdk_monitor_for_hypr(name) { - Some(mon) => { - window.set_monitor(Some(&mon)); - true - } - None => { - eprintln!("breadbar: no GDK monitor for {name}"); - false - } - } -} - -fn spawn_satellite(name: &str) -> Option> { - if gdk_monitor_for_hypr(name).is_none() { - eprintln!("breadbar: skip bar on {name}: no matching GDK output"); - return None; - } - let ctrl = App::builder() - .launch(BarInit { - screenshot: None, - monitor: Some(name.to_string()), - primary: false, - }) - .detach(); - ctrl.widget().present(); - Some(ctrl) -} - -fn drop_satellite(satellites: &mut Vec<(String, Controller)>, name: &str) { - satellites.retain(|(n, ctrl)| { - if n == name { - ctrl.widget().set_visible(false); - false - } else { - true - } - }); -} - -#[cfg(test)] -mod launcher_route_tests { - use super::{resolve_launcher_route, LauncherRoute}; - - #[test] - fn focused_monitor_is_own_stays_local() { - assert_eq!( - resolve_launcher_route(Some("eDP-1"), "eDP-1", &["DVI-I-1"]), - LauncherRoute::Local - ); - } - - #[test] - fn focused_monitor_is_tracked_satellite_forwards() { - assert_eq!( - resolve_launcher_route(Some("DVI-I-1"), "eDP-1", &["DVI-I-1"]), - LauncherRoute::Satellite("DVI-I-1".to_string()) - ); - } - - #[test] - fn focused_monitor_with_no_tracked_satellite_falls_back_local() { - // e.g. reconcile hasn't caught up with a very recent hotplug yet. - assert_eq!( - resolve_launcher_route(Some("HDMI-A-1"), "eDP-1", &["DVI-I-1"]), - LauncherRoute::Local - ); - } - - #[test] - fn no_focused_monitor_falls_back_local() { - // Hyprland's monitor query failed (screenshot mode, hyprctl missing). - assert_eq!( - resolve_launcher_route(None, "eDP-1", &["DVI-I-1"]), - LauncherRoute::Local - ); - } -} - -#[cfg(test)] -mod capsule_dismiss_hole_tests { - use super::capsule_dismiss_hole; - - #[test] - fn centered_capsule_on_primary_monitor_at_origin() { - // A 520px-wide capsule centered on a 1920px-wide monitor at global - // origin (0,0): global center x = 960, monitor origin x = 0. - let (x, w) = capsule_dismiss_hole(960, 0, 520); - assert_eq!((x, w), (960 - 260, 520)); - } - - #[test] - fn negative_origin_secondary_monitor_converts_to_local() { - // This machine's own DVI-I-1 (`hyprctl layers -j`, quoted in this - // module's doc comments): monitor origin x = -1080. A capsule - // centered on that output's own 1080px-wide span sits at global - // center x = -1080 + 540 = -540. - let (x, w) = capsule_dismiss_hole(-540, -1080, 520); - // Local center is 540 (origin subtracted back out); hole starts - // 260px to either side of it, independent of the monitor's sign. - assert_eq!((x, w), (540 - 260, 520)); - } - - #[test] - fn hole_width_always_matches_requested_column_width() { - let (_, w) = capsule_dismiss_hole(100, 0, 480); - assert_eq!(w, 480); - } -} - -#[cfg(test)] -mod url_open_target_tests { - use super::url_open_target; - - #[test] - fn bare_host_gets_https_scheme() { - assert_eq!(url_open_target("example.com"), "https://example.com"); - } - - #[test] - fn existing_scheme_is_left_untouched() { - assert_eq!(url_open_target("http://example.com"), "http://example.com"); - assert_eq!( - url_open_target("ftp://example.com/file"), - "ftp://example.com/file" - ); - } + let app = RelmApp::new("sh.breadway.breadbar"); + app.run::(()); } diff --git a/src/notifications/history.rs b/src/notifications/history.rs deleted file mode 100644 index 66c8783..0000000 --- a/src/notifications/history.rs +++ /dev/null @@ -1,458 +0,0 @@ -use std::collections::VecDeque; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime}; - -use gtk4::prelude::*; -use gtk4_layer_shell::{KeyboardMode, LayerShell}; -use serde::{Deserialize, Serialize}; - -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())) -} - -/// 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(); - 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(); - } -} - -/// Best-effort write of the in-memory store (already bounded) to the -/// XDG state file. Failures are silent — history stays in memory. -pub fn persist(store: &Store) { - if let Some(path) = history_path() { - let _ = persist_to(store, &path); - } -} - -fn history_path() -> Option { - Some(state_dir()?.join("history.json")) -} - -fn state_dir() -> Option { - if let Ok(xdg) = std::env::var("XDG_STATE_HOME") { - if !xdg.is_empty() { - return Some(PathBuf::from(xdg).join("breadbar")); - } - } - let home = std::env::var_os("HOME")?; - Some(PathBuf::from(home).join(".local/state/breadbar")) -} - -#[derive(Serialize, Deserialize)] -struct PersistedEntry { - id: u32, - app_name: String, - summary: String, - body: String, - urgency: String, - received_unix: u64, -} - -fn urgency_name(u: Urgency) -> &'static str { - match u { - Urgency::Low => "low", - Urgency::Normal => "normal", - Urgency::Critical => "critical", - } -} - -fn urgency_from_name(s: &str) -> Urgency { - match s { - "low" => Urgency::Low, - "critical" => Urgency::Critical, - _ => Urgency::Normal, - } -} - -fn to_persisted(entry: &Entry) -> PersistedEntry { - let received_unix = entry - .received - .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - PersistedEntry { - id: entry.id, - app_name: entry.app_name.clone(), - summary: entry.summary.clone(), - body: entry.body.clone(), - urgency: urgency_name(entry.urgency).into(), - received_unix, - } -} - -fn from_persisted(entry: PersistedEntry) -> Entry { - Entry { - id: entry.id, - app_name: entry.app_name, - summary: entry.summary, - body: entry.body, - urgency: urgency_from_name(&entry.urgency), - received: SystemTime::UNIX_EPOCH + Duration::from_secs(entry.received_unix), - } -} - -fn persist_to(store: &Store, path: &Path) -> std::io::Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let payload: Vec = store.lock().unwrap().iter().map(to_persisted).collect(); - let bytes = serde_json::to_vec(&payload).map_err(std::io::Error::other)?; - let tmp = path.with_extension("json.tmp"); - fs::write(&tmp, bytes)?; - fs::rename(&tmp, path) -} - -fn load_into(store: &Store, path: &Path) { - let Ok(bytes) = fs::read(path) else { - return; - }; - let Ok(parsed) = serde_json::from_slice::>(&bytes) else { - return; - }; - let mut hist = store.lock().unwrap(); - hist.clear(); - for entry in parsed.into_iter().take(LIMIT) { - hist.push_back(from_persisted(entry)); - } -} - -pub fn build_window(store: Store) -> Ui { - let window = gtk4::Window::new(); - window.add_css_class("breadbar-history"); - window.init_layer_shell(); - window.set_namespace(Some("breadbar-notif")); - crate::surface::apply(&window, "breadbar-notif"); - // Overrides `[surfaces."breadbar-notif"].width` (320px, the live-toast - // popup's width — see `surface::apply`'s doc comment): the history - // window genuinely wants a different width on the same namespace, and - // that isn't something the manifest schema models today. Both calls - // must be overridden, not just `set_default_width` — `apply()` also - // pins `set_size_request` to the toast's 320px, and a bare width alone - // would lose to that pin the same way it lost to a wide child before. - window.set_default_width(360); - window.set_size_request(360, -1); - window.set_keyboard_mode(KeyboardMode::OnDemand); - crate::theme::bind_auto(&window); - - let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 8); - outer.set_margin_top(10); - 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…"); - } - - #[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 4668798..b6c60b2 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -1,191 +1,23 @@ -pub mod history; pub mod popup; -use std::collections::HashMap; use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::Mutex; -use std::time::{Duration, SystemTime}; 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"; - -/// 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") -/// and a critical-urgency notification with no explicit timeout, which -/// conventionally shouldn't auto-dismiss either. -#[derive(Debug, Clone, Copy)] -pub enum Expire { - Never, - After(Duration), -} - pub enum NotifEvent { Show { id: u32, app_name: String, summary: String, body: String, - urgency: Urgency, - expire: Expire, - actions: Vec, - /// Placeholder for the inline-reply field, if one should be shown. - inline_reply: Option, + timeout_ms: u32, }, Close(u32), - ToggleHistory, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Urgency { - Low, - Normal, - Critical, -} - -impl Urgency { - /// Spec: hints["urgency"] is a byte, 0=low, 1=normal, 2=critical (default normal). - fn from_hint(hint: Option<&OwnedValue>) -> Self { - match hint.and_then(|v| u8::try_from(v.clone()).ok()) { - Some(0) => Urgency::Low, - Some(2) => Urgency::Critical, - _ => Urgency::Normal, - } - } - - /// CSS class suffix for `.notification-card.urgency-`. - pub fn css_class(self) -> Option<&'static str> { - match self { - Urgency::Low => None, - Urgency::Normal => Some("urgency-normal"), - Urgency::Critical => Some("urgency-critical"), - } - } -} - -/// 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 -/// picks a default" (5s here, except critical notifications, which -/// conventionally persist); any non-negative value is taken literally. -/// Pulled out of `NotifServer::notify` so this mapping is unit-testable -/// without a live D-Bus connection. -fn compute_expire(expire_timeout: i32, urgency_critical: bool) -> Expire { - match expire_timeout { - 0 => Expire::Never, - t if t < 0 => { - if urgency_critical { - Expire::Never - } else { - Expire::After(Duration::from_millis(5000)) - } - } - t => Expire::After(Duration::from_millis(t as u64)), - } } 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>, - 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 -/// `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")] @@ -199,57 +31,20 @@ impl NotifServer { _app_icon: &str, summary: &str, body: &str, - actions: Vec, - hints: std::collections::HashMap, + _actions: Vec, + _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) }; - // Per spec: 0 means "never expire" — this used to be lumped in - // with "-1: let the server pick a default" and coerced to a fixed - // 5s, so a sender explicitly asking for a persistent notification - // (e.g. a progress/error dialog) got auto-dismissed anyway. - // Critical-urgency notifications conventionally persist too, even - // 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, - history::Entry { - id, - app_name: app_name.to_string(), - summary: summary.to_string(), - body: body.to_string(), - urgency, - received: SystemTime::now(), - }, - ); - if self.persist_history { - history::persist(&self.history); - } - + let timeout_ms = if expire_timeout <= 0 { + 5000 + } else { + expire_timeout as u32 + }; let _ = self .tx .send(NotifEvent::Show { @@ -257,10 +52,7 @@ impl NotifServer { app_name: app_name.to_string(), summary: summary.to_string(), body: body.to_string(), - urgency, - expire, - actions, - inline_reply, + timeout_ms, }) .await; id @@ -271,7 +63,7 @@ impl NotifServer { } fn get_capabilities(&self) -> Vec { - CAPABILITIES.iter().map(|s| (*s).to_string()).collect() + vec!["body".to_string()] } fn get_server_information(&self) -> (String, String, String, String) { @@ -284,384 +76,26 @@ impl NotifServer { } } -/// 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, -} - -impl SampleKind { - fn sample_event(&self) -> NotifEvent { - let urgency = match self { - SampleKind::Normal => Urgency::Normal, - SampleKind::Critical => Urgency::Critical, - }; - 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, - actions: vec![], - inline_reply: None, - } - } -} - -/// 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(); +pub fn spawn() { 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, None).await; - }); - } - None => { - let (conn_tx, conn_rx) = tokio::sync::oneshot::channel(); - 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, + next_id: AtomicU32::new(1), + }; + // 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"); + std::future::pending::<()>().await + }); - relm4::spawn(async move { - let server = NotifServer { - tx: tx.clone(), - 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. - let conn = zbus::connection::Builder::session() - .unwrap() - .name("org.freedesktop.Notifications") - .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"); - // 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), Some(history_ui)).await; - } - }); - } - } - - window -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn zero_timeout_never_expires_regardless_of_urgency() { - assert!(matches!(compute_expire(0, false), Expire::Never)); - assert!(matches!(compute_expire(0, true), Expire::Never)); - } - - #[test] - fn negative_timeout_defaults_to_five_seconds_for_normal_urgency() { - match compute_expire(-1, false) { - Expire::After(d) => assert_eq!(d, Duration::from_millis(5000)), - Expire::Never => panic!("expected a 5s default, got Never"), - } - } - - #[test] - fn negative_timeout_persists_for_critical_urgency() { - assert!(matches!(compute_expire(-1, true), Expire::Never)); - } - - #[test] - fn positive_timeout_is_taken_literally() { - match compute_expire(1500, false) { - Expire::After(d) => assert_eq!(d, Duration::from_millis(1500)), - Expire::Never => panic!("expected 1500ms, got Never"), - } - // Even for critical urgency, an explicit positive timeout is honored - // rather than overridden to Never — "critical persists" is only the - // *default* when the sender didn't specify one. - match compute_expire(1500, true) { - Expire::After(d) => assert_eq!(d, Duration::from_millis(1500)), - 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()), - history: history::new_store(), - persist_history: false, - }, - 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); - } - - #[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"); - } - - #[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"); - } + relm4::spawn_local(popup::run(rx)); } diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 7e2abd4..c8a7e50 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -1,33 +1,14 @@ -use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Instant}; +use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Duration}; -use gtk4::glib::ControlFlow; use gtk4::prelude::*; -use gtk4_layer_shell::{KeyboardMode, LayerShell}; +use gtk4_layer_shell::{Edge, Layer, LayerShell}; use tokio::sync::mpsc::Receiver; -use super::{history, Action, Expire, NotifEvent, Urgency, INLINE_REPLY_KEY}; +use super::NotifEvent; type Cards = Rc>>; -// Bumped every time an id gets a (re)placed card — an auto-dismiss timer -// scheduled for an earlier Show captures the generation it was scheduled -// under, and checks it's still current before dismissing. Without this, a -// notification that replaces an existing id (replaces_id) doesn't cancel -// the original's timer, so the *replacement* card gets dismissed on the -// *original*'s deadline instead of its own. -type Generations = Rc>>; -/// NotificationClosed reason codes per the freedesktop spec. -mod close_reason { - pub const EXPIRED: u32 = 1; - pub const DISMISSED_BY_USER: u32 = 2; - pub const CLOSE_NOTIFICATION_CALL: u32 = 3; -} - -/// 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) { +pub async fn run(mut rx: Receiver) { let window = create_window(); let cards_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); cards_box.set_margin_top(8); @@ -36,40 +17,7 @@ pub fn build_window() -> (gtk4::Window, gtk4::Box) { cards_box.set_margin_end(8); window.set_child(Some(&cards_box)); - // NOTIFICATION INTERACTION #B: the surface (and `window.surface()`) - // doesn't exist until map, same constraint `surface::click_through` - // documents — this is the initial-region counterpart of that hook, - // recomputing against whatever's already in `cards_box` at the moment - // the toast becomes visible (empty on the very first map, but this - // window remaps on every reappearance after being fully dismissed — - // see `dismiss`'s `window.set_visible(false)` — and by the time a new - // notification's `Show` handler calls `set_visible(true)` again, its - // card is already a child of `cards_box`). `refresh_hit_region`'s own - // per-event calls below are the steady-state path; this is the - // just-in-case one for the map race itself. - let cbox_for_map = cards_box.clone(); - window.connect_map(move |win| { - apply_hit_region(win, &cbox_for_map); - }); - - (window, cards_box) -} - -/// `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, - history_ui: Option, -) { let cards: Cards = Rc::new(RefCell::new(HashMap::new())); - let generations: Generations = Rc::new(RefCell::new(HashMap::new())); while let Some(event) = rx.recv().await { match event { @@ -78,518 +26,79 @@ pub async fn run( app_name, summary, body, - urgency, - expire, - actions, - inline_reply, + timeout_ms, } => { // 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(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(), - }); + let card = make_card(&app_name, &summary, &body); cards_box.prepend(&card); cards.borrow_mut().insert(id, card.clone()); window.set_visible(true); - // ANIMATION WORK #6: spring the new card's own height in - // from 0 to its natural content height instead of it - // appearing at full size in one frame — since it's - // `prepend`ed (the vertical box's first child), the - // existing cards below get pushed down smoothly as this - // grows, rather than jumping straight to their new - // position. - spring_in_card(&card); - refresh_hit_region(&window, &cards_box); - if let Some(ui) = &history_ui { - history::refresh_if_visible(ui); - } - let my_generation = { - let mut gens = generations.borrow_mut(); - let g = gens.entry(id).or_insert(0); - *g += 1; - *g - }; - - // `Expire::Never` (expire_timeout=0, or a critical-urgency - // notification with no explicit timeout) schedules no timer - // at all — it persists until an explicit CloseNotification. - if let Expire::After(duration) = expire { - let cards_clone = cards.clone(); - let cards_box_clone = cards_box.clone(); - let win_clone = window.clone(); - let generations_clone = generations.clone(); - let conn_clone = conn.clone(); - relm4::spawn_local(async move { - 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) - { - emit_closed(&conn_clone, id, close_reason::EXPIRED).await; - } - }); - } + // Auto-dismiss via GLib-native timer (safe inside spawn_local) + let cards_clone = cards.clone(); + let cards_box_clone = cards_box.clone(); + let win_clone = window.clone(); + relm4::spawn_local(async move { + gtk4::glib::timeout_future(Duration::from_millis(timeout_ms as u64)).await; + dismiss(&cards_box_clone, &win_clone, &cards_clone, id); + }); } NotifEvent::Close(id) => { - if dismiss(&cards_box, &window, &cards, id) { - emit_closed(&conn, id, close_reason::CLOSE_NOTIFICATION_CALL).await; - } - } - NotifEvent::ToggleHistory => { - if let Some(ui) = &history_ui { - history::toggle(ui); - } + dismiss(&cards_box, &window, &cards, id); } } } } -/// Removes `id`'s card if present. Returns whether a card was actually -/// removed, so callers only emit `NotificationClosed` for a real dismissal -/// (not a no-op on an id that's already gone or was never shown). Every -/// caller (auto-expire, `CloseNotification`, an action/reply invocation, -/// and the card's own dismiss button) goes through this one function, so -/// this is also the one place that needs to recompute the hit region -/// (NOTIFICATION INTERACTION #B) on removal — a card gone from `cards_box` -/// but still counted in the input region would leave a dead click-through -/// hole where a live button used to be. -fn dismiss(cards_box: >k4::Box, window: >k4::Window, cards: &Cards, id: u32) -> bool { - let removed = cards.borrow_mut().remove(&id); - let Some(card) = removed else { - return false; - }; - cards_box.remove(&card); +fn dismiss(cards_box: >k4::Box, window: >k4::Window, cards: &Cards, id: u32) { + if let Some(card) = cards.borrow_mut().remove(&id) { + cards_box.remove(&card); + } if cards.borrow().is_empty() { window.set_visible(false); } - refresh_hit_region(window, cards_box); - true -} - -/// 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. 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>, - "/org/freedesktop/Notifications", - "org.freedesktop.Notifications", - "NotificationClosed", - &(id, reason), - ) - .await; - if let Err(e) = result { - eprintln!("breadbar: failed to emit NotificationClosed for {id}: {e}"); - } -} - -/// ANIMATION WORK #6: springs `card`'s own height from 0 up to its natural -/// content height (`bread_theme::anim::spring_to` + `set_size_request`, -/// same "GTK4 CSS has no height transition" technique `main.rs`'s -/// `animate_drawer_height` already uses for the capsule drawer) instead of -/// it appearing at full size in one frame. Measured AFTER `card` is already -/// a child of `cards_box` (the caller's job), not before: an unparented -/// widget isn't rooted under this window's style provider chain yet, so its -/// `measure()` wouldn't see the real `.notification-card` padding/border — -/// only a widget that's actually in the tree gets an accurate natural size. -/// -/// One-shot and self-contained — no cancellation bookkeeping, unlike -/// `animate_drawer_height`/`animate_osd_fill`'s own `Rc>` -/// tick-id storage: a card's entrance can't be interrupted by a second one, -/// since a same-id replacement tears the whole card down and builds a -/// fresh one (see the `Show` handler's `cards_box.remove(&old)`) rather -/// than reusing it. -const CARD_GROW_MS: f64 = 380.0; - -fn spring_in_card(card: >k4::Box) { - let (_, target_h, _, _) = card.measure(gtk4::Orientation::Vertical, -1); - card.set_size_request(-1, 0); - let target = card.clone(); - bread_theme::anim::spring_to(card, 0, target_h, CARD_GROW_MS, move |h| { - target.set_size_request(-1, h.max(0)); - }); -} - -thread_local! { - // NOTIFICATION INTERACTION #B: the tick callback that keeps - // `refresh_hit_region` recomputing the toast's input region while a - // card's entrance (`spring_in_card` above) or the stack's push-down - // reflow could still be moving a button. One process-wide toast window - // (this crate registers a single `org.freedesktop.Notifications` name), - // so a thread-local — not a field threaded through every call site — is - // enough, same reasoning as `theme::SHELL_THEME_MONITOR`. - static HIT_TRACKER: RefCell> = const { RefCell::new(None) }; -} - -/// How long after a card set/layout change to keep recomputing the hit -/// region every frame — long enough to cover both `spring_in_card`'s -/// `CARD_GROW_MS` and the CSS `notif-in` keyframe's 0.45s slide-in (see -/// `theme.rs`'s `.notification-card` rule), whichever finishes last. -const HIT_TRACK_MS: f64 = 700.0; - -/// Recomputes the toast surface's clickable input region immediately, then -/// keeps recomputing it every frame for `HIT_TRACK_MS` — covering both a -/// newly-shown card's own entrance and the stack's push-down settle, either -/// of which can still be moving a button on the frame this is called. -/// Called any time the card set could have changed: shown, dismissed -/// (including via the new dismiss button — see `dismiss` above), or -/// expired. Cancels any previous tracking run first, so a rapid burst of -/// notifications doesn't accumulate overlapping tick callbacks. -fn refresh_hit_region(window: >k4::Window, cards_box: >k4::Box) { - apply_hit_region(window, cards_box); - - if let Some(id) = HIT_TRACKER.with(|c| c.borrow_mut().take()) { - id.remove(); - } - let started = Instant::now(); - let win = window.clone(); - let cbox = cards_box.clone(); - let id = window.add_tick_callback(move |_, _| { - apply_hit_region(&win, &cbox); - if started.elapsed().as_secs_f64() * 1000.0 >= HIT_TRACK_MS { - HIT_TRACKER.with(|c| c.borrow_mut().take()); - return ControlFlow::Break; - } - ControlFlow::Continue - }); - HIT_TRACKER.with(|c| *c.borrow_mut() = Some(id)); -} - -/// One frame's worth of `refresh_hit_region`'s work: walk `cards_box` for -/// every currently-interactive widget (action/dismiss buttons, the -/// inline-reply entry) and hand their rectangles to -/// `surface::set_hit_region`. Split out from `refresh_hit_region` so the -/// initial immediate call and the tracking tick callback share the exact -/// same logic. -fn apply_hit_region(window: >k4::Window, cards_box: >k4::Box) { - let mut widgets = Vec::new(); - collect_interactive(cards_box.upcast_ref::(), &mut widgets); - crate::surface::set_hit_region(window, &widgets); -} - -/// Depth-first walk of `root`'s widget tree collecting every `GtkButton` -/// (action buttons, the reply-send button, the dismiss button) and -/// `GtkEntry` (the inline-reply field) — the only things on a card a user -/// should ever be able to click into. Everything else (the summary/body -/// labels, the card's own background) stays click-through, same as the -/// blanket empty region did before NOTIFICATION INTERACTION #B. Walking -/// the real widget tree rather than tracking a flat list as cards/buttons -/// are built means this can't drift out of sync with `make_card`'s own -/// structure (e.g. the dismiss button living inside a `gtk4::Overlay` -/// rather than directly under `card`). -fn collect_interactive(root: >k4::Widget, out: &mut Vec) { - let mut child = root.first_child(); - while let Some(w) = child { - if w.is::() || w.is::() { - out.push(w.clone()); - } - collect_interactive(&w, out); - child = w.next_sibling(); - } } fn create_window() -> gtk4::Window { let window = gtk4::Window::new(); window.add_css_class("breadbar-notification"); window.init_layer_shell(); - window.set_namespace(Some("breadbar-notif")); - crate::surface::apply(&window, "breadbar-notif"); - // Toasts are purely informational — they never grab keyboard focus, - // full stop, regardless of what's clickable on them (KeyboardMode::None - // stays; do NOT change this — see the NOTIFICATION INTERACTION #B task - // note). Historically ("stop toast popups from stealing focus or - // blocking clicks") that also meant a fully empty input region: every - // pointer event passed straight through to whatever's underneath, but - // that made `make_card`'s own action buttons, its inline-reply - // `GtkEntry`, and the dismiss button below permanently unreachable too. - // `crate::surface::set_hit_region` (called from `build_window`'s - // `connect_map` and from `refresh_hit_region` below, any time the card - // set or layout could have changed) replaces the old blanket - // `surface::click_through` empty region with the union of just those - // widgets' own rectangles — everywhere else on the surface stays - // click-through, same as before. A toast that genuinely has none of - // them yet (`cards_box` empty) still gets the same all-empty region - // `click_through` set, since a rectangle union over zero widgets is - // the empty region. - window.set_keyboard_mode(KeyboardMode::None); - crate::theme::bind_auto(&window); + window.set_layer(Layer::Overlay); + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Right, true); + window.set_margin(Edge::Top, 20); + window.set_margin(Edge::Right, 20); + window.set_default_width(320); window } -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 { +fn make_card(app_name: &str, summary: &str, body: &str) -> gtk4::Box { let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4); card.add_css_class("notification-card"); - 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 !spec.app_name.is_empty() && !spec.app_name.eq_ignore_ascii_case(spec.summary) { - let lbl = gtk4::Label::new(Some(spec.app_name)); + if !app_name.is_empty() { + let lbl = gtk4::Label::new(Some(app_name)); lbl.add_css_class("notification-app"); lbl.set_xalign(0.0); - content.append(&lbl); + card.append(&lbl); } - let summary_lbl = gtk4::Label::new(Some(spec.summary)); + let summary_lbl = gtk4::Label::new(Some(summary)); summary_lbl.add_css_class("notification-summary"); summary_lbl.set_xalign(0.0); summary_lbl.set_wrap(true); - content.append(&summary_lbl); + card.append(&summary_lbl); - if !spec.body.is_empty() { - let body_lbl = gtk4::Label::new(None); + if !body.is_empty() { + let body_lbl = gtk4::Label::new(Some(body)); body_lbl.add_css_class("notification-body"); body_lbl.set_xalign(0.0); body_lbl.set_wrap(true); - apply_body_text(&body_lbl, spec.body); - content.append(&body_lbl); - } - - if spec.actions.iter().any(|a| a.key == "default") { - content.add_css_class("notification-default"); - let gesture = gtk4::GestureClick::new(); - let invoke = Invoke { - conn: spec.conn.clone(), - cards: spec.cards.clone(), - cards_box: spec.cards_box.clone(), - window: spec.window.clone(), - id: spec.id, - }; - gesture.connect_released(move |_, _, _, _| { - invoke_action(invoke.clone(), "default"); - }); - content.add_controller(gesture); - } - - // NOTIFICATION INTERACTION #A: a direct dismiss control. Floated in - // the card's top-right corner via an Overlay rather than a full extra - // header row, so it doesn't add vertical bulk the approved demo's own - // card never has (see the `.notification-dismiss` CSS in theme.rs). - // `collect_interactive` (this module) picks it up the same way it - // picks up the action/reply buttons below, by walking the real widget - // tree — it doesn't need to know this button lives one level deeper, - // inside the overlay, than they do. - let overlay = gtk4::Overlay::new(); - overlay.set_child(Some(&content)); - - let dismiss_btn = gtk4::Button::with_label("×"); - dismiss_btn.add_css_class("notification-dismiss"); - dismiss_btn.set_halign(gtk4::Align::End); - dismiss_btn.set_valign(gtk4::Align::Start); - let dismiss_invoke = Invoke { - conn: spec.conn.clone(), - cards: spec.cards.clone(), - cards_box: spec.cards_box.clone(), - window: spec.window.clone(), - id: spec.id, - }; - dismiss_btn.connect_clicked(move |_| { - dismiss_card(dismiss_invoke.clone()); - }); - overlay.add_overlay(&dismiss_btn); - - card.append(&overlay); - - let visible: Vec<&Action> = spec - .actions - .iter() - .filter(|a| a.key != "default" && a.key != INLINE_REPLY_KEY) - .collect(); - if !visible.is_empty() { - let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - row.add_css_class("notification-actions"); - row.set_halign(gtk4::Align::End); - for action in visible { - let btn = gtk4::Button::with_label(&action.label); - btn.add_css_class("notification-action"); - let invoke = Invoke { - conn: spec.conn.clone(), - cards: spec.cards.clone(), - cards_box: spec.cards_box.clone(), - window: spec.window.clone(), - id: spec.id, - }; - let key = action.key.clone(); - btn.connect_clicked(move |_| { - invoke_action(invoke.clone(), &key); - }); - row.append(&btn); - } - card.append(&row); - } - - if let Some(placeholder) = spec.inline_reply { - let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - row.add_css_class("notification-reply"); - - let entry = gtk4::Entry::new(); - entry.add_css_class("notification-reply-entry"); - entry.set_placeholder_text(Some(placeholder)); - entry.set_hexpand(true); - - let send_label = spec - .actions - .iter() - .find(|a| a.key == INLINE_REPLY_KEY) - .map(|a| a.label.as_str()) - .filter(|l| !l.is_empty()) - .unwrap_or("Send"); - let send = gtk4::Button::with_label(send_label); - send.add_css_class("notification-action"); - - let invoke = Invoke { - conn: spec.conn.clone(), - cards: spec.cards.clone(), - cards_box: spec.cards_box.clone(), - window: spec.window.clone(), - id: spec.id, - }; - let entry_for_btn = entry.clone(); - let invoke_btn = invoke.clone(); - send.connect_clicked(move |_| { - submit_reply(&entry_for_btn, invoke_btn.clone()); - }); - entry.connect_activate(move |e| { - submit_reply(e, invoke.clone()); - }); - - row.append(&entry); - row.append(&send); - card.append(&row); + card.append(&body_lbl); } card } - -/// FDO `body-markup` is a small Pango-ish subset (``, ``, ``, -/// ``). Invalid markup falls back to plain text so a bad sender -/// doesn't blank the card. -fn apply_body_text(label: >k4::Label, body: &str) { - if body.contains('<') && gtk4::pango::parse_markup(body, '\0').is_ok() { - label.set_markup(body); - return; - } - label.set_text(body); -} - -#[derive(Clone)] -struct Invoke { - conn: Option, - cards: Cards, - cards_box: gtk4::Box, - window: gtk4::Window, - id: u32, -} - -fn invoke_action(invoke: Invoke, key: &str) { - let key = key.to_string(); - relm4::spawn_local(async move { - emit_action(&invoke.conn, invoke.id, &key).await; - if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { - emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; - } - }); -} - -/// NOTIFICATION INTERACTION #A: the card's own dismiss button. Unlike -/// `invoke_action`, this never emits `ActionInvoked` — there's no action -/// key here, the user just closed the toast unprompted — only the -/// spec-mandated `NotificationClosed(id, reason)`, with -/// `close_reason::DISMISSED_BY_USER` (freedesktop value 2, "dismissed by -/// the user") so clients are told properly, same reason code -/// `invoke_action` above and `submit_reply` below already use for their -/// own user-initiated dismissals. -fn dismiss_card(invoke: Invoke) { - relm4::spawn_local(async move { - if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { - emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; - } - }); -} - -fn submit_reply(entry: >k4::Entry, invoke: Invoke) { - let text = entry.text().to_string(); - if text.trim().is_empty() { - return; - } - relm4::spawn_local(async move { - emit_replied(&invoke.conn, invoke.id, &text).await; - emit_action(&invoke.conn, invoke.id, INLINE_REPLY_KEY).await; - if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { - emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; - } - }); -} - -async fn emit_action(conn: &Option, id: u32, action_key: &str) { - let Some(conn) = conn else { return }; - let result = conn - .emit_signal( - None::<&str>, - "/org/freedesktop/Notifications", - "org.freedesktop.Notifications", - "ActionInvoked", - &(id, action_key), - ) - .await; - if let Err(e) = result { - eprintln!("breadbar: failed to emit ActionInvoked for {id}: {e}"); - } -} - -/// GNOME/KDE (and clients such as Discord/Telegram) listen for this -/// non-spec signal on `org.freedesktop.Notifications` when the user -/// submits an inline reply. Signature: `NotificationReplied(u32 id, s text)`. -/// We also emit `ActionInvoked(id, "inline-reply")` so senders that only -/// watch the spec signal still see the send. -async fn emit_replied(conn: &Option, id: u32, text: &str) { - let Some(conn) = conn else { return }; - let result = conn - .emit_signal( - None::<&str>, - "/org/freedesktop/Notifications", - "org.freedesktop.Notifications", - "NotificationReplied", - &(id, text), - ) - .await; - if let Err(e) = result { - eprintln!("breadbar: failed to emit NotificationReplied for {id}: {e}"); - } -} diff --git a/src/osd.rs b/src/osd.rs index 6860c3f..55106ec 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -1,13 +1,7 @@ -use std::{ - cell::{Cell, RefCell}, - process::Child, - rc::Rc, - sync::{Mutex, Once}, - time::Duration, -}; +use std::{cell::Cell, rc::Rc, time::Duration}; use gtk4::prelude::*; -use gtk4_layer_shell::LayerShell; +use gtk4_layer_shell::{Edge, Layer, LayerShell}; use tokio::sync::mpsc; enum OsdEvent { @@ -15,90 +9,14 @@ enum OsdEvent { Brightness { pct: u8 }, } -/// 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, -} - -/// Live `pactl subscribe` children spawned by [`volume_watcher`]. Kept so a -/// best-effort cleanup can kill them when breadbar exits: `pactl subscribe` -/// blocks until the server connection dies, so without this every breadbar -/// restart orphaned one that kept its PulseAudio connection open — until -/// pipewire-pulse's client cap filled up and new clients (settings apps -/// included) were refused, showing "no devices". Also reaped here, so a -/// watcher that dies on its own never lingers as a zombie. -static WATCHER_CHILDREN: Mutex> = Mutex::new(Vec::new()); -static REGISTER_EXIT_HOOK: Once = Once::new(); - -extern "C" { - /// libc `atexit(3)`. Declared directly rather than pulling the libc - /// crate in for a single function. - fn atexit(cb: extern "C" fn()) -> i32; -} - -extern "C" fn exit_cleanup() { - kill_watchers(); -} - -fn register_exit_hook() { - // Safety: `atexit` is provided by libc on every Linux target; the - // callback is a `static` C-ABI fn valid for the whole process. - unsafe { - let _ = atexit(exit_cleanup); - } -} - -/// Kill any live `pactl subscribe` watcher children and reap them. Safe to -/// call more than once (an already-dead child is a no-op). Runs from the -/// process-exit hook and the SIGINT/SIGTERM handlers in `main`. -pub fn kill_watchers() { - if let Ok(mut children) = WATCHER_CHILDREN.lock() { - for mut child in children.drain(..) { - let _ = child.kill(); - let _ = child.wait(); - } - } -} - -impl SampleKind { - fn sample_event(&self) -> OsdEvent { - match self { - 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 { +pub fn spawn() { let (tx, rx) = mpsc::channel::(8); - 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)); - } - } + let tx1 = tx.clone(); + std::thread::spawn(move || volume_watcher(tx1)); + std::thread::spawn(move || brightness_watcher(tx)); - let window = create_window(); - relm4::spawn_local(run_osd(window.clone(), rx)); - window + relm4::spawn_local(run_osd(rx)); } fn volume_watcher(tx: mpsc::Sender) { @@ -115,14 +33,6 @@ fn volume_watcher(tx: mpsc::Sender) { }; let Some(stdout) = child.stdout.take() else { return }; - // The child is meant to outlive this reader loop (it blocks until - // breadbar itself dies), so hand it to `kill_watchers` — the exit - // hook plus the SIGINT/SIGTERM handlers in main — instead of letting - // it orphan on restart. - REGISTER_EXIT_HOOK.call_once(register_exit_hook); - if let Ok(mut children) = WATCHER_CHILDREN.lock() { - children.push(child); - } let reader = BufReader::new(stdout); for line in reader.lines().map_while(Result::ok) { @@ -209,49 +119,44 @@ fn brightness_watcher(tx: mpsc::Sender) { } } -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); - container.set_margin_start(14); - container.set_margin_end(14); +async fn run_osd(mut rx: mpsc::Receiver) { + let window = create_window(); + + let container = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + container.set_margin_top(12); + container.set_margin_bottom(12); + container.set_margin_start(16); + container.set_margin_end(16); window.set_child(Some(&container)); - let icon = crate::svg_image(crate::bar::stats::ICON_VOLUME); - icon.add_css_class("osd-icon"); - container.append(&icon); + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + let kind_lbl = gtk4::Label::new(Some("Volume")); + kind_lbl.add_css_class("osd-kind"); + kind_lbl.set_hexpand(true); + kind_lbl.set_xalign(0.0); + let pct_lbl = gtk4::Label::new(Some("0%")); + pct_lbl.add_css_class("osd-pct"); + header.append(&kind_lbl); + header.append(&pct_lbl); + container.append(&header); let pbar = gtk4::ProgressBar::new(); pbar.add_css_class("osd-bar"); - pbar.set_hexpand(true); - pbar.set_valign(gtk4::Align::Center); container.append(&pbar); let dismiss_token = Rc::new(Cell::new(0u32)); - // OSD fill overshoot (ANIMATION WORK #5): current fraction (as a whole - // percent, matching `anim::spring_to`'s `i32` interpolation), the - // in-flight tick callback (so a fast double-tap of volume-up cancels the - // previous run instead of fighting it), and a generation token so a - // superseded run's queued second leg (see `animate_osd_fill` below) - // never applies after a newer event has already taken over. - let fill_pct = Rc::new(Cell::new(0i32)); - let fill_anim: Rc>> = Rc::new(RefCell::new(None)); - let fill_token = Rc::new(Cell::new(0u32)); while let Some(event) = rx.recv().await { - let (icon_svg, pct, muted) = match event { - OsdEvent::Volume { pct, muted } => (crate::bar::stats::ICON_VOLUME, pct, muted), - OsdEvent::Brightness { pct } => (crate::bar::stats::ICON_BRIGHTNESS, pct, false), + let (kind, pct) = match event { + OsdEvent::Volume { pct, muted } => { + (if muted { "Volume (Muted)" } else { "Volume" }, pct) + } + OsdEvent::Brightness { pct } => ("Brightness", pct), }; - icon.set_paintable(Some(&crate::svg_texture(icon_svg))); - crate::prepare_icon(&icon, crate::theme::shell_theme().tokens().icon_px() as i32); - if muted { - icon.add_css_class("osd-icon-muted"); - } else { - icon.remove_css_class("osd-icon-muted"); - } - animate_osd_fill(&pbar, &fill_pct, &fill_anim, &fill_token, pct); + kind_lbl.set_label(kind); + pct_lbl.set_label(&format!("{pct}%")); + pbar.set_fraction(pct as f64 / 100.0); window.set_visible(true); let token = dismiss_token.get().wrapping_add(1); @@ -267,85 +172,13 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver) { } } -/// How many percentage points the fill runs past its real target before -/// easing back — subtle, matching the rest of the theme's "spring" motion -/// rather than a dramatic bounce. -const OSD_OVERSHOOT_PCT: i32 = 4; -/// Leg 1 (toward the overshoot point) / leg 2 (settling back onto the real -/// target) durations. Two legs, not one: `anim::spring_to` clamps every -/// frame to `[min(from, to), max(from, to)]` (see its own doc comment and -/// tests) specifically so a caller like the capsule drawer can never end up -/// with a negative size request — which also means a single `spring_to` -/// call can *never* visibly overshoot `to`, no matter how much the -/// underlying curve wants to. Chaining two calls — first to a point past -/// the target, then back onto it — is what actually produces the overshoot. -const OSD_LEG1_MS: f64 = 160.0; -const OSD_LEG2_MS: f64 = 200.0; - -/// Animates `pbar`'s fraction from wherever `current` says it currently is -/// to `target`, overshooting slightly past it and settling back — see the -/// constants above for why this takes two `spring_to` legs instead of one. -/// Cancels any run already in flight (a fast double volume-step must -/// continue from the current visual position, not fight or restart it) and -/// stamps a fresh generation token so a superseded run's leg 2, queued via -/// `timeout_add_local_once` below, is a no-op if it fires after a newer -/// call has already taken over. -fn animate_osd_fill( - pbar: >k4::ProgressBar, - current: &Rc>, - anim: &Rc>>, - token: &Rc>, - target: u8, -) { - if let Some(id) = anim.borrow_mut().take() { - id.remove(); - } - let my_token = token.get().wrapping_add(1); - token.set(my_token); - - let from = current.get(); - let to = i32::from(target); - if from == to { - pbar.set_fraction(f64::from(to) / 100.0); - return; - } - let overshoot = if to > from { - (to + OSD_OVERSHOOT_PCT).min(100) - } else { - (to - OSD_OVERSHOOT_PCT).max(0) - }; - - let leg1_bar = pbar.clone(); - let leg1_current = current.clone(); - let id = bread_theme::anim::spring_to(pbar, from, overshoot, OSD_LEG1_MS, move |v| { - leg1_current.set(v); - leg1_bar.set_fraction(f64::from(v) / 100.0); - }); - *anim.borrow_mut() = Some(id); - - let leg2_bar = pbar.clone(); - let leg2_current = current.clone(); - let leg2_anim = anim.clone(); - let leg2_token = token.clone(); - gtk4::glib::timeout_add_local_once(Duration::from_millis(OSD_LEG1_MS as u64), move || { - if leg2_token.get() != my_token { - return; // superseded by a newer OSD event before leg 1 finished - } - let inner_bar = leg2_bar.clone(); - let id = bread_theme::anim::spring_to(&leg2_bar, overshoot, to, OSD_LEG2_MS, move |v| { - leg2_current.set(v); - inner_bar.set_fraction(f64::from(v) / 100.0); - }); - *leg2_anim.borrow_mut() = Some(id); - }); -} - fn create_window() -> gtk4::Window { let window = gtk4::Window::new(); window.add_css_class("breadbar-osd"); window.init_layer_shell(); - window.set_namespace(Some("breadbar-osd")); - crate::surface::apply(&window, "breadbar-osd"); - crate::theme::bind_auto(&window); + window.set_layer(Layer::Overlay); + window.set_anchor(Edge::Bottom, true); + window.set_margin(Edge::Bottom, 80); + window.set_default_width(280); window } diff --git a/src/panel.rs b/src/panel.rs deleted file mode 100644 index 1c0a67c..0000000 --- a/src/panel.rs +++ /dev/null @@ -1,317 +0,0 @@ -//! Standalone layer-shell panels for wifi / control / media. -//! -//! GTK `Popover` is an xdg_popup child of the island, so it paints over the -//! bar and Hyprland can only fade it. These are their own surfaces, parked -//! *below* the exclusive zone, and Hyprland slides `breadbar-panel` in from -//! the right. - -use std::cell::{Cell, RefCell}; -use std::rc::Rc; - -use gtk4::gdk::Key; -use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, KeyboardMode, LayerShell}; - -use crate::{bind_layer_monitor, theme}; - -/// Outside this rectangle's local x/y span, the dismiss window's own real -/// size (Wayland clips an input region to the surface's actual bounds, same -/// as `surface::set_hit_region`'s empty-region trick) — big enough to cover -/// any realistic monitor layout, including a negative-origin secondary -/// output (`hyprctl layers -j` reported `x: -1080` for this machine's own -/// DVI-I-1). Centered on the origin so it's safe regardless of which way a -/// hole's coordinates end up signed. -const HOLE_CANVAS_SPAN: i32 = 20_000; - -/// A boxed, ref-counted, optionally-unset click-away callback — see -/// `PanelSet::on_dismiss`'s own doc comment. -type DismissCallback = Rc>>>; - -/// The capsule-column hole punched in the dismiss scrim's input region — -/// local x, y, width, height — see `PanelSet::dismiss_hole`'s own doc -/// comment. Shared (not just passed by value) so `make_dismiss`'s -/// `connect_map` hook and every `show_capsule_dismiss`/`reset_dismiss_margin` -/// call agree on the current value. -type DismissHole = Rc>>; - -#[derive(Clone)] -pub struct PanelSet { - pub connectivity: gtk4::Window, - pub control: gtk4::Window, - pub media: gtk4::Window, - dismiss: gtk4::Window, - // Theme 04/spotlight's capsule (plan §7 phase 6c): an extra click-away - // callback invoked alongside the popover-dismiss path below, so the - // SAME `breadbar-dismiss` surface/click-catcher also collapses the - // capsule's drawer — see `show_capsule_dismiss`/`hide_dismiss` and - // `set_on_dismiss`. `None` under every other theme (never set). - on_dismiss: DismissCallback, - // The rectangle (local to `dismiss`'s own surface coordinates) that - // should stay click-through even while the scrim otherwise covers the - // screen — `show_capsule_dismiss`'s own doc comment explains why this - // exists and how it's computed. `None` = no hole, the plain - // margin-based popover behaviour applies instead. Read inside - // `dismiss`'s own `connect_map` (the input region can only be set once - // the surface is real — see `surface::set_hit_region`'s doc comment for - // the same constraint) and, for the case where `dismiss` is already - // mapped from a prior show, applied immediately too. - dismiss_hole: DismissHole, -} - -impl PanelSet { - pub fn new( - monitor: &str, - connectivity_child: &impl IsA, - control_child: &impl IsA, - media_child: &impl IsA, - ) -> Self { - let connectivity = make_panel("wifi-popover", connectivity_child, monitor); - let control = make_panel("control-panel", control_child, monitor); - let media = make_panel("media-popover", media_child, monitor); - let dismiss_hole: DismissHole = Rc::new(Cell::new(None)); - let dismiss = make_dismiss(monitor, &dismiss_hole); - - let set = Self { - connectivity, - control, - media, - dismiss, - on_dismiss: Rc::new(RefCell::new(None)), - dismiss_hole, - }; - set.wire_dismiss(); - set.wire_escape(); - set - } - - pub fn toggle(&self, which: >k4::Window) { - if which.is_visible() { - self.hide_all(); - } else { - self.show(which); - } - } - - pub fn show(&self, which: >k4::Window) { - self.hide_panels(); - // A prior capsule search (see `show_capsule_dismiss`) may have left - // the shared dismiss surface's top margin pushed down past its - // popover-shaped default — restore it before this popover uses it. - self.reset_dismiss_margin(); - // Dismiss first so the panel maps above it (same Overlay layer). - self.dismiss.set_visible(true); - self.dismiss.present(); - which.set_visible(true); - which.present(); - } - - pub fn hide_all(&self) { - self.hide_panels(); - self.dismiss.set_visible(false); - } - - fn hide_panels(&self) { - self.connectivity.set_visible(false); - self.control.set_visible(false); - self.media.set_visible(false); - } - - /// Theme 04/spotlight's capsule (plan §7 phase 6c): registers `cb` to - /// run whenever the shared dismiss surface is clicked, alongside the - /// popovers' own `hide_all`. `cb` is expected to no-op when the capsule - /// isn't actually open (matching `close_fn`'s own guard in main.rs), so - /// this firing on an ordinary popover click-away is harmless. - pub fn set_on_dismiss(&self, cb: impl Fn() + 'static) { - *self.on_dismiss.borrow_mut() = Some(Rc::new(cb)); - } - - /// Shows the dismiss scrim with its clickable region starting at - /// `top_margin` px from the screen top, rather than the theme's own - /// popover-shaped default. See the call site in main.rs's capsule - /// `open_fn` for why this needs to be at least the capsule's own row - /// height plus the drawer's maximum possible height: the dismiss - /// surface's layer (`overlay`) always renders above the bar's own - /// (`top`), so if its clickable region ever reached up into where the - /// drawer is actually drawn, it would swallow clicks meant for a - /// result row instead of forwarding them. - /// - /// Before this fix, that safety was bought with a `set_margin` that - /// pushed the scrim's *entire width* down by `top_margin` — leaving a - /// full-screen-wide dead band above it (up to ~470px on a 1200px-tall - /// display) where a click neither dismissed nor hit anything else, the - /// "it only sometimes is dismissed when you click somewhere else" - /// report. `hole`, when known (local-to-this-surface x-start/width, in - /// `main.rs`'s `capsule_dismiss_hole`), keeps exactly the same - /// vertical safety margin but scopes the dead band to the capsule's - /// own column instead of the full width, so everywhere else in that - /// band is dismiss-clickable too. `None` (geometry unavailable, e.g. - /// `hyprctl` failed) falls back to the old full-width behaviour rather - /// than risk a hole in the wrong place. - pub fn show_capsule_dismiss(&self, top_margin: i32, hole: Option<(i32, i32)>) { - match hole { - Some((x, w)) if w > 0 => { - self.dismiss.set_margin(Edge::Top, 0); - self.dismiss_hole.set(Some((x, 0, w, top_margin))); - } - _ => { - self.dismiss.set_margin(Edge::Top, top_margin); - self.dismiss_hole.set(None); - } - } - apply_dismiss_hole(&self.dismiss, &self.dismiss_hole); - self.dismiss.set_visible(true); - self.dismiss.present(); - } - - /// Hides the dismiss scrim and restores its margin to the theme's own - /// popover default, so a later popover `show()` isn't left with a - /// leftover capsule-sized gap. - pub fn hide_dismiss(&self) { - self.reset_dismiss_margin(); - self.dismiss.set_visible(false); - } - - fn reset_dismiss_margin(&self) { - let theme = theme::shell_theme(); - if let Some(surf) = theme.surfaces().get("breadbar-dismiss") { - let top = surf.offset.first().copied().unwrap_or(0.0) as i32; - self.dismiss.set_margin(Edge::Top, top); - } - // A stale capsule-shaped hole must not leak into a popover's own - // full-width dead zone. - self.dismiss_hole.set(None); - apply_dismiss_hole(&self.dismiss, &self.dismiss_hole); - } - - fn wire_dismiss(&self) { - let set = self.clone(); - let click = gtk4::GestureClick::new(); - click.set_button(0); - click.connect_pressed(move |_, _, _, _| { - set.hide_all(); - if let Some(cb) = set.on_dismiss.borrow().as_ref() { - cb(); - } - }); - if let Some(child) = self.dismiss.child() { - child.add_controller(click); - } else { - self.dismiss.add_controller(click); - } - } - - fn wire_escape(&self) { - for win in [&self.connectivity, &self.control, &self.media] { - let set = self.clone(); - let keys = gtk4::EventControllerKey::new(); - keys.connect_key_pressed(move |_, key, _, _| { - if key == Key::Escape { - set.hide_all(); - gtk4::glib::Propagation::Stop - } else { - gtk4::glib::Propagation::Proceed - } - }); - win.add_controller(keys); - } - } -} - -fn make_panel(class: &str, child: &impl IsA, monitor: &str) -> gtk4::Window { - let window = gtk4::Window::new(); - window.add_css_class("breadbar-panel"); - window.add_css_class(class); - window.set_decorated(false); - window.set_resizable(false); - window.init_layer_shell(); - window.set_namespace(Some("breadbar-panel")); - crate::surface::apply(&window, "breadbar-panel"); - window.set_exclusive_zone(-1); - window.set_keyboard_mode(KeyboardMode::OnDemand); - window.set_child(Some(child)); - bind_layer_monitor(&window, monitor); - theme::bind_output(&window, monitor); - window.set_visible(false); - window -} - -fn make_dismiss(monitor: &str, hole: &DismissHole) -> gtk4::Window { - let window = gtk4::Window::new(); - window.add_css_class("breadbar-dismiss"); - window.init_layer_shell(); - window.set_namespace(Some("breadbar-dismiss")); - // Overlay with the panels, but mapped first so they sit above it. - // Top margin keeps the island's chips clickable. - // - // NOTE — deliberate, not a bug: this surface's top margin is 8px less - // than `breadbar-panel`'s (see `make_panel` above / `[surfaces.*]` in - // the active theme). The panels start 8px lower than the dismiss - // scrim's clickable region. This predates Phase 2 and is preserved - // exactly for pixel-identical rendering — do not "fix" this gap. - crate::surface::apply(&window, "breadbar-dismiss"); - window.set_exclusive_zone(-1); - window.set_keyboard_mode(KeyboardMode::None); - // An empty window never maps a hit region. A filling child + a hair of - // alpha is what actually receives the click-away. - let hit = gtk4::Box::new(gtk4::Orientation::Vertical, 0); - hit.add_css_class("breadbar-dismiss-hit"); - hit.set_hexpand(true); - hit.set_vexpand(true); - window.set_child(Some(&hit)); - bind_layer_monitor(&window, monitor); - theme::bind_output(&window, monitor); - window.set_visible(false); - // The underlying `GdkSurface` (and therefore `window.surface()`, which - // `apply_dismiss_hole` needs) doesn't exist until the window is mapped - // — same constraint `surface::set_hit_region` documents. This surface - // gets hidden/shown repeatedly (every popover open/close, every - // capsule search), and GTK4 unmaps-then-remaps a toplevel each time - // its visibility toggles off then on, so re-applying here on every - // `map` (not just the first) is what keeps a freshly (re)shown surface - // honouring whatever hole was set before this particular `present()`. - { - let hole = Rc::clone(hole); - window.connect_map(move |win| apply_dismiss_hole(win, &hole)); - } - window -} - -/// Sets `dismiss`'s click-away input region to "everywhere" minus `hole` -/// (if any) — see `PanelSet::show_capsule_dismiss`'s doc comment for why. -/// Only takes effect once `dismiss.surface()` is real, i.e. the window is -/// currently mapped; harmlessly no-ops otherwise (the `connect_map` hook in -/// `make_dismiss` re-runs this the moment that stops being true). -fn apply_dismiss_hole(dismiss: >k4::Window, hole: &DismissHole) { - let Some(surface) = dismiss.surface() else { - return; - }; - match hole.get() { - Some((x, y, w, h)) => { - let canvas = gtk4::cairo::RectangleInt::new( - -HOLE_CANVAS_SPAN, - -HOLE_CANVAS_SPAN, - HOLE_CANVAS_SPAN * 2, - HOLE_CANVAS_SPAN * 2, - ); - let region = gtk4::cairo::Region::create_rectangle(&canvas); - let punch = gtk4::cairo::RectangleInt::new(x, y, w, h); - if region.subtract_rectangle(&punch).is_ok() { - surface.set_input_region(Some(®ion)); - } else { - // Punching the hole failed for some reason (an invalid - // cairo status on a plain rectangle op, effectively - // unreachable in practice) — falling back to `None` (the - // protocol's documented "no input region set: whole - // surface hits") is still safer than leaving whatever - // region predates this call in place, which could be - // stale from a completely different mode (e.g. an old - // popover-shaped margin-only region with no hole at all). - eprintln!( - "breadbar: could not punch capsule hole in dismiss scrim's input region" - ); - surface.set_input_region(None); - } - } - None => surface.set_input_region(None), - } -} diff --git a/src/screenshot.rs b/src/screenshot.rs deleted file mode 100644 index 47c0ff4..0000000 --- a/src/screenshot.rs +++ /dev/null @@ -1,386 +0,0 @@ -//! `--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. -//! -//! 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 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; - -/// 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 -/// `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 = SETTLE_DELAY; - -const KNOWN_VIEWS: &[&str] = &[ - "bar", - "control-panel", - "connectivity-wifi", - "connectivity-bluetooth", - "media-popover", - "notification", - "notification-critical", - "osd-volume", - "osd-brightness", - "wifi-add-dialog", - // Theme 04/spotlight's capsule (plan §6b) — see `dispatch`'s two new - // match arms. "capsule-collapsed" captures the same region "bar" - // always has (bar_capture_height already reflects the active theme's - // own window height/margin); it exists as its own name purely so a - // verification script doesn't have to already know "bar" means "the - // capsule, collapsed" under spotlight specifically. - "capsule-collapsed", - "capsule-expanded", - // Phase 6c: query sections and the `=` calc mode — see `dispatch`'s - // two new match arms below. "capsule-expanded" already exercises the - // search-state width/radius change (item E: it types a query, which - // now also drives `open_fn`'s `.searching` root class + capsule-width - // spring animation), so that gap doesn't need its own view. - "capsule-sections", - "capsule-calc", -]; - -#[derive(Parser)] -#[command(name = "breadbar")] -pub struct Cli { - /// Render the named view, capture it, then exit instead of running - /// normally. See `screenshot::KNOWN_VIEWS` for the full list. - #[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 = DEFAULT_WIDTH)] - pub width: u32, - - /// Capture canvas height — see `width`. - #[arg(long, default_value_t = DEFAULT_HEIGHT)] - 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 { - pub view: String, - pub output: PathBuf, - pub width: u32, - pub height: u32, -} - -impl Cli { - /// `None` for a normal run. Exits the process with an error if the - /// `--screenshot` / `--output` pair is incomplete, before any GTK/relm4 - /// setup happens. - pub fn screenshot_request(&self) -> Option { - if let Err(e) = validate_pair(self.screenshot.as_deref(), self.output.as_deref()) { - eprintln!("breadbar: {e}"); - std::process::exit(1); - } - Some(ScreenshotRequest { - view: self.screenshot.clone()?, - output: self.output.clone()?, - width: self.width, - height: self.height, - }) - } -} - -/// 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_panel: gtk4::Window, - pub connectivity_panel: gtk4::Window, - pub wifi_tab_btn: gtk4::ToggleButton, - pub bt_tab_btn: gtk4::ToggleButton, - 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 - /// `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, - /// Theme 04/spotlight's capsule centre module — see the - /// "capsule-expanded" view, which focuses it and types a query to - /// drive the drawer open before capturing. - pub launcher_entry: gtk4::Entry, - /// The `drawer` slot's own container — read back after the open - /// animation settles so "capsule-expanded"'s capture height matches - /// however tall the results actually grew, rather than a guessed - /// constant. - pub drawer_box: gtk4::Box, -} - -/// Capture height for the `bar` view: layer-shell top margin + widget -/// height (the exclusive zone). Unlike the other views' full canvas, -/// this never varies with `--width`/`--height`. -fn bar_capture_height() -> i32 { - let window = crate::theme::shell_theme().window().clone(); - window.height + window.margin.top -} - -/// Y-origin for the `bar`/`capsule-collapsed` capture rectangle (axis 2, -/// daylight). Every theme through spotlight anchors top, so the bar's own -/// pixels always start at the canvas's own top edge (`y = 0`) — that's what -/// every existing call site here hardcoded. A bottom-anchored bar's real -/// on-screen footprint is instead the LAST `bar_capture_height()` pixels of -/// the canvas; capturing from `y = 0` unchanged grabs the isolated -/// compositor's empty background/gradient near the top of the output and -/// misses the actual bar entirely (confirmed empirically while verifying -/// this task — `bread-capture --app breadbar --view bar` under -/// `BREAD_SHELL_THEME=daylight` returned a plain gradient with no dock in -/// frame at all, before this fix). `canvas_height` is the capture canvas's -/// own height (`req.height`, i.e. `--height`/`isolate_height`), not this -/// crate's own bar height. -fn bar_capture_y(canvas_height: i32, bar_height: i32) -> i32 { - let window = crate::theme::shell_theme().window().clone(); - if window.anchors.iter().any(|a| a == "bottom") { - (canvas_height - bar_height).max(0) - } else { - 0 - } -} - -pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) { - let output = req.output; - let (width, height) = (req.width as i32, req.height as i32); - let bar_height = bar_capture_height(); - let bar_y = bar_capture_y(height, bar_height); - - match req.view.as_str() { - "bar" => { - root.connect_map(move |_| { - let output = output.clone(); - gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { - finish(bread_screenshots::capture_region(0, bar_y, width, bar_height, &output)); - }); - }); - } - "control-panel" => { - 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_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_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 - // 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_widget.add_css_class("playing"); - handles.media_track_lbl.set_text("Sample Track — Sample Artist"); - open_panel_on_root_map(root, handles.media_panel, 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); - } - "capsule-collapsed" => { - // Identical to "bar" — see KNOWN_VIEWS's doc comment on why - // this has its own name anyway. - root.connect_map(move |_| { - let output = output.clone(); - gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { - finish(bread_screenshots::capture_region(0, bar_y, width, bar_height, &output)); - }); - }); - } - "capsule-expanded" => { - // Focus + a real query, the same way a person opens the - // capsule (`connect_changed`/`EventControllerFocus::enter` in - // main.rs's capsule wiring do the rest: `open_fn` runs, - // `results.set_query` filters, and `animate_drawer_height` - // grows `drawer_box`). Capture height is bar height + however - // tall the drawer actually settled, not a hardcoded guess — - // this stays correct even if `dot_widths`/font/entry-count - // numbers change later. - let entry = handles.launcher_entry; - let drawer_box = handles.drawer_box; - root.connect_map(move |_| { - let output = output.clone(); - let entry = entry.clone(); - let drawer_box = drawer_box.clone(); - gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { - entry.grab_focus(); - entry.set_text("f"); - let output = output.clone(); - let drawer_box = drawer_box.clone(); - // Past the 360ms spring_to run plus a normal capture - // settle, so the drawer's size_request (and the actual - // on-screen layer-shell surface it grows) has reached - // its final height before grabbing pixels. - gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || { - let drawer_h = drawer_box.size_request().1.max(0); - let capture_h = bar_height + drawer_h; - finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output)); - }); - }); - }); - } - "capsule-sections" => { - // Focus with NO query typed — the idle browse view - // (`ResultsList::new`'s "Recent"/"Apps" header rows are visible - // from construction; `set_query` is what would hide them, and - // it's never called here). Same settle timing as - // "capsule-expanded", just without the `entry.set_text` step. - let entry = handles.launcher_entry; - let drawer_box = handles.drawer_box; - root.connect_map(move |_| { - let output = output.clone(); - let entry = entry.clone(); - let drawer_box = drawer_box.clone(); - gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { - entry.grab_focus(); - let output = output.clone(); - let drawer_box = drawer_box.clone(); - gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || { - let drawer_h = drawer_box.size_request().1.max(0); - let capture_h = bar_height + drawer_h; - finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output)); - }); - }); - }); - } - "capsule-calc" => { - // `=` mode (item C): the drawer's `mode_list` shows a single - // evaluated result row instead of `launcher_results.scroller` - // (see `populate_mode_list`'s `QueryKind::Calc` arm). - let entry = handles.launcher_entry; - let drawer_box = handles.drawer_box; - root.connect_map(move |_| { - let output = output.clone(); - let entry = entry.clone(); - let drawer_box = drawer_box.clone(); - gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { - entry.grab_focus(); - entry.set_text("=6*7"); - let output = output.clone(); - let drawer_box = drawer_box.clone(); - gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || { - let drawer_h = drawer_box.size_request().1.max(0); - let capture_h = bar_height + drawer_h; - finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output)); - }); - }); - }); - } - "wifi-add-dialog" => { - let anchor = handles.wifi_tab_btn; - root.connect_map(move |_| { - let output = output.clone(); - 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: {})", - KNOWN_VIEWS.join(", ") - ); - std::process::exit(1); - } - } -} - -/// 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, - panel: gtk4::Window, - settle: Duration, - output: PathBuf, - width: i32, - height: i32, -) { - let panel_to_open = panel.clone(); - root.connect_map(move |_| { - let panel_to_open = panel_to_open.clone(); - gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { - panel_to_open.set_visible(true); - panel_to_open.present(); - }); - }); - 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)); - }); - }); -} - -/// 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), - Err(e) => { - eprintln!("breadbar: screenshot capture failed: {e}"); - std::process::exit(1); - } - } -} diff --git a/src/surface.rs b/src/surface.rs deleted file mode 100644 index 88b094f..0000000 --- a/src/surface.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! Applies a `[surfaces.]` entry (plan §4/§6, Phase 2) to a -//! satellite layer-shell window: anchor, margin (from `offset`), width and -//! layer. Deliberately narrow — it only understands the four anchor shapes -//! breadbar's four built-in surfaces actually use today ("breadbar-notif", -//! "breadbar-osd", "breadbar-panel", "breadbar-dismiss": `top_right`, -//! `bottom_right`, `bottom_centre`, `fill`), not a general anchor DSL (the -//! plan's own anti-goal, §2). `bottom_right` was added for daylight (plan -//! §11 phase 7, axis 2) — the first bottom-anchored bar, whose satellites -//! need to hug the bottom-right corner the way every top-anchored theme's -//! already hug the top-right one. `exclusive` zone and `keyboard` mode -//! aren't part of the `[surfaces.*]` schema (`bread_theme::shell::Surface` -//! has no such fields) and stay hardcoded at each call site, same as before -//! this refactor. - -use bread_theme::shell::SurfaceWidth; -use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, Layer, LayerShell}; - -/// `namespace` should be a key in the active theme's `[surfaces.*]` table — -/// every call site in this crate passes one of breadbar's own namespace -/// literals, so a miss here means the active theme fell out of sync with -/// the Rust source, not a bad runtime value. Logs and leaves the window at -/// gtk4-layer-shell's own defaults rather than panicking, matching every -/// other "malformed/incomplete theme" fallback in this system. -/// -/// The width applied here is not authoritative for a namespace shared by -/// more than one window with genuinely different widths (`breadbar-notif`'s -/// live toast is 320px, its history sibling is 360px, and only the toast's -/// width is modeled in `[surfaces.*]` — see the Phase 0 constant inventory); -/// callers that need a different width than the theme's own set it -/// explicitly afterward. Because a `Px` width is pinned with BOTH -/// `set_default_width` and `set_size_request` (see below — the latter is -/// what actually holds against a wide child), such a caller must override -/// both, not just `set_default_width`, or the pin from here wins. -pub fn apply(window: >k4::Window, namespace: &str) { - let theme = crate::theme::shell_theme(); - let Some(surf) = theme.surfaces().get(namespace) else { - eprintln!( - "breadbar: no [surfaces.{namespace}] entry in the active theme; \ - window left at layer-shell defaults" - ); - return; - }; - - window.set_layer(if surf.layer == "top" { - Layer::Top - } else { - Layer::Overlay - }); - - match surf.anchor.as_str() { - "top_right" => { - window.set_anchor(Edge::Top, true); - window.set_anchor(Edge::Right, true); - // offset = [right, top] for this anchor shape. - let right = surf.offset.first().copied().unwrap_or(0.0) as i32; - let top = surf.offset.get(1).copied().unwrap_or(0.0) as i32; - window.set_margin(Edge::Right, right); - window.set_margin(Edge::Top, top); - } - // Axis 2 (daylight): every theme through spotlight anchors its bar - // to the TOP, so `top_right` always put breadbar-notif/ - // breadbar-panel naturally close to the bar. A bottom-anchored bar - // has nothing in the original three shapes that keeps its - // satellites near it — `top_right` would land them at the opposite - // corner of the screen from the dock they belong to. Mirrors - // `top_right` exactly, just on the bottom edge. - "bottom_right" => { - window.set_anchor(Edge::Bottom, true); - window.set_anchor(Edge::Right, true); - // offset = [right, bottom], same convention as top_right's - // [right, top]. - let right = surf.offset.first().copied().unwrap_or(0.0) as i32; - let bottom = surf.offset.get(1).copied().unwrap_or(0.0) as i32; - window.set_margin(Edge::Right, right); - window.set_margin(Edge::Bottom, bottom); - } - "bottom_centre" => { - window.set_anchor(Edge::Bottom, true); - let bottom = surf.offset.first().copied().unwrap_or(0.0) as i32; - window.set_margin(Edge::Bottom, bottom); - } - "fill" => { - for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { - window.set_anchor(edge, true); - } - // offset = [top, bottom] — a fullscreen click-away scrim that - // leaves a gap clear of the bar on whichever edge the bar - // actually anchors to. Every theme through spotlight anchors - // top, so only `offset[0]` (top) was ever meaningful before - // daylight; a single-value `offset` (every existing theme's - // manifest) still means exactly what it always did, since - // `offset.get(1)` falls back to 0 — a bottom-anchored theme is - // the first to give this a real, nonzero bottom value instead. - let top = surf.offset.first().copied().unwrap_or(0.0) as i32; - let bottom = surf.offset.get(1).copied().unwrap_or(0.0) as i32; - window.set_margin(Edge::Top, top); - window.set_margin(Edge::Bottom, bottom); - } - other => eprintln!( - "breadbar: surfaces.{namespace}.anchor = \"{other}\" is not one of \ - top_right|bottom_right|bottom_centre|fill — breadbar's satellite \ - windows don't understand any other shape yet, leaving this window \ - unanchored" - ), - } - - if let SurfaceWidth::Px(px) = surf.width { - window.set_default_width(px); - // set_default_width alone is only a preference — a wide child (an - // unwrapped app-name label, or a long summary/body before GTK has - // any allocation narrower than its natural width to wrap against) - // overrides it, so the window renders wider than the theme's - // requested px and stops matching the theme. Same trap, same fix, - // as main.rs's capsule `Width::Px` handling — see its comment. - window.set_size_request(px, -1); - } -} - -/// Sets `window`'s input region to the union of `widgets`' current -/// allocations, each measured relative to `window` itself (the surface's -/// own coordinate space, same as `bar::workspaces::button_geom`'s own -/// `compute_bounds` call relative to its Fixed host) — everywhere else on -/// the surface stays click-through. An empty (or all-invisible/all- -/// unallocated) `widgets` slice is not a special case: -/// `Region::create_rectangles(&[])` is already the fully click-through -/// empty region — passing `&[]` here is exactly the old blanket -/// `Region::create()` this function replaces (see git history around -/// `notifications/popup.rs`'s "stop toast popups from stealing focus or -/// blocking clicks" fix, and NOTIFICATION INTERACTION #B in the current -/// task notes: a toast must never block clicks or steal focus from -/// whatever's underneath it EXCEPT on its own buttons — an all-empty -/// region made those unreachable too). -/// -/// Only meaningful after `window.surface()` exists (i.e. from -/// `connect_map` onward — the surface doesn't exist before the window is -/// mapped) and after `widgets` have a real allocation — a widget with no -/// allocation yet (`compute_bounds` returning `None`) is simply skipped -/// rather than contributing a garbage rectangle, so a call made one frame -/// too early just yields a smaller-than-intended region for that one frame -/// rather than a wrong one. -/// -/// Callers are responsible for RECOMPUTING this every time the hittable -/// set could have moved: a widget added or removed, or a layout pass (an -/// entrance animation, a push-down reflow) still in flight. A stale region -/// either swallows clicks meant for the window below or leaves a real -/// button dead. -pub fn set_hit_region(window: >k4::Window, widgets: &[gtk4::Widget]) { - let Some(surface) = window.surface() else { - return; - }; - let rects: Vec = widgets - .iter() - .filter(|w| w.is_visible()) - .filter_map(|w| { - let b = w.compute_bounds(window)?; - Some(gtk4::cairo::RectangleInt::new( - b.x().floor() as i32, - b.y().floor() as i32, - b.width().ceil() as i32, - b.height().ceil() as i32, - )) - }) - .collect(); - surface.set_input_region(Some(>k4::cairo::Region::create_rectangles(&rects))); -} diff --git a/src/theme.rs b/src/theme.rs index 4e279cb..dc7e149 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,803 +1,93 @@ -use bread_theme::shell::ShellTheme; -use bread_theme::{gtk as bgtk, ink_on, load_palette, load_palette_for, Palette}; -use gtk4::prelude::IsA; +use bread_theme::{gtk as bgtk, hex_to_rgba, ink_on, load_palette}; use gtk4::CssProvider; use std::cell::RefCell; -use std::rc::Rc; thread_local! { static USER_PROVIDER: RefCell> = const { RefCell::new(None) }; - // Loaded lazily on first access and cached — the underlying - // `bread_theme::shell::load()` call happens at most once per process, - // not once per read site (plan §5/§6, Phase 2). Stored as an `Rc` so - // window/surface-geometry call sites elsewhere in the crate can hold a - // cheap clone rather than re-reading the cell each time. - static SHELL_THEME: RefCell> = - RefCell::new(Rc::new(bread_theme::shell::load())); -} - -/// The active shell theme — window geometry, `[surfaces.*]`, and CSS tokens -/// (plan §5). Every consumer (this module's own `load_css`, plus main.rs, -/// osd.rs, panel.rs, notifications/, and `surface::apply`) reads through -/// this single shared instance instead of calling `bread_theme::shell::load()` -/// itself. -pub fn shell_theme() -> Rc { - SHELL_THEME.with(|cell| cell.borrow().clone()) -} - -/// Replaces the shared shell theme in place. Only used by the optional -/// `theme.toml` hot-reload watch (see `watch_hot_reload` below) — per plan -/// §10, a window-spec change (anchors, margins, exclusive zone, keyboard) -/// still needs a restart to take effect, since those are read once at -/// window-construction time; only CSS token values re-resolve live, the -/// next time `load_css` runs. -pub fn set_shell_theme(theme: ShellTheme) { - SHELL_THEME.with(|cell| *cell.borrow_mut() = Rc::new(theme)); -} - -/// The one bar-chip height every chip in the row shares (vol/wifi/battery/ -/// menu/media, and the workspace pills for Trail/Pill styles) — see the -/// `chip_h` local in `load_css` for why this is a hardcoded per-` -/// WorkspaceStyle` override rather than `Tokens::chip_height()`. Used both -/// by that CSS and by `bar::workspaces::make_button`'s `set_size_request`, -/// which otherwise still forces the stale token value as a hard GTK -/// minimum that a CSS `min-height` alone cannot out-rank. -pub fn approved_chip_height(style: bread_theme::shell::WorkspaceStyle) -> i64 { - use bread_theme::shell::WorkspaceStyle::*; - match style { - Trail => 26, - Pill | Dots => 22, - } } fn load_css() -> String { + let p = load_palette(); // breadbar-specific rules only — fonts, base colours, and generic widgets // come from the shared ecosystem stylesheet (applied first in `apply()`). // Colour is set on each surface (bar, active workspace pill, notification // card) and child labels inherit it, so text stays legible whatever lightness // pywal hands a given slot. `on_*` are luminance-picked ink (black/white) for // that background — the pywal hues themselves are untouched. - // - // Glass workbench: 16px island on the bar, 12px cards/popovers, pill OSD. - // Hyprland `layerrule = blur, breadbar` frosts the translucent fills — - // the CSS just leaves alpha. Colours are bread-theme tokens so pywal - // accents (`@accent`) flow through on SIGHUP / `bread-theme reload`. - // - // These ~250 lines are breadbar-specific chrome (notifications, wifi - // popover, control panel, media widget) that `ShellTheme::css()` does - // not template — only the window/workspace/clock chrome the manifest's - // own concepts model does (plan §6 scope note). This function stays - // hand-written CSS; it now just reads its radius/pad/easing numbers from - // the theme's tokens instead of hardcoding them. - let theme = shell_theme(); - let tokens = theme.tokens(); - let radius = format!("{}px", tokens.radius_card()); - let radius_bar = format!("{}px", tokens.radius_bar()); - let radius_sm = format!("{}px", tokens.radius_sm()); - let radius_pill = format!("{}px", tokens.radius_pill()); - let pad = format!("{}px", tokens.pad()); - // Two curves, not one: `spring` is the overshoot/bounce curve (clock - // flips, pop-ins, the workspace caret draw); `spring_settle` is the - // flatter curve used for hovers and background/opacity transitions. - // Do not collapse these — they read differently and cover different - // sites below (see the Phase 0 constant inventory). - let spring = tokens.spring(); - let spring_settle = tokens.spring_settle(); - let bg_alpha = tokens.bg_alpha(); - // Palette token NAME (never hex — see every builtin theme.toml's own - // comment on this), used below by the dots/launcher-entry/drawer rules. - // liquid-motion/glass-workbench never render those (see the match arms - // and unconditional-but-unused block below), so this being "accent" vs - // "green" vs "pink" per theme has no visible effect on them. - let accent_from = tokens.accent_from(); - // `accent_to` (Trail's own gradient end stop) and `accent2` (a second, - // independent accent — daylight's amber equaliser, distinct from its - // teal workspace-trail) — both palette token names, never hex, same - // reasoning as `accent_from` above. - let accent_to = tokens.accent_to(); - let accent2 = tokens.accent2(); - - // Axis 1 (daylight, plan §11 phase 7): `tokens.light()` — see that - // method's doc comment (bread-theme) for the full reasoning. Every - // surface/ink pair in this stylesheet through spotlight hardcoded - // `@bg` (a FIXED, never-pywal-derived dark hex — see - // `bread_theme::palette`'s `FIXED_BACKGROUND`) as the translucent - // surface fill and `@on-bg` (that fixed dark colour's computed-legible, - // therefore always near-white, ink) as the text/wash colour. That is - // exactly backwards for an ink-on-paper theme: `panel`/`ink` swap which - // of those two FIXED, anti-correlated tokens plays which role. This is - // NOT a general "pick any light surface colour" mechanism — it works - // only because `@bg` is pinned dark and `@on-bg` is its computed - // opposite, by construction, regardless of pywal. See the task report - // for the full inventory of every site this swap had to reach. - let light = tokens.light(); - let (panel, ink): (&str, &str) = if light { ("@on-bg", "@bg") } else { ("@bg", "@on-bg") }; - // The notification/history/OSD/wifi-add-dialog cards (0.70) and the - // control/wifi/media popover window (0.72) hardcode their own alpha - // literals independent of `tokens.bg_alpha()` — reasonable for a dark - // "glass" surface (0.70-0.72 alpha over a dark fill still reads as - // deliberately translucent glass), but daylight's demo draws these as - // fully OPAQUE paper (`.note`/`.osd { background: var(--paper) }`, no - // alpha at all) — at the old 0.70 literal, daylight's near-white - // `{panel}` fill instead reads as pale, background-tinted glass, which - // is exactly the "physical object" read the design brief calls for - // NOT doing. Swapping these two literals wholesale to `{bg_alpha}` - // would also move liquid-motion/glass-workbench/spotlight's own - // rendering (0.70/0.72 -> 0.72/0.82 for spotlight) — a real, visible - // regression the task rules out — so this only substitutes `bg_alpha` - // (0.94 for daylight) in for `light` themes and keeps the exact prior - // literal for every other theme. - let card_alpha = if light { bg_alpha } else { 0.70 }; - let panel_surface_alpha = if light { bg_alpha } else { 0.72 }; - // The OSD/widget-node progress troughs' UNFILLED track is - // `alpha(@accent, 0.25)` — a teal-at-25%-alpha tint that reads as a - // faint, visible track against a dark pill (every theme through - // spotlight), but is nearly indistinguishable from daylight's own - // near-white pill (confirmed empirically: the OSD volume slider's - // empty track was barely visible against its own paper background). - // `{ink}`-based for light themes gives a neutral faint-dark track - // instead, independent of whatever hue the accent happens to be; - // every other theme's literal `alpha(@accent, 0.25)` is unchanged. - let trough_bg = if light { - format!("alpha({ink}, 0.14)") - } else { - "alpha(@accent, 0.25)".to_string() - }; - // `[launcher].search_radius` (plan §7 phase 6c) — `LauncherMode:: - // Embedded` only (spotlight); `.launcher().radius` itself already - // equals `radius_bar` for that theme (see its own theme.toml comment), - // so a theme that omits `search_radius` gets `radius_search == - // radius_bar` here too, i.e. no visible shrink, matching bread-theme's - // own "default to the idle value" fallback. - let radius_search = format!("{}px", theme.launcher().search_radius); - - // `tokens.bar_border()` (plan §11 Phase 5): "full" (default, liquid- - // motion's floating island) draws a border on all four edges; "bottom" - // (glass-workbench's flush edge-to-edge bar) draws only the hairline - // the demo's `.bar { border-bottom: 1px solid #ffffff12 }` calls for — - // a full border on a bar flush against the screen's top/left/right - // edges would otherwise show as a stray line along those edges an - // island never has to worry about. Reused below for the centerbox's - // horizontal padding too: the flush bar's demo padding (`0 12px`, - // symmetric) differs from the island's own asymmetric `0 8px 0 6px`. - let flush = tokens.bar_border() == "bottom"; - // Axis 3 (daylight, plan §11 phase 7): `bar_border == "segmented"` — - // `window.breadbar` itself draws NO fill/border/radius/shadow at all - // (fully transparent); the bar's three slot-group containers - // (`workspace_row`/`center_area`/`stats_box`, each carrying a - // `.bar-segment` class added unconditionally in main.rs) draw their own - // pill surfaces instead — see `segment_css` below. This is what lets - // one GTK window read as three detached floating pills rather than one - // continuous strip; see the task report for exactly what this can and - // can't express (the window is still ONE input-hit-testable surface — - // clicking in a transparent gap between pills does not click through to - // whatever's behind the bar, only true multi-window segmentation would - // do that). - let segmented = tokens.bar_border() == "segmented"; - let window_chrome = if segmented { - "background-color: transparent; border: none; box-shadow: none;".to_string() - } else if flush { - format!( - "background-color: alpha({panel}, {bg_alpha}); border: none; \ - border-bottom: 1px solid alpha({ink}, 0.07);" - ) - } else { - format!("background-color: alpha({panel}, {bg_alpha}); border: 1px solid alpha({ink}, 0.08);") - }; - let bar_radius = if segmented { - "0px".to_string() - } else { - radius_bar.clone() - }; - let centerbox_padding = if flush || segmented { "0 14px" } else { "0 8px 0 6px" }; - // The three detached pills themselves — a no-op empty rule under every - // non-segmented theme, so `.bar-segment` (added unconditionally to all - // three slot-group boxes in main.rs) renders nothing extra for them. - // Demo: `.seg { background: rgba(255,255,255,.94); border: 1px solid - // rgba(26,29,34,.10); border-radius: 14px; - // box-shadow: 0 2px 10px rgba(26,29,34,.13), 0 0 0 .5px rgba(255,255,255,.7) inset }`. - // Safe to pair a real box-shadow with an unblurred surface (axis 4: - // daylight's own `[compositor.breadbar].blur = false`) — a box-shadow - // above a BLURRED, `ignore_alpha`d surface is the shadow-halo bug fixed - // in breadbox by removing the shadow outright; with blur off there is - // no blur pass to catch this rectangle inside. - let segment_css = if segmented { - format!( - ".bar-segment {{ background-color: alpha({panel}, {bg_alpha}); \ - border: 1px solid alpha({ink}, 0.10); border-radius: {radius_bar}; \ - box-shadow: 0 2px 10px alpha({ink}, 0.13); }}\ - " - ) - } else { - String::new() - }; - - // Radius for `.stat-pair` (vol/wifi/battery/hamburger chips): radius_sm - // for liquid-motion (9px) and glass-workbench (6px, exact match to that - // demo's `.chip` radius) reads as "the same small-control rounding this - // theme uses everywhere else" — but spotlight's overall language is - // dramatically rounder (radius_bar 22px, workspace dots at radius_pill - // 999px) than either sibling theme, so its one `.stat-pair` occupant - // (battery — the only slot entry besides a Lua widget under - // `[bar.slots].right`) looked like a stray sharp-cornered rectangle - // dropped inside a capsule and next to fully-round dots (reported: - // "the spotlight battery chip... radii that don't match their - // neighbours"). Keying off the same `WorkspaceStyle` enum - // `workspace_css` below already switches on, rather than the theme id, - // so this stays in step if a future theme ever reuses the "dots" style. - let chip_radius = match theme.modules().workspaces.style { - bread_theme::shell::WorkspaceStyle::Dots => radius_pill.clone(), - _ => radius_sm.clone(), - }; - - // ONE chip highlight height per bar, vertically centred — every chip - // (vol/wifi/battery/menu/media, icon-only and labelled alike) shares - // it so their fills align, instead of each sizing to its own content - // box (reported: battery sits high, wifi/menu are taller than their - // neighbours). Liquid Motion 26px / Glass Workbench 22px / Spotlight - // 22px, per the approved demo spec. - // - // HARDCODED, not `tokens.chip_height()`: that token is `breadbar:: - // CHIP_HEIGHT` (32) carried over from before this design pass and was - // never updated for the three builtin `theme.toml`s (32/20/36) — it - // predates and disagrees with the demo numbers above. bread-ecosystem - // is owned by a sibling agent this pass, so this stays a local - // override (same `WorkspaceStyle` this file already keys `chip_radius` - // off) rather than an edit to that repo's schema/manifests. Flagged in - // the task report: `chip_height` should become 26/22/22 upstream. - let chip_h = approved_chip_height(theme.modules().workspaces.style); - let chip_height_px = format!("{chip_h}px"); - - // `modules.workspaces.style` (plan §11 Phase 5): "trail" (default, - // liquid-motion) is exactly today's CSS, unchanged byte-for-byte — - // dimmed/translucent buttons with the gradient trail overlay supplying - // the active fill. "pill"/"dots" (glass-workbench, Phase 6+) render the - // active state as a solid accent fill on the button itself instead, - // since neither style ever calls `WorkspaceTrail::place`/`stretch` - // (see `App::rebuild_buttons`) — the trail's own `.workspace-trail` - // pill CSS is therefore irrelevant for them (it's never made visible). - let workspace_css = match theme.modules().workspaces.style { - // Radius and height were hardcoded (12px, 28px) instead of reading - // from this theme's own tokens/demo — 12px is neither radius_sm - // (9px) nor any other token this theme defines, and the demo's - // `.ws-btn`/`.trail` both draw a 26px-tall, 9px-radius pill (not - // 28px/12px). radius_sm happens to be an exact match for the - // demo's 9px here, unlike glass-workbench's Pill style below where - // it's also used but for a different, already-correct reason. - // Reported: "the pills on liquid motion just look off". - bread_theme::shell::WorkspaceStyle::Trail => format!( - // `@{{accent_from}}`/`@{{accent_to}}`, not the literal - // `@accent`/`@teal` this hardcoded through spotlight: harmless - // while every Trail-style theme's own accent_from/accent_to - // happened to BE "accent"/"teal" (liquid-motion — the only - // other Trail theme so far), but daylight sets - // accent_from = accent_to = "teal" for a FLAT fill, and the old - // hardcode would have painted a stray blue-to-teal gradient - // over it regardless of that setting. See Tokens::accent_to's - // doc comment (bread-theme) — this is the fix that finally - // consumes it for real. - ".workspace-trail {{ background-image: linear-gradient(90deg, @{accent_from}, @{accent_to});\ - background-color: @{accent_from}; border-radius: {radius_sm}; }}\ - .workspace-btn {{ background: transparent; opacity: 0.36; color: {ink};\ - border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\ - min-width: 28px; min-height: {chip_height_px}; margin: 0; padding: 0 7px;\ - font-size: 22px; font-weight: bold;\ - transition: opacity 0.22s {spring_settle},\ - background-color 0.22s {spring_settle}; }}\ - .workspace-btn:hover {{ opacity: 0.85; background: alpha({ink}, 0.08); }}\ - .workspace-btn.occupied {{ opacity: 0.78; }}\ - .workspace-btn.active {{ background: transparent; color: @on-accent; opacity: 1; }}\ - .workspace-btn.active:hover {{ background: transparent; }}\ - .workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}", - ), - bread_theme::shell::WorkspaceStyle::Pill => { - let accent = theme.tokens().accent_from(); - format!( - ".workspace-btn {{ background: transparent; opacity: 1; color: alpha({ink}, 0.4);\ - border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\ - min-width: 22px; min-height: {chip_height_px}; margin: 0; padding: 0 6px;\ - font-size: 12px; font-weight: 600;\ - transition: background-color 0.22s {spring_settle},\ - color 0.22s {spring_settle}, opacity 0.22s {spring_settle}; }}\ - .workspace-btn:hover {{ background: alpha({ink}, 0.08); }}\ - .workspace-btn.occupied {{ color: alpha({ink}, 0.8); }}\ - .workspace-btn:not(.occupied):not(.active) {{ opacity: 0.35; }}\ - .workspace-btn.active {{ background: @{accent}; color: @on-accent; opacity: 1; }}\ - .workspace-btn.active:hover {{ background: @{accent}; }}\ - .workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}", - ) - } - // "dots" (theme 04/spotlight): a label-less pill whose WIDTH comes - // from `modules.workspaces.dot_widths` and is set directly via - // `Widget::set_size_request` in `bar::workspaces::make_dot_button` - // — GTK CSS has no per-instance variable width, so unlike the demo's - // `.dots button[data-n="N"]` rules this class only supplies colour/ - // opacity/radius, never a width. `04-spotlight.html`'s own base - // rule (`background: #5a4a54`) is a *dim, desaturated* grey, not the - // bar's ink colour — approximated here as a low-alpha `@on-bg` fill - // so it still tracks pywal instead of hardcoding a hex that would - // clash with a light palette. - bread_theme::shell::WorkspaceStyle::Dots => { - let accent = theme.tokens().accent_from(); - format!( - // 9px, not the demo's 6px — kept in sync with - // `bar::workspaces::make_dot_button`'s `DOT_HEIGHT` const, - // which is the value that actually governs the rendered - // size (a direct `set_size_request`, not CSS min-height - // participating in layout the normal way) — see that - // constant's own doc comment for why. This min-height - // exists mainly so the property isn't silently absent from - // the stylesheet a reader would expect to define it. - ".workspace-dot {{ background-color: alpha({ink}, 0.35); color: transparent;\ - border-radius: {radius_pill}; border: none; outline: none; box-shadow: none;\ - min-height: 9px; margin: 0; padding: 0;\ - transition: background-color 0.25s {spring_settle},\ - opacity 0.25s {spring_settle}; }}\ - .workspace-dot:hover {{ background-color: alpha({ink}, 0.55); }}\ - .workspace-dot:not(.occupied):not(.active) {{ opacity: 0.35; }}\ - .workspace-dot.active {{ background-color: @{accent}; opacity: 1; }}\ - .workspace-dot.active:hover {{ background-color: @{accent}; }}", - ) - } - }; - format!( - "@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\ - @keyframes osd-in {{ from {{ opacity: 0; margin-bottom: -8px; }} }}\ - @keyframes media-eq {{ to {{ min-height: 14px; }} }}\ - @keyframes pop-in {{ from {{ opacity: 0; margin-top: -10px; }} to {{ opacity: 1; margin-top: 0; }} }}\ - @keyframes pop-out {{ from {{ opacity: 1; margin-top: 0; }} to {{ opacity: 0; margin-top: -6px; }} }}\ - @keyframes row-in {{ from {{ opacity: 0; margin-top: 8px; }} to {{ opacity: 1; margin-top: 0; }} }}\ - @keyframes digit-flip {{ from {{ opacity: 0; margin-top: 7px; }} to {{ opacity: 1; margin-top: 0; }} }}\ - @keyframes caret-draw {{ from {{ margin-right: 200px; opacity: 0.2; }} to {{ margin-right: 4px; opacity: 1; }} }}\ - /* ANIMATION WORK #3, bar entrance on first map: opacity ONLY —\ - no margin/geometry term — so this can never perturb any\ - descendant's own box-model size (see main.rs's own long\ - comment on this, next to where `bar-entrance` gets added, for\ - why that matters to the workspace trail specifically).\ - Liquid Motion additionally springs the surface's own\ - layer-shell top margin via `anim::spring_to` in Rust, which\ - this keyframe knows nothing about; glass-workbench never adds\ - this class at all. */\ - @keyframes bar-in {{ from {{ opacity: 0; }} }}\ - .bar-entrance {{ animation: bar-in 0.4s {spring_settle} both; }}\ - window.breadbar {{ color: {ink}; border-radius: {bar_radius}; {window_chrome}\ - transition: border-radius 0.3s {spring_settle}; }}\ - /* `[launcher].search_radius` (plan §7 phase 6c, spotlight only —\ - `launcher_entry` never gets focus under any other theme, so\ - `.searching` never lands on `window.breadbar` there). */\ - window.breadbar.searching {{ border-radius: {radius_search}; }}\ - /* `> box > centerbox`, not `> centerbox`: the root is a vbox (bar\ - row + drawer, plan §2) as of the `drawer` slot wiring — every\ - theme's centerbox is now one level deeper than before, this\ - selector just follows it there. Since `padding` doesn't depend\ - on nesting depth, liquid-motion/glass-workbench render byte-\ - identical CSS either way. */\ - window.breadbar > box > centerbox {{ padding: {centerbox_padding}; }}\ - /* `color` here for the same reason `window.breadbar-panel button`\ - (below) needs it: a real GtkButton's own text (the bar's\ - hamburger, `.control-panel-btn`) doesn't inherit `color` from\ - this window — the shared, ecosystem-wide `button {{ color:\ - @on-surface }}` rule (lib.rs) matches it directly first. See\ - that rule's own comment for the full explanation. */\ - window.breadbar button {{ min-height: 0; min-width: 0; color: {ink}; }}\ - {segment_css}\ - {workspace_css}\ - .clock-box {{ padding: 0 4px; }}\ - .clock-label {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\ - min-height: 0; padding: 0; margin-top: 3px; }}\ - .clock-digit {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\ - min-width: 15px; min-height: 0; padding: 0; margin: 0; }}\ - .clock-colon {{ min-width: 10px; opacity: 0.7; }}\ - .clock-digit.flip {{ animation: digit-flip 0.45s {spring} both; }}\ - .clock-plain {{ padding: 0 4px; }}\ - .clock-plain-time {{ font-size: 15px; font-weight: 600; letter-spacing: 0.04em; }}\ - .date-label {{ font-size: 12px; opacity: 0.48; letter-spacing: 0.04em; }}\ - .stat-label {{ font-size: 14px; letter-spacing: 0.02em; opacity: 0.92; }}\ - .stat-label.tick {{ animation: digit-flip 0.35s {spring} both; }}\ - /* Odometer digit chips (volume/battery, ANIMATION WORK #2): one\ - `.stat-label` per character instead of one label for the whole\ - number, each with a fixed min-width so a `9` -> `10` or\ - `8` -> `9` transition doesn't jitter the chip's overall width\ - as narrower/wider glyphs swap in. `.flip` reuses the exact\ - `digit-flip` keyframe + timing the clock's `.clock-digit.flip`\ - already plays. */\ - .stat-digit {{ min-width: 9px; padding: 0; margin: 0; }}\ - .stat-digit.flip {{ animation: digit-flip 0.35s {spring} both; }}\ - .stats-box {{ margin-right: 0; }}\ - /* Radius was a hardcoded 10px here regardless of theme — right by\ - coincidence for liquid-motion's demo (`.chip {{ border-radius:\ - 10px }}`, this theme's radius_sm is 9px, a 1px rounding-off),\ - wrong for glass-workbench (demo's `.chip` is 6px, exactly this\ - theme's radius_sm — the hardcoded 10px never matched it), and\ - wrong-in-spirit for spotlight even though no `.chip` class\ - exists in that demo to compare against: a small, sharp-ish\ - radius reads as a stray rectangle inside a 22px-radius capsule\ - sitting right next to 999px-radius workspace dots (reported:\ - spotlight's battery chip not matching its neighbours). See\ - `chip_radius` above — radius_sm for the other two themes,\ - radius_pill for spotlight, so every theme's stat chips round\ - the way that theme's *other* rounded chrome already does,\ - instead of all three sharing one borrowed hardcoded number. */\ - /* min-height (not the old `min-height: 0`): decision #1 — ONE\ - chip highlight height per bar, vertically centred, shared by\ - every chip so their fills align instead of each sizing to its\ - own content box (reported: battery sat high, wifi/menu were\ - taller than their neighbours). See `chip_height_px` above. */\ - .stat-pair {{ margin: 0; border-radius: {chip_radius}; padding: 5px 9px;\ - min-height: {chip_height_px};\ - transition: background-color 0.22s {spring_settle},\ - opacity 0.18s ease; }}\ - .stat-pair:hover {{ background: alpha({ink}, 0.12); }}\ - .stat-pair:active {{ background: alpha({ink}, 0.18); }}\ - /* No border-radius override here (was a hardcoded 999px, making\ - wifi/hamburger — the only two `.icon-only` chips — fully\ - circular while their row neighbours vol/battery stayed a\ - rounded rect at `.stat-pair`'s own radius: a visible rounding\ - mismatch inside one row, reported against the liquid-motion\ - hamburger specifically). Every demo's `.chip` class (liquid-\ - motion, glass-workbench) draws vol/wifi/bat/menu identically,\ - none of them circular — dropping the override here just lets\ - `.stat-pair`'s own `chip_radius` cascade through unchanged, so\ - the icon-only chips match their siblings instead of standing\ - out (spotlight has no icon-only chip today, but would get the\ - same pill radius as its one `.stat-pair` sibling if it ever did). */\ - .stat-pair.icon-only {{ padding: 4px;\ - min-width: {chip_height_px}; min-height: {chip_height_px}; }}\ - .stat-icon {{ margin-right: 6px; }}\ - .stat-pair.icon-only .stat-icon {{ margin: 0; }}\ - .bt-icon {{ margin-right: 8px; }} - separator.bar-sep {{ min-height: 12px; min-width: 1px; margin: 0 10px 0 2px;\ - background: alpha({ink}, 0.10); }}\ - window.breadbar-notification {{ background-color: transparent; color: {ink}; }}\ - window.breadbar-history {{ background-color: alpha({panel}, {card_alpha}); color: {ink};\ - border-radius: {radius}; border: 1px solid alpha({ink}, 0.10);\ - animation: pop-in 0.45s {spring} both; }}\ - .notification-card {{ background: alpha({panel}, {card_alpha}); color: {ink}; border-radius: {radius};\ - padding: {pad}; margin-bottom: 8px; border: 1px solid alpha({ink}, 0.10);\ - border-left: 3px solid transparent;\ - animation: notif-in 0.45s {spring_settle} both; }}\ - .notification-card.urgency-critical {{ border-left-color: @red; }}\ - .notification-card.urgency-normal {{ border-left-color: @accent; }}\ + "window.breadbar {{ background-color: {bg_rgba}; color: {on_bg}; border-radius: 0; }}\ + .workspace-btn {{ background: transparent; opacity: 0.45;\ + border-radius: 0; border: none; outline: none; box-shadow: none;\ + min-width: 24px; padding: 4px 8px; }}\ + .workspace-btn:hover {{ opacity: 0.8; }}\ + .workspace-btn.active {{ background: {accent}; color: {on_accent}; opacity: 1; }}\ + .stats-box {{ margin-right: 8px; }}\ + .stat-pair {{ margin-right: 14px; }}\ + .stat-icon {{ margin-right: 2px; }}\ + .bt-icon {{ margin-right: 14px; }}\ + window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; }}\ + .notification-card {{ background: {surface}; color: {on_surface}; border-radius: 8px;\ + padding: 12px; margin-bottom: 8px; }}\ .notification-summary {{ font-weight: bold; }}\ - .notification-app {{ opacity: 0.55; font-size: 11px; letter-spacing: 0.04em; }}\ - .notification-actions {{ margin-top: 6px; }}\ - .notification-action {{ padding: 2px 8px; font-size: 11px; border-radius: {radius_sm}; }}\ - .notification-reply {{ margin-top: 6px; }}\ - .notification-reply-entry {{ min-width: 0; }}\ - /* NOTIFICATION INTERACTION #A: a direct dismiss control, floated\ - in the card's top-right corner via an Overlay (see popup.rs's\ - `make_card`) rather than a full extra header row, so it doesn't\ - add vertical bulk the approved demo's own card never has. */\ - .notification-dismiss {{ min-width: 18px; min-height: 18px; padding: 0;\ - margin: 2px; border-radius: {radius_pill}; background: transparent;\ - color: {ink}; opacity: 0.45; font-size: 12px; font-weight: bold;\ - border: none; outline: none; box-shadow: none;\ - transition: background-color 0.18s {spring_settle}, opacity 0.18s ease; }}\ - .notification-dismiss:hover {{ opacity: 1; background: alpha({ink}, 0.16); }}\ - .notification-dismiss:active {{ background: alpha({ink}, 0.24); }}\ - .history-title {{ font-weight: bold; font-size: 13px; }}\ - .history-close {{ padding: 2px 8px; }}\ - .history-empty {{ opacity: 0.5; padding: 8px 0; }}\ - .history-time {{ opacity: 0.5; font-size: 11px; }}\ - .history-body {{ opacity: 0.75; }}\ - .history-card {{ margin-bottom: 6px; }}\ - window.breadbar-osd {{ background-color: alpha({panel}, {card_alpha}); color: {ink};\ - border-radius: {radius_pill}; border: 1px solid alpha({ink}, 0.10);\ - animation: osd-in 0.4s {spring_settle} both; }}\ - .osd-icon {{ opacity: 0.85; margin-right: 8px; }}\ - .osd-icon-muted {{ opacity: 0.35; }}\ - progressbar.osd-bar {{ min-height: 6px; }}\ - progressbar.osd-bar trough {{ background-image: none; background-color: {trough_bg};\ - border-radius: 3px; min-height: 6px; }}\ - progressbar.osd-bar trough progress {{ background-image: none; background-color: @accent;\ - border-radius: 3px; min-height: 6px; }}\ - window.breadbar-panel {{ background-color: alpha({panel}, {panel_surface_alpha}); color: {ink};\ - border-radius: 14px; border: 1px solid alpha({ink}, 0.12); }}\ - /* A real GtkButton's own label text does NOT inherit `color` from\ - an ancestor window: `bread_theme::stylesheet()`'s shared,\ - ecosystem-wide `button {{ color: @on-surface }}` rule (lib.rs,\ - applied to every bread app before this file's CSS layers on\ - top) matches the button element directly, and a direct match\ - always beats inheritance regardless of specificity. `@on-surface`\ - is `ink_on(@surface)`, and `@surface` is `bread_theme::palette`'s\ - FIXED_SURFACE constant — pinned dark, same as `@bg` — so every\ - button's label (power row, hamburger, wifi/bluetooth popover\ - rows, the add-network dialog's Cancel/Connect) rendered\ - near-white regardless of the active shell theme. Invisible-but-\ - correct on every dark theme through spotlight; confirmed\ - empirically as near-invisible ghost text under daylight (isolated\ - `bread-capture` control-panel screenshot, pre-fix). One rule,\ - scoped by ancestor class so its specificity beats the shared\ - unscoped `button` rule, instead of patching each `.power-btn`/\ - `.control-panel-btn`/`.wifi-popover-row`/etc. class individually. */\ - window.breadbar-panel button, window.wifi-popover button,\ - window.wifi-add-dialog button, window.breadbar-notification button,\ - window.breadbar-history button {{ color: {ink}; }}\ - window.breadbar-dismiss, .breadbar-dismiss-hit {{\ - background-color: alpha(#000000, 0.02); }}\ - .popover-caret {{ min-height: 2px; margin: 2px 4px 10px; border-radius: 2px;\ - background-color: @accent;\ - background-image: linear-gradient(90deg, @accent, @teal);\ - animation: caret-draw 0.45s {spring} both; }}\ - .wifi-popover-inner {{ min-width: 228px; padding: {pad}; }}\ - window.wifi-popover button {{ min-height: 0; min-width: 0; }}\ - .popover-tab-row {{ background: alpha({ink}, 0.06); border-radius: 10px;\ - padding: 3px; margin-bottom: 10px; }}\ - .popover-tab {{ background: transparent; color: {ink}; border: none; box-shadow: none;\ - outline: none; border-radius: 999px; padding: 0 14px; min-height: 32px;\ - font-size: 17px; font-weight: bold; opacity: 0.55;\ - transition: background-color 0.22s {spring_settle},\ - opacity 0.22s ease, color 0.22s ease; }}\ - .popover-tab:hover {{ opacity: 0.8; }}\ - .popover-tab:checked {{ background: alpha(@accent, 0.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; }}\ + .notification-app {{ opacity: 0.6; }}\ + window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: 8px; }}\ + .osd-kind {{ opacity: 0.75; font-size: 12px; }}\ + .osd-pct {{ font-weight: bold; font-size: 12px; }}\ + progressbar.osd-bar {{ min-height: 8px; }}\ + progressbar.osd-bar trough {{ background-image: none; background-color: {trough}; border-radius: 4px; min-height: 8px; }}\ + progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 4px; min-height: 8px; }}\ + .clickable {{ cursor: pointer; }}\ + .wifi-pair {{ border-radius: 4px; padding: 0 2px; }}\ + .wifi-pair:hover {{ background: alpha({on_bg}, 0.12); }}\ + .wifi-popover-inner {{ min-width: 180px; padding: 2px; }}\ + .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; }}\ .wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\ - outline: none; border-radius: 10px; padding: 0 12px; min-height: 42px;\ - transition: background-color 0.18s {spring_settle}; }}\ - .wifi-popover-row label {{ font-size: 18px; }}\ - .wifi-popover-row:hover {{ background: alpha({ink}, 0.08); }}\ - .wifi-popover-row-active {{ background: alpha(@accent, 0.14); color: @accent; }}\ - .wifi-popover-row-active:hover {{ background: alpha(@accent, 0.20); }}\ - .row-in {{ animation: row-in 0.32s {spring} both; }}\ - .stagger-0 {{ animation-delay: 0ms; }} .stagger-1 {{ animation-delay: 28ms; }}\ - .stagger-2 {{ animation-delay: 56ms; }} .stagger-3 {{ animation-delay: 84ms; }}\ - .stagger-4 {{ animation-delay: 112ms; }} .stagger-5 {{ animation-delay: 140ms; }}\ - .stagger-6 {{ animation-delay: 168ms; }} .stagger-7 {{ animation-delay: 196ms; }}\ - .stagger-8 {{ animation-delay: 224ms; }} .stagger-9 {{ animation-delay: 252ms; }}\ - .stagger-10 {{ animation-delay: 280ms; }} .stagger-11 {{ animation-delay: 308ms; }}\ + border-radius: 4px; padding: 2px 6px; }}\ + .wifi-popover-row:hover {{ background: alpha({on_bg}, 0.08); }}\ + .wifi-popover-row-active {{ color: {accent}; }}\ .wifi-popover-row-unsaved {{ opacity: 0.4; }}\ .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ - switch.bt-switch, switch.bt-switch:hover, switch.bt-switch:checked,\ - switch.bt-switch:checked:hover {{ min-width: 42px; min-height: 24px; padding: 2px;\ - border: none; outline: none; box-shadow: none; background-image: none;\ - border-radius: 99px; }}\ - switch.bt-switch {{ background-color: alpha({ink}, 0.14);\ - transition: background-color 0.25s {spring_settle}; }}\ - switch.bt-switch:checked {{ background-color: @accent; }}\ - switch.bt-switch slider {{ min-width: 20px; min-height: 20px; margin: 0;\ - border-radius: 99px; border: none; outline: none; box-shadow: none;\ - background-image: none; background-color: {ink}; }}\ - window.wifi-add-dialog {{ background-color: alpha({panel}, {card_alpha}); color: {ink}; min-width: 240px;\ - border-radius: {radius}; border: 1px solid alpha({ink}, 0.10);\ - animation: pop-in 0.45s {spring} both; }}\ - window.wifi-add-dialog headerbar {{ background-color: alpha({panel}, {card_alpha}); color: {ink};\ - border-top-left-radius: {radius}; border-top-right-radius: {radius};\ - border-bottom: 1px solid alpha({ink}, 0.10); box-shadow: none; }}\ - .confirm-button {{ background-color: @accent; color: @on-accent; }}\ - .confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\ - /* min-height: decision #1 — the media chip is a bar chip like\ - any other, so it shares the same row height instead of sizing\ - to its own eq-bar/label content. */\ - .media-widget {{ border-radius: 10px; padding: 4px 8px; min-height: {chip_height_px};\ - transition: background-color 0.22s {spring_settle}; }}\ - .media-widget:hover {{ background: alpha({ink}, 0.08); }}\ - .media-widget.media-in {{ animation: row-in 0.4s {spring} both; }}\ - .media-eq {{ min-height: 14px; margin-right: 4px; }}\ - .media-eq-bar {{ min-width: 3px; min-height: 5px; background-color: @{accent2};\ - border-radius: 2px; }}\ - .media-widget.playing .media-eq-bar {{\ - animation: media-eq 0.85s ease-in-out infinite alternate; }}\ - .media-widget.playing .media-eq-bar:nth-child(2) {{ animation-delay: 0.1s; min-height: 11px; }}\ - .media-widget.playing .media-eq-bar:nth-child(3) {{ animation-delay: 0.22s; min-height: 7px; }}\ - .media-widget.playing .media-eq-bar:nth-child(4) {{ animation-delay: 0.06s; min-height: 13px; }}\ - .media-track-lbl {{ font-size: 17px; }}\ - .media-controls {{ padding: 4px; }}\ - .media-btn {{ min-width: 32px; padding: 4px 8px; border-radius: {radius_sm};\ - transition: background-color 0.18s ease; }}\ - .media-btn:hover {{ background: alpha({ink}, 0.10); }}\ - /* No padding/border-radius/min-width/min-height here (was\ - `padding: 5px 8px; border-radius: 10px; min-width: 0;\ - min-height: 0`): the hamburger is the only button carrying both\ - `.stat-pair.icon-only` AND `.control-panel-btn`, and because\ - this rule sits later in the cascade its hardcoded 10px radius\ - and 0 min-size were silently winning over `.stat-pair`'s own\ - `chip_radius`/`chip_height_px` — the exact hamburger-corner-\ - mismatch bug decision #2 describes, and a second copy of\ - decision #1's height bug, both reintroduced by\ - this one class alone. Dropping the four properties lets\ - `.stat-pair`/`.stat-pair.icon-only` cascade through unchanged,\ - same fix shape as the icon-only border-radius removal above. */\ - .control-panel-btn {{ margin: 0;\ - opacity: 0.92; font-size: 18px; line-height: 1;\ - background: transparent; border: none; outline: none; box-shadow: none;\ - transition: background-color 0.22s {spring_settle},\ - opacity 0.18s ease; }}\ - .control-panel-btn:hover {{ opacity: 1; background: alpha({ink}, 0.10); }}\ - .control-panel-btn:active {{ background: alpha({ink}, 0.16); }}\ + window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px; }}\ + .media-widget {{ border-radius: 4px; padding: 0 6px; cursor: pointer; }}\ + .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 {{ font-size: 16px; min-width: 36px; padding: 2px 8px; }}\ + .control-panel-btn {{ font-size: 14px; padding: 0 6px; margin-left: 6px; border-radius: 4px; }}\ .control-panel {{ }}\ - .control-panel-inner {{ min-width: 248px; padding: {pad}; }}\ - .sys-grid {{ margin: 2px 0 6px; }}\ - .sys-stat {{ padding: 4px 2px; background: transparent; }}\ - .sys-stat:hover {{ background: transparent; }}\ - .control-panel-header {{ font-size: 12px; font-weight: bold; letter-spacing: 0.12em;\ - opacity: 0.45; margin-bottom: 8px; }}\ - .control-panel-row {{ margin: 8px 0; }}\ - .control-panel-row-label {{ font-size: 16px; opacity: 0.78; }}\ - .control-panel-slider {{ margin: 0; padding: 0; min-height: 18px; }}\ - scale.control-panel-slider trough {{ min-height: 6px; border-radius: 99px;\ - background-image: none; background-color: alpha({ink}, 0.12);\ - border: none; outline: none; box-shadow: none; }}\ - scale.control-panel-slider highlight {{ min-height: 6px; border-radius: 99px;\ - background-image: none; background-color: @accent; }}\ - scale.control-panel-slider slider {{ min-width: 0; min-height: 0; margin: 0;\ - padding: 0; opacity: 0; background: transparent; border: none;\ - outline: none; box-shadow: none; }}\ - .control-panel-section {{ margin: 8px 0 0; }}\ - .sink-row label {{ font-size: 15px; }}\ - .power-row {{ margin-top: 8px; }}\ - .power-btn {{ min-width: 0; min-height: 0; padding: 8px 10px; border-radius: 8px;\ - background: alpha({ink}, 0.08); font-size: 13px; border: none;\ - outline: none; box-shadow: none;\ - transition: background-color 0.2s {spring_settle}; }}\ - .power-btn:hover {{ background: alpha({ink}, 0.14); }}\ - .power-btn:active {{ background: alpha(@accent, 0.22); }}\ - .notification-action {{ transition: background-color 0.18s ease; }}\ - .tray-btn {{ transition: opacity 0.2s ease, background-color 0.2s ease; }}\ - separator {{ margin: 4px 0; background: alpha({ink}, 0.10); }}\ - /* Lua-declared widgets (see Documentation.md's Widgets §style): the\ - slot rule below is what the four inline `.bread-widget-slot`\ - containers in main.rs rely on for the same 12px stat-pair rhythm\ - 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: {trough_bg}; border-radius: 3px; min-height: 6px; }}\ - progressbar.bread-widget-node trough progress {{ background-image: none; background-color: @accent; border-radius: 3px; min-height: 6px; }}\ - progressbar.bread-widget-node.bread-color-fg trough progress {{ background-color: @fg; }}\ - progressbar.bread-widget-node.bread-color-dim trough progress {{ background-color: alpha(@fg, 0.6); }}\ - 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; }}\ - /* Theme 04/spotlight's embedded launcher (plan §7). Unconditional,\ - like `.clock-plain-time` above: `launcher_entry`/`launcher_results`\ - are built regardless of the active theme (see main.rs's \"Assemble\"\ - section), just never placed in a slot outside spotlight, so these\ - rules render nothing on liquid-motion/glass-workbench. */\ - .launcher-entry {{ background: transparent; color: {ink}; border: none;\ - outline: none; box-shadow: none; caret-color: @{accent_from};\ - font-size: 13px; font-weight: 500; letter-spacing: 0.06em;\ - padding: 0; margin: 0; min-height: 0; }}\ - .launcher-entry.searching {{ font-size: 15px; letter-spacing: 0; }}\ - .bread-drawer {{ min-height: 0; }}\ - .bread-drawer.open {{ border-top: 1px solid alpha({ink}, 0.08);\ - margin-top: 6px; padding-top: 2px; }}\ - .bread-drawer listbox {{ background: transparent; padding: 2px 0; }}\ - .bread-drawer row {{ padding: 8px 14px; border-radius: {radius_sm};\ - color: {ink}; background-color: transparent; }}\ - .bread-drawer row:hover {{ background-color: alpha({ink}, 0.08); }}\ - .bread-drawer row:selected {{ background-color: alpha(@{accent_from}, 0.18);\ - color: {ink}; }}\ - .bread-drawer .app-name {{ font-size: 14px; font-weight: 500; }}\ - .bread-drawer .app-muted {{ opacity: 0.45; font-size: 11px; }}\ - /* `[launcher].sections` (plan §7 phase 6c) — the idle drawer's\ - \"Recent\"/\"Apps\" group labels (`bread_launcher::gtk::\ - build_header_row`). Unconditional, same reasoning as every other\ - launcher rule above: only spotlight ever builds a row with this\ - class at all. */\ - .bread-drawer-section-header {{ padding: 6px 14px 2px; }}\ - .section-header-label {{ font-size: 11px; font-weight: 600;\ - letter-spacing: 0.08em; text-transform: uppercase;\ - opacity: 0.45; }}", - // Implicit capture (2021 edition) for every `{name}` above: each - // matches an in-scope `let` binding of the same name (`radius`, - // `spring`, `ink`, `panel`, `bar_radius`, `window_chrome`, - // `segment_css`, `accent_from`, `accent2`, ...) rather than a hand- - // maintained `name = name,` list — axis 1's `panel`/`ink` swap and - // axis 3's `segment_css`/`window_chrome`/`bar_radius` locals both - // needed a growing, easy-to-desync explicit list to stay in step - // with the string body above; switching the whole call to implicit - // capture removes that failure mode instead of extending it further. - // (`radius_bar`, `flush`, `light`, `segmented`, `window_border` - // itself, and `accent_to` are each read by name ABOVE this literal, - // not inside it, so they're deliberately absent here — an unused - // implicit-capture name is a hard compile error, same discipline - // this crate already applies to unread `theme.toml` keys.) + .control-panel-inner {{ min-width: 240px; padding: 8px; }}\ + .control-panel-row {{ margin: 4px 0; }}\ + .control-panel-row-icon {{ opacity: 0.75; }}\ + .control-panel-slider {{ margin: 0; }}\ + .control-panel-stats {{ margin: 8px 0; }}\ + .control-panel-stat {{ font-size: 12px; opacity: 0.85; margin: 1px 0; }}\ + .control-panel-section {{ margin: 6px 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 {{ font-size: 16px; min-width: 44px; padding: 4px; border-radius: 6px; }}\ + separator {{ margin: 4px 0; }}", + bg_plain = p.background, + bg_rgba = hex_to_rgba(&p.background, 0.92), + surface = p.color0, + accent = p.color4, + on_bg = ink_on(&p.background), + on_surface = ink_on(&p.color0), + on_accent = ink_on(&p.color4), + trough = hex_to_rgba(&p.color4, 0.25), ) } /// Returns the ink colour for icon tinting in the stats bar — the same -/// luminance-picked colour the bar's text uses, so icons stay legible on the -/// bar whatever lightness pywal gives the background. -/// -/// FIXED — axis 1, daylight: this used to be unconditionally -/// `ink_on(&load_palette().background)`. `load_palette().background` is -/// `bread_theme::palette::FIXED_BACKGROUND` (`"#0c0c0c"`), pinned dark -/// regardless of pywal OR the active shell theme (see that constant's own -/// doc comment) — so this always resolved to the SAME near-white value, -/// baked directly into a rasterised SVG texture at icon-build time -/// (`svg_texture_sized`, the only call site), completely outside CSS and -/// therefore untouched by `load_css`'s own `panel`/`ink` swap. Every icon -/// built through [`crate::svg_image`]/[`crate::svg_texture`] (volume, wifi, -/// battery, hamburger, media transport, the OSD glyph) was near-white on -/// every existing (dark) theme, which read as correct by construction — -/// until daylight's near-white paper pills made the SAME near-white glyph -/// nearly invisible against its own background. Confirmed empirically -/// (isolated `bread-capture` OSD-volume screenshot, pre-fix) before this -/// fix. Mirrors `load_css`'s own `ink` local exactly: the dark theme's -/// unchanged `ink_on(background)` (near-white), or — for a light theme — -/// `background` itself (the fixed dark hex IS the correct dark ink, the -/// same identity `load_css`'s `ink = "@bg"` case relies on). +/// luminance-picked colour the bar's text uses, so icons stay legible on the bar +/// whatever lightness pywal gives the background. pub fn fg_color() -> String { - let p = load_palette(); - if shell_theme().tokens().light() { - p.background.clone() - } else { - ink_on(&p.background).to_string() - } -} - -/// Ink colour for the given Hyprland output's wallpaper palette. See -/// [`fg_color`]'s doc comment for the same light-theme fix; kept in step -/// even though this accessor has no call site today. -#[allow(dead_code)] -pub fn fg_color_for(output: &str) -> String { - let p = load_palette_for(output); - if shell_theme().tokens().light() { - p.background.clone() - } else { - ink_on(&p.background).to_string() - } -} - -/// Bind this window (and its popover children) to `output`'s palette. -/// -/// App CSS still uses `@accent` / `@on-bg` tokens; `bind_window_with_app_css` -/// resolves them against that output. Display-level [`apply`] stays as the -/// SIGHUP / single-output fallback. -pub fn bind_output(widget: &impl IsA, output: &str) { - bgtk::bind_window_with_app_css(widget, output, load_css_for); -} - -/// Bind a satellite window (notification, history, OSD, wifi dialog) to -/// whichever output it is actually rendered on. -pub fn bind_auto(window: &impl IsA) { - bgtk::bind_window_auto_with_app_css(window, load_css_for); -} - -fn load_css_for(_palette: &Palette) -> String { - load_css() + ink_on(&load_palette().background).to_string() } /// Apply (or reload) the theme CSS. Safe to call from `glib::MainContext::invoke`. @@ -814,31 +104,3 @@ pub fn apply() { let user_path = std::path::PathBuf::from(format!("{home}/.config/breadbar/style.css")); USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell)); } - -thread_local! { - // `bread_theme::shell::ThemeWatch`, not a bare `gio::FileMonitor`: the - // watch now re-arms itself onto a new theme's directory when the active - // theme id changes underneath it (see that type's doc comment), so the - // handle we keep alive is opaque, not a single fixed monitor. - static SHELL_THEME_MONITOR: RefCell> = - const { RefCell::new(None) }; -} - -/// Wires `bread_theme::shell::watch()` (plan §10) so editing the active -/// theme's `theme.toml`/`extra.css` on disk re-resolves CSS tokens without a -/// restart, the same way a pywal palette change already does via -/// `apply_app_css`. Window-spec values (anchors, margins, exclusive zone, -/// keyboard mode) are read once at window-construction time and are *not* -/// re-applied here — per plan §10 those need a restart, since live-swapping -/// a mapped layer-shell surface's anchors/exclusive-zone is a lot of -/// teardown risk for a rare operation. -/// -/// Call once at startup (primary instance only — every satellite window -/// calling this would just re-arm the same watch redundantly). -pub fn watch_hot_reload() { - let monitor = bread_theme::shell::watch(|new_theme| { - set_shell_theme(new_theme); - apply(); - }); - SHELL_THEME_MONITOR.with(|cell| *cell.borrow_mut() = Some(monitor)); -} diff --git a/src/widgets/client.rs b/src/widgets/client.rs deleted file mode 100644 index 84d043b..0000000 --- a/src/widgets/client.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! 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 deleted file mode 100644 index 019f434..0000000 --- a/src/widgets/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! 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 deleted file mode 100644 index 785470b..0000000 --- a/src/widgets/render.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! 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_RESTART, ICON_SHUTDOWN, ICON_SLEEP, ICON_VOLUME, WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG, - WIFI_WEAK, - }; - 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-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, - }) -} - -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()); - crate::prepare_icon(&image, px as i32); - 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 -}