diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml new file mode 100644 index 0000000..9bbb301 --- /dev/null +++ b/.forgejo/workflows/check.yml @@ -0,0 +1,24 @@ +name: check + +# Fast-fail lint/test on short-lived work branches, before it ever reaches +# main and triggers a dev-track release build. +on: + push: + branches: ['feature/**', 'fix/**'] + +jobs: + check: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: clippy + run: cd src && bash ci/build.sh cargo clippy --all-targets --locked -- -D warnings + + - name: test + run: cd src && bash ci/build.sh cargo test --locked diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml new file mode 100644 index 0000000..13f0666 --- /dev/null +++ b/.forgejo/workflows/dev-release.yml @@ -0,0 +1,76 @@ +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 deleted file mode 100644 index 26a9b37..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Mirror to GitHub - -on: - push: - branches: ['**'] - tags: ['**'] - -jobs: - mirror: - runs-on: [self-hosted, hestia] - steps: - - name: Mirror to GitHub - run: | - set -euo pipefail - git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git - cd repo.git - git push --prune \ - "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadbar.git" \ - '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml deleted file mode 100644 index 2895a76..0000000 --- a/.forgejo/workflows/package.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Build and publish package - -on: - push: - tags: ['v*'] - -jobs: - package: - runs-on: [self-hosted, hestia] - container: - image: archlinux:latest - steps: - # Note: no actions/checkout — the archlinux image has no Node, which JS - # actions require. Everything runs as shell steps and clones manually. - - name: Build and publish - env: - PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -euo pipefail - VERSION="${GITHUB_REF_NAME#v}" - pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell libpulse iw - useradd -m builder - git config --global --add safe.directory '*' - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src - cd /home/builder/src - git archive --format=tar.gz --prefix="breadbar-${VERSION}/" HEAD \ - > packaging/arch/breadbar-${VERSION}.tar.gz - SHA=$(sha256sum packaging/arch/breadbar-${VERSION}.tar.gz | awk '{print $1}') - sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD - sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD - chown -R builder:builder /home/builder/src - # --nocheck: packaging builds the artifact; tests belong in a CI job. - su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" - PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) - curl -fsS -X PUT \ - -H "Authorization: token ${PUBLISH_TOKEN}" \ - -H "Content-Type: application/octet-stream" \ - --data-binary "@${PKG}" \ - "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml new file mode 100644 index 0000000..769b700 --- /dev/null +++ b/.forgejo/workflows/rc-release.yml @@ -0,0 +1,57 @@ +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 8124ec6..7814150 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,6 +6,7 @@ on: jobs: build: + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout @@ -16,7 +17,16 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: | + set -euo pipefail + if [ ! -f src/ci/build.sh ]; then + echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper" + exit 1 + fi + cd src && bash ci/build.sh cargo build --release --locked || { + echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked." + exit 1 + } - name: prepare artifacts run: | @@ -28,12 +38,19 @@ 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 816e2ad..4f90dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ logs/ # Internal design documents (not for distribution) aster-brief.md + +# graphify knowledge-graph output (local tool cache, not for commit) +graphify-out/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1207e2f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# AGENTS.md — Repo hygiene + +Scope: this file covers *repo hygiene* — branching, remotes, CI — plus a +short map of the binary. It is not user-facing project documentation. + +This repo follows the branch/release workflow documented in `CONTRIBUTING.md` +— read and follow it for any git, branch, or release work here (the +single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, +etc). Don't improvise a different workflow. The short version: there is one +long-lived branch, `main` — no `dev` or `beta` branch exists. `main` +auto-publishes a dev-track build on every push. "Beta" and "stable" are both +just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track +build, push a plain `vX.Y.Z` tag to cut the signed stable release. +"Freezing" for stabilization means pausing pushes to `main`, not moving a +branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model +after `main` was found to have silently rotted out of sync with `dev`/`beta` +across most repos in this ecosystem. + +When starting work on a new feature, create branch `feature/`. +When working on a bug or issue, create branch `fix/`. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push `origin` only; GitHub auto-mirrors. + +## CI +- `dev-release.yml` triggers on `push: branches: ['main']`. +- `rc-release.yml` triggers on `vX.Y.Z-rc.N` tag pushes (beta track). +- `release.yml` triggers on any other `v*` tag push (stable). + None of these run on plain commits or PRs beyond what's listed. + +## Architecture + +One GTK4/`relm4` binary, four surfaces: + +| Area | Path | Role | +|---|---|---| +| Bar | `src/bar/` | Layer-shell top bar: workspaces, clock, media, stats, wifi, bluetooth, control panel + SNI tray | +| Notifications | `src/notifications/` | `org.freedesktop.Notifications` daemon + stacked popups + 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 new file mode 100644 index 0000000..e171853 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# 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 78efbfb..14765ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,10 +9,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "anyhow" -version = "1.0.102" +name = "anstream" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arrayref" @@ -22,9 +72,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "async-broadcast" @@ -46,7 +96,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -68,18 +118,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -96,32 +146,75 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "1.3.2" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] -name = "bitflags" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +name = "bread-screenshots" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#a9754d90ed32efcc26765abd01c9f441bfb01b1e" +dependencies = [ + "anyhow", + "bread-utils", + "tracing", +] + +[[package]] +name = "bread-shared" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +dependencies = [ + "dirs 5.0.1", + "serde", + "serde_json", + "toml 0.8.23", +] + +[[package]] +name = "bread-shared" +version = "0.8.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0-rc.1#2485e1af1f941c724461c0d59416c829ded638fe" +dependencies = [ + "dirs 6.0.0", + "serde", + "serde_json", + "toml 0.8.23", + "uuid", +] [[package]] name = "bread-theme" -version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" dependencies = [ - "dirs", + "dirs 5.0.1", "gtk4", "serde", "serde_json", ] [[package]] -name = "breadbar" -version = "0.2.3" +name = "bread-utils" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#a9754d90ed32efcc26765abd01c9f441bfb01b1e" dependencies = [ + "bread-shared 0.7.0", + "dirs 5.0.1", + "serde", + "serde_json", +] + +[[package]] +name = "breadbar" +version = "0.3.3" +dependencies = [ + "anyhow", + "bread-screenshots", + "bread-shared 0.8.0", "bread-theme", + "bread-utils", + "clap", "futures-lite", "gtk4", "gtk4-layer-shell", @@ -142,15 +235,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cairo-rs" @@ -158,7 +251,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" dependencies = [ - "bitflags 2.13.0", + "bitflags", "cairo-sys-rs", "glib", "libc", @@ -172,7 +265,7 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -192,14 +285,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "concurrent-queue" -version = "2.5.0" +name = "clap" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ - "crossbeam-utils", + "clap_builder", + "clap_derive", ] +[[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.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "convert_case" version = "0.10.0" @@ -218,12 +348,6 @@ 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" @@ -249,7 +373,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.119", "unicode-xid", ] @@ -259,7 +383,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -270,15 +403,27 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] [[package]] -name = "either" -version = "1.16.0" +name = "dirs-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "endi" @@ -304,7 +449,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -334,11 +479,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -355,11 +499,11 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", ] [[package]] @@ -409,12 +553,6 @@ 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" @@ -426,9 +564,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -441,9 +579,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -451,15 +589,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -468,9 +606,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -487,32 +625,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -547,14 +685,14 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] name = "gdk4" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" +checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39" dependencies = [ "cairo-rs", "gdk-pixbuf", @@ -568,9 +706,9 @@ dependencies = [ [[package]] name = "gdk4-sys" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" +checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -580,7 +718,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -596,36 +734,22 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 5.3.0", - "wasip2", + "r-efi", "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - [[package]] name = "gio" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" +checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded" dependencies = [ "futures-channel", "futures-core", @@ -640,14 +764,14 @@ dependencies = [ [[package]] name = "gio-sys" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5" dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", "windows-sys 0.61.2", ] @@ -673,11 +797,11 @@ dependencies = [ [[package]] name = "glib" -version = "0.22.7" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" +checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" dependencies = [ - "bitflags 2.13.0", + "bitflags", "futures-channel", "futures-core", "futures-executor", @@ -701,17 +825,17 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "glib-sys" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" +checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -722,37 +846,35 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] name = "graphene-rs" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff" dependencies = [ "glib", "graphene-sys", - "libc", ] [[package]] name = "graphene-sys" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "pkg-config", - "system-deps", + "system-deps 7.0.8", ] [[package]] name = "gsk4" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff" dependencies = [ "cairo-rs", "gdk4", @@ -765,9 +887,9 @@ dependencies = [ [[package]] name = "gsk4-sys" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088" dependencies = [ "cairo-sys-rs", "gdk4-sys", @@ -776,14 +898,14 @@ dependencies = [ "graphene-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] name = "gtk4" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" +checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9" dependencies = [ "cairo-rs", "field-offset", @@ -802,11 +924,11 @@ dependencies = [ [[package]] name = "gtk4-layer-shell" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" dependencies = [ - "bitflags 2.13.0", + "bitflags", "gdk4", "glib", "glib-sys", @@ -817,34 +939,34 @@ dependencies = [ [[package]] name = "gtk4-layer-shell-sys" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" dependencies = [ "gdk4-sys", "glib-sys", "gtk4-sys", "libc", - "system-deps", + "system-deps 8.0.0", ] [[package]] name = "gtk4-macros" -version = "0.11.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "gtk4-sys" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" +checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -856,16 +978,7 @@ dependencies = [ "gsk4-sys", "libc", "pango-sys", - "system-deps", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", + "system-deps 7.0.8", ] [[package]] @@ -912,20 +1025,14 @@ checksum = "31157e6ccefbad4b0cd7e549db6696691a70c11b108f26bf6bf76eef26af8c10" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] -[[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.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "indexmap" @@ -934,11 +1041,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "hashbrown", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -947,13 +1058,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -965,32 +1075,27 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kurbo" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" dependencies = [ "arrayvec", "euclid", + "polycool", "smallvec", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -1012,15 +1117,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -1043,9 +1148,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -1067,6 +1172,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" @@ -1085,13 +1196,12 @@ dependencies = [ [[package]] name = "pango" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" +checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c" dependencies = [ "gio", "glib", - "libc", "pango-sys", ] @@ -1104,7 +1214,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1133,17 +1243,17 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "png" -version = "0.17.16" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 1.3.2", + "bitflags", "crc32fast", "fdeflate", "flate2", @@ -1151,13 +1261,12 @@ dependencies = [ ] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "polycool" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" dependencies = [ - "proc-macro2", - "syn", + "arrayvec", ] [[package]] @@ -1166,33 +1275,27 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1207,7 +1310,18 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", ] [[package]] @@ -1241,14 +1355,14 @@ checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "resvg" -version = "0.44.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a325d5e8d1cebddd070b13f44cec8071594ab67d1012797c121f27a669b7958" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" dependencies = [ "log", "pico-args", @@ -1269,9 +1383,12 @@ dependencies = [ [[package]] name = "roxmltree" -version = "0.20.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] [[package]] name = "rustc_version" @@ -1288,7 +1405,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -1297,9 +1414,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "scopeguard" @@ -1315,9 +1432,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1325,29 +1442,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1358,13 +1475,22 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", ] [[package]] @@ -1388,9 +1514,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simplecss" @@ -1415,15 +1541,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1431,9 +1557,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -1448,10 +1574,16 @@ dependencies = [ ] [[package]] -name = "svgtypes" -version = "0.15.3" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ "kurbo", "siphasher", @@ -1459,9 +1591,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -1477,7 +1620,20 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml", + "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", ] @@ -1494,7 +1650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1506,7 +1662,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", ] [[package]] @@ -1517,14 +1682,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] name = "tiny-skia" -version = "0.11.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" dependencies = [ "arrayref", "arrayvec", @@ -1537,9 +1713,9 @@ dependencies = [ [[package]] name = "tiny-skia-path" -version = "0.11.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" dependencies = [ "arrayref", "bytemuck", @@ -1548,9 +1724,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1565,28 +1741,49 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +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" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -1600,30 +1797,50 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", - "toml_datetime", + "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_parser", - "winnow", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow", + "winnow 1.0.4", ] [[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" +name = "toml_write" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tracing" @@ -1644,7 +1861,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1687,9 +1904,9 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "usvg" -version = "0.44.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7447e703d7223b067607655e625e0dbca80822880248937da65966194c4864e6" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" dependencies = [ "base64", "data-url", @@ -1708,11 +1925,18 @@ dependencies = [ ] [[package]] -name = "uuid" -version = "1.23.2" +name = "utf8parse" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -1730,29 +1954,11 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -1763,9 +1969,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1773,60 +1979,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" 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" @@ -1910,112 +2082,27 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "winnow" -version = "1.0.3" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] [[package]] -name = "wit-bindgen" -version = "0.51.0" +name = "winnow" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ - "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", + "memchr", ] [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xmlwriter" @@ -2025,9 +2112,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "zbus" -version = "5.16.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-recursion", @@ -2047,7 +2134,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -2055,14 +2142,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.16.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 3.0.3", "zbus_names", "zvariant", "zvariant_utils", @@ -2070,57 +2157,67 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.2" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow", + "winnow 1.0.4", "zvariant", ] [[package]] -name = "zmij" -version = "1.0.21" +name = "zcheapstr" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.12.0" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.12.0" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 3.0.3", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.4.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" dependencies = [ "proc-macro2", "quote", "serde", - "syn", - "winnow", + "syn 3.0.3", + "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index 0f562ff..1825aa8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,26 @@ [package] name = "breadbar" -version = "0.2.3" +version = "0.3.3" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" -authors = ["Breadway "] -repository = "https://github.com/Breadway/breadbar" +authors = ["Breadway "] +repository = "https://git.breadway.dev/Breadway/breadbar" keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } +# Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) +# for talking to breadd's IPC socket, and bread-shared purely for the +# WidgetSpec/WidgetNode wire types so we deserialize into real structs +# instead of hand-parsing serde_json::Value. See src/widgets/. +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["bread-client"] } +# v0.8.0-rc.1 carries bread_shared::widget; keep this bread tag even if +# ecosystem crates move independently. +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" } +# Capture primitives for `--screenshot` mode — see src/screenshot.rs. +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } @@ -20,9 +30,11 @@ 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.44", default-features = false } +resvg = { version = "0.47", default-features = false } [profile.release] lto = "thin" diff --git a/README.md b/README.md index 7e58eff..3addfa1 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,14 @@ 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 (`hyprlock`), suspend, reboot, poweroff +- Power buttons: lock (`breadlock`), 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**: @@ -138,9 +139,11 @@ Example — change the font size: | `src/bar/wifi.rs` | WiFi details popover, `breadcrumbs` profile/scan integration | | `src/bar/control.rs` | Control panel data: volume (`wpctl`), brightness (`brightnessctl`), sinks (`pactl`) | | `src/bar/tray.rs` | `org.kde.StatusNotifierWatcher` D-Bus service, SNI item rendering | -| `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service | +| `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service + `dev.breadway.Bar` history IPC | | `src/notifications/popup.rs` | Layer-shell popup window and card stack | +| `src/notifications/history.rs` | Bounded 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 new file mode 100644 index 0000000..c176fff --- /dev/null +++ b/assets/Bluetooth Settings.svg @@ -0,0 +1 @@ + diff --git a/assets/Brightness.svg b/assets/Brightness.svg new file mode 100644 index 0000000..8afaa73 --- /dev/null +++ b/assets/Brightness.svg @@ -0,0 +1 @@ + diff --git a/assets/GPU.svg b/assets/GPU.svg new file mode 100644 index 0000000..03c7f1c --- /dev/null +++ b/assets/GPU.svg @@ -0,0 +1 @@ + diff --git a/assets/Lock.svg b/assets/Lock.svg new file mode 100644 index 0000000..0be852f --- /dev/null +++ b/assets/Lock.svg @@ -0,0 +1 @@ + diff --git a/assets/Next.svg b/assets/Next.svg new file mode 100644 index 0000000..44d5519 --- /dev/null +++ b/assets/Next.svg @@ -0,0 +1 @@ + diff --git a/assets/Pause.svg b/assets/Pause.svg new file mode 100644 index 0000000..3ff4ce2 --- /dev/null +++ b/assets/Pause.svg @@ -0,0 +1 @@ + diff --git a/assets/Play.svg b/assets/Play.svg new file mode 100644 index 0000000..2821df0 --- /dev/null +++ b/assets/Play.svg @@ -0,0 +1 @@ + diff --git a/assets/Previous.svg b/assets/Previous.svg new file mode 100644 index 0000000..23ce806 --- /dev/null +++ b/assets/Previous.svg @@ -0,0 +1 @@ + diff --git a/assets/Restart.svg b/assets/Restart.svg new file mode 100644 index 0000000..b8b9c76 --- /dev/null +++ b/assets/Restart.svg @@ -0,0 +1 @@ + diff --git a/assets/Shutdown.svg b/assets/Shutdown.svg new file mode 100644 index 0000000..787d1a3 --- /dev/null +++ b/assets/Shutdown.svg @@ -0,0 +1 @@ + diff --git a/assets/Sleep.svg b/assets/Sleep.svg new file mode 100644 index 0000000..a49d306 --- /dev/null +++ b/assets/Sleep.svg @@ -0,0 +1 @@ + diff --git a/assets/Volume.svg b/assets/Volume.svg new file mode 100644 index 0000000..3ffce7f --- /dev/null +++ b/assets/Volume.svg @@ -0,0 +1 @@ + diff --git a/assets/icons-needed.txt b/assets/icons-needed.txt deleted file mode 100644 index e228054..0000000 --- a/assets/icons-needed.txt +++ /dev/null @@ -1,48 +0,0 @@ -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 bd1153f..0226f34 100644 --- a/bakery.toml +++ b/bakery.toml @@ -3,7 +3,8 @@ description = "Minimal status bar and notification daemon for Hyprland" binaries = ["breadbar"] system_deps = ["gtk4", "gtk4-layer-shell", "wireplumber", "pipewire-pulse", "brightnessctl", "iw"] optional_system_deps = ["hyprland"] -bread_deps = [] +bread_deps = ["bread"] +license_file = "LICENSE" [config] dir = "~/.config/breadbar" diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev new file mode 100644 index 0000000..34e7aa9 --- /dev/null +++ b/ci/bread-ecosystem.rev @@ -0,0 +1 @@ +147cfbbf96ae4b171027defa1130d2caddb934b1 diff --git a/ci/build.sh b/ci/build.sh new file mode 100755 index 0000000..2d59cfe --- /dev/null +++ b/ci/build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Delegates to bread-ecosystem's shared CI build image/script, pinned to +# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI +# files now affect every product's release pipeline, so bumping the pin +# is a deliberate act instead of silent drift (see the bread-theme test +# that broke here for exactly that reason, before it was pinned by rev). +# +# Usage: ci/build.sh cargo build --release --locked +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")" + +CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}" +if [ ! -d "$CACHE_DIR" ]; then + rm -rf /tmp/bread-ecosystem-ci-* + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR" + git -C "$CACHE_DIR" checkout --quiet "$REV" +fi + +bash "${CACHE_DIR}/ci/build.sh" breadbar "$ROOT" "$@" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD deleted file mode 100644 index bb55a53..0000000 --- a/packaging/arch/PKGBUILD +++ /dev/null @@ -1,36 +0,0 @@ -# Maintainer: Breadway - -pkgname=breadbar -pkgver=0.2.0 -pkgrel=1 -pkgdesc="Minimal status bar and notification daemon for Hyprland" -arch=('x86_64') -url="https://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 new file mode 100644 index 0000000..422b298 --- /dev/null +++ b/src/bar/bluetooth.rs @@ -0,0 +1,123 @@ +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 4501fde..45e25e6 100644 --- a/src/bar/clock.rs +++ b/src/bar/clock.rs @@ -1,11 +1,21 @@ use crate::{App, AppInput}; use relm4::ComponentSender; +pub fn now() -> gtk4::glib::DateTime { + gtk4::glib::DateTime::now_local().expect("local time") +} + +pub fn time() -> String { + let dt = now(); + format!("{:02}:{:02}", dt.hour(), dt.minute()) +} + +pub fn date() -> String { + now().format("%a %d/%m").expect("date format").to_string() +} + pub fn current() -> String { - let dt = gtk4::glib::DateTime::now_local().expect("local time"); - let date = dt.format("%a %d/%m").expect("date format"); - let time = format!("{:02}:{:02}", dt.hour(), dt.minute()); - format!("{} {}", date, time) + format!("{} {}", date(), time()) } pub fn spawn_ticker(sender: ComponentSender) { diff --git a/src/bar/control.rs b/src/bar/control.rs index ed99568..43cb6c3 100644 --- a/src/bar/control.rs +++ b/src/bar/control.rs @@ -121,11 +121,29 @@ pub fn spawn_set_brightness(v: f64) { }); } -pub fn spawn_set_sink(name: String) { +pub fn spawn_set_sink(name: String, sender: ComponentSender) { 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 2563f23..e006a63 100644 --- a/src/bar/mod.rs +++ b/src/bar/mod.rs @@ -1,3 +1,4 @@ +pub mod bluetooth; pub mod clock; pub mod control; pub mod media; diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 3991324..26358e0 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -25,6 +25,14 @@ pub const WIFI_MEDIUM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), " pub const WIFI_WEAK: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Weak.svg")); pub const WIFI_OFF: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Disconnect.svg")); +/// Adwaita symbolic names — these are drawn for 16px status bars, not our +/// hand-cropped Lucide arcs. +pub const WIFI_ICON_EXCELLENT: &str = "network-wireless-signal-excellent-symbolic"; +pub const WIFI_ICON_GOOD: &str = "network-wireless-signal-good-symbolic"; +pub const WIFI_ICON_OK: &str = "network-wireless-signal-ok-symbolic"; +pub const WIFI_ICON_WEAK: &str = "network-wireless-signal-weak-symbolic"; +pub const WIFI_ICON_OFF: &str = "network-wireless-offline-symbolic"; + pub const BAT_HIGH: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 3 Bars.svg")); pub const BAT_MID: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 2 Bars.svg")); pub const BAT_LOW: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 1 Bar.svg")); @@ -37,11 +45,22 @@ 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, @@ -54,6 +73,7 @@ pub struct Stats { pub gpu_temp: Option, pub net_rx_kbs: f32, pub net_tx_kbs: f32, + pub volume_pct: u8, } struct CpuSnapshot { @@ -65,7 +85,7 @@ static PREV_CPU: OnceLock> = OnceLock::new(); static BAT_PATH: OnceLock> = OnceLock::new(); static AC_PATH: OnceLock> = OnceLock::new(); static WIFI_CACHE: LazyLock> = - LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_OFF))); + LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_ICON_OFF))); static WIFI_TICK: AtomicU8 = AtomicU8::new(0); fn read_cpu() -> f32 { @@ -99,7 +119,8 @@ fn read_cpu() -> f32 { (dtotal - didle) as f32 / dtotal as f32 * 100.0 } -fn read_ram() -> u64 { +/// Returns (used_kb, total_kb). +fn read_ram() -> (u64, u64) { let text = fs::read_to_string("/proc/meminfo").unwrap_or_default(); let mut total = 0u64; let mut avail = 0u64; @@ -121,7 +142,7 @@ fn read_ram() -> u64 { break; } } - total.saturating_sub(avail) + (total.saturating_sub(avail), total) } fn bat_path() -> Option<&'static PathBuf> { @@ -259,7 +280,7 @@ fn wifi_iface() -> Option<&'static str> { async fn read_wifi() -> (String, &'static str) { let Some(iface) = wifi_iface() else { - return ("—".into(), WIFI_OFF); + return ("—".into(), WIFI_ICON_OFF); }; let link_out = tokio::process::Command::new("iw") @@ -269,7 +290,7 @@ async fn read_wifi() -> (String, &'static str) { .ok(); let link_stdout = match link_out { Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(), - _ => return ("—".into(), WIFI_OFF), + _ => return ("—".into(), WIFI_ICON_OFF), }; let mut ssid = None; @@ -284,13 +305,14 @@ async fn read_wifi() -> (String, &'static str) { } let Some(ssid) = ssid else { - return ("—".into(), WIFI_OFF); + return ("—".into(), WIFI_ICON_OFF); }; let icon = match rssi { - Some(r) if r >= -55 => WIFI_STRONG, - Some(r) if r >= -70 => WIFI_MEDIUM, - _ => WIFI_WEAK, + Some(r) if r >= -55 => WIFI_ICON_EXCELLENT, + Some(r) if r >= -70 => WIFI_ICON_GOOD, + Some(r) if r >= -80 => WIFI_ICON_OK, + _ => WIFI_ICON_WEAK, }; (ssid, icon) @@ -380,7 +402,7 @@ fn read_crumbs_profile() -> Option { for line in text.lines() { if let Some(rest) = line.trim().strip_prefix("profile") { let val = rest - .trim_start_matches(|c: char| c == ' ' || c == '=') + .trim_start_matches([' ', '=']) .trim_matches('"'); if !val.is_empty() { return Some(val.to_string()); @@ -392,10 +414,17 @@ fn read_crumbs_profile() -> Option { pub async fn poll() -> Stats { let cpu = read_cpu(); - let mem = read_ram(); - let power = read_power().map_or_else(|| "—W".into(), |w| format!("{w:.1}W")); + 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 pct = read_battery(); - let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}%")); + // Demo bar prints the bare number ("83"), not "83%". + let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}")); let bat_icon = pct.map_or(BAT_MID, bat_level_icon); let ac_connected = read_ac(); // BT and WiFi both refresh every 8 cycles (~16 s); cache in between. @@ -424,14 +453,18 @@ 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, @@ -444,9 +477,30 @@ pub async fn poll() -> Stats { gpu_temp, net_rx_kbs, net_tx_kbs, + volume_pct, } } +/// `wpctl get-volume` prints `Volume: 0.44 [MUTED]`. Scale to a 0–150 percent +/// for the bar chip. Missing pipewire / wpctl degrades to 0 rather than +/// blocking the rest of the poll. +fn read_volume_pct() -> u8 { + let out = std::process::Command::new("wpctl") + .args(["get-volume", "@DEFAULT_AUDIO_SINK@"]) + .output() + .ok(); + let Some(o) = out.filter(|o| o.status.success()) else { + return 0; + }; + String::from_utf8_lossy(&o.stdout) + .trim() + .strip_prefix("Volume:") + .and_then(|s| s.split_whitespace().next()) + .and_then(|s| s.parse::().ok()) + .map(|v| (v * 100.0).round().clamp(0.0, 150.0) as u8) + .unwrap_or(0) +} + pub fn spawn_poller(sender: ComponentSender) { relm4::spawn(async move { loop { diff --git a/src/bar/wifi.rs b/src/bar/wifi.rs index 6531ece..cf5e272 100644 --- a/src/bar/wifi.rs +++ b/src/bar/wifi.rs @@ -24,6 +24,8 @@ pub struct ScanEntry { pub struct WifiPopoverData { pub profiles: Vec<(String, bool)>, // (name, is_active) pub scan: Vec, + /// False while nmcli is still listing APs — profiles must still be usable. + pub scan_ready: bool, } async fn fetch_status() -> Option { @@ -73,31 +75,57 @@ async fn fetch_profile_list() -> Vec<(String, bool)> { .collect() } +async fn saved_ssids() -> std::collections::HashSet { + let out = tokio::process::Command::new("nmcli") + .args(["-t", "-f", "NAME,TYPE", "connection", "show"]) + .output() + .await; + let Ok(o) = out else { + return std::collections::HashSet::new(); + }; + String::from_utf8_lossy(&o.stdout) + .lines() + .filter_map(|line| { + let (name, ty) = line.rsplit_once(':')?; + if ty == "802-11-wireless" || ty == "wifi" { + Some(name.to_string()) + } else { + None + } + }) + .collect() +} + +/// Cached AP list (no rescan). Fast enough to paint next to profiles. async fn fetch_scan() -> Vec { - let Ok(Ok(out)) = tokio::time::timeout( - Duration::from_secs(10), - tokio::process::Command::new("breadcrumbs") - .args(["scan-list", "--json"]) + let out = tokio::time::timeout( + Duration::from_secs(4), + tokio::process::Command::new("nmcli") + .args(["-t", "-f", "SSID,SIGNAL,IN-USE", "device", "wifi", "list"]) .output(), ) - .await - else { + .await; + let Ok(Ok(o)) = out else { return vec![]; }; - let arr: Vec = - serde_json::from_slice(&out.stdout).unwrap_or_default(); - arr.into_iter() - .filter_map(|v| { - let ssid = v["ssid"].as_str()?.to_string(); - if ssid.is_empty() { + let saved = saved_ssids().await; + let mut seen = std::collections::HashSet::new(); + String::from_utf8_lossy(&o.stdout) + .lines() + .filter_map(|line| { + let mut parts = line.rsplitn(3, ':'); + let _in_use = parts.next()?; + let signal = parts.next()?.parse::().ok().unwrap_or(0); + let ssid = parts.next()?.replace("\\:", ":"); + if ssid.is_empty() || ssid == "--" || !seen.insert(ssid.clone()) { return None; } - let signal = v["signal"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - let saved = v["saved"].as_bool().unwrap_or(false); - Some(ScanEntry { ssid, signal, saved }) + let saved = saved.contains(&ssid); + Some(ScanEntry { + ssid, + signal, + saved, + }) }) .collect() } @@ -114,11 +142,32 @@ pub fn spawn_status_poller(sender: ComponentSender) { }); } -/// Called when the popover opens — loads profiles + scan in parallel. +/// Profiles first (so you can switch Home/Away immediately), then the +/// cached AP list. A background rescan refreshes the list if it finds more. pub fn spawn_popover_load(sender: ComponentSender) { relm4::spawn(async move { - let (profiles, scan) = tokio::join!(fetch_profile_list(), fetch_scan()); - sender.input(AppInput::WifiPopoverData(WifiPopoverData { profiles, scan })); + let profiles = fetch_profile_list().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles: profiles.clone(), + scan: vec![], + scan_ready: false, + })); + let scan = fetch_scan().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles: profiles.clone(), + scan: scan.clone(), + scan_ready: true, + })); + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "rescan"]) + .output() + .await; + let scan = fetch_scan().await; + sender.input(AppInput::WifiPopoverData(WifiPopoverData { + profiles, + scan, + scan_ready: true, + })); }); } @@ -132,29 +181,27 @@ pub fn spawn_profile_set(name: String) { }); } -/// Fire-and-forget: connect to a specific saved SSID via `breadcrumbs join`. +/// Fire-and-forget: connect to a known SSID via NetworkManager. pub fn spawn_join(ssid: String) { relm4::spawn(async move { - let _ = tokio::process::Command::new("breadcrumbs") - .args(["join", &ssid]) + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "connect", &ssid]) .output() .await; }); } -/// Fire-and-forget: save a new network with its password, then join it. +/// Save in breadcrumbs (if the CLI still accepts `add`) and connect with nmcli. pub fn spawn_add_and_join(ssid: String, password: String) { relm4::spawn(async move { - let added = tokio::process::Command::new("breadcrumbs") + let _ = tokio::process::Command::new("breadcrumbs") .args(["add", &ssid, &password]) .output() .await; - if matches!(added, Ok(o) if o.status.success()) { - let _ = tokio::process::Command::new("breadcrumbs") - .args(["join", &ssid]) - .output() - .await; - } + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "connect", &ssid, "password", &password]) + .output() + .await; }); } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index f3e2209..2c05a15 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -1,7 +1,12 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Instant; + use futures_lite::StreamExt; +use gtk4::glib::ControlFlow; use gtk4::prelude::*; use hyprland::{ - data::{Workspace, Workspaces}, + data::{Monitors, Workspaces}, event_listener::{Event, EventStream}, prelude::*, shared::WorkspaceId, @@ -10,43 +15,399 @@ 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 { - 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)); - } + sync_state(&sender).await; - 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())); + // 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); + } + _ => {} } - _ => {} } + + 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) -> gtk4::Button { +pub fn make_button( + id: WorkspaceId, + name: &str, + active: WorkspaceId, + occupied: bool, +) -> gtk4::Button { let btn = gtk4::Button::with_label(name); btn.add_css_class("workspace-btn"); + if occupied { + btn.add_css_class("occupied"); + } if id == active { btn.add_css_class("active"); } + btn.set_valign(gtk4::Align::Center); + btn.set_halign(gtk4::Align::Center); + btn.set_vexpand(false); + btn.set_hexpand(false); + btn.set_size_request(-1, crate::CHIP_HEIGHT); + if let Some(child) = btn.child() { + child.set_halign(gtk4::Align::Center); + child.set_valign(gtk4::Align::Center); + } btn.connect_clicked(move |_| { - use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial}; - let _ = Dispatch::call(DispatchType::Workspace(WorkspaceIdentifierWithSpecial::Id( - id, - ))); + relm4::spawn(async move { + switch_workspace(id).await; + }); }); btn } + +#[derive(Clone, Copy)] +struct Geom { + x: f64, + y: f64, + w: f64, + h: f64, +} + +struct TrailInner { + tick: Option, + geom: Geom, +} + +/// Overlay + Fixed pill sitting *behind* the workspace buttons. The +/// Overlay's measured size comes from the button row; the pill is the +/// main child so it paints underneath and never steals clicks. +pub struct WorkspaceTrail { + pub overlay: gtk4::Overlay, + pub buttons: gtk4::Box, + host: gtk4::Fixed, + pill: gtk4::Box, + inner: Rc>, +} + +impl WorkspaceTrail { + pub fn new() -> Self { + let overlay = gtk4::Overlay::new(); + overlay.add_css_class("workspace-overlay"); + overlay.set_valign(gtk4::Align::Center); + overlay.set_vexpand(false); + + let host = gtk4::Fixed::new(); + host.set_can_target(false); + + let pill = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + pill.add_css_class("workspace-trail"); + pill.set_can_target(false); + pill.set_visible(false); + host.put(&pill, 0.0, 0.0); + + let buttons = gtk4::Box::new(gtk4::Orientation::Horizontal, 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, + 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) 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) + }; + 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); + } + + fn from_geom(&self, from: Option<>k4::Button>) -> Option { + let st = self.inner.borrow(); + // A leftover mid-stretch can be as wide as the whole row — never + // treat that as the start of the next animation. + if self.pill.is_visible() && st.geom.w > 0.5 && st.geom.w <= MAX_CHIP_W { + return Some(st.geom); + } + drop(st); + from.and_then(|b| button_geom(b, &self.host).map(inset_pill)) + } +} + +/// 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) +} diff --git a/src/main.rs b/src/main.rs index b10c130..5210546 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,31 +7,67 @@ macro_rules! asset { mod bar; mod notifications; mod osd; +mod panel; +mod screenshot; mod theme; +mod widgets; + +/// Floating island bar: widget height, layer-shell inset, reserved zone. +/// Exclusive zone is height + top margin so tiled clients sit below the gap. +pub const BAR_HEIGHT: i32 = 44; +pub const BAR_MARGIN_TOP: i32 = 12; +pub const BAR_MARGIN_SIDES: i32 = 16; +/// Chip / workspace-pill height. Must stay smaller than `BAR_HEIGHT` so +/// hover/active highlights hug the glyphs instead of filling the island. +pub const CHIP_HEIGHT: i32 = 32; +pub const ICON_PX: i32 = 24; use gtk4::prelude::*; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use hyprland::data::Workspace; use hyprland::shared::WorkspaceId; use relm4::prelude::*; +use relm4::{Component, ComponentController, Controller}; use std::cell::Cell; use std::rc::Rc; +pub struct BarInit { + pub screenshot: Option, + pub monitor: Option, + pub primary: bool, +} + pub struct App { + monitor: String, + primary: bool, + satellites: Vec<(String, Controller)>, + // ── Workspaces ──────────────────────────────────────────────────────── workspaces: Vec, active_ws: WorkspaceId, workspace_box: gtk4::Box, + workspace_trail: bar::workspaces::WorkspaceTrail, button_map: std::collections::HashMap, // ── Clock ───────────────────────────────────────────────────────────── time_str: String, - clock_lbl: gtk4::Label, + clock_digits: Vec, + date_lbl: gtk4::Label, // ── Stats bar ───────────────────────────────────────────────────────── + // Island chrome matches the Liquid Motion demo: volume / wifi / battery + // / hamburger. CPU/RAM/power live in the control panel, not on the bar. + system_stats_box: gtk4::Box, + system_sep: gtk4::Separator, + cpu_pair: gtk4::Box, + mem_pair: gtk4::Box, + pwr_pair: gtk4::Box, + gpu_pair: gtk4::Box, cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, + gpu_lbl: gtk4::Label, + vol_lbl: gtk4::Label, bat_lbl: gtk4::Label, bat_img: gtk4::Image, bat_textures: std::collections::HashMap, @@ -40,57 +76,74 @@ pub struct App { bt_textures: std::collections::HashMap, wifi_lbl: gtk4::Label, wifi_img: gtk4::Image, - wifi_textures: std::collections::HashMap, // ── WiFi popover ────────────────────────────────────────────────────── - wifi_popover_box: gtk4::Box, + wifi_pane: 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_btn: gtk4::Button, + media_play_icon: gtk4::Image, 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>, - 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, + sink_box: gtk4::Box, + sink_section: gtk4::Box, // ── Tray ────────────────────────────────────────────────────────────── + tray_section: gtk4::Box, + tray_sep: gtk4::Separator, tray_box: gtk4::Box, tray_items: std::collections::HashMap, + + // ── Lua-declared widgets ───────────────────────────────────────────── + // One container per WidgetPlacement (see bread_shared::widget), fully + // rebuilt on every AppInput::WidgetsUpdate — see widgets::client's + // module doc for why that's simpler than incremental patching here. + widget_containers: std::collections::HashMap, + widget_tray_section: gtk4::Box, + widget_tray_sep: gtk4::Separator, + + panels: panel::PanelSet, } #[derive(Debug)] pub enum AppInput { - WorkspaceList(Vec), - ActiveWorkspace(WorkspaceId), + WorkspaceSync { + workspaces: Vec, + actives: std::collections::HashMap, + }, + MonitorAdded(String), + MonitorRemoved(String), ClockTick, StatsUpdate(bar::stats::Stats), TrayUpdate(bar::tray::TrayUpdate), 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, } #[relm4::component(pub)] impl SimpleComponent for App { - type Init = (); + type Init = BarInit; type Input = AppInput; type Output = (); @@ -98,41 +151,84 @@ impl SimpleComponent for App { gtk::ApplicationWindow { add_css_class: "breadbar", set_title: Some("breadbar"), - set_default_height: 32, + set_default_height: BAR_HEIGHT, #[name = "center_box"] gtk::CenterBox { - #[wrap(Some)] - set_start_widget = >k::Box { - set_orientation: gtk::Orientation::Horizontal, - set_spacing: 0, - - #[name = "workspace_box"] - gtk::Box { - set_orientation: gtk::Orientation::Horizontal, - set_spacing: 4, - } - }, } } } fn init( - _: Self::Init, + init: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { + let screenshot_req = init.screenshot; + let monitor_name = init + .monitor + .clone() + .or_else(primary_hypr_monitor) + .unwrap_or_else(|| "eDP-1".into()); + root.init_layer_shell(); + root.set_namespace(Some("breadbar")); root.set_layer(Layer::Top); root.set_anchor(Edge::Top, true); root.set_anchor(Edge::Left, true); root.set_anchor(Edge::Right, true); - root.set_exclusive_zone(32); + root.set_margin(Edge::Top, BAR_MARGIN_TOP); + root.set_margin(Edge::Left, BAR_MARGIN_SIDES); + root.set_margin(Edge::Right, BAR_MARGIN_SIDES); + root.set_exclusive_zone(BAR_HEIGHT + BAR_MARGIN_TOP); + eprintln!( + "breadbar: init monitor={monitor_name} primary={}", + init.primary + ); + if screenshot_req.is_none() && !bind_layer_monitor(&root, &monitor_name) { + // Unbound satellites must not map on the compositor default + // (that stacks a second exclusive-zone bar on the laptop). + root.set_exclusive_zone(-1); + if !init.primary { + root.set_visible(false); + } + } + + // ── Workspace row (left) ──────────────────────────────────────── + // Built imperatively (not via the view! macro) so a widget + // container can sit as a plain sibling of workspace_box — see + // WidgetPlacement::RightOfWorkspaces below. The Overlay trail + // lives behind the buttons; rebuild_buttons only touches the + // button box, never the trail host. + let workspace_trail = bar::workspaces::WorkspaceTrail::new(); + let workspace_box = workspace_trail.buttons.clone(); + let workspace_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + workspace_row.set_margin_start(8); + workspace_row.set_valign(gtk4::Align::Center); + workspace_row.set_vexpand(false); + workspace_row.append(&workspace_trail.overlay); + + // ── Lua-declared widget containers ────────────────────────────── + // One per WidgetPlacement; positioned into the layout below as each + // surrounding section (workspace row / center area / stats box / + // control popover) is built. Populated by widgets::client's + // events.subscribe-driven refresh loop, started at the end of init. + use bread_shared::widget::WidgetPlacement; + let widget_right_of_workspaces = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_right_of_workspaces.add_css_class("bread-widget-slot"); + workspace_row.append(&widget_right_of_workspaces); + + let widget_left_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_left_of_clock.add_css_class("bread-widget-slot"); + let widget_right_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_right_of_clock.add_css_class("bread-widget-slot"); + + let widget_left_of_stats = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_left_of_stats.add_css_class("bread-widget-slot"); // ── SVG icon sets ──────────────────────────────────────────────── use bar::stats::{ - AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, WIFI_MEDIUM, - WIFI_OFF, WIFI_STRONG, WIFI_WEAK, + AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, ICON_VOLUME, }; let bat_textures: std::collections::HashMap = [BAT_HIGH, BAT_MID, BAT_LOW] @@ -144,26 +240,27 @@ 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 = stat_label(); let bat_lbl = stat_label(); + let vol_img = svg_image(ICON_VOLUME); + vol_img.add_css_class("stat-icon"); let bat_img = gtk4::Image::from_paintable(Some( bat_textures.get(&(BAT_MID.as_ptr() as usize)).unwrap(), )); - let ac_img = gtk4::Image::from_paintable(Some(&svg_texture(AC_POWER))); + prepare_icon(&bat_img, ICON_PX); + let ac_img = svg_image(AC_POWER); ac_img.set_visible(false); let bt_img = gtk4::Image::from_paintable(Some( bt_textures.get(&(BT_OFF.as_ptr() as usize)).unwrap(), )); + prepare_icon(&bt_img, ICON_PX); + bt_img.set_visible(false); // ── WiFi pair + popover ────────────────────────────────────────── let wifi_lbl = gtk4::Label::new(None); @@ -172,50 +269,36 @@ impl SimpleComponent for App { wifi_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); wifi_lbl.set_max_width_chars(28); wifi_lbl.set_xalign(0.0); - let wifi_img = - gtk4::Image::from_paintable(Some(&svg_texture(asset!("WiFi Connecting.svg")))); - - let wifi_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - wifi_pair.add_css_class("stat-pair"); - wifi_pair.add_css_class("wifi-pair"); + // 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"); - 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); + // 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 loading_lbl = gtk4::Label::new(Some("Scanning…")); loading_lbl.add_css_class("wifi-popover-loading"); - 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()); - }); + wifi_pane.append(&loading_lbl); // ── Media widget (center) ──────────────────────────────────────── let media_widget = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); media_widget.add_css_class("media-widget"); + bar_chip(&media_widget); media_widget.set_visible(false); - let media_indicator = gtk4::Label::new(Some("▶")); - media_indicator.add_css_class("media-indicator"); + let media_eq = gtk4::Box::new(gtk4::Orientation::Horizontal, 3); + media_eq.add_css_class("media-eq"); + media_eq.set_valign(gtk4::Align::Center); + for _ in 0..4 { + let bar = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + bar.add_css_class("media-eq-bar"); + bar.set_valign(gtk4::Align::End); + media_eq.append(&bar); + } let media_track_lbl = gtk4::Label::new(None); media_track_lbl.add_css_class("media-track-lbl"); @@ -223,7 +306,7 @@ impl SimpleComponent for App { media_track_lbl.set_max_width_chars(42); media_track_lbl.set_xalign(0.0); - media_widget.append(&media_indicator); + media_widget.append(&media_eq); media_widget.append(&media_track_lbl); // Media controls popover @@ -234,18 +317,22 @@ impl SimpleComponent for App { media_controls_box.set_margin_start(4); media_controls_box.set_margin_end(4); - let prev_btn = gtk4::Button::with_label("⏮"); + let prev_btn = gtk4::Button::new(); + prev_btn.set_child(Some(&svg_image(asset!("Previous.svg")))); prev_btn.add_css_class("flat"); prev_btn.add_css_class("media-btn"); prev_btn.connect_clicked(|_| bar::media::spawn_cmd("previous")); - let media_play_btn = gtk4::Button::with_label("⏸"); + let media_play_icon = svg_image(asset!("Pause.svg")); + let media_play_btn = gtk4::Button::new(); + media_play_btn.set_child(Some(&media_play_icon)); media_play_btn.add_css_class("flat"); 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::with_label("⏭"); + let next_btn = gtk4::Button::new(); + next_btn.set_child(Some(&svg_image(asset!("Next.svg")))); next_btn.add_css_class("flat"); next_btn.add_css_class("media-btn"); next_btn.connect_clicked(|_| bar::media::spawn_cmd("next")); @@ -254,147 +341,217 @@ impl SimpleComponent for App { media_controls_box.append(&media_play_btn); media_controls_box.append(&next_btn); - let media_popover = gtk4::Popover::new(); - media_popover.add_css_class("media-popover"); - media_popover.set_child(Some(&media_controls_box)); - media_popover.set_parent(&media_widget); + // Clock: time is the hero, date sits beside it quieter. + // Per-glyph labels so a minute rollover can flip only the digits + // that changed — same motion as the Liquid Motion demo. + let clock_time = bar::clock::time(); + let clock_digits = make_clock_digits(&clock_time); + let clock_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + clock_box.add_css_class("clock-box"); + clock_box.add_css_class("clock-label"); + clock_box.set_valign(gtk4::Align::Center); + clock_box.set_vexpand(false); + // Varela Round's em box sits optically high in the 44px island. + clock_box.set_margin_top(3); + for digit in &clock_digits { + clock_box.append(digit); + } + let date_lbl = gtk4::Label::new(Some(&bar::clock::date())); + date_lbl.add_css_class("date-label"); + date_lbl.set_visible(false); - let mpop = media_popover.clone(); - let mgesture = gtk4::GestureClick::new(); - mgesture.connect_released(move |_, _, _, _| { - if mpop.is_visible() { mpop.popdown(); } else { mpop.popup(); } - }); - media_widget.add_controller(mgesture); - - // Clock label - let clock_lbl = gtk4::Label::new(Some(&bar::clock::current())); - clock_lbl.add_css_class("clock-label"); - - // Center area: [media_widget · clock] - let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + // Center area: [media_widget · widgets · clock · widgets] + let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); center_area.add_css_class("center-area"); + center_area.set_valign(gtk4::Align::Center); + center_area.set_vexpand(false); center_area.append(&media_widget); - center_area.append(&clock_lbl); + center_area.append(&widget_left_of_clock); + center_area.append(&clock_box); + center_area.append(&widget_right_of_clock); // ── Stats box (right side) ─────────────────────────────────────── - let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + // Demo order: [vol 64] [wifi] [bat 83] [☰] + let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); stats_box.add_css_class("stats-box"); - stats_box.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)); + stats_box.set_margin_end(2); + stats_box.set_valign(gtk4::Align::Center); + stats_box.set_vexpand(false); + stats_box.append(&widget_left_of_stats); + + // CPU/RAM/power draw 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); + 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); + vol_lbl.add_css_class("stat-label"); + vol_box.append(&vol_img); + vol_box.append(&vol_lbl); + stats_box.append(&vol_box); let bat_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); bat_box.add_css_class("stat-pair"); + bar_chip(&bat_box); bat_img.add_css_class("stat-icon"); bat_lbl.add_css_class("stat-label"); ac_img.add_css_class("stat-icon"); - ac_img.set_margin_start(6); bat_box.append(&bat_img); bat_box.append(&bat_lbl); - bat_box.append(&ac_img); - stats_box.append(&bat_box); bt_img.add_css_class("bt-icon"); - 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(); - }); + + // 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_controller(bt_gesture); - stats_box.append(&bt_img); - stats_box.append(&wifi_pair); + 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("wifi-pair"); + connectivity_pair.add_css_class("icon-only"); + bar_chip(&connectivity_pair); + wifi_img.set_halign(gtk4::Align::Center); + wifi_img.set_hexpand(false); + connectivity_pair.append(&wifi_img); + stats_box.append(&connectivity_pair); + stats_box.append(&bat_box); // ── Control panel popover ──────────────────────────────────────── + // Liquid Motion chrome: CONTROL / vol / bl / lock·sleep·off. + // SNI tray + Lua tray widgets still mount here, but only as a + // headerless icon row when something actually registers. let panel_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); panel_inner.add_css_class("control-panel-inner"); - // Volume row - let vol_row = build_slider_row("🔊", 0.0, 1.5, 0.02); + let panel_header = gtk4::Label::new(Some("CONTROL")); + panel_header.add_css_class("control-panel-header"); + panel_header.set_xalign(0.0); + panel_inner.append(&panel_header); + panel_inner.append(&popover_caret()); + + let vol_row = build_slider_row("vol", 0.0, 1.5, 0.02); let panel_vol_slider = vol_row.1.clone(); panel_inner.append(&vol_row.0); - // Brightness row - let bright_row = build_slider_row("☀", 0.0, 1.0, 0.02); + let sink_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + sink_section.add_css_class("control-panel-section"); + let sink_header = gtk4::Label::new(Some("OUTPUT")); + sink_header.add_css_class("control-panel-header"); + sink_header.set_xalign(0.0); + sink_header.set_margin_top(6); + let sink_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + sink_section.append(&sink_header); + sink_section.append(&sink_box); + sink_section.set_visible(false); + panel_inner.append(&sink_section); + + let bright_row = build_slider_row("bl", 0.0, 1.0, 0.02); let panel_bright_slider = bright_row.1.clone(); panel_inner.append(&bright_row.0); - panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + 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); - // 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); + let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); power_row.add_css_class("power-row"); + power_row.set_halign(gtk4::Align::Center); for (label, cmd) in [ - ("🔒", vec!["hyprlock"]), - ("💤", vec!["systemctl", "suspend"]), - ("🔄", vec!["systemctl", "reboot"]), - ("⏻", vec!["systemctl", "poweroff"]), + // 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"]), ] { let btn = gtk4::Button::with_label(label); btn.add_css_class("flat"); @@ -409,62 +566,178 @@ impl SimpleComponent for App { }); power_row.append(&btn); } - power_section.append(&power_row); - panel_inner.append(&power_section); + panel_inner.append(&power_row); - let control_popover = gtk4::Popover::new(); - control_popover.add_css_class("control-panel"); - control_popover.set_child(Some(&panel_inner)); + // SNI / Lua tray sit under the demo chrome so a single icon cannot + // split the sliders from the power chips. + let tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + tray_section.add_css_class("control-panel-section"); + let tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + tray_box.add_css_class("tray-box"); + tray_box.set_halign(gtk4::Align::Center); + tray_section.append(&tray_box); + tray_section.set_visible(false); + panel_inner.append(&tray_section); + let tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); + tray_sep.set_visible(false); - // Hamburger button + let widget_tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + widget_tray_section.add_css_class("control-panel-section"); + let widget_tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_tray_box.add_css_class("tray-box"); + widget_tray_box.set_halign(gtk4::Align::Center); + widget_tray_section.append(&widget_tray_box); + widget_tray_section.set_visible(false); + panel_inner.append(&widget_tray_section); + let widget_tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); + widget_tray_sep.set_visible(false); + + // Hamburger button — same chip chrome as volume / wifi / battery. let hamburger_btn = gtk4::Button::with_label("☰"); hamburger_btn.add_css_class("flat"); hamburger_btn.add_css_class("control-panel-btn"); - - control_popover.set_parent(&hamburger_btn); - - let cpop = control_popover.clone(); - hamburger_btn.connect_clicked(move |_| { - if cpop.is_visible() { cpop.popdown(); } else { cpop.popup(); } - }); - - let sender_cp = sender.clone(); - control_popover.connect_show(move |_| { - bar::control::spawn_load(sender_cp.clone()); - }); + hamburger_btn.add_css_class("stat-pair"); + hamburger_btn.add_css_class("icon-only"); + bar_chip(&hamburger_btn); // Slider signals — use Rc> to suppress feedback during data load let panel_loading = Rc::new(Cell::new(false)); let loading_v = panel_loading.clone(); + let vol_lbl_live = vol_lbl.clone(); panel_vol_slider.connect_value_changed(move |s| { - if loading_v.get() { return; } + vol_lbl_live.set_label(&format!("{:.0}", s.value() * 100.0)); + if loading_v.get() { + return; + } bar::control::spawn_set_volume(s.value()); }); let loading_b = panel_loading.clone(); panel_bright_slider.connect_value_changed(move |s| { - if loading_b.get() { return; } + if loading_b.get() { + return; + } bar::control::spawn_set_brightness(s.value()); }); stats_box.append(&hamburger_btn); + // Standalone layer windows — below the island, slid in by Hyprland. + let panels = panel::PanelSet::new( + &monitor_name, + &connectivity_inner, + &panel_inner, + &media_controls_box, + ); + + let sender_conn = sender.clone(); + panels.connectivity.connect_map(move |_| { + bar::wifi::spawn_popover_load(sender_conn.clone()); + bar::bluetooth::spawn_popover_load(sender_conn.clone()); + }); + let sender_cp = sender.clone(); + panels.control.connect_map(move |_| { + bar::control::spawn_load(sender_cp.clone()); + }); + + { + let panels = panels.clone(); + let win = panels.connectivity.clone(); + let gesture = gtk4::GestureClick::new(); + gesture.connect_released(move |_, _, _, _| { + panels.toggle(&win); + }); + connectivity_pair.add_controller(gesture); + } + { + let panels = panels.clone(); + let win = panels.control.clone(); + hamburger_btn.connect_clicked(move |_| { + panels.toggle(&win); + }); + } + { + let panels = panels.clone(); + let win = panels.control.clone(); + let vol_gesture = gtk4::GestureClick::new(); + vol_gesture.connect_released(move |_, _, _, _| { + panels.toggle(&win); + }); + vol_box.add_controller(vol_gesture); + } + { + let panels = panels.clone(); + let win = panels.media.clone(); + let mgesture = gtk4::GestureClick::new(); + mgesture.connect_released(move |_, _, _, _| { + panels.toggle(&win); + }); + media_widget.add_controller(mgesture); + } + + let widget_containers = std::collections::HashMap::from([ + ( + WidgetPlacement::RightOfWorkspaces, + widget_right_of_workspaces, + ), + (WidgetPlacement::LeftOfClock, widget_left_of_clock), + (WidgetPlacement::RightOfClock, widget_right_of_clock), + (WidgetPlacement::LeftOfStats, widget_left_of_stats), + (WidgetPlacement::Tray, widget_tray_box), + ]); + // ── Assemble ───────────────────────────────────────────────────── let widgets = view_output!(); + widgets.center_box.set_start_widget(Some(&workspace_row)); widgets.center_box.set_center_widget(Some(¢er_area)); widgets.center_box.set_end_widget(Some(&stats_box)); - let mut model = App { + // 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(); + + // Never launch sibling App windows from inside this init — RelmApp + // is still in GApplication activate, and a same-type launch here + // creates a second primary on the laptop. Idle reconcile after. + let satellites = Vec::new(); + if init.primary && screenshot_req.is_none() { + let later = sender.clone(); + gtk4::glib::idle_add_local_once(move || { + later.input(AppInput::ReconcileMonitors); + }); + } + + let model = App { + monitor: monitor_name, + primary: init.primary, + satellites, workspaces: vec![], active_ws: 1, - workspace_box: gtk4::Box::new(gtk4::Orientation::Horizontal, 4), + workspace_box, + workspace_trail, button_map: std::collections::HashMap::new(), time_str: bar::clock::current(), - clock_lbl, + clock_digits, + date_lbl, + system_stats_box, + system_sep, + cpu_pair, + mem_pair, + pwr_pair, + gpu_pair, cpu_lbl, mem_lbl, pwr_lbl, + gpu_lbl, + vol_lbl, bat_lbl, bat_img, bat_textures, @@ -473,76 +746,186 @@ impl SimpleComponent for App { bt_textures, wifi_lbl, wifi_img, - wifi_textures, - wifi_popover_box, + wifi_pane, 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_btn, + media_play_icon, media_last: None, media_paused_at: None, - control_popover, panel_vol_slider, panel_bright_slider, panel_loading, - panel_sink_store, - panel_sink_dropdown, - panel_sink_signal: None, - panel_sinks: vec![], - panel_cpu_lbl, - panel_gpu_lbl, - panel_net_lbl, + sink_box, + sink_section, + tray_section, + tray_sep, tray_box, tray_items: std::collections::HashMap::new(), + widget_containers, + 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()); - notifications::spawn(); - osd::spawn(); + if init.primary { + bar::tray::spawn_watcher(sender.clone()); + widgets::client::spawn(sender.clone()); + } + + // Screenshot mode primes these with sample content instead of the + // real D-Bus/pactl/backlight sources — see notifications::SampleKind + // 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, + }, + ); + } ComponentParts { model, widgets } } fn update(&mut self, msg: Self::Input, sender: ComponentSender) { match msg { - AppInput::WorkspaceList(list) => { - let mut sorted = list; + AppInput::WorkspaceSync { + workspaces, + actives, + } => { + let mut sorted = workspaces; sorted.sort_by_key(|w| w.id); + let new_active = actives + .get(&self.monitor) + .copied() + .unwrap_or(self.active_ws); + // Workspace also carries last-window title/address, which + // change constantly. Only the visible row (id/name/monitor/ + // occupied) should rebuild the pills — otherwise a title + // flicker on switch cancels the trail mid-stretch. + let rows_changed = visible_ws_rows(&sorted, &self.monitor, new_active) + != visible_ws_rows(&self.workspaces, &self.monitor, self.active_ws); + let active_changed = new_active != self.active_ws; self.workspaces = sorted; - self.rebuild_buttons(); + if self.primary { + self.reconcile_satellites(); + } + if rows_changed { + self.active_ws = new_active; + self.rebuild_buttons(active_changed); + } else if active_changed { + let from = self.button_map.get(&self.active_ws).cloned(); + if let Some(old) = &from { + old.remove_css_class("active"); + } + self.active_ws = new_active; + if let Some(btn) = self.button_map.get(&self.active_ws).cloned() { + btn.add_css_class("active"); + self.workspace_trail.stretch(from.as_ref(), &btn); + } + } } - AppInput::ActiveWorkspace(id) => { - if let Some(old) = self.button_map.get(&self.active_ws) { - old.remove_css_class("active"); + AppInput::MonitorAdded(name) => { + if !self.primary || name == self.monitor { + return; } - self.active_ws = id; - if let Some(btn) = self.button_map.get(&self.active_ws) { - btn.add_css_class("active"); + if self.satellites.iter().any(|(n, _)| n == &name) { + return; } + if let Some(ctrl) = spawn_satellite(&name) { + self.satellites.push((name, ctrl)); + } + } + AppInput::MonitorRemoved(name) => { + drop_satellite(&mut self.satellites, &name); } AppInput::ClockTick => { self.time_str = bar::clock::current(); - self.clock_lbl.set_label(&self.time_str); + flip_clock_digits(&self.clock_digits, &bar::clock::time()); + self.date_lbl.set_label(&bar::clock::date()); } AppInput::StatsUpdate(stats) => { - self.cpu_lbl.set_label(&stats.cpu); + let cpu = match stats.cpu_temp { + Some(t) => format!("{} · {:.0}°", stats.cpu, t), + None => stats.cpu, + }; + self.cpu_lbl.set_label(&cpu); self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); - self.bat_lbl.set_label(&stats.bat); + match stats.gpu_usage { + Some(g) => { + let gpu = match stats.gpu_temp { + Some(t) => format!("{g}% · {t:.0}°"), + None => format!("{g}%"), + }; + self.gpu_lbl.set_label(&gpu); + self.gpu_pair.set_visible(true); + } + None => self.gpu_pair.set_visible(false), + } + self.system_sep.set_visible(false); + + tick_label(&self.vol_lbl, &stats.volume_pct.to_string()); + self.vol_lbl.set_tooltip_text(Some(&format!("volume {}%", stats.volume_pct))); + tick_label(&self.bat_lbl, &stats.bat); if let Some(tex) = self.bat_textures.get(&(stats.bat_icon.as_ptr() as usize)) { self.bat_img.set_paintable(Some(tex)); } - self.ac_img.set_visible(stats.ac_connected); + let bat_tip = if stats.ac_connected { + format!("{}% · charging", stats.bat) + } else { + format!("{}%", stats.bat) + }; + self.bat_img.set_tooltip_text(Some(&bat_tip)); + self.ac_img.set_visible(false); if let Some(tex) = self.bt_textures.get(&(stats.bt_icon.as_ptr() as usize)) { self.bt_img.set_paintable(Some(tex)); } @@ -557,36 +940,12 @@ impl SimpleComponent for App { .map(|s| s.internet && !s.captive_portal) .unwrap_or(true); let icon = if !internet_ok && stats.wifi_ssid != "—" { - bar::stats::WIFI_OFF + bar::stats::WIFI_ICON_OFF } else { stats.wifi_icon }; - if let Some(tex) = self.wifi_textures.get(&(icon.as_ptr() as usize)) { - self.wifi_img.set_paintable(Some(tex)); - } + self.wifi_img.set_icon_name(Some(icon)); - // Live-update control panel stats while open - if self.control_popover.is_visible() { - let cpu_str = match (stats.cpu_temp, stats.cpu.as_str()) { - (Some(t), pct) => format!("CPU {pct} {t:.0}°C"), - (None, pct) => format!("CPU {pct}"), - }; - self.panel_cpu_lbl.set_label(&cpu_str); - - 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) { @@ -602,19 +961,34 @@ 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(); @@ -627,8 +1001,19 @@ impl SimpleComponent for App { format!("{} · {}", state.artist, state.title) }; self.media_track_lbl.set_label(&label); - self.media_play_btn - .set_label(if state.playing { "⏸" } else { "▶" }); + 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, ICON_PX); + if state.playing { + self.media_widget.add_css_class("playing"); + } else { + self.media_widget.remove_css_class("playing"); + } if state.playing { self.media_paused_at = None; @@ -638,21 +1023,22 @@ impl SimpleComponent for App { let within_linger = self .media_paused_at - .map_or(true, |t| t.elapsed().as_secs() < 30 * 60); + .is_none_or(|t| t.elapsed().as_secs() < 30 * 60); self.media_last = Some(state); - self.media_widget.set_visible(within_linger); + reveal_media(&self.media_widget, within_linger); } else { // Player gone — honour linger from last pause + self.media_widget.remove_css_class("playing"); if let Some(paused_at) = self.media_paused_at { if paused_at.elapsed().as_secs() < 30 * 60 { - self.media_widget.set_visible(true); + reveal_media(&self.media_widget, true); } else { - self.media_widget.set_visible(false); + reveal_media(&self.media_widget, false); self.media_last = None; self.media_paused_at = None; } } else { - self.media_widget.set_visible(false); + reveal_media(&self.media_widget, false); self.media_last = None; } } @@ -663,61 +1049,181 @@ impl SimpleComponent for App { self.panel_vol_slider.set_value(data.volume); self.panel_bright_slider.set_value(data.brightness); self.panel_loading.set(false); - - // Rebuild sink dropdown — disconnect, repopulate, reconnect - if let Some(id) = self.panel_sink_signal.take() { - self.panel_sink_dropdown.disconnect(id); + self.rebuild_sinks(&data.sinks, &sender); + } + AppInput::WidgetsUpdate(specs) => { + self.reconcile_widgets(specs); + } + AppInput::ReconcileMonitors => { + if self.primary { + self.reconcile_satellites(); } - // Clear store - let n = self.panel_sink_store.n_items(); - for i in (0..n).rev() { - self.panel_sink_store.remove(i); - } - for sink in &data.sinks { - self.panel_sink_store.append(&sink.description); - } - if let Some(idx) = data.sinks.iter().position(|s| s.is_default) { - self.panel_sink_dropdown.set_selected(idx as u32); - } - self.panel_sinks = data.sinks; - - let sinks = self.panel_sinks.clone(); - let id = self.panel_sink_dropdown.connect_selected_notify(move |dd| { - let idx = dd.selected() as usize; - if let Some(sink) = sinks.get(idx) { - bar::control::spawn_set_sink(sink.name.clone()); - } - }); - self.panel_sink_signal = Some(id); + } + AppInput::DismissPanels => { + self.panels.hide_all(); } } } } impl App { + fn reconcile_widgets(&mut self, specs: Vec) { + for container in self.widget_containers.values() { + while let Some(child) = container.first_child() { + container.remove(&child); + } + } + + let mut by_placement: std::collections::HashMap< + bread_shared::widget::WidgetPlacement, + Vec<&bread_shared::widget::WidgetSpec>, + > = std::collections::HashMap::new(); + for spec in &specs { + by_placement.entry(spec.placement).or_default().push(spec); + } + + for (placement, mut group) in by_placement { + let Some(container) = self.widget_containers.get(&placement) else { + continue; + }; + group.sort_by_key(|s| s.order); + for spec in group { + if !spec.visible { + continue; + } + let node = widgets::build_node(&spec.root, &spec.id); + if let Some(tooltip) = &spec.tooltip { + node.set_tooltip_text(Some(tooltip)); + } + container.append(&node); + } + } + + // The Tray placement has its own section/separator (handled below, + // same as the existing SNI tray items) — an empty inline slot has no + // such wrapper, so it must hide itself to stop contributing to + // center_area's `spacing` gap. + for (placement, container) in &self.widget_containers { + if *placement == bread_shared::widget::WidgetPlacement::Tray { + continue; + } + container.set_visible(container.first_child().is_some()); + } + + let has_tray_widgets = specs + .iter() + .any(|s| s.visible && s.placement == bread_shared::widget::WidgetPlacement::Tray); + self.widget_tray_section.set_visible(has_tray_widgets); + self.widget_tray_sep.set_visible(has_tray_widgets); + } + + fn 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) { + fn rebuild_buttons(&mut self, animate: bool) { + self.workspace_trail.cancel(); + let prev: std::collections::HashSet = + self.button_map.keys().copied().collect(); while let Some(child) = self.workspace_box.first_child() { self.workspace_box.remove(&child); } self.button_map.clear(); for ws in &self.workspaces { - let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws); + if ws.monitor != self.monitor { + continue; + } + // Persistent empty Hyprland workspaces stay off the bar unless + // this output is actually looking at them. + if ws.windows == 0 && ws.id != self.active_ws { + continue; + } + let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0); + if !prev.contains(&ws.id) { + play_once(&btn, "ws-in", 360); + } self.workspace_box.append(&btn); self.button_map.insert(ws.id, btn); } + match self.button_map.get(&self.active_ws).cloned() { + Some(btn) if animate => self.workspace_trail.stretch(None, &btn), + Some(btn) => self.workspace_trail.place(&btn), + None => self.workspace_trail.clear(), + } } fn rebuild_wifi_popover(&mut self, sender: &ComponentSender) { - while let Some(child) = self.wifi_popover_box.first_child() { - self.wifi_popover_box.remove(&child); + let panels = self.panels.clone(); + while let Some(child) = self.wifi_pane.first_child() { + self.wifi_pane.remove(&child); } if let Some(st) = &self.crumbs_status { @@ -747,71 +1253,52 @@ 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_popover_box.append(&header); - self.wifi_popover_box + self.wifi_pane.append(&header); + self.wifi_pane .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); } - let Some(data) = &self.wifi_popover_data else { + let nh = gtk4::Label::new(Some("NETWORKS")); + nh.add_css_class("wifi-popover-section"); + nh.set_xalign(0.0); + nh.set_margin_top(2); + nh.set_margin_bottom(4); + self.wifi_pane.append(&nh); + + let data = self.wifi_popover_data.as_ref(); + let scan_ready = data.map(|d| d.scan_ready).unwrap_or(false); + let scan = data.map(|d| d.scan.as_slice()).unwrap_or(&[]); + let profiles = data + .map(|d| unique_profiles(&d.profiles)) + .unwrap_or_default(); + + if !scan_ready { let lbl = gtk4::Label::new(Some("Scanning…")); lbl.add_css_class("wifi-popover-loading"); - self.wifi_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); - 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 { + self.wifi_pane.append(&lbl); + } else if scan.is_empty() { + let lbl = gtk4::Label::new(Some("No networks found")); + lbl.add_css_class("wifi-popover-loading"); + lbl.set_xalign(0.0); + self.wifi_pane.append(&lbl); + } else { + for (i, entry) in scan.iter().enumerate() { let row = gtk4::Button::new(); row.add_css_class("flat"); row.add_css_class("wifi-popover-row"); + stagger_row(&row, i); if !entry.saved { row.add_css_class("wifi-popover-row-unsaved"); } @@ -820,96 +1307,227 @@ impl App { row.add_css_class("wifi-popover-row-active"); } - let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - let icon_svg = wifi_icon_for_signal(entry.signal); - if let Some(tex) = self.wifi_textures.get(&(icon_svg.as_ptr() as usize)) { - let img = gtk4::Image::from_paintable(Some(tex)); - img.add_css_class("stat-icon"); - row_box.append(&img); - } - let lbl = gtk4::Label::new(Some(&format!( - "{}{}", - if is_current { "● " } else { " " }, - entry.ssid, - ))); + let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let img = gtk4::Image::from_icon_name(wifi_icon_for_signal(entry.signal)); + prepare_icon(&img, 18); + img.add_css_class("stat-icon"); + row_box.append(&img); + let lbl = gtk4::Label::new(Some(&entry.ssid)); lbl.set_xalign(0.0); + lbl.set_hexpand(true); + lbl.set_valign(gtk4::Align::Center); row_box.append(&lbl); row.set_child(Some(&row_box)); let ssid_clone = entry.ssid.clone(); let saved = entry.saved; + let panels = panels.clone(); row.connect_clicked(move |btn| { if saved { bar::wifi::spawn_join(ssid_clone.clone()); } else { - show_add_network_dialog(btn, ssid_clone.clone()); + show_add_network_dialog(btn, ssid_clone.clone(), |_| {}); } - close_parent_popover(btn); + panels.hide_all(); }); - self.wifi_popover_box.append(&row); + self.wifi_pane.append(&row); } } - self.wifi_popover_box.set_visible(true); + 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); } } // ── Helpers ─────────────────────────────────────────────────────────────────── -fn build_slider_row(icon: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { +fn build_slider_row(label: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); row.add_css_class("control-panel-row"); - row.set_margin_top(2); - row.set_margin_bottom(2); - let icon_lbl = gtk4::Label::new(Some(icon)); - icon_lbl.add_css_class("control-panel-row-icon"); - icon_lbl.set_width_chars(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 slider = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, min, max, step); slider.set_draw_value(false); slider.set_hexpand(true); - slider.set_width_request(180); + slider.set_width_request(160); slider.add_css_class("control-panel-slider"); - row.append(&icon_lbl); + row.append(&lbl); row.append(&slider); (row, slider) } -fn fmt_speed(kbs: f32) -> String { - if kbs >= 1024.0 { - format!("{:.1} MB/s", kbs / 1024.0) - } else { - format!("{:.0} KB/s", kbs) - } -} - fn wifi_icon_for_signal(pct: u8) -> &'static str { - use bar::stats::{WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG, WIFI_WEAK}; + use bar::stats::{ + WIFI_ICON_EXCELLENT, WIFI_ICON_GOOD, WIFI_ICON_OFF, WIFI_ICON_OK, WIFI_ICON_WEAK, + }; match pct { - 75..=100 => WIFI_STRONG, - 50..=74 => WIFI_MEDIUM, - 25..=49 => WIFI_WEAK, - _ => WIFI_OFF, + 75..=100 => WIFI_ICON_EXCELLENT, + 50..=74 => WIFI_ICON_GOOD, + 25..=49 => WIFI_ICON_OK, + 1..=24 => WIFI_ICON_WEAK, + _ => WIFI_ICON_OFF, } } -fn close_parent_popover(widget: >k4::Button) { - if let Some(w) = widget.ancestor(gtk4::Popover::static_type()) { - if let Ok(p) = w.downcast::() { - p.popdown(); - } - } -} + /// Small modal prompting for a password, then saves + joins the network via -/// `breadcrumbs add` + `breadcrumbs join`. -fn show_add_network_dialog(anchor: >k4::Button, ssid: String) { +/// `breadcrumbs add` + `breadcrumbs join`. `on_build` runs on the freshly +/// built dialog *before* it's presented — screenshot mode's only hook point, +/// since `connect_map` registered any later would miss a map that already +/// happened. The real call site passes a no-op. +fn show_add_network_dialog( + anchor: &impl IsA, + ssid: String, + on_build: impl FnOnce(>k4::Window), +) { let dialog = gtk4::Window::new(); dialog.set_title(Some(&format!("Add “{ssid}”"))); dialog.set_resizable(false); 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)); @@ -935,7 +1553,14 @@ fn show_add_network_dialog(anchor: >k4::Button, ssid: String) { btn_row.set_halign(gtk4::Align::End); let cancel_btn = gtk4::Button::with_label("Cancel"); let connect_btn = gtk4::Button::with_label("Connect"); - connect_btn.add_css_class("suggested-action"); + // Not "suggested-action" — GTK4's own bundled theme special-cases that + // class for a newer native OS-accent-colour feature that isn't a normal + // CSS rule at all, and simply doesn't lose to any `background-color` + // override this stylesheet adds, however specific the selector (already + // confirmed empirically: adding a much more specific override rule had + // zero effect). "confirm-button" is the same ecosystem-wide accent + // button convention breadman/breadpad already use successfully. + connect_btn.add_css_class("confirm-button"); btn_row.append(&cancel_btn); btn_row.append(&connect_btn); body.append(&btn_row); @@ -969,6 +1594,7 @@ fn show_add_network_dialog(anchor: >k4::Button, ssid: String) { dialog_for_activate.close(); }); + on_build(&dialog); dialog.present(); entry.grab_focus(); } @@ -976,24 +1602,180 @@ fn show_add_network_dialog(anchor: >k4::Button, ssid: String) { fn stat_pair(icon_svg: &str, label: >k4::Label) -> gtk4::Box { let pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); pair.add_css_class("stat-pair"); - let img = gtk4::Image::from_paintable(Some(&svg_texture(icon_svg))); + bar_chip(&pair); + let img = svg_image(icon_svg); img.add_css_class("stat-icon"); pair.append(&img); pair.append(label); pair } -fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { +/// 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); + } + } +} + +fn tick_label(lbl: >k4::Label, text: &str) { + if lbl.label().as_str() == text { + return; + } + lbl.set_label(text); + play_once(lbl, "tick", 360); +} + +fn reveal_media(widget: >k4::Box, show: bool) { + if show && !widget.is_visible() { + play_once(widget, "media-in", 420); + } + widget.set_visible(show); +} + +fn popover_tab(label: &str) -> gtk4::ToggleButton { + let btn = gtk4::ToggleButton::with_label(label); + btn.add_css_class("popover-tab"); + btn.set_hexpand(true); + btn.set_valign(gtk4::Align::Center); + btn.set_vexpand(false); + btn.set_size_request(-1, CHIP_HEIGHT); + if let Some(child) = btn.child() { + child.set_halign(gtk4::Align::Center); + child.set_valign(gtk4::Align::Center); + child.set_hexpand(true); + child.set_vexpand(false); + if let Ok(lbl) = child.downcast::() { + lbl.set_xalign(0.5); + lbl.set_yalign(0.5); + } + } + btn +} + +/// breadcrumbs can list the same profile twice under different case +/// (`Home` / `home`). Keep the active spelling when there is one. +fn unique_profiles(profiles: &[(String, bool)]) -> Vec<(String, bool)> { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for (name, active) in profiles { + if *active { + seen.insert(name.to_ascii_lowercase()); + out.push((name.clone(), true)); + } + } + for (name, active) in profiles { + if seen.insert(name.to_ascii_lowercase()) { + out.push((name.clone(), *active)); + } + } + out +} + +pub(crate) fn prepare_icon(img: >k4::Image, px: i32) { + img.set_pixel_size(px); + img.set_valign(gtk4::Align::Center); + img.set_vexpand(false); +} + +pub(crate) fn svg_image(svg_src: &str) -> gtk4::Image { + svg_image_sized(svg_src, ICON_PX as u32) +} + +pub(crate) fn svg_image_sized(svg_src: &str, px: u32) -> gtk4::Image { + let img = gtk4::Image::from_paintable(Some(&svg_texture_sized(svg_src, px))); + prepare_icon(&img, px as i32); + img +} + +pub(crate) fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { + svg_texture_sized(svg_src, ICON_PX as u32) +} + +/// Rasterise at 2× the display size so Lucide strokes stay sharp when GTK +/// displays the texture at `px` via `Image::set_pixel_size`. +pub(crate) fn svg_texture_sized(svg_src: &str, px: u32) -> gtk4::gdk::Texture { use resvg::{tiny_skia, usvg}; + let raster = px.saturating_mul(2).max(1); let fg = theme::fg_color(); + let dim = format!(r#"width="{raster}" height="{raster}""#); let svg = svg_src .replace("currentColor", &fg) - .replace(r#"width="24" height="24""#, r#"width="16" height="16""#); + .replace(r#"stroke-width="2""#, r#"stroke-width="2.35""#) + .replace(r#"width="24" height="24""#, &dim); let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("parse svg"); let size = tree.size().to_int_size(); let (w, h) = (size.width(), size.height()); let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap"); - resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut()); + resvg::render( + &tree, + tiny_skia::Transform::identity(), + &mut pixmap.as_mut(), + ); let bytes = gtk4::glib::Bytes::from_owned(pixmap.take()); gtk4::gdk::MemoryTexture::new( w as i32, @@ -1005,6 +1787,8 @@ fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { .upcast() } + + fn stat_label() -> gtk4::Label { let lbl = gtk4::Label::new(None); lbl.add_css_class("stat-label"); @@ -1013,6 +1797,21 @@ 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"); @@ -1022,6 +1821,150 @@ fn main() { } }); - let app = RelmApp::new("sh.breadway.breadbar"); - app.run::(()); + // `with_args(vec![])` stops relm4 from handing our own --screenshot/ + // --output flags to GLib's option parser (`app.run()`'s default), which + // would otherwise reject them as unrecognized before Cli::parse() above + // ever sees argv. allow_multiple_instances is needed for screenshot runs + // specifically: GApplication is single-instance by default, and a normal + // breadbar is typically already running, so without this a screenshot + // invocation would just activate that existing instance instead of + // starting a fresh one whose `init()` receives the request at all. + let app = RelmApp::new("sh.breadway.breadbar").with_args(vec![]); + if screenshot_req.is_some() { + app.allow_multiple_instances(true); + } + app.run::(BarInit { + screenshot: screenshot_req, + monitor: None, + primary: true, + }); +} + +/// Live outputs only. `hyprctl monitors all` (and the hyprland crate's +/// `Monitors::get`) keep ghost connectors after a rename — `DVI-I-1` stayed +/// at 0×0 with `disabled=false` after the panel became `DVI-I-2`, and a +/// geometry fallback then stacked a second bar on the laptop. +#[derive(Debug, Clone, serde::Deserialize)] +struct HyprMon { + name: String, + x: i32, + y: i32, + #[serde(default)] + focused: bool, + #[serde(default)] + disabled: bool, +} + +fn hypr_monitors_live() -> Vec { + let output = match std::process::Command::new("hyprctl") + .args(["monitors", "-j"]) + .output() + { + Ok(o) if o.status.success() => o.stdout, + _ => return Vec::new(), + }; + serde_json::from_slice::>(&output) + .unwrap_or_default() + .into_iter() + .filter(|m| !m.disabled) + .collect() +} + +fn primary_hypr_monitor() -> Option { + let mons = hypr_monitors_live(); + mons.iter() + .find(|m| m.focused) + .or_else(|| mons.first()) + .map(|m| m.name.clone()) +} + +fn hypr_monitor_names() -> Vec { + hypr_monitors_live() + .into_iter() + .map(|m| m.name) + .collect() +} + +fn hypr_monitor_origin(name: &str) -> Option<(i32, i32)> { + hypr_monitors_live() + .into_iter() + .find(|m| m.name == name) + .map(|m| (m.x, m.y)) +} + +/// Hyprland connector names and GDK connector names can disagree after a +/// hotplug (`DVI-I-1` vs `DVI-I-2`). Match the connector first, then the +/// output's origin — transform swaps width/height so size is not reliable. +/// Never steal a GDK output whose connector is already a live Hyprland name. +fn gdk_monitor_for_hypr(name: &str) -> Option { + use gtk4::gdk::prelude::MonitorExt; + use gtk4::gio::prelude::ListModelExt; + let display = gtk4::gdk::Display::default()?; + let list = display.monitors(); + for i in 0..list.n_items() { + let Some(mon) = list.item(i).and_downcast::() else { + continue; + }; + if mon.connector().as_deref() == Some(name) { + return Some(mon); + } + } + let (hx, hy) = hypr_monitor_origin(name)?; + let live = hypr_monitor_names(); + for i in 0..list.n_items() { + let Some(mon) = list.item(i).and_downcast::() else { + continue; + }; + let g = mon.geometry(); + if g.x() != hx || g.y() != hy { + continue; + } + if let Some(conn) = mon.connector() { + if live.iter().any(|n| n != name && n == conn.as_str()) { + return None; + } + } + return Some(mon); + } + None +} + +pub(crate) fn bind_layer_monitor(window: &impl LayerShell, name: &str) -> bool { + match gdk_monitor_for_hypr(name) { + Some(mon) => { + window.set_monitor(Some(&mon)); + true + } + None => { + eprintln!("breadbar: no GDK monitor for {name}"); + false + } + } +} + +fn spawn_satellite(name: &str) -> Option> { + if gdk_monitor_for_hypr(name).is_none() { + eprintln!("breadbar: skip bar on {name}: no matching GDK output"); + return None; + } + let ctrl = App::builder() + .launch(BarInit { + screenshot: None, + monitor: Some(name.to_string()), + primary: false, + }) + .detach(); + ctrl.widget().present(); + Some(ctrl) +} + +fn drop_satellite(satellites: &mut Vec<(String, Controller)>, name: &str) { + satellites.retain(|(n, ctrl)| { + if n == name { + ctrl.widget().set_visible(false); + false + } else { + true + } + }); } diff --git a/src/notifications/history.rs b/src/notifications/history.rs new file mode 100644 index 0000000..61adc0f --- /dev/null +++ b/src/notifications/history.rs @@ -0,0 +1,454 @@ +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::{Edge, KeyboardMode, Layer, 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")); + window.set_layer(Layer::Overlay); + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Right, true); + window.set_margin(Edge::Top, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8); + window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES); + window.set_default_width(360); + window.set_keyboard_mode(KeyboardMode::OnDemand); + crate::theme::bind_auto(&window); + + let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + outer.set_margin_top(10); + 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 b6c60b2..4668798 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -1,23 +1,191 @@ +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, - timeout_ms: u32, + urgency: Urgency, + expire: Expire, + actions: Vec, + /// Placeholder for the inline-reply field, if one should be shown. + inline_reply: Option, }, 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")] @@ -31,20 +199,57 @@ 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) }; - let timeout_ms = if expire_timeout <= 0 { - 5000 - } else { - expire_timeout as u32 - }; + // 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 _ = self .tx .send(NotifEvent::Show { @@ -52,7 +257,10 @@ impl NotifServer { app_name: app_name.to_string(), summary: summary.to_string(), body: body.to_string(), - timeout_ms, + urgency, + expire, + actions, + inline_reply, }) .await; id @@ -63,7 +271,7 @@ impl NotifServer { } fn get_capabilities(&self) -> Vec { - vec!["body".to_string()] + CAPABILITIES.iter().map(|s| (*s).to_string()).collect() } fn get_server_information(&self) -> (String, String, String, String) { @@ -76,26 +284,384 @@ impl NotifServer { } } -pub fn spawn() { +/// 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(); let (tx, rx) = mpsc::channel(32); - 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 - }); + 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_local(popup::run(rx)); + 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"); + } } diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index c8a7e50..9c767b6 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -1,14 +1,32 @@ -use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Duration}; +use std::{cell::RefCell, collections::HashMap, rc::Rc}; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, Layer, LayerShell}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use tokio::sync::mpsc::Receiver; -use super::NotifEvent; +use super::{history, Action, Expire, NotifEvent, Urgency, INLINE_REPLY_KEY}; type Cards = Rc>>; +// Bumped every time an id gets a (re)placed card — an auto-dismiss timer +// 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>>; -pub async fn run(mut rx: Receiver) { +/// 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) { let window = create_window(); let cards_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); cards_box.set_margin_top(8); @@ -16,8 +34,24 @@ pub async fn run(mut rx: Receiver) { cards_box.set_margin_start(8); cards_box.set_margin_end(8); window.set_child(Some(&cards_box)); + (window, cards_box) +} +/// `conn`: `None` in screenshot mode, which skips real D-Bus registration +/// entirely (see `super::spawn`) — there's no external client that needs to +/// reach a screenshot-only instance, and registering the well-known name +/// would just race the real breadbar for it. `NotificationClosed` is a +/// spec-mandated signal for real clients only, so it's simply not emitted +/// when there's no real connection to emit it on. +pub async fn run( + window: gtk4::Window, + cards_box: gtk4::Box, + mut rx: Receiver, + conn: Option, + 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 { @@ -26,79 +60,351 @@ pub async fn run(mut rx: Receiver) { app_name, summary, body, - timeout_ms, + urgency, + expire, + actions, + inline_reply, } => { // Replace existing card with same id (replaces_id case) if let Some(old) = cards.borrow_mut().remove(&id) { cards_box.remove(&old); } - let card = make_card(&app_name, &summary, &body); + let card = make_card(CardSpec { + id, + app_name: &app_name, + summary: &summary, + body: &body, + urgency, + actions: &actions, + inline_reply: inline_reply.as_deref(), + conn: conn.clone(), + cards: cards.clone(), + cards_box: cards_box.clone(), + window: window.clone(), + }); cards_box.prepend(&card); cards.borrow_mut().insert(id, card.clone()); window.set_visible(true); + if let Some(ui) = &history_ui { + history::refresh_if_visible(ui); + } - // 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); - }); + 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; + } + }); + } } NotifEvent::Close(id) => { - dismiss(&cards_box, &window, &cards, 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); + } } } } } -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); - } +/// 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). +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); if cards.borrow().is_empty() { window.set_visible(false); } + 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}"); + } } fn create_window() -> gtk4::Window { let window = gtk4::Window::new(); window.add_css_class("breadbar-notification"); window.init_layer_shell(); + window.set_namespace(Some("breadbar-notif")); window.set_layer(Layer::Overlay); window.set_anchor(Edge::Top, true); window.set_anchor(Edge::Right, true); - window.set_margin(Edge::Top, 20); - window.set_margin(Edge::Right, 20); + window.set_margin(Edge::Top, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8); + window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES); window.set_default_width(320); + // Toasts are purely informational for now: never grab keyboard focus... + window.set_keyboard_mode(KeyboardMode::None); + // ...and click through entirely — an empty input region means every + // pointer event passes straight to whatever's underneath instead of + // hitting the toast. + window.connect_map(|win| { + if let Some(surface) = win.surface() { + surface.set_input_region(Some(>k4::cairo::Region::create())); + } + }); + crate::theme::bind_auto(&window); window } -fn make_card(app_name: &str, summary: &str, body: &str) -> gtk4::Box { +struct CardSpec<'a> { + id: u32, + app_name: &'a str, + summary: &'a str, + body: &'a str, + urgency: Urgency, + actions: &'a [Action], + inline_reply: Option<&'a str>, + conn: Option, + cards: Cards, + cards_box: gtk4::Box, + window: gtk4::Window, +} + +fn make_card(spec: CardSpec<'_>) -> gtk4::Box { let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4); card.add_css_class("notification-card"); - - if !app_name.is_empty() { - let lbl = gtk4::Label::new(Some(app_name)); - lbl.add_css_class("notification-app"); - lbl.set_xalign(0.0); - card.append(&lbl); + if let Some(class) = spec.urgency.css_class() { + card.add_css_class(class); } - let summary_lbl = gtk4::Label::new(Some(summary)); + 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)); + lbl.add_css_class("notification-app"); + lbl.set_xalign(0.0); + content.append(&lbl); + } + + let summary_lbl = gtk4::Label::new(Some(spec.summary)); summary_lbl.add_css_class("notification-summary"); summary_lbl.set_xalign(0.0); summary_lbl.set_wrap(true); - card.append(&summary_lbl); + content.append(&summary_lbl); - if !body.is_empty() { - let body_lbl = gtk4::Label::new(Some(body)); + if !spec.body.is_empty() { + let body_lbl = gtk4::Label::new(None); body_lbl.add_css_class("notification-body"); body_lbl.set_xalign(0.0); body_lbl.set_wrap(true); - card.append(&body_lbl); + apply_body_text(&body_lbl, spec.body); + content.append(&body_lbl); + } + + if spec.actions.iter().any(|a| a.key == "default") { + content.add_css_class("notification-default"); + let gesture = gtk4::GestureClick::new(); + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + gesture.connect_released(move |_, _, _, _| { + invoke_action(invoke.clone(), "default"); + }); + content.add_controller(gesture); + } + + card.append(&content); + + let visible: Vec<&Action> = spec + .actions + .iter() + .filter(|a| a.key != "default" && a.key != INLINE_REPLY_KEY) + .collect(); + if !visible.is_empty() { + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + row.add_css_class("notification-actions"); + row.set_halign(gtk4::Align::End); + for action in visible { + let btn = gtk4::Button::with_label(&action.label); + btn.add_css_class("notification-action"); + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + let key = action.key.clone(); + btn.connect_clicked(move |_| { + invoke_action(invoke.clone(), &key); + }); + row.append(&btn); + } + card.append(&row); + } + + if let Some(placeholder) = spec.inline_reply { + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + row.add_css_class("notification-reply"); + + let entry = gtk4::Entry::new(); + entry.add_css_class("notification-reply-entry"); + entry.set_placeholder_text(Some(placeholder)); + entry.set_hexpand(true); + + let send_label = spec + .actions + .iter() + .find(|a| a.key == INLINE_REPLY_KEY) + .map(|a| a.label.as_str()) + .filter(|l| !l.is_empty()) + .unwrap_or("Send"); + let send = gtk4::Button::with_label(send_label); + send.add_css_class("notification-action"); + + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + let entry_for_btn = entry.clone(); + let invoke_btn = invoke.clone(); + send.connect_clicked(move |_| { + submit_reply(&entry_for_btn, invoke_btn.clone()); + }); + entry.connect_activate(move |e| { + submit_reply(e, invoke.clone()); + }); + + row.append(&entry); + row.append(&send); + card.append(&row); } card } + +/// FDO `body-markup` is a small Pango-ish subset (``, ``, ``, +/// ``). Invalid markup falls back to plain text so a bad sender +/// doesn't blank the card. +fn apply_body_text(label: >k4::Label, body: &str) { + if body.contains('<') && gtk4::pango::parse_markup(body, '\0').is_ok() { + label.set_markup(body); + return; + } + label.set_text(body); +} + +#[derive(Clone)] +struct Invoke { + conn: Option, + cards: Cards, + cards_box: gtk4::Box, + window: gtk4::Window, + id: u32, +} + +fn invoke_action(invoke: Invoke, key: &str) { + let key = key.to_string(); + relm4::spawn_local(async move { + emit_action(&invoke.conn, invoke.id, &key).await; + if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { + emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; + } + }); +} + +fn submit_reply(entry: >k4::Entry, invoke: Invoke) { + let text = entry.text().to_string(); + if text.trim().is_empty() { + return; + } + relm4::spawn_local(async move { + emit_replied(&invoke.conn, invoke.id, &text).await; + emit_action(&invoke.conn, invoke.id, INLINE_REPLY_KEY).await; + if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { + emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; + } + }); +} + +async fn emit_action(conn: &Option, id: u32, action_key: &str) { + let Some(conn) = conn else { return }; + let result = conn + .emit_signal( + None::<&str>, + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "ActionInvoked", + &(id, action_key), + ) + .await; + if let Err(e) = result { + eprintln!("breadbar: failed to emit ActionInvoked for {id}: {e}"); + } +} + +/// GNOME/KDE (and clients such as Discord/Telegram) listen for this +/// non-spec signal on `org.freedesktop.Notifications` when the user +/// submits an inline reply. Signature: `NotificationReplied(u32 id, s text)`. +/// We also emit `ActionInvoked(id, "inline-reply")` so senders that only +/// watch the spec signal still see the send. +async fn emit_replied(conn: &Option, id: u32, text: &str) { + let Some(conn) = conn else { return }; + let result = conn + .emit_signal( + None::<&str>, + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "NotificationReplied", + &(id, text), + ) + .await; + if let Err(e) = result { + eprintln!("breadbar: failed to emit NotificationReplied for {id}: {e}"); + } +} diff --git a/src/osd.rs b/src/osd.rs index 55106ec..d438292 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -9,14 +9,50 @@ enum OsdEvent { Brightness { pct: u8 }, } -pub fn spawn() { +/// A fixed sample event for `--screenshot osd-volume`/`osd-brightness` — +/// substitutes for the real `pactl subscribe`/backlight-sysfs watchers so a +/// capture doesn't depend on this machine's actual volume/brightness at +/// capture time. +pub enum SampleKind { + Volume, + Brightness, +} + +impl SampleKind { + fn sample_event(&self) -> OsdEvent { + match self { + SampleKind::Volume => OsdEvent::Volume { pct: 65, muted: false }, + SampleKind::Brightness => OsdEvent::Brightness { pct: 80 }, + } + } +} + +/// Builds the OSD window synchronously (so a caller — screenshot mode, via +/// `sample`, in particular — has a real window to hook `connect_map` on +/// before the async event loop below ever runs) and spawns the event loop +/// that shows/updates/hides it. +/// +/// `sample`: `Some` skips the real volume/brightness watchers entirely and +/// seeds the loop with one fixed sample event instead — screenshot mode +/// only, so a capture never depends on (or is disrupted by) this machine's +/// actual audio/backlight state. +pub fn spawn(sample: Option) -> gtk4::Window { let (tx, rx) = mpsc::channel::(8); - let tx1 = tx.clone(); - std::thread::spawn(move || volume_watcher(tx1)); - std::thread::spawn(move || brightness_watcher(tx)); + match sample { + Some(kind) => { + let _ = tx.try_send(kind.sample_event()); + } + None => { + let tx1 = tx.clone(); + std::thread::spawn(move || volume_watcher(tx1)); + std::thread::spawn(move || brightness_watcher(tx)); + } + } - relm4::spawn_local(run_osd(rx)); + let window = create_window(); + relm4::spawn_local(run_osd(window.clone(), rx)); + window } fn volume_watcher(tx: mpsc::Sender) { @@ -119,43 +155,39 @@ fn brightness_watcher(tx: mpsc::Sender) { } } -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); +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); window.set_child(Some(&container)); - 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 icon = crate::svg_image(crate::bar::stats::ICON_VOLUME); + icon.add_css_class("osd-icon"); + container.append(&icon); 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)); while let Some(event) = rx.recv().await { - let (kind, pct) = match event { - OsdEvent::Volume { pct, muted } => { - (if muted { "Volume (Muted)" } else { "Volume" }, pct) - } - OsdEvent::Brightness { pct } => ("Brightness", pct), + 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), }; - kind_lbl.set_label(kind); - pct_lbl.set_label(&format!("{pct}%")); + icon.set_paintable(Some(&crate::svg_texture(icon_svg))); + crate::prepare_icon(&icon, crate::ICON_PX); + if muted { + icon.add_css_class("osd-icon-muted"); + } else { + icon.remove_css_class("osd-icon-muted"); + } pbar.set_fraction(pct as f64 / 100.0); window.set_visible(true); @@ -176,9 +208,11 @@ fn create_window() -> gtk4::Window { let window = gtk4::Window::new(); window.add_css_class("breadbar-osd"); window.init_layer_shell(); + window.set_namespace(Some("breadbar-osd")); window.set_layer(Layer::Overlay); window.set_anchor(Edge::Bottom, true); window.set_margin(Edge::Bottom, 80); - window.set_default_width(280); + window.set_default_width(180); + crate::theme::bind_auto(&window); window } diff --git a/src/panel.rs b/src/panel.rs new file mode 100644 index 0000000..c1bcd75 --- /dev/null +++ b/src/panel.rs @@ -0,0 +1,154 @@ +//! Standalone layer-shell panels for wifi / control / media. +//! +//! GTK `Popover` is an xdg_popup child of the island, so it paints over the +//! bar and Hyprland can only fade it. These are their own surfaces, parked +//! *below* the exclusive zone, and Hyprland slides `breadbar-panel` in from +//! the right. + +use gtk4::gdk::Key; +use gtk4::prelude::*; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; + +use crate::{bind_layer_monitor, theme, BAR_HEIGHT, BAR_MARGIN_SIDES, BAR_MARGIN_TOP}; + +const BELOW_BAR: i32 = BAR_MARGIN_TOP + BAR_HEIGHT + 8; + +#[derive(Clone)] +pub struct PanelSet { + pub connectivity: gtk4::Window, + pub control: gtk4::Window, + pub media: gtk4::Window, + dismiss: gtk4::Window, +} + +impl PanelSet { + pub fn new( + monitor: &str, + connectivity_child: &impl IsA, + control_child: &impl IsA, + media_child: &impl IsA, + ) -> Self { + let connectivity = make_panel("wifi-popover", connectivity_child, monitor); + let control = make_panel("control-panel", control_child, monitor); + let media = make_panel("media-popover", media_child, monitor); + let dismiss = make_dismiss(monitor); + + let set = Self { + connectivity, + control, + media, + dismiss, + }; + set.wire_dismiss(); + set.wire_escape(); + set + } + + pub fn toggle(&self, which: >k4::Window) { + if which.is_visible() { + self.hide_all(); + } else { + self.show(which); + } + } + + pub fn show(&self, which: >k4::Window) { + self.hide_panels(); + // Dismiss first so the panel maps above it (same Overlay layer). + self.dismiss.set_visible(true); + self.dismiss.present(); + which.set_visible(true); + which.present(); + } + + pub fn hide_all(&self) { + self.hide_panels(); + self.dismiss.set_visible(false); + } + + fn hide_panels(&self) { + self.connectivity.set_visible(false); + self.control.set_visible(false); + self.media.set_visible(false); + } + + fn wire_dismiss(&self) { + let set = self.clone(); + let click = gtk4::GestureClick::new(); + click.set_button(0); + click.connect_pressed(move |_, _, _, _| { + set.hide_all(); + }); + if let Some(child) = self.dismiss.child() { + child.add_controller(click); + } else { + self.dismiss.add_controller(click); + } + } + + fn wire_escape(&self) { + for win in [&self.connectivity, &self.control, &self.media] { + let set = self.clone(); + let keys = gtk4::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, _| { + if key == Key::Escape { + set.hide_all(); + gtk4::glib::Propagation::Stop + } else { + gtk4::glib::Propagation::Proceed + } + }); + win.add_controller(keys); + } + } +} + +fn make_panel(class: &str, child: &impl IsA, monitor: &str) -> gtk4::Window { + let window = gtk4::Window::new(); + window.add_css_class("breadbar-panel"); + window.add_css_class(class); + window.set_decorated(false); + window.set_resizable(false); + window.init_layer_shell(); + window.set_namespace(Some("breadbar-panel")); + window.set_layer(Layer::Overlay); + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Right, true); + window.set_margin(Edge::Top, BELOW_BAR); + window.set_margin(Edge::Right, BAR_MARGIN_SIDES); + window.set_exclusive_zone(-1); + window.set_keyboard_mode(KeyboardMode::OnDemand); + window.set_child(Some(child)); + bind_layer_monitor(&window, monitor); + theme::bind_output(&window, monitor); + window.set_visible(false); + window +} + +fn make_dismiss(monitor: &str) -> gtk4::Window { + let window = gtk4::Window::new(); + window.add_css_class("breadbar-dismiss"); + window.init_layer_shell(); + window.set_namespace(Some("breadbar-dismiss")); + // Overlay with the panels, but mapped first so they sit above it. + // Top margin keeps the island's chips clickable. + window.set_layer(Layer::Overlay); + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Bottom, true); + window.set_anchor(Edge::Left, true); + window.set_anchor(Edge::Right, true); + window.set_margin(Edge::Top, BAR_MARGIN_TOP + BAR_HEIGHT); + window.set_exclusive_zone(-1); + window.set_keyboard_mode(KeyboardMode::None); + // An empty window never maps a hit region. A filling child + a hair of + // alpha is what actually receives the click-away. + let hit = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + hit.add_css_class("breadbar-dismiss-hit"); + hit.set_hexpand(true); + hit.set_vexpand(true); + window.set_child(Some(&hit)); + bind_layer_monitor(&window, monitor); + theme::bind_output(&window, monitor); + window.set_visible(false); + window +} diff --git a/src/screenshot.rs b/src/screenshot.rs new file mode 100644 index 0000000..05e9af6 --- /dev/null +++ b/src/screenshot.rs @@ -0,0 +1,246 @@ +//! `--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", +]; + +#[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, +} + +/// Capture height for the `bar` view: layer-shell top margin + widget +/// height (the exclusive zone). Unlike the other views' full canvas, +/// this never varies with `--width`/`--height`. +const BAR_HEIGHT: i32 = crate::BAR_HEIGHT + crate::BAR_MARGIN_TOP; + +pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) { + let output = req.output; + let (width, height) = (req.width as i32, req.height as i32); + + match req.view.as_str() { + "bar" => { + root.connect_map(move |_| { + let output = output.clone(); + gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { + finish(bread_screenshots::capture_region(0, 0, width, BAR_HEIGHT, &output)); + }); + }); + } + "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); + } + "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/theme.rs b/src/theme.rs index dc7e149..f11b8d2 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,4 +1,5 @@ -use bread_theme::{gtk as bgtk, hex_to_rgba, ink_on, load_palette}; +use bread_theme::{gtk as bgtk, ink_on, load_palette, load_palette_for, Palette}; +use gtk4::prelude::IsA; use gtk4::CssProvider; use std::cell::RefCell; @@ -7,79 +8,273 @@ thread_local! { } fn load_css() -> String { - let p = load_palette(); // breadbar-specific rules only — fonts, base colours, and generic widgets // come from the shared ecosystem stylesheet (applied first in `apply()`). // Colour is set on each surface (bar, active workspace pill, notification // 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`. + let radius = "12px"; + let radius_bar = "16px"; + let radius_sm = "9px"; + let radius_pill = "999px"; + let pad = "12px"; + format!( - "window.breadbar {{ background-color: {bg_rgba}; color: {on_bg}; border-radius: 0; }}\ - .workspace-btn {{ background: transparent; opacity: 0.45;\ - 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; }}\ + "@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\ + @keyframes osd-in {{ from {{ opacity: 0; margin-bottom: -8px; }} }}\ + @keyframes media-eq {{ to {{ min-height: 14px; }} }}\ + @keyframes pop-in {{ from {{ opacity: 0; margin-top: -10px; }} to {{ opacity: 1; margin-top: 0; }} }}\ + @keyframes pop-out {{ from {{ opacity: 1; margin-top: 0; }} to {{ opacity: 0; margin-top: -6px; }} }}\ + @keyframes row-in {{ from {{ opacity: 0; margin-top: 8px; }} to {{ opacity: 1; margin-top: 0; }} }}\ + @keyframes digit-flip {{ from {{ opacity: 0; margin-top: 7px; }} to {{ opacity: 1; margin-top: 0; }} }}\ + @keyframes caret-draw {{ from {{ margin-right: 200px; opacity: 0.2; }} to {{ margin-right: 4px; opacity: 1; }} }}\ + window.breadbar {{ background-color: alpha(@bg, 0.72); color: @on-bg;\ + border-radius: {radius_bar}; border: 1px solid alpha(@on-bg, 0.08); }}\ + window.breadbar > centerbox {{ padding: 0 8px 0 6px; }}\ + window.breadbar button {{ min-height: 0; min-width: 0; }}\ + .workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\ + background-color: @accent; border-radius: 12px; }}\ + .workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\ + border-radius: 12px; border: none; outline: none; box-shadow: none;\ + min-width: 28px; min-height: 28px; margin: 0; padding: 0 7px;\ + font-size: 22px; font-weight: bold;\ + transition: opacity 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\ + background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + .workspace-btn:hover {{ opacity: 0.85; background: alpha(@on-bg, 0.08); }}\ + .workspace-btn.occupied {{ opacity: 0.78; }}\ + .workspace-btn.active {{ background: transparent; color: @on-accent; opacity: 1; }}\ + .workspace-btn.active:hover {{ background: transparent; }}\ + .workspace-btn.ws-in {{ animation: row-in 0.32s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\ + .clock-box {{ padding: 0 4px; }}\ + .clock-label {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\ + min-height: 0; padding: 0; margin-top: 3px; }}\ + .clock-digit {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\ + min-width: 15px; min-height: 0; padding: 0; margin: 0; }}\ + .clock-colon {{ min-width: 10px; opacity: 0.7; }}\ + .clock-digit.flip {{ animation: digit-flip 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .date-label {{ font-size: 14px; opacity: 0.52; letter-spacing: 0.04em; }}\ + .stat-label {{ font-size: 14px; letter-spacing: 0.02em; opacity: 0.92; }}\ + .stat-label.tick {{ animation: digit-flip 0.35s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .stats-box {{ margin-right: 0; }}\ + .stat-pair {{ margin: 0; border-radius: 10px; padding: 5px 9px; min-height: 0;\ + transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\ + opacity 0.18s ease; }}\ + .stat-pair:hover {{ background: alpha(@on-bg, 0.12); }}\ + .stat-pair:active {{ background: alpha(@on-bg, 0.18); }}\ + .stat-pair.icon-only {{ padding: 4px; border-radius: 999px;\ + min-width: 32px; min-height: 32px; }}\ + .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(@on-bg, 0.10); }}\ + window.breadbar-notification {{ background-color: transparent; color: @on-bg; }}\ + window.breadbar-history {{ background-color: alpha(@bg, 0.70); color: @on-bg;\ + border-radius: {radius}; border: 1px solid alpha(@on-bg, 0.10);\ + animation: pop-in 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .notification-card {{ background: alpha(@bg, 0.70); color: @on-bg; border-radius: {radius};\ + padding: {pad}; margin-bottom: 8px; border: 1px solid alpha(@on-bg, 0.10);\ + border-left: 3px solid transparent;\ + animation: notif-in 0.45s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\ + .notification-card.urgency-critical {{ border-left-color: @red; }}\ + .notification-card.urgency-normal {{ border-left-color: @accent; }}\ .notification-summary {{ font-weight: bold; }}\ - .notification-app {{ opacity: 0.6; }}\ - 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; }}\ + .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; }}\ + .history-title {{ font-weight: bold; font-size: 13px; }}\ + .history-close {{ padding: 2px 8px; }}\ + .history-empty {{ opacity: 0.5; padding: 8px 0; }}\ + .history-time {{ opacity: 0.5; font-size: 11px; }}\ + .history-body {{ opacity: 0.75; }}\ + .history-card {{ margin-bottom: 6px; }}\ + window.breadbar-osd {{ background-color: alpha(@bg, 0.70); color: @on-bg;\ + border-radius: {radius_pill}; border: 1px solid alpha(@on-bg, 0.10);\ + animation: osd-in 0.4s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\ + .osd-icon {{ opacity: 0.85; margin-right: 8px; }}\ + .osd-icon-muted {{ opacity: 0.35; }}\ + progressbar.osd-bar {{ min-height: 6px; }}\ + progressbar.osd-bar trough {{ background-image: none; background-color: alpha(@accent, 0.25);\ + border-radius: 3px; min-height: 6px; }}\ + progressbar.osd-bar trough progress {{ background-image: none; background-color: @accent;\ + border-radius: 3px; min-height: 6px; }}\ + .wifi-pair {{ padding: 6px; }}\ + window.breadbar-panel {{ background-color: alpha(@bg, 0.72); color: @on-bg;\ + border-radius: 14px; border: 1px solid alpha(@on-bg, 0.12); }}\ + window.breadbar-dismiss, .breadbar-dismiss-hit {{\ + background-color: alpha(#000000, 0.02); }}\ + .popover-caret {{ min-height: 2px; margin: 2px 4px 10px; border-radius: 2px;\ + background-color: @accent;\ + background-image: linear-gradient(90deg, @accent, @teal);\ + animation: caret-draw 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .wifi-popover-inner {{ min-width: 228px; padding: {pad}; }}\ + window.wifi-popover button {{ min-height: 0; min-width: 0; }}\ + .popover-tab-row {{ background: alpha(@on-bg, 0.06); border-radius: 10px;\ + padding: 3px; margin-bottom: 10px; }}\ + .popover-tab {{ background: transparent; color: @on-bg; border: none; box-shadow: none;\ + outline: none; border-radius: 999px; padding: 0 14px; min-height: 32px;\ + font-size: 17px; font-weight: bold; opacity: 0.55;\ + transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\ + opacity 0.22s ease, color 0.22s ease; }}\ + .popover-tab:hover {{ opacity: 0.8; }}\ + .popover-tab:checked {{ background: alpha(@accent, 0.22); color: @accent; opacity: 1; }}\ + .popover-tab label {{ padding: 0; margin: 0; }}\ + .wifi-popover-ssid {{ font-weight: bold; font-size: 18px; }}\ + .wifi-popover-ip {{ opacity: 0.6; font-size: 16px; }}\ + .wifi-popover-status {{ font-size: 16px; margin-top: 2px; }}\ + .wifi-popover-section {{ font-size: 13px; font-weight: bold; opacity: 0.45;\ + letter-spacing: 0.12em; }}\ .wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\ - border-radius: 4px; padding: 2px 6px; }}\ - .wifi-popover-row:hover {{ background: alpha({on_bg}, 0.08); }}\ - .wifi-popover-row-active {{ color: {accent}; }}\ + outline: none; border-radius: 10px; padding: 0 12px; min-height: 42px;\ + transition: background-color 0.18s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + .wifi-popover-row label {{ font-size: 18px; }}\ + .wifi-popover-row:hover {{ background: alpha(@on-bg, 0.08); }}\ + .wifi-popover-row-active {{ background: alpha(@accent, 0.14); color: @accent; }}\ + .wifi-popover-row-active:hover {{ background: alpha(@accent, 0.20); }}\ + .row-in {{ animation: row-in 0.32s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .stagger-0 {{ animation-delay: 0ms; }} .stagger-1 {{ animation-delay: 28ms; }}\ + .stagger-2 {{ animation-delay: 56ms; }} .stagger-3 {{ animation-delay: 84ms; }}\ + .stagger-4 {{ animation-delay: 112ms; }} .stagger-5 {{ animation-delay: 140ms; }}\ + .stagger-6 {{ animation-delay: 168ms; }} .stagger-7 {{ animation-delay: 196ms; }}\ + .stagger-8 {{ animation-delay: 224ms; }} .stagger-9 {{ animation-delay: 252ms; }}\ + .stagger-10 {{ animation-delay: 280ms; }} .stagger-11 {{ animation-delay: 308ms; }}\ .wifi-popover-row-unsaved {{ opacity: 0.4; }}\ .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ - window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px; }}\ - .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; }}\ + switch.bt-switch, switch.bt-switch:hover, switch.bt-switch:checked,\ + switch.bt-switch:checked:hover {{ min-width: 42px; min-height: 24px; padding: 2px;\ + border: none; outline: none; box-shadow: none; background-image: none;\ + border-radius: 99px; }}\ + switch.bt-switch {{ background-color: alpha(@on-bg, 0.14);\ + transition: background-color 0.25s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + switch.bt-switch:checked {{ background-color: @accent; }}\ + switch.bt-switch slider {{ min-width: 20px; min-height: 20px; margin: 0;\ + border-radius: 99px; border: none; outline: none; box-shadow: none;\ + background-image: none; background-color: @on-bg; }}\ + window.wifi-add-dialog {{ background-color: alpha(@bg, 0.70); color: @on-bg; min-width: 240px;\ + border-radius: {radius}; border: 1px solid alpha(@on-bg, 0.10);\ + animation: pop-in 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + window.wifi-add-dialog headerbar {{ background-color: alpha(@bg, 0.70); color: @on-bg;\ + border-top-left-radius: {radius}; border-top-right-radius: {radius};\ + border-bottom: 1px solid alpha(@on-bg, 0.10); box-shadow: none; }}\ + .confirm-button {{ background-color: @accent; color: @on-accent; }}\ + .confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\ + .media-widget {{ border-radius: 10px; padding: 4px 8px; min-height: 0;\ + transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + .media-widget:hover {{ background: alpha(@on-bg, 0.08); }}\ + .media-widget.media-in {{ animation: row-in 0.4s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\ + .media-eq {{ min-height: 14px; margin-right: 4px; }}\ + .media-eq-bar {{ min-width: 3px; min-height: 5px; background-color: @accent;\ + border-radius: 2px; }}\ + .media-widget.playing .media-eq-bar {{\ + animation: media-eq 0.85s ease-in-out infinite alternate; }}\ + .media-widget.playing .media-eq-bar:nth-child(2) {{ animation-delay: 0.1s; min-height: 11px; }}\ + .media-widget.playing .media-eq-bar:nth-child(3) {{ animation-delay: 0.22s; min-height: 7px; }}\ + .media-widget.playing .media-eq-bar:nth-child(4) {{ animation-delay: 0.06s; min-height: 13px; }}\ + .media-track-lbl {{ font-size: 17px; }}\ + .media-controls {{ padding: 4px; }}\ + .media-btn {{ min-width: 32px; padding: 4px 8px; border-radius: {radius_sm};\ + transition: background-color 0.18s ease; }}\ + .media-btn:hover {{ background: alpha(@on-bg, 0.10); }}\ + .control-panel-btn {{ padding: 5px 8px; margin: 0; border-radius: 10px;\ + opacity: 0.92; font-size: 18px; line-height: 1; min-width: 0; min-height: 0;\ + background: transparent; border: none; outline: none; box-shadow: none;\ + transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\ + opacity 0.18s ease; }}\ + .control-panel-btn:hover {{ opacity: 1; background: alpha(@on-bg, 0.10); }}\ + .control-panel-btn:active {{ background: alpha(@on-bg, 0.16); }}\ .control-panel {{ }}\ - .control-panel-inner {{ min-width: 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), + .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(@on-bg, 0.12);\ + border: none; outline: none; box-shadow: none; }}\ + scale.control-panel-slider highlight {{ min-height: 6px; border-radius: 99px;\ + background-image: none; background-color: @accent; }}\ + scale.control-panel-slider slider {{ min-width: 0; min-height: 0; margin: 0;\ + padding: 0; opacity: 0; background: transparent; border: none;\ + outline: none; box-shadow: none; }}\ + .control-panel-section {{ margin: 8px 0 0; }}\ + .sink-row label {{ font-size: 15px; }}\ + .power-row {{ margin-top: 8px; }}\ + .power-btn {{ min-width: 0; min-height: 0; padding: 8px 10px; border-radius: 8px;\ + background: alpha(@on-bg, 0.08); font-size: 13px; border: none;\ + outline: none; box-shadow: none;\ + transition: background-color 0.2s cubic-bezier(0.22, 1.2, 0.36, 1); }}\ + .power-btn:hover {{ background: alpha(@on-bg, 0.14); }}\ + .power-btn:active {{ background: alpha(@accent, 0.22); }}\ + .notification-action {{ transition: background-color 0.18s ease; }}\ + .tray-btn {{ transition: opacity 0.2s ease, background-color 0.2s ease; }}\ + separator {{ margin: 4px 0; background: alpha(@on-bg, 0.10); }}\ + /* Lua-declared widgets (see Documentation.md's Widgets §style): the\ + slot rule below is what the four inline `.bread-widget-slot`\ + containers in main.rs rely on for the same 12px stat-pair rhythm\ + everything else in the bar uses (they carried the class with no\ + rule defining it until now). Everything after that is the fixed,\ + closed `style` vocabulary a `WidgetNode` can opt into — one class\ + per enum variant, so a module can only ever pick from this set,\ + never inject arbitrary CSS. The progress-bar rules give an\ + unstyled Progress node an intentional accent-colored fill instead\ + of Adwaita's default blue-on-gray, and let `style.color` retint\ + that fill the same way it retints label/icon text. */\ + .bread-widget-slot {{ margin-right: 12px; }}\ + progressbar.bread-widget-node trough {{ background-image: none; background-color: alpha(@accent, 0.25); border-radius: 3px; min-height: 6px; }}\ + progressbar.bread-widget-node trough progress {{ background-image: none; background-color: @accent; border-radius: 3px; min-height: 6px; }}\ + progressbar.bread-widget-node.bread-color-fg trough progress {{ background-color: @fg; }}\ + progressbar.bread-widget-node.bread-color-dim trough progress {{ background-color: alpha(@fg, 0.6); }}\ + progressbar.bread-widget-node.bread-color-accent trough progress {{ background-color: @accent; }}\ + progressbar.bread-widget-node.bread-color-red trough progress {{ background-color: @red; }}\ + progressbar.bread-widget-node.bread-color-green trough progress {{ background-color: @green; }}\ + progressbar.bread-widget-node.bread-color-yellow trough progress {{ background-color: @yellow; }}\ + progressbar.bread-widget-node.bread-color-blue trough progress {{ background-color: @blue; }}\ + progressbar.bread-widget-node.bread-color-pink trough progress {{ background-color: @pink; }}\ + progressbar.bread-widget-node.bread-color-teal trough progress {{ background-color: @teal; }}\ + .bread-color-fg {{ color: @fg; }}\ + .bread-color-dim {{ color: @fg; opacity: 0.6; }}\ + .bread-color-accent {{ color: @accent; }}\ + .bread-color-red {{ color: @red; }}\ + .bread-color-green {{ color: @green; }}\ + .bread-color-yellow {{ color: @yellow; }}\ + .bread-color-blue {{ color: @blue; }}\ + .bread-color-pink {{ color: @pink; }}\ + .bread-color-teal {{ color: @teal; }}\ + .bread-weight-normal {{ font-weight: normal; }}\ + .bread-weight-bold {{ font-weight: bold; }}\ + .bread-size-xs {{ font-size: 10px; }}\ + .bread-size-sm {{ font-size: 12px; }}\ + .bread-size-md {{ font-size: 14px; }}\ + .bread-size-lg {{ font-size: 16px; }}\ + .bread-size-xl {{ font-size: 20px; }}\ + .bread-bg-none {{ background-color: transparent; }}\ + .bread-bg-surface {{ background-color: @surface; color: @on-surface; }}\ + .bread-bg-card {{ background-color: @surface; color: @on-surface; border-radius: 8px; padding: 12px; }}\ + .bread-radius-none {{ border-radius: 0; }}\ + .bread-radius-sm {{ border-radius: 4px; }}\ + .bread-radius-md {{ border-radius: 8px; }}\ + .bread-radius-full {{ border-radius: 999px; }}\ + .bread-padding-none {{ padding: 0; }}\ + .bread-padding-xs {{ padding: 4px; }}\ + .bread-padding-sm {{ padding: 8px; }}\ + .bread-padding-md {{ padding: 12px; }}", + radius = radius, + radius_bar = radius_bar, + radius_sm = radius_sm, + radius_pill = radius_pill, + pad = pad, ) } @@ -90,6 +285,31 @@ pub fn fg_color() -> String { ink_on(&load_palette().background).to_string() } +/// Ink colour for the given Hyprland output's wallpaper palette. +#[allow(dead_code)] +pub fn fg_color_for(output: &str) -> String { + ink_on(&load_palette_for(output).background).to_string() +} + +/// Bind this window (and its popover children) to `output`'s palette. +/// +/// App CSS still uses `@accent` / `@on-bg` tokens; `bind_window_with_app_css` +/// resolves them against that output. Display-level [`apply`] stays as the +/// SIGHUP / single-output fallback. +pub fn bind_output(widget: &impl IsA, output: &str) { + bgtk::bind_window_with_app_css(widget, output, load_css_for); +} + +/// Bind a satellite window (notification, history, OSD, wifi dialog) to +/// whichever output it is actually rendered on. +pub fn bind_auto(window: &impl IsA) { + bgtk::bind_window_auto_with_app_css(window, load_css_for); +} + +fn load_css_for(_palette: &Palette) -> String { + load_css() +} + /// Apply (or reload) the theme CSS. Safe to call from `glib::MainContext::invoke`. pub fn apply() { // Shared ecosystem base (fonts, palette, generic widgets) — applied first diff --git a/src/widgets/client.rs b/src/widgets/client.rs new file mode 100644 index 0000000..84d043b --- /dev/null +++ b/src/widgets/client.rs @@ -0,0 +1,106 @@ +//! Connects to breadd's IPC socket and keeps the bar's widget set in sync. +//! +//! breadbar is level-triggered here, not edge-triggered: `bread.widget.*` +//! events are used purely as a "something changed, go re-fetch" signal, not +//! applied as incremental patches. Every dirty signal (and the initial +//! connect) re-requests the complete widget list and hands it to `update()` +//! as one `AppInput::WidgetsUpdate`, which reconciles the bar's containers +//! from scratch. This sidesteps event-ordering/drop concerns entirely, and +//! widget registries are small enough that re-fetching the full list on +//! every change is not a real cost. + +use crate::{App, AppInput}; +use bread_shared::widget::WidgetSpec; +use bread_utils::bread_client::BreadClient; +use relm4::ComponentSender; +use std::time::Duration; + +/// breadbar's own registered app id — already reserved in +/// `bread_shared::apps::KNOWN_APPS` (see `Documentation.md`'s Namespaces +/// section). Used both to fetch widgets and to publish click events. +pub const APP_ID: &str = "bar"; + +/// Safety-net poll interval. `bread.widget.cleared` (emitted once per +/// daemon reload, including a full restart — see breadd's `reload_internal`) +/// is meant to catch the case where a module stops registering widgets +/// without anything else re-triggering a fetch, but a *restart* (as opposed +/// to a live `bread reload`) drops the subscription entirely; if that one +/// event fires before `BreadClient::subscribe`'s reconnect-with-backoff +/// finishes re-establishing the stream, it's missed and there's no second +/// chance from the event side. This poll is the backstop for that race — +/// infrequent enough that it's not a real cost, frequent enough that a missed +/// event self-heals well within a session rather than needing a manual +/// breadbar restart to clear stale widgets. +const POLL_INTERVAL: Duration = Duration::from_secs(30); + +/// Start the widget subsystem: an initial fetch, a live subscription that +/// re-fetches on every `bread.widget.*` change, and a low-frequency poll as +/// a backstop against the reconnect race described above. Call once from +/// `init`. +pub fn spawn(sender: ComponentSender) { + // BreadClient::request is blocking std I/O; run it off the tokio + // runtime breadbar's other pollers rely on, same as the reasoning in + // `BreadClient::subscribe`'s own background-thread design. + let initial = sender.clone(); + std::thread::spawn(move || fetch_and_send(&initial)); + + // `subscribe` already reconnects with backoff on its own background + // thread for the lifetime of the process — there is no natural point to + // stop it before the app exits, so the handle is intentionally leaked + // rather than threaded through App just to be dropped at shutdown. + let live = sender.clone(); + let client = BreadClient::connect(APP_ID); + let subscription = client.subscribe("bread.widget.**", move |_event| { + fetch_and_send(&live); + }); + std::mem::forget(subscription); + + let polled = sender.clone(); + relm4::spawn(async move { + loop { + tokio::time::sleep(POLL_INTERVAL).await; + let polled = polled.clone(); + std::thread::spawn(move || fetch_and_send(&polled)); + } + }); +} + +fn fetch_and_send(sender: &ComponentSender) { + let client = BreadClient::connect(APP_ID); + let Some(result) = client.request("widgets.list", serde_json::Value::Null) else { + return; + }; + // Decode element-wise rather than `Vec` in one shot — one + // malformed entry from any module (a bad `class`, an unknown enum value, + // ...) must not blank out every other module's widgets. + let raw: Vec = serde_json::from_value(result).unwrap_or_default(); + let specs: Vec = raw + .into_iter() + .filter_map(|v| { + // `id`/`module` are read before the value is consumed by the + // failed parse below, so a malformed spec still names itself in + // the warning instead of just printing a bare serde error. + let id = v.get("id").and_then(|x| x.as_str()).unwrap_or("?").to_string(); + let module = v.get("module").and_then(|x| x.as_str()).unwrap_or("?").to_string(); + match serde_json::from_value::(v) { + Ok(spec) => Some(spec), + Err(e) => { + eprintln!( + "breadbar: dropping malformed widget spec (id={id}, module={module}): {e}" + ); + None + } + } + }) + .collect(); + sender.input(AppInput::WidgetsUpdate(specs)); +} + +/// Publish a widget click back to breadd. `action` is whatever opaque value +/// the Lua module put in the clicked node's `on_click`. +pub fn emit_click(widget_id: &str, action: &serde_json::Value) { + BreadClient::connect(APP_ID).emit( + "bread.bar.widget_clicked", + serde_json::json!({ "widget_id": widget_id, "action": action }), + ); +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..019f434 --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1,7 @@ +//! Lua-declared, live-updating widgets (see `Documentation.md`'s "Widgets" +//! section in the `bread` repo) rendered into breadbar's fixed layout slots. + +pub mod client; +mod render; + +pub use render::build_node; diff --git a/src/widgets/render.rs b/src/widgets/render.rs new file mode 100644 index 0000000..785470b --- /dev/null +++ b/src/widgets/render.rs @@ -0,0 +1,219 @@ +//! 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 +}