Compare commits
42 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
297207a7aa | ||
|
|
3049a20be3 | ||
|
|
700fc3ed16 | ||
|
|
9c4205b1b2 | ||
|
|
cd7465a18b | ||
|
|
e4c12e9b62 | ||
|
|
241dfd17a7 | ||
|
|
96d666b3cb | ||
|
|
1806c6f912 | ||
|
|
ae1fee3591 | ||
|
|
62c6dd5ea3 | ||
|
|
110f0cd3df | ||
|
|
4fddad510f | ||
|
|
9691485bd6 | ||
|
|
465088dc55 | ||
|
|
614dca71af | ||
|
|
89f7a93e8a | ||
|
|
92f2e52c1f | ||
|
|
be0b54e1ea | ||
|
|
d8c766abe4 | ||
|
|
bba4aa2d2b | ||
|
|
9dc82817e9 | ||
|
|
1752db84c8 | ||
|
|
c53d504208 | ||
|
|
872e7e1589 | ||
|
|
566aeeed8b | ||
|
|
059e11cdeb | ||
|
|
ac6ccfe88a | ||
|
|
a00934a53d | ||
|
|
56e8b02599 | ||
|
|
f5daf902ce | ||
| 9b5b475036 | |||
|
|
552d771e15 | ||
|
|
175af5d483 | ||
|
|
905d91580d | ||
|
|
5af8b6097d | ||
|
|
e8cd2c88bc | ||
|
|
15f2b111f1 | ||
|
|
3996bce3a9 | ||
|
|
4d33c0e9ae | ||
|
|
174440bcbf | ||
|
|
d6baab24fc |
33 changed files with 4743 additions and 999 deletions
24
.forgejo/workflows/check.yml
Normal file
24
.forgejo/workflows/check.yml
Normal file
|
|
@ -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
|
||||
76
.forgejo/workflows/dev-release.yml
Normal file
76
.forgejo/workflows/dev-release.yml
Normal file
|
|
@ -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}"
|
||||
|
|
@ -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/*'
|
||||
|
|
@ -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"
|
||||
57
.forgejo/workflows/rc-release.yml
Normal file
57
.forgejo/workflows/rc-release.yml
Normal file
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
51
AGENTS.md
Normal file
51
AGENTS.md
Normal file
|
|
@ -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/<feature-name>`.
|
||||
When working on a bug or issue, create branch `fix/<issue you are fixing>`.
|
||||
|
||||
## 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.
|
||||
84
CONTRIBUTING.md
Normal file
84
CONTRIBUTING.md
Normal file
|
|
@ -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/<short-name>
|
||||
fix/<issue-number-or-short-name>
|
||||
```
|
||||
|
||||
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
|
||||
branches get deleted on merge — they never accumulate the kind of drift a
|
||||
second long-lived branch does.
|
||||
|
||||
## The release cycle
|
||||
|
||||
There's no separate `beta` or release branch — "stable" and "beta" are both
|
||||
just **tags** on `main`, not branches that need to be kept in sync:
|
||||
|
||||
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
|
||||
auto-publishes a dev build — install it with `bakery track set dev` and
|
||||
`bakery update --all`, then fix anything broken with another push.
|
||||
2. When you want to stabilize before a real release, tag a release
|
||||
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
|
||||
both remotes). That tag alone triggers a beta-track build —
|
||||
"freezing" is just pausing pushes to `main` while you test it, not a
|
||||
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
|
||||
3. Once an RC has gone without issues, tag the real release:
|
||||
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
|
||||
signed stable release build.
|
||||
|
||||
## Tracks, from a user's perspective
|
||||
|
||||
```
|
||||
bakery track show # what you're currently on (defaults to stable)
|
||||
bakery track set dev # or beta, or stable
|
||||
bakery update --all # pull the latest build on your current track
|
||||
```
|
||||
|
||||
| Track | What it is | Published from |
|
||||
|--------|-----------|-----------------|
|
||||
| `stable` | The last tagged release | a `vX.Y.Z` tag |
|
||||
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
|
||||
| `dev` | Bleeding edge | `main`, on every push |
|
||||
|
||||
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
|
||||
latest published stable tag, so they always sort as newer than what you
|
||||
have installed — no manual version bumping needed. Beta versions are just
|
||||
the RC tag itself (already valid semver, already sorts below the real
|
||||
release it's a candidate for).
|
||||
|
||||
## Local development
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
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.
|
||||
711
Cargo.lock
generated
711
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
18
Cargo.toml
18
Cargo.toml
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "breadbar"
|
||||
version = "0.3.0"
|
||||
version = "0.3.3"
|
||||
edition = "2021"
|
||||
description = "Minimal status bar and notification daemon for Hyprland on Wayland"
|
||||
license = "MIT"
|
||||
|
|
@ -10,7 +10,17 @@ 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"
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ A single Rust binary that provides a full-width top bar, a D-Bus notification da
|
|||
- Implements `org.freedesktop.Notifications` (D-Bus) — works with any standard sender (`notify-send`, etc.)
|
||||
- Popups appear top-right, stack vertically, auto-dismiss after the sender-specified timeout (default 5 s)
|
||||
- Supports `CloseNotification` and `replaces_id`
|
||||
- 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`.
|
||||
|
|
|
|||
1
assets/GPU.svg
Normal file
1
assets/GPU.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="18" height="12" rx="2"/><rect x="8" y="9" width="6" height="6" rx="1"/><path d="M7 18v2"/><path d="M12 18v2"/><path d="M17 18v2"/><path d="M17 6V4"/></svg>
|
||||
|
After Width: | Height: | Size: 362 B |
|
|
@ -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"
|
||||
|
|
|
|||
1
ci/bread-ecosystem.rev
Normal file
1
ci/bread-ecosystem.rev
Normal file
|
|
@ -0,0 +1 @@
|
|||
147cfbbf96ae4b171027defa1130d2caddb934b1
|
||||
21
ci/build.sh
Executable file
21
ci/build.sh
Executable file
|
|
@ -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" "$@"
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
# Maintainer: Breadway <plasticbread849@gmail.com>
|
||||
|
||||
pkgname=breadbar
|
||||
pkgver=0.2.0
|
||||
pkgrel=1
|
||||
pkgdesc="Minimal status bar and notification daemon for Hyprland"
|
||||
arch=('x86_64')
|
||||
url="https://git.breadway.dev/Breadway/breadbar"
|
||||
license=('MIT')
|
||||
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
|
||||
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
|
||||
# causing undefined-symbol errors. Disable LTO.
|
||||
options=(!lto !debug)
|
||||
depends=('gtk4' 'gtk4-layer-shell' 'wireplumber' 'pipewire-pulse' 'brightnessctl' 'iw')
|
||||
optdepends=(
|
||||
'hyprland: workspace and window data integration'
|
||||
)
|
||||
makedepends=('rust' 'cargo')
|
||||
source=("${pkgname}-${pkgver}.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
cargo build --release --locked
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
cargo test --release --locked
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
install -Dm755 target/release/breadbar "${pkgdir}/usr/bin/breadbar"
|
||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
|
|
@ -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<App>) {
|
||||
|
|
|
|||
|
|
@ -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<App>) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ pub const WIFI_MEDIUM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "
|
|||
pub const WIFI_WEAK: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Weak.svg"));
|
||||
pub const WIFI_OFF: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Disconnect.svg"));
|
||||
|
||||
/// Adwaita symbolic names — these are drawn for 16px status bars, not our
|
||||
/// hand-cropped Lucide arcs.
|
||||
pub const WIFI_ICON_EXCELLENT: &str = "network-wireless-signal-excellent-symbolic";
|
||||
pub const WIFI_ICON_GOOD: &str = "network-wireless-signal-good-symbolic";
|
||||
pub const WIFI_ICON_OK: &str = "network-wireless-signal-ok-symbolic";
|
||||
pub const WIFI_ICON_WEAK: &str = "network-wireless-signal-weak-symbolic";
|
||||
pub const WIFI_ICON_OFF: &str = "network-wireless-offline-symbolic";
|
||||
|
||||
pub const BAT_HIGH: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 3 Bars.svg"));
|
||||
pub const BAT_MID: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 2 Bars.svg"));
|
||||
pub const BAT_LOW: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 1 Bar.svg"));
|
||||
|
|
@ -65,6 +73,7 @@ pub struct Stats {
|
|||
pub gpu_temp: Option<f32>,
|
||||
pub net_rx_kbs: f32,
|
||||
pub net_tx_kbs: f32,
|
||||
pub volume_pct: u8,
|
||||
}
|
||||
|
||||
struct CpuSnapshot {
|
||||
|
|
@ -76,7 +85,7 @@ static PREV_CPU: OnceLock<Mutex<CpuSnapshot>> = OnceLock::new();
|
|||
static BAT_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
static AC_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
static WIFI_CACHE: LazyLock<Mutex<(String, &'static str)>> =
|
||||
LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_OFF)));
|
||||
LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_ICON_OFF)));
|
||||
static WIFI_TICK: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
fn read_cpu() -> f32 {
|
||||
|
|
@ -271,7 +280,7 @@ fn wifi_iface() -> Option<&'static str> {
|
|||
|
||||
async fn read_wifi() -> (String, &'static str) {
|
||||
let Some(iface) = wifi_iface() else {
|
||||
return ("—".into(), WIFI_OFF);
|
||||
return ("—".into(), WIFI_ICON_OFF);
|
||||
};
|
||||
|
||||
let link_out = tokio::process::Command::new("iw")
|
||||
|
|
@ -281,7 +290,7 @@ async fn read_wifi() -> (String, &'static str) {
|
|||
.ok();
|
||||
let link_stdout = match link_out {
|
||||
Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
|
||||
_ => return ("—".into(), WIFI_OFF),
|
||||
_ => return ("—".into(), WIFI_ICON_OFF),
|
||||
};
|
||||
|
||||
let mut ssid = None;
|
||||
|
|
@ -296,13 +305,14 @@ async fn read_wifi() -> (String, &'static str) {
|
|||
}
|
||||
|
||||
let Some(ssid) = ssid else {
|
||||
return ("—".into(), WIFI_OFF);
|
||||
return ("—".into(), WIFI_ICON_OFF);
|
||||
};
|
||||
|
||||
let icon = match rssi {
|
||||
Some(r) if r >= -55 => WIFI_STRONG,
|
||||
Some(r) if r >= -70 => WIFI_MEDIUM,
|
||||
_ => WIFI_WEAK,
|
||||
Some(r) if r >= -55 => WIFI_ICON_EXCELLENT,
|
||||
Some(r) if r >= -70 => WIFI_ICON_GOOD,
|
||||
Some(r) if r >= -80 => WIFI_ICON_OK,
|
||||
_ => WIFI_ICON_WEAK,
|
||||
};
|
||||
|
||||
(ssid, icon)
|
||||
|
|
@ -392,7 +402,7 @@ fn read_crumbs_profile() -> Option<String> {
|
|||
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());
|
||||
|
|
@ -413,7 +423,8 @@ pub async fn poll() -> Stats {
|
|||
let power_watts = read_power();
|
||||
let power = power_watts.map_or_else(|| "—W".into(), |w| format!("{w:.1}W"));
|
||||
let pct = read_battery();
|
||||
let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}%"));
|
||||
// Demo bar prints the bare number ("83"), not "83%".
|
||||
let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}"));
|
||||
let bat_icon = pct.map_or(BAT_MID, bat_level_icon);
|
||||
let ac_connected = read_ac();
|
||||
// BT and WiFi both refresh every 8 cycles (~16 s); cache in between.
|
||||
|
|
@ -442,6 +453,7 @@ pub async fn poll() -> Stats {
|
|||
let gpu_usage = read_gpu_usage();
|
||||
let gpu_temp = read_gpu_temp();
|
||||
let (net_rx_kbs, net_tx_kbs) = read_net_throughput();
|
||||
let volume_pct = read_volume_pct();
|
||||
Stats {
|
||||
cpu: format!("{cpu:.0}%"),
|
||||
cpu_pct: cpu,
|
||||
|
|
@ -465,9 +477,30 @@ pub async fn poll() -> Stats {
|
|||
gpu_temp,
|
||||
net_rx_kbs,
|
||||
net_tx_kbs,
|
||||
volume_pct,
|
||||
}
|
||||
}
|
||||
|
||||
/// `wpctl get-volume` prints `Volume: 0.44 [MUTED]`. Scale to a 0–150 percent
|
||||
/// for the bar chip. Missing pipewire / wpctl degrades to 0 rather than
|
||||
/// blocking the rest of the poll.
|
||||
fn read_volume_pct() -> u8 {
|
||||
let out = std::process::Command::new("wpctl")
|
||||
.args(["get-volume", "@DEFAULT_AUDIO_SINK@"])
|
||||
.output()
|
||||
.ok();
|
||||
let Some(o) = out.filter(|o| o.status.success()) else {
|
||||
return 0;
|
||||
};
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.trim()
|
||||
.strip_prefix("Volume:")
|
||||
.and_then(|s| s.split_whitespace().next())
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.map(|v| (v * 100.0).round().clamp(0.0, 150.0) as u8)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn spawn_poller(sender: ComponentSender<App>) {
|
||||
relm4::spawn(async move {
|
||||
loop {
|
||||
|
|
|
|||
107
src/bar/wifi.rs
107
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<ScanEntry>,
|
||||
/// False while nmcli is still listing APs — profiles must still be usable.
|
||||
pub scan_ready: bool,
|
||||
}
|
||||
|
||||
async fn fetch_status() -> Option<CrumbsStatus> {
|
||||
|
|
@ -73,31 +75,57 @@ async fn fetch_profile_list() -> Vec<(String, bool)> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
async fn saved_ssids() -> std::collections::HashSet<String> {
|
||||
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<ScanEntry> {
|
||||
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::Value> =
|
||||
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::<u8>().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::<u8>().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<App>) {
|
|||
});
|
||||
}
|
||||
|
||||
/// 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<App>) {
|
||||
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])
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["device", "wifi", "connect", &ssid, "password", &password])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,17 +15,63 @@ use relm4::ComponentSender;
|
|||
|
||||
use crate::AppInput;
|
||||
|
||||
/// Fetches the current workspace list + active workspace and pushes both to
|
||||
/// the app — used both for the initial state and to re-sync after the event
|
||||
/// stream reconnects (state may have changed while we were disconnected).
|
||||
/// Stock Hyprland accepts `hyprctl dispatch workspace N`. Lua-config
|
||||
/// Hyprland (BOS) rewrites that as `hl.dispatch(workspace N)`, which is
|
||||
/// a syntax error — the working form is `hl.dsp.focus({workspace=N})`.
|
||||
async fn switch_workspace(id: hyprland::shared::WorkspaceId) {
|
||||
let arg = id.to_string();
|
||||
let stock = tokio::process::Command::new("hyprctl")
|
||||
.args(["dispatch", "workspace", &arg])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(o) = &stock {
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
let out = String::from_utf8_lossy(&o.stdout);
|
||||
if o.status.success() && !err.contains("hl.dispatch") && !out.contains("hl.dispatch") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let expr = format!("hl.dispatch(hl.dsp.focus({{workspace={arg}}}))");
|
||||
let lua = tokio::process::Command::new("hyprctl")
|
||||
.args(["eval", &expr])
|
||||
.output()
|
||||
.await;
|
||||
match lua {
|
||||
Ok(o) if o.status.success() => {}
|
||||
Ok(o) => eprintln!(
|
||||
"breadbar: workspace {arg}: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
),
|
||||
Err(e) => eprintln!("breadbar: workspace {arg}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stretch to the old→new span, then snap onto the destination — CSS
|
||||
/// transitions cannot widen a pill across two buttons, so the trail's
|
||||
/// Fixed allocation is interpolated on the frame clock instead.
|
||||
const STRETCH_MS: f64 = 220.0;
|
||||
const SNAP_MS: f64 = 380.0;
|
||||
|
||||
/// Full workspace + per-monitor active snapshot. Each bar filters this to
|
||||
/// its own output so a second display does not inherit the laptop's set.
|
||||
async fn sync_state(sender: &ComponentSender<crate::App>) {
|
||||
if let Ok(ws) = Workspaces::get_async().await {
|
||||
sender.input(AppInput::WorkspaceList(ws.to_vec()));
|
||||
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);
|
||||
}
|
||||
if let Ok(active) = Workspace::get_active_async().await {
|
||||
sender.input(AppInput::ActiveWorkspace(active.id));
|
||||
}
|
||||
}
|
||||
sender.input(AppInput::WorkspaceSync {
|
||||
workspaces,
|
||||
actives,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
|
||||
relm4::spawn(async move {
|
||||
|
|
@ -41,13 +92,21 @@ pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
|
|||
while let Some(Ok(event)) = stream.next().await {
|
||||
backoff = std::time::Duration::from_millis(500);
|
||||
match event {
|
||||
Event::WorkspaceChanged(data) => {
|
||||
sender.input(AppInput::ActiveWorkspace(data.id));
|
||||
Event::WorkspaceChanged(_)
|
||||
| Event::WorkspaceAdded(_)
|
||||
| Event::WorkspaceDeleted(_) => {
|
||||
sync_state(&sender).await;
|
||||
}
|
||||
Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => {
|
||||
if let Ok(ws) = Workspaces::get_async().await {
|
||||
sender.input(AppInput::WorkspaceList(ws.to_vec()));
|
||||
Event::MonitorAdded(data) => {
|
||||
sender.input(AppInput::MonitorAdded(data.name));
|
||||
sync_state(&sender).await;
|
||||
}
|
||||
Event::MonitorRemoved(name) => {
|
||||
sender.input(AppInput::MonitorRemoved(name));
|
||||
sync_state(&sender).await;
|
||||
}
|
||||
Event::ActiveWindowChanged(_) => {
|
||||
sender.input(AppInput::DismissPanels);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -65,17 +124,290 @@ pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
|
|||
});
|
||||
}
|
||||
|
||||
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<gtk4::TickCallbackId>,
|
||||
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<RefCell<TrailInner>>,
|
||||
}
|
||||
|
||||
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<Geom> {
|
||||
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<Geom> {
|
||||
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<RefCell<TrailInner>>, 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)
|
||||
}
|
||||
|
|
|
|||
1599
src/main.rs
1599
src/main.rs
File diff suppressed because it is too large
Load diff
454
src/notifications/history.rs
Normal file
454
src/notifications/history.rs
Normal file
|
|
@ -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<Mutex<VecDeque<Entry>>>;
|
||||
|
||||
#[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<PathBuf> {
|
||||
Some(state_dir()?.join("history.json"))
|
||||
}
|
||||
|
||||
fn state_dir() -> Option<PathBuf> {
|
||||
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<PersistedEntry> = 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::<Vec<PersistedEntry>>(&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<Entry> = 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::<Vec<_>>().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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,44 @@
|
|||
pub mod history;
|
||||
pub mod popup;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
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 `<b>`/`<i>` 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<Duration>` mainly for readability at call sites —
|
||||
/// `Never` covers both the spec's `expire_timeout == 0` ("never expire")
|
||||
|
|
@ -24,8 +58,12 @@ pub enum NotifEvent {
|
|||
body: String,
|
||||
urgency: Urgency,
|
||||
expire: Expire,
|
||||
actions: Vec<Action>,
|
||||
/// Placeholder for the inline-reply field, if one should be shown.
|
||||
inline_reply: Option<String>,
|
||||
},
|
||||
Close(u32),
|
||||
ToggleHistory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -55,6 +93,36 @@ impl Urgency {
|
|||
}
|
||||
}
|
||||
|
||||
/// Spec: `actions` is a flat list of pairs `(id, localized label)`. An
|
||||
/// unpaired trailing id is ignored. Empty keys are dropped.
|
||||
fn parse_actions(raw: &[String]) -> Vec<Action> {
|
||||
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<String, OwnedValue>,
|
||||
) -> Option<String> {
|
||||
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
|
||||
|
|
@ -79,6 +147,45 @@ fn compute_expire(expire_timeout: i32, urgency_critical: bool) -> Expire {
|
|||
struct NotifServer {
|
||||
tx: mpsc::Sender<NotifEvent>,
|
||||
next_id: AtomicU32,
|
||||
/// (app_name, synchronous-hint tag) -> id, for senders relying on
|
||||
/// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`.
|
||||
sync_tags: Mutex<HashMap<(String, String), u32>>,
|
||||
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<NotifEvent>,
|
||||
}
|
||||
|
||||
#[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")]
|
||||
|
|
@ -92,12 +199,28 @@ impl NotifServer {
|
|||
_app_icon: &str,
|
||||
summary: &str,
|
||||
body: &str,
|
||||
_actions: Vec<String>,
|
||||
actions: Vec<String>,
|
||||
hints: std::collections::HashMap<String, OwnedValue>,
|
||||
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)
|
||||
};
|
||||
|
|
@ -109,6 +232,23 @@ impl NotifServer {
|
|||
// when the sender left expire_timeout at the server-default (-1).
|
||||
let urgency = Urgency::from_hint(hints.get("urgency"));
|
||||
let expire = compute_expire(expire_timeout, urgency == Urgency::Critical);
|
||||
let actions = parse_actions(&actions);
|
||||
let inline_reply = inline_reply_placeholder(&actions, &hints);
|
||||
|
||||
history::record(
|
||||
&self.history,
|
||||
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
|
||||
|
|
@ -119,6 +259,8 @@ impl NotifServer {
|
|||
body: body.to_string(),
|
||||
urgency,
|
||||
expire,
|
||||
actions,
|
||||
inline_reply,
|
||||
})
|
||||
.await;
|
||||
id
|
||||
|
|
@ -129,7 +271,7 @@ impl NotifServer {
|
|||
}
|
||||
|
||||
fn get_capabilities(&self) -> Vec<String> {
|
||||
vec!["body".to_string()]
|
||||
CAPABILITIES.iter().map(|s| (*s).to_string()).collect()
|
||||
}
|
||||
|
||||
fn get_server_information(&self) -> (String, String, String, String) {
|
||||
|
|
@ -142,15 +284,71 @@ 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<SampleKind>) -> gtk4::Window {
|
||||
let (window, cards_box) = popup::build_window();
|
||||
let (tx, rx) = mpsc::channel(32);
|
||||
|
||||
match sample {
|
||||
Some(kind) => {
|
||||
let _ = tx.try_send(kind.sample_event());
|
||||
let window_for_loop = window.clone();
|
||||
relm4::spawn_local(async move {
|
||||
popup::run(window_for_loop, cards_box, rx, None, None).await;
|
||||
});
|
||||
}
|
||||
None => {
|
||||
let (conn_tx, conn_rx) = tokio::sync::oneshot::channel();
|
||||
let store = history::load_store();
|
||||
let next_id = history::next_id(&store);
|
||||
let history_ui = history::build_window(store.clone());
|
||||
|
||||
relm4::spawn(async move {
|
||||
let server = NotifServer {
|
||||
tx,
|
||||
next_id: AtomicU32::new(1),
|
||||
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()
|
||||
|
|
@ -158,6 +356,8 @@ pub fn spawn() {
|
|||
.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");
|
||||
|
|
@ -168,12 +368,17 @@ pub fn spawn() {
|
|||
std::future::pending::<()>().await
|
||||
});
|
||||
|
||||
let window_for_loop = window.clone();
|
||||
relm4::spawn_local(async move {
|
||||
if let Ok(conn) = conn_rx.await {
|
||||
popup::run(rx, conn).await;
|
||||
popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
@ -212,4 +417,251 @@ mod tests {
|
|||
Expire::Never => panic!("expected 1500ms, got Never"),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_server() -> (NotifServer, mpsc::Receiver<NotifEvent>) {
|
||||
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<String, OwnedValue> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
use std::{cell::RefCell, collections::HashMap, rc::Rc};
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4_layer_shell::{Edge, Layer, LayerShell};
|
||||
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
|
||||
use super::{Expire, NotifEvent, Urgency};
|
||||
use super::{history, Action, Expire, NotifEvent, Urgency, INLINE_REPLY_KEY};
|
||||
|
||||
type Cards = Rc<RefCell<HashMap<u32, gtk4::Box>>>;
|
||||
// Bumped every time an id gets a (re)placed card — an auto-dismiss timer
|
||||
|
|
@ -18,12 +18,15 @@ type Generations = Rc<RefCell<HashMap<u32, u64>>>;
|
|||
/// NotificationClosed reason codes per the freedesktop spec.
|
||||
mod close_reason {
|
||||
pub const EXPIRED: u32 = 1;
|
||||
#[allow(dead_code)] // no in-app dismiss button exists yet (see make_card)
|
||||
pub const DISMISSED_BY_USER: u32 = 2;
|
||||
pub const CLOSE_NOTIFICATION_CALL: u32 = 3;
|
||||
}
|
||||
|
||||
pub async fn run(mut rx: Receiver<NotifEvent>, conn: zbus::Connection) {
|
||||
/// Builds the notification window synchronously — so a caller (screenshot
|
||||
/// mode in particular) has a real window to hook `connect_map` on before
|
||||
/// `run`'s event loop, which needs an async `zbus::Connection` handshake in
|
||||
/// the real path, ever starts.
|
||||
pub fn build_window() -> (gtk4::Window, gtk4::Box) {
|
||||
let window = create_window();
|
||||
let cards_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
cards_box.set_margin_top(8);
|
||||
|
|
@ -31,7 +34,22 @@ pub async fn run(mut rx: Receiver<NotifEvent>, conn: zbus::Connection) {
|
|||
cards_box.set_margin_start(8);
|
||||
cards_box.set_margin_end(8);
|
||||
window.set_child(Some(&cards_box));
|
||||
(window, cards_box)
|
||||
}
|
||||
|
||||
/// `conn`: `None` in screenshot mode, which skips real D-Bus registration
|
||||
/// entirely (see `super::spawn`) — there's no external client that needs to
|
||||
/// reach a screenshot-only instance, and registering the well-known name
|
||||
/// would just race the real breadbar for it. `NotificationClosed` is a
|
||||
/// spec-mandated signal for real clients only, so it's simply not emitted
|
||||
/// when there's no real connection to emit it on.
|
||||
pub async fn run(
|
||||
window: gtk4::Window,
|
||||
cards_box: gtk4::Box,
|
||||
mut rx: Receiver<NotifEvent>,
|
||||
conn: Option<zbus::Connection>,
|
||||
history_ui: Option<history::Ui>,
|
||||
) {
|
||||
let cards: Cards = Rc::new(RefCell::new(HashMap::new()));
|
||||
let generations: Generations = Rc::new(RefCell::new(HashMap::new()));
|
||||
|
||||
|
|
@ -44,15 +62,32 @@ pub async fn run(mut rx: Receiver<NotifEvent>, conn: zbus::Connection) {
|
|||
body,
|
||||
urgency,
|
||||
expire,
|
||||
actions,
|
||||
inline_reply,
|
||||
} => {
|
||||
// Replace existing card with same id (replaces_id case)
|
||||
if let Some(old) = cards.borrow_mut().remove(&id) {
|
||||
cards_box.remove(&old);
|
||||
}
|
||||
let card = make_card(&app_name, &summary, &body, urgency);
|
||||
let card = make_card(CardSpec {
|
||||
id,
|
||||
app_name: &app_name,
|
||||
summary: &summary,
|
||||
body: &body,
|
||||
urgency,
|
||||
actions: &actions,
|
||||
inline_reply: inline_reply.as_deref(),
|
||||
conn: conn.clone(),
|
||||
cards: cards.clone(),
|
||||
cards_box: cards_box.clone(),
|
||||
window: window.clone(),
|
||||
});
|
||||
cards_box.prepend(&card);
|
||||
cards.borrow_mut().insert(id, card.clone());
|
||||
window.set_visible(true);
|
||||
if let Some(ui) = &history_ui {
|
||||
history::refresh_if_visible(ui);
|
||||
}
|
||||
|
||||
let my_generation = {
|
||||
let mut gens = generations.borrow_mut();
|
||||
|
|
@ -74,8 +109,7 @@ pub async fn run(mut rx: Receiver<NotifEvent>, conn: zbus::Connection) {
|
|||
gtk4::glib::timeout_future(duration).await;
|
||||
let still_current =
|
||||
generations_clone.borrow().get(&id) == Some(&my_generation);
|
||||
if still_current
|
||||
&& dismiss(&cards_box_clone, &win_clone, &cards_clone, id)
|
||||
if still_current && dismiss(&cards_box_clone, &win_clone, &cards_clone, id)
|
||||
{
|
||||
emit_closed(&conn_clone, id, close_reason::EXPIRED).await;
|
||||
}
|
||||
|
|
@ -87,6 +121,11 @@ pub async fn run(mut rx: Receiver<NotifEvent>, conn: zbus::Connection) {
|
|||
emit_closed(&conn, id, close_reason::CLOSE_NOTIFICATION_CALL).await;
|
||||
}
|
||||
}
|
||||
NotifEvent::ToggleHistory => {
|
||||
if let Some(ui) = &history_ui {
|
||||
history::toggle(ui);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -109,8 +148,10 @@ fn dismiss(cards_box: >k4::Box, window: >k4::Window, cards: &Cards, id: u32)
|
|||
/// Emits the spec-mandated `NotificationClosed(id, reason)` signal. Sent
|
||||
/// directly over the connection rather than through the zbus interface
|
||||
/// macro's generated helper, since the dismiss decision happens here in the
|
||||
/// popup task, not inside `NotifServer`'s own method bodies.
|
||||
async fn emit_closed(conn: &zbus::Connection, id: u32, reason: u32) {
|
||||
/// popup task, not inside `NotifServer`'s own method bodies. No-op when
|
||||
/// `conn` is `None` (screenshot mode — see `run`'s doc comment).
|
||||
async fn emit_closed(conn: &Option<zbus::Connection>, id: u32, reason: u32) {
|
||||
let Some(conn) = conn else { return };
|
||||
let result = conn
|
||||
.emit_signal(
|
||||
None::<&str>,
|
||||
|
|
@ -129,45 +170,241 @@ 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, urgency: Urgency) -> gtk4::Box {
|
||||
struct CardSpec<'a> {
|
||||
id: u32,
|
||||
app_name: &'a str,
|
||||
summary: &'a str,
|
||||
body: &'a str,
|
||||
urgency: Urgency,
|
||||
actions: &'a [Action],
|
||||
inline_reply: Option<&'a str>,
|
||||
conn: Option<zbus::Connection>,
|
||||
cards: Cards,
|
||||
cards_box: gtk4::Box,
|
||||
window: gtk4::Window,
|
||||
}
|
||||
|
||||
fn make_card(spec: CardSpec<'_>) -> gtk4::Box {
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
card.add_css_class("notification-card");
|
||||
if let Some(class) = urgency.css_class() {
|
||||
if let Some(class) = spec.urgency.css_class() {
|
||||
card.add_css_class(class);
|
||||
}
|
||||
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
|
||||
// Senders often set the title/summary to their own app name (e.g. a bare
|
||||
// "Spotify" notification) — showing app_name above an identical summary
|
||||
// is pure repetition, so skip the app label in that case.
|
||||
if !app_name.is_empty() && !app_name.eq_ignore_ascii_case(summary) {
|
||||
let lbl = gtk4::Label::new(Some(app_name));
|
||||
if !spec.app_name.is_empty() && !spec.app_name.eq_ignore_ascii_case(spec.summary) {
|
||||
let lbl = gtk4::Label::new(Some(spec.app_name));
|
||||
lbl.add_css_class("notification-app");
|
||||
lbl.set_xalign(0.0);
|
||||
card.append(&lbl);
|
||||
content.append(&lbl);
|
||||
}
|
||||
|
||||
let summary_lbl = gtk4::Label::new(Some(summary));
|
||||
let summary_lbl = gtk4::Label::new(Some(spec.summary));
|
||||
summary_lbl.add_css_class("notification-summary");
|
||||
summary_lbl.set_xalign(0.0);
|
||||
summary_lbl.set_wrap(true);
|
||||
card.append(&summary_lbl);
|
||||
content.append(&summary_lbl);
|
||||
|
||||
if !body.is_empty() {
|
||||
let body_lbl = gtk4::Label::new(Some(body));
|
||||
if !spec.body.is_empty() {
|
||||
let body_lbl = gtk4::Label::new(None);
|
||||
body_lbl.add_css_class("notification-body");
|
||||
body_lbl.set_xalign(0.0);
|
||||
body_lbl.set_wrap(true);
|
||||
card.append(&body_lbl);
|
||||
apply_body_text(&body_lbl, spec.body);
|
||||
content.append(&body_lbl);
|
||||
}
|
||||
|
||||
if spec.actions.iter().any(|a| a.key == "default") {
|
||||
content.add_css_class("notification-default");
|
||||
let gesture = gtk4::GestureClick::new();
|
||||
let invoke = Invoke {
|
||||
conn: spec.conn.clone(),
|
||||
cards: spec.cards.clone(),
|
||||
cards_box: spec.cards_box.clone(),
|
||||
window: spec.window.clone(),
|
||||
id: spec.id,
|
||||
};
|
||||
gesture.connect_released(move |_, _, _, _| {
|
||||
invoke_action(invoke.clone(), "default");
|
||||
});
|
||||
content.add_controller(gesture);
|
||||
}
|
||||
|
||||
card.append(&content);
|
||||
|
||||
let visible: Vec<&Action> = spec
|
||||
.actions
|
||||
.iter()
|
||||
.filter(|a| a.key != "default" && a.key != INLINE_REPLY_KEY)
|
||||
.collect();
|
||||
if !visible.is_empty() {
|
||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
|
||||
row.add_css_class("notification-actions");
|
||||
row.set_halign(gtk4::Align::End);
|
||||
for action in visible {
|
||||
let btn = gtk4::Button::with_label(&action.label);
|
||||
btn.add_css_class("notification-action");
|
||||
let invoke = Invoke {
|
||||
conn: spec.conn.clone(),
|
||||
cards: spec.cards.clone(),
|
||||
cards_box: spec.cards_box.clone(),
|
||||
window: spec.window.clone(),
|
||||
id: spec.id,
|
||||
};
|
||||
let key = action.key.clone();
|
||||
btn.connect_clicked(move |_| {
|
||||
invoke_action(invoke.clone(), &key);
|
||||
});
|
||||
row.append(&btn);
|
||||
}
|
||||
card.append(&row);
|
||||
}
|
||||
|
||||
if let Some(placeholder) = spec.inline_reply {
|
||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
|
||||
row.add_css_class("notification-reply");
|
||||
|
||||
let entry = gtk4::Entry::new();
|
||||
entry.add_css_class("notification-reply-entry");
|
||||
entry.set_placeholder_text(Some(placeholder));
|
||||
entry.set_hexpand(true);
|
||||
|
||||
let send_label = spec
|
||||
.actions
|
||||
.iter()
|
||||
.find(|a| a.key == INLINE_REPLY_KEY)
|
||||
.map(|a| a.label.as_str())
|
||||
.filter(|l| !l.is_empty())
|
||||
.unwrap_or("Send");
|
||||
let send = gtk4::Button::with_label(send_label);
|
||||
send.add_css_class("notification-action");
|
||||
|
||||
let invoke = Invoke {
|
||||
conn: spec.conn.clone(),
|
||||
cards: spec.cards.clone(),
|
||||
cards_box: spec.cards_box.clone(),
|
||||
window: spec.window.clone(),
|
||||
id: spec.id,
|
||||
};
|
||||
let entry_for_btn = entry.clone();
|
||||
let invoke_btn = invoke.clone();
|
||||
send.connect_clicked(move |_| {
|
||||
submit_reply(&entry_for_btn, invoke_btn.clone());
|
||||
});
|
||||
entry.connect_activate(move |e| {
|
||||
submit_reply(e, invoke.clone());
|
||||
});
|
||||
|
||||
row.append(&entry);
|
||||
row.append(&send);
|
||||
card.append(&row);
|
||||
}
|
||||
|
||||
card
|
||||
}
|
||||
|
||||
/// FDO `body-markup` is a small Pango-ish subset (`<b>`, `<i>`, `<u>`,
|
||||
/// `<a href>`). 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<zbus::Connection>,
|
||||
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<zbus::Connection>, 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<zbus::Connection>, 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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
src/osd.rs
51
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<SampleKind>) -> gtk4::Window {
|
||||
let (tx, rx) = mpsc::channel::<OsdEvent>(8);
|
||||
|
||||
match sample {
|
||||
Some(kind) => {
|
||||
let _ = tx.try_send(kind.sample_event());
|
||||
}
|
||||
None => {
|
||||
let tx1 = tx.clone();
|
||||
std::thread::spawn(move || volume_watcher(tx1));
|
||||
std::thread::spawn(move || brightness_watcher(tx));
|
||||
}
|
||||
}
|
||||
|
||||
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<OsdEvent>) {
|
||||
|
|
@ -119,9 +155,7 @@ fn brightness_watcher(tx: mpsc::Sender<OsdEvent>) {
|
|||
}
|
||||
}
|
||||
|
||||
async fn run_osd(mut rx: mpsc::Receiver<OsdEvent>) {
|
||||
let window = create_window();
|
||||
|
||||
async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver<OsdEvent>) {
|
||||
let container = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
container.set_margin_top(10);
|
||||
container.set_margin_bottom(10);
|
||||
|
|
@ -129,9 +163,7 @@ async fn run_osd(mut rx: mpsc::Receiver<OsdEvent>) {
|
|||
container.set_margin_end(14);
|
||||
window.set_child(Some(&container));
|
||||
|
||||
let icon = gtk4::Image::from_paintable(Some(&crate::svg_texture(
|
||||
crate::bar::stats::ICON_VOLUME,
|
||||
)));
|
||||
let icon = crate::svg_image(crate::bar::stats::ICON_VOLUME);
|
||||
icon.add_css_class("osd-icon");
|
||||
container.append(&icon);
|
||||
|
||||
|
|
@ -150,6 +182,7 @@ async fn run_osd(mut rx: mpsc::Receiver<OsdEvent>) {
|
|||
};
|
||||
|
||||
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 {
|
||||
|
|
@ -175,9 +208,11 @@ fn create_window() -> gtk4::Window {
|
|||
let window = gtk4::Window::new();
|
||||
window.add_css_class("breadbar-osd");
|
||||
window.init_layer_shell();
|
||||
window.set_namespace(Some("breadbar-osd"));
|
||||
window.set_layer(Layer::Overlay);
|
||||
window.set_anchor(Edge::Bottom, true);
|
||||
window.set_margin(Edge::Bottom, 80);
|
||||
window.set_default_width(180);
|
||||
crate::theme::bind_auto(&window);
|
||||
window
|
||||
}
|
||||
|
|
|
|||
154
src/panel.rs
Normal file
154
src/panel.rs
Normal file
|
|
@ -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<gtk4::Widget>,
|
||||
control_child: &impl IsA<gtk4::Widget>,
|
||||
media_child: &impl IsA<gtk4::Widget>,
|
||||
) -> 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<gtk4::Widget>, 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
|
||||
}
|
||||
246
src/screenshot.rs
Normal file
246
src/screenshot.rs
Normal file
|
|
@ -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<String>,
|
||||
|
||||
/// PNG path to write the capture to. Required together with --screenshot.
|
||||
#[arg(long)]
|
||||
pub output: Option<PathBuf>,
|
||||
|
||||
/// Capture canvas width — matches the isolated compositor's output width
|
||||
/// (`bread-capture --isolate-width`) 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<ScreenshotRequest> {
|
||||
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<gtk4::Window>,
|
||||
/// Same deal as `notification_window`, via `osd::spawn(Some(kind))`.
|
||||
pub osd_window: Option<gtk4::Window>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
350
src/theme.rs
350
src/theme.rs
|
|
@ -1,4 +1,5 @@
|
|||
use bread_theme::{gtk as bgtk, hex_to_rgba, ink_on, load_palette};
|
||||
use bread_theme::{gtk as bgtk, ink_on, load_palette, load_palette_for, Palette};
|
||||
use gtk4::prelude::IsA;
|
||||
use gtk4::CssProvider;
|
||||
use std::cell::RefCell;
|
||||
|
||||
|
|
@ -7,7 +8,6 @@ thread_local! {
|
|||
}
|
||||
|
||||
fn load_css() -> String {
|
||||
let p = load_palette();
|
||||
// breadbar-specific rules only — fonts, base colours, and generic widgets
|
||||
// come from the shared ecosystem stylesheet (applied first in `apply()`).
|
||||
// Colour is set on each surface (bar, active workspace pill, notification
|
||||
|
|
@ -15,89 +15,266 @@ fn load_css() -> String {
|
|||
// pywal hands a given slot. `on_*` are luminance-picked ink (black/white) for
|
||||
// that background — the pywal hues themselves are untouched.
|
||||
//
|
||||
// Shared tokens: one radius and one padding rhythm reused across every
|
||||
// popover/card/OSD surface so they read as one design system rather than
|
||||
// four different ones. `radius_pill` is only for the tiny transient OSD.
|
||||
let radius = "10px";
|
||||
let radius_sm = "6px";
|
||||
let radius_pill = "20px";
|
||||
let pad = "10px";
|
||||
// Glass workbench: 16px island on the bar, 12px cards/popovers, pill OSD.
|
||||
// Hyprland `layerrule = blur, breadbar` frosts the translucent fills —
|
||||
// the CSS just leaves alpha. Colours are bread-theme tokens so pywal
|
||||
// accents (`@accent`) flow through on SIGHUP / `bread-theme reload`.
|
||||
let radius = "12px";
|
||||
let radius_bar = "16px";
|
||||
let radius_sm = "9px";
|
||||
let radius_pill = "999px";
|
||||
let pad = "12px";
|
||||
|
||||
format!(
|
||||
"window.breadbar {{ background-color: {bg_rgba}; color: {on_bg}; border-radius: 0; }}\
|
||||
.workspace-btn {{ background: transparent; opacity: 0.45; color: {on_bg};\
|
||||
border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\
|
||||
min-width: 20px; margin: 5px 2px; padding: 2px 9px; }}\
|
||||
.workspace-btn:hover {{ opacity: 0.8; }}\
|
||||
.workspace-btn.active {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\
|
||||
.stats-box {{ margin-right: 8px; }}\
|
||||
.stat-pair {{ margin-right: 14px; }}\
|
||||
.stat-icon {{ margin-right: 2px; }}\
|
||||
.bt-icon {{ margin-right: 14px; }}\
|
||||
separator.bar-sep {{ min-height: 14px; margin: 0 8px 0 0; background: alpha({on_bg}, 0.14); }}\
|
||||
window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; }}\
|
||||
.notification-card {{ background: {surface}; color: {on_surface}; border-radius: {radius};\
|
||||
padding: {pad}; margin-bottom: 8px; border-left: 3px solid transparent; }}\
|
||||
.notification-card.urgency-critical {{ border-left-color: {critical}; }}\
|
||||
.notification-card.urgency-normal {{ border-left-color: {accent}; }}\
|
||||
"@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\
|
||||
@keyframes osd-in {{ from {{ opacity: 0; margin-bottom: -8px; }} }}\
|
||||
@keyframes media-eq {{ to {{ min-height: 14px; }} }}\
|
||||
@keyframes pop-in {{ from {{ opacity: 0; margin-top: -10px; }} to {{ opacity: 1; margin-top: 0; }} }}\
|
||||
@keyframes pop-out {{ from {{ opacity: 1; margin-top: 0; }} to {{ opacity: 0; margin-top: -6px; }} }}\
|
||||
@keyframes row-in {{ from {{ opacity: 0; margin-top: 8px; }} to {{ opacity: 1; margin-top: 0; }} }}\
|
||||
@keyframes digit-flip {{ from {{ opacity: 0; margin-top: 7px; }} to {{ opacity: 1; margin-top: 0; }} }}\
|
||||
@keyframes caret-draw {{ from {{ margin-right: 200px; opacity: 0.2; }} to {{ margin-right: 4px; opacity: 1; }} }}\
|
||||
window.breadbar {{ background-color: alpha(@bg, 0.72); color: @on-bg;\
|
||||
border-radius: {radius_bar}; border: 1px solid alpha(@on-bg, 0.08); }}\
|
||||
window.breadbar > centerbox {{ padding: 0 8px 0 6px; }}\
|
||||
window.breadbar button {{ min-height: 0; min-width: 0; }}\
|
||||
.workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\
|
||||
background-color: @accent; border-radius: 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: {radius_pill}; }}\
|
||||
.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: {trough}; border-radius: 3px; min-height: 6px; }}\
|
||||
progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 3px; min-height: 6px; }}\
|
||||
.clickable {{ cursor: pointer; }}\
|
||||
.wifi-pair {{ border-radius: {radius_sm}; padding: 0 2px; }}\
|
||||
.wifi-pair:hover {{ background: alpha({on_bg}, 0.12); }}\
|
||||
.wifi-popover-inner {{ min-width: 200px; padding: {pad}; }}\
|
||||
.popover-tab-row {{ margin-bottom: {pad}; }}\
|
||||
.popover-tab {{ background: transparent; color: {on_bg}; border: none; box-shadow: none;\
|
||||
outline: none; border-radius: {radius_sm}; padding: 4px 10px; font-size: 11px;\
|
||||
font-weight: bold; opacity: 0.55; }}\
|
||||
progressbar.osd-bar trough {{ background-image: none; background-color: alpha(@accent, 0.25);\
|
||||
border-radius: 3px; min-height: 6px; }}\
|
||||
progressbar.osd-bar trough progress {{ background-image: none; background-color: @accent;\
|
||||
border-radius: 3px; min-height: 6px; }}\
|
||||
.wifi-pair {{ padding: 6px; }}\
|
||||
window.breadbar-panel {{ background-color: alpha(@bg, 0.72); color: @on-bg;\
|
||||
border-radius: 14px; border: 1px solid alpha(@on-bg, 0.12); }}\
|
||||
window.breadbar-dismiss, .breadbar-dismiss-hit {{\
|
||||
background-color: alpha(#000000, 0.02); }}\
|
||||
.popover-caret {{ min-height: 2px; margin: 2px 4px 10px; border-radius: 2px;\
|
||||
background-color: @accent;\
|
||||
background-image: linear-gradient(90deg, @accent, @teal);\
|
||||
animation: caret-draw 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
|
||||
.wifi-popover-inner {{ min-width: 228px; padding: {pad}; }}\
|
||||
window.wifi-popover button {{ min-height: 0; min-width: 0; }}\
|
||||
.popover-tab-row {{ background: alpha(@on-bg, 0.06); border-radius: 10px;\
|
||||
padding: 3px; margin-bottom: 10px; }}\
|
||||
.popover-tab {{ background: transparent; color: @on-bg; border: none; box-shadow: none;\
|
||||
outline: none; border-radius: 999px; padding: 0 14px; min-height: 32px;\
|
||||
font-size: 17px; font-weight: bold; opacity: 0.55;\
|
||||
transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\
|
||||
opacity 0.22s ease, color 0.22s ease; }}\
|
||||
.popover-tab:hover {{ opacity: 0.8; }}\
|
||||
.popover-tab:checked {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\
|
||||
.wifi-popover-ssid {{ font-weight: bold; font-size: 13px; }}\
|
||||
.wifi-popover-ip {{ opacity: 0.6; font-size: 11px; }}\
|
||||
.wifi-popover-status {{ font-size: 11px; margin-top: 2px; }}\
|
||||
.wifi-popover-section {{ font-size: 10px; font-weight: bold; opacity: 0.5; letter-spacing: 0.08em; }}\
|
||||
.popover-tab:checked {{ background: alpha(@accent, 0.22); color: @accent; opacity: 1; }}\
|
||||
.popover-tab label {{ padding: 0; margin: 0; }}\
|
||||
.wifi-popover-ssid {{ font-weight: bold; font-size: 18px; }}\
|
||||
.wifi-popover-ip {{ opacity: 0.6; font-size: 16px; }}\
|
||||
.wifi-popover-status {{ font-size: 16px; margin-top: 2px; }}\
|
||||
.wifi-popover-section {{ font-size: 13px; font-weight: bold; opacity: 0.45;\
|
||||
letter-spacing: 0.12em; }}\
|
||||
.wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\
|
||||
border-radius: {radius_sm}; padding: 4px 6px; }}\
|
||||
.wifi-popover-row:hover {{ background: alpha({on_bg}, 0.08); }}\
|
||||
.wifi-popover-row-active {{ color: {accent}; }}\
|
||||
outline: none; border-radius: 10px; padding: 0 12px; min-height: 42px;\
|
||||
transition: background-color 0.18s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
|
||||
.wifi-popover-row label {{ font-size: 18px; }}\
|
||||
.wifi-popover-row:hover {{ background: alpha(@on-bg, 0.08); }}\
|
||||
.wifi-popover-row-active {{ background: alpha(@accent, 0.14); color: @accent; }}\
|
||||
.wifi-popover-row-active:hover {{ background: alpha(@accent, 0.20); }}\
|
||||
.row-in {{ animation: row-in 0.32s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
|
||||
.stagger-0 {{ animation-delay: 0ms; }} .stagger-1 {{ animation-delay: 28ms; }}\
|
||||
.stagger-2 {{ animation-delay: 56ms; }} .stagger-3 {{ animation-delay: 84ms; }}\
|
||||
.stagger-4 {{ animation-delay: 112ms; }} .stagger-5 {{ animation-delay: 140ms; }}\
|
||||
.stagger-6 {{ animation-delay: 168ms; }} .stagger-7 {{ animation-delay: 196ms; }}\
|
||||
.stagger-8 {{ animation-delay: 224ms; }} .stagger-9 {{ animation-delay: 252ms; }}\
|
||||
.stagger-10 {{ animation-delay: 280ms; }} .stagger-11 {{ animation-delay: 308ms; }}\
|
||||
.wifi-popover-row-unsaved {{ opacity: 0.4; }}\
|
||||
.wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\
|
||||
window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px; }}\
|
||||
.media-widget {{ border-radius: {radius_sm}; 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 {{ min-width: 32px; padding: 4px 8px; }}\
|
||||
.control-panel-btn {{ padding: 0 6px; margin-left: 6px; border-radius: {radius_sm}; }}\
|
||||
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: 220px; padding: {pad}; }}\
|
||||
.control-panel-row {{ margin: 4px 0; }}\
|
||||
.control-panel-row-icon {{ opacity: 1; margin-right: 4px; }}\
|
||||
.control-panel-slider {{ margin: 0; }}\
|
||||
.control-panel-stats {{ margin: {pad} 0; }}\
|
||||
.control-panel-stat {{ font-size: 12px; opacity: 0.85; margin: 1px 0; }}\
|
||||
.control-panel-section {{ margin: {pad} 0; }}\
|
||||
.control-panel-section-header {{ font-size: 10px; font-weight: bold; opacity: 0.5;\
|
||||
letter-spacing: 0.08em; margin-bottom: 4px; }}\
|
||||
.control-panel-sink-dropdown {{ }}\
|
||||
.power-row {{ margin-top: 2px; }}\
|
||||
.power-btn {{ min-width: 40px; padding: 8px; border-radius: {radius_sm}; }}\
|
||||
separator {{ margin: 4px 0; }}",
|
||||
bg_plain = p.background,
|
||||
bg_rgba = hex_to_rgba(&p.background, 0.92),
|
||||
surface = p.color0,
|
||||
accent = p.color4,
|
||||
critical = p.color1,
|
||||
on_bg = ink_on(&p.background),
|
||||
on_surface = ink_on(&p.color0),
|
||||
trough = hex_to_rgba(&p.color4, 0.25),
|
||||
.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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -108,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<gtk4::Widget>, 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<gtk4::Native>) {
|
||||
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
|
||||
|
|
|
|||
106
src/widgets/client.rs
Normal file
106
src/widgets/client.rs
Normal file
|
|
@ -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<App>) {
|
||||
// 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<App>) {
|
||||
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<WidgetSpec>` 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::Value> = serde_json::from_value(result).unwrap_or_default();
|
||||
let specs: Vec<WidgetSpec> = 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::<WidgetSpec>(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 }),
|
||||
);
|
||||
}
|
||||
7
src/widgets/mod.rs
Normal file
7
src/widgets/mod.rs
Normal file
|
|
@ -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;
|
||||
219
src/widgets/render.rs
Normal file
219
src/widgets/render.rs
Normal file
|
|
@ -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<gtk4::gdk::Texture> {
|
||||
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 `<module>.<id>`, 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue