From c10bad38770d909c23dc12d6c5e094ea367cc3c2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 09:58:26 +0800 Subject: [PATCH 01/21] ci: add dev/beta build track workflows Adds dev-release.yml (publishes on every push to dev) and beta-release.yml (publishes on a beta-v* tag), mirroring the pattern landing in bread-ecosystem/bread. See bread-ecosystem/docs/release-channels.md for the three-track policy. --- .forgejo/workflows/beta-release.yml | 56 +++++++++++++++++++++++++ .forgejo/workflows/dev-release.yml | 65 +++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 .forgejo/workflows/beta-release.yml create mode 100644 .forgejo/workflows/dev-release.yml diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml new file mode 100644 index 0000000..8d9d27e --- /dev/null +++ b/.forgejo/workflows/beta-release.yml @@ -0,0 +1,56 @@ +name: beta release + +# Publishes a beta-track build when a `beta-v*` tag is pushed — +# separate from release.yml's tag-triggered stable releases. See +# bread-ecosystem's docs/release-channels.md for the three-track policy +# this is part of. +on: + push: + tags: ['beta-v*'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: test + run: cd src && cargo test --release --locked + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#beta-v}" + PKG_DIR="/srv/breadway-dl/beta/breadpaper/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/breadpaper" "${PKG_DIR}/breadpaper-x86_64" + strip "${PKG_DIR}/breadpaper-x86_64" + sha256sum "${PKG_DIR}/breadpaper-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/breadpaper-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadpaper/latest" + + # No GitHub Release upload — beta, like the other non-stable track, + # is only distributed via dl.breadway.dev/beta/. + - name: regenerate beta index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)" + exit 1 + fi + rm -rf /tmp/bread-ecosystem-ci + # --branch dev: the TRACK-aware gen-index.sh isn't merged to + # bread-ecosystem's main yet. + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci + TRACK=beta bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml new file mode 100644 index 0000000..8fc57e5 --- /dev/null +++ b/.forgejo/workflows/dev-release.yml @@ -0,0 +1,65 @@ +name: dev release + +# Publishes a dev-track build on every push to `dev` — +# separate from release.yml's tag-triggered stable releases. See +# bread-ecosystem's docs/release-channels.md for the three-track policy +# this is part of. +on: + push: + branches: ['dev'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch dev --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: test + run: cd src && cargo test --release --locked + + - name: compute dev version + run: | + set -euo pipefail + cd src + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + IFS='.' read -r MA MI PA <<< "${CUR}" + SHA="$(git rev-parse --short HEAD)" + TS="$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV" + + - name: prepare artifacts + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/breadpaper/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/breadpaper" "${PKG_DIR}/breadpaper-x86_64" + strip "${PKG_DIR}/breadpaper-x86_64" + sha256sum "${PKG_DIR}/breadpaper-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/breadpaper-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadpaper/latest" + + # No GitHub Release upload — dev, like the other non-stable track, + # is only distributed via dl.breadway.dev/dev/. + - name: regenerate dev index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)" + exit 1 + fi + rm -rf /tmp/bread-ecosystem-ci + # --branch dev: the TRACK-aware gen-index.sh isn't merged to + # bread-ecosystem's main yet. + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci + TRACK=dev bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh From 25a3175776c9464fb795a0c878ef1030ae6994ac Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 10:10:10 +0800 Subject: [PATCH 02/21] ci: retrigger dev-track build now that BAKERY_MINISIGN_SEC_KEY_PATH is set From 2d652f2bbfda0dc42c906502411b5c164539ae22 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 10:24:33 +0800 Subject: [PATCH 03/21] ci: use a unique temp dir for the bread-ecosystem clone in dev/beta CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixed /tmp/bread-ecosystem-ci path races when multiple repos' dev/beta workflows run close together on the same self-hosted runner — one job's rm -rf/clone can stomp another's in-progress checkout, causing the regenerate-index step to fail intermittently. Switch to mktemp -d. --- .forgejo/workflows/beta-release.yml | 12 +++++++----- .forgejo/workflows/dev-release.yml | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index 8d9d27e..e0883b1 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -49,8 +49,10 @@ jobs: echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)" exit 1 fi - rm -rf /tmp/bread-ecosystem-ci - # --branch dev: the TRACK-aware gen-index.sh isn't merged to - # bread-ecosystem's main yet. - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci - TRACK=beta bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true + # mktemp: a fixed clone path races when multiple repos' dev/beta + # workflows run close together on the same self-hosted runner. + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 8fc57e5..178a81c 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -58,8 +58,10 @@ jobs: echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)" exit 1 fi - rm -rf /tmp/bread-ecosystem-ci - # --branch dev: the TRACK-aware gen-index.sh isn't merged to - # bread-ecosystem's main yet. - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci - TRACK=dev bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true + # mktemp: a fixed clone path races when multiple repos' dev/beta + # workflows run close together on the same self-hosted runner. + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" From e77da145d90404c2a273060ab429b35477278d62 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 11:56:44 +0800 Subject: [PATCH 04/21] random commit message, read it yourself --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ea8c4bf..e96a201 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /target + +# Local hygiene notes (not for commit) +CLAUDE.md From fa3ccacca5f8823ce32a67b22a47fce9223fc97d Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 13:52:32 +0800 Subject: [PATCH 05/21] ci: base dev version on the latest published tag, not Cargo.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo.toml can drift stale relative to the actual last release (observed on breadbox/breadpad/breadcrumbs/breadpaper), which made the auto-bumped dev version sort as OLDER than what's already installed — bakery's semver check correctly refused those "updates". Deriving the base version from git ls-remote --tags instead is self-healing regardless of Cargo.toml drift, with a Cargo.toml fallback only for a repo with no tags yet. --- .forgejo/workflows/dev-release.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 178a81c..6bf3688 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -29,7 +29,19 @@ jobs: run: | set -euo pipefail cd src - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + # Base the dev version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a dev build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi IFS='.' read -r MA MI PA <<< "${CUR}" SHA="$(git rev-parse --short HEAD)" TS="$(date -u +%Y%m%d%H%M%S)" From eb8667991dade11a861f4739e1c69b8a5267b01a Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 18:37:32 +0800 Subject: [PATCH 06/21] ci: make beta a branch-triggered freeze track, not a one-off tag Beta is now a real stabilization branch: publishes on every push to `beta` (mirroring dev's model, auto-versioned X.Y.Z-beta.+, base version from the latest published tag) instead of a manual beta-v* tag. Fixes made during the freeze land via fix/ branches merged into `beta` directly. The gen-index.sh clone for beta pulls bread-ecosystem's default branch (main) rather than pinning to dev, since beta is the more stable track and main now carries the TRACK-aware script. --- .forgejo/workflows/beta-release.yml | 41 ++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index e0883b1..ff71311 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -1,12 +1,12 @@ name: beta release -# Publishes a beta-track build when a `beta-v*` tag is pushed — -# separate from release.yml's tag-triggered stable releases. See -# bread-ecosystem's docs/release-channels.md for the three-track policy -# this is part of. +# Publishes a beta-track build on every push to `beta` — a frozen +# stabilization branch cut from `dev` when ready to stabilize; only +# fix/ branches merged into `beta` should land here afterward. +# See bread-ecosystem's docs/release-channels.md for the three-track policy. on: push: - tags: ['beta-v*'] + branches: ['beta'] jobs: build: @@ -16,7 +16,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + git clone --branch beta --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -25,10 +25,31 @@ jobs: - name: test run: cd src && cargo test --release --locked + - name: compute beta version + run: | + set -euo pipefail + cd src + # Base the beta version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a beta build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi + IFS='.' read -r MA MI PA <<< "${CUR}" + SHA="$(git rev-parse --short HEAD)" + TS="$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=${MA}.${MI}.$((PA + 1))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" + - name: prepare artifacts run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#beta-v}" PKG_DIR="/srv/breadway-dl/beta/breadpaper/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/breadpaper" "${PKG_DIR}/breadpaper-x86_64" @@ -38,8 +59,8 @@ jobs: cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadpaper/latest" - # No GitHub Release upload — beta, like the other non-stable track, - # is only distributed via dl.breadway.dev/beta/. + # No GitHub Release upload — beta, like dev, is only distributed via + # dl.breadway.dev/beta/. - name: regenerate beta index.json env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} @@ -53,6 +74,6 @@ jobs: # mktemp: a fixed clone path races when multiple repos' dev/beta # workflows run close together on the same self-hosted runner. ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" rm -rf "${ECOSYSTEM_CI_DIR}" From 61775c91a69ae0b2e757b91ff662f482f9164629 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 19:41:10 +0800 Subject: [PATCH 07/21] docs: add CONTRIBUTING.md Documents the dev/beta/main branch and release-track workflow shared across the bread ecosystem. See bread-ecosystem's docs/release-channels.md for the full policy this implements. --- CONTRIBUTING.md | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8262426 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,91 @@ +# Contributing + +`breadpaper` — Wallpaper manager for the bread desktop. + +Part of the bread ecosystem; this repo follows the same branch/release +workflow as every other ecosystem product. + +## Branches + +- **`main`** — release branch, always tag-ready. Nothing is committed to it + directly; it only moves forward via a `beta` merge (see below). +- **`dev`** — integration branch. All day-to-day work lands here first. + Every push to `dev` automatically builds and publishes a **dev-track** + build (see Tracks below) — use this to test your change in a real install + before it goes any further. +- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. + Every push to `beta` automatically builds and publishes a **beta-track** + build. While a freeze is active, only fixes for issues found *in that + freeze* should land on `beta`. + +New work — features and bug fixes alike — goes on a short-lived branch: + +``` +feature/ +fix/ +``` + +Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing +something reported against an active `beta` freeze, branch off `beta` +instead, merge the fix there to unblock testers, and also forward the same +fix into `dev` so it doesn't quietly reappear next cycle. + +## The release cycle + +1. Work accumulates on `dev` via `feature/x` / `fix/x` branches. Each push + auto-publishes a dev build — install it with `bakery track set dev` and + `bakery update --all`, then report or fix anything broken with another + push to `dev`. +2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut + fresh from `dev`'s current tip. This freezes it as the stabilization + target — `dev` keeps moving independently starting the next cycle. +3. `beta` is open for anyone to test: `bakery track set beta` and + `bakery update --all`. **File issues against anything you find on this + repo's Forgejo issue tracker.** Fixes land via `fix/` branches + merged into `beta`. +4. Once `beta` has gone roughly **a month** without new issues, it's merged + into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the + stable release build. `beta` is then reset from `dev` to start the next + cycle. + +## Tracks, from a user's perspective + +``` +bakery track show # what you're currently on (defaults to stable) +bakery track set dev # or beta, or stable +bakery update --all # pull the latest build on your current track +``` + +| Track | What it is | Published from | +|--------|-----------|-----------------| +| `stable` | The last tagged release | `main`, on a `vX.Y.Z` tag push | +| `beta` | Current stabilization freeze | `beta`, on every push | +| `dev` | Bleeding edge | `dev`, on every push | + +Dev/beta versions are auto-computed (`X.Y.Z-dev.+` / +`-beta.…`) from the latest published stable tag, so they always sort as +newer than what you have installed — no manual version bumping needed when +pushing to `dev` or `beta`. + +## Local development + +```sh +cargo build --release +cargo test --release +``` + +## CI + +- `dev-release.yml` — triggered on push to `dev`. +- `beta-release.yml` — triggered on push to `beta`. +- `release.yml` — triggered on a `v*` tag push, cuts the actual stable release. +- `package.yml` — publishes to the `[breadway]` pacman repo, also tag-triggered. + +All CI runs on a self-hosted runner; nothing runs automatically on plain +commits or PRs beyond the track builds above. See +[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md) +for the full policy, including how a new product gets wired onto these tracks. + +## Questions + +Open an issue on this repo's Forgejo tracker. From 3b7434f2c97bfb28b8dc2d9477928e56e11ebe95 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 23 Jul 2026 10:25:12 +0800 Subject: [PATCH 08/21] Drop pacman packaging, bakery-only distribution bakery already fully covers what the PKGBUILD provided (binary, systemd --user service where applicable, dependency declarations) except a LICENSE copy, which bakery.toml's new license_file field now closes. Removes packaging/arch/ and .forgejo/workflows/package.yml; adds the LICENSE artifact to each release/dev-release/beta-release workflow's prepare step. Not pacman-installed inside BOS today (BOS already consumes these apps exclusively via build-local.sh's skel-staging), so this only removes the option to `pacman -S` outside of BOS/bakery. --- .forgejo/workflows/beta-release.yml | 1 + .forgejo/workflows/dev-release.yml | 1 + .forgejo/workflows/package.yml | 37 ----------------------------- .forgejo/workflows/release.yml | 1 + bakery.toml | 1 + packaging/arch/PKGBUILD | 34 -------------------------- 6 files changed, 4 insertions(+), 71 deletions(-) delete mode 100644 .forgejo/workflows/package.yml delete mode 100644 packaging/arch/PKGBUILD diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index ff71311..229f035 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -56,6 +56,7 @@ jobs: strip "${PKG_DIR}/breadpaper-x86_64" sha256sum "${PKG_DIR}/breadpaper-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/breadpaper-x86_64.sha256" + cp src/LICENSE "${PKG_DIR}/" cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadpaper/latest" diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 6bf3688..f7e3a8a 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -56,6 +56,7 @@ jobs: strip "${PKG_DIR}/breadpaper-x86_64" sha256sum "${PKG_DIR}/breadpaper-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/breadpaper-x86_64.sha256" + cp src/LICENSE "${PKG_DIR}/" cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadpaper/latest" diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml deleted file mode 100644 index 1a26be9..0000000 --- a/.forgejo/workflows/package.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Build and publish package - -on: - push: - tags: ['v*'] - -jobs: - package: - runs-on: [self-hosted, hestia] - container: - image: archlinux:latest - steps: - - 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 - 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="breadpaper-${VERSION}/" HEAD \ - > packaging/arch/breadpaper-${VERSION}.tar.gz - SHA=$(sha256sum packaging/arch/breadpaper-${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 - su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" - PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) - curl -fsS -X PUT \ - -H "Authorization: token ${PUBLISH_TOKEN}" \ - -H "Content-Type: application/octet-stream" \ - --data-binary "@${PKG}" \ - "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index ff3f0c2..0c45193 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -39,6 +39,7 @@ jobs: strip "${PKG_DIR}/breadpaper-x86_64" sha256sum "${PKG_DIR}/breadpaper-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/breadpaper-x86_64.sha256" + cp LICENSE "${PKG_DIR}/" cp bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "${DL_DIR}/breadpaper/latest" diff --git a/bakery.toml b/bakery.toml index 2253fa9..6889a7d 100644 --- a/bakery.toml +++ b/bakery.toml @@ -4,6 +4,7 @@ binaries = ["breadpaper"] system_deps = ["python-pywal"] optional_system_deps = ["awww"] bread_deps = ["bread"] +license_file = "LICENSE" [install] post_install = [] diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD deleted file mode 100644 index 7f20f48..0000000 --- a/packaging/arch/PKGBUILD +++ /dev/null @@ -1,34 +0,0 @@ -# Maintainer: Breadway - -pkgname=breadpaper -pkgver=0.1.0 -pkgrel=1 -pkgdesc="Wallpaper manager for the bread desktop" -arch=('x86_64') -url="https://github.com/Breadway/breadpaper" -license=('MIT') -options=(!lto !debug) -depends=('glibc') -optdepends=( - 'python-pywal: colour palette generation from wallpaper (AUR)' - 'awww: Wayland wallpaper daemon' -) -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/breadpaper "${pkgdir}/usr/bin/breadpaper" - install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" -} From d329839af22108996fb6495638c97927f9b8413a Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:05:29 +0800 Subject: [PATCH 09/21] =?UTF-8?q?CI:=20single-trunk=20model=20=E2=80=94=20?= =?UTF-8?q?dev=20triggers=20on=20main,=20beta=20becomes=20RC-tag-triggered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dev/beta branch split with one trunk (main): dev-track builds still publish on every push, but the beta track now publishes from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained beta branch. Removes the branch nobody reliably kept in sync. --- .forgejo/workflows/dev-release.yml | 15 ++++---- .forgejo/workflows/mirror.yml | 19 ---------- .../{beta-release.yml => rc-release.yml} | 38 +++++-------------- .forgejo/workflows/release.yml | 1 + 4 files changed, 17 insertions(+), 56 deletions(-) delete mode 100644 .forgejo/workflows/mirror.yml rename .forgejo/workflows/{beta-release.yml => rc-release.yml} (58%) diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index f7e3a8a..da8d667 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -1,12 +1,11 @@ name: dev release -# Publishes a dev-track build on every push to `dev` — -# separate from release.yml's tag-triggered stable releases. See -# bread-ecosystem's docs/release-channels.md for the three-track policy -# this is part of. +# Publishes a dev-track build on every push to `main` (the trunk +# branch — there is no separate `dev` branch). See bread-ecosystem's +# docs/release-channels.md for the release-track policy this is part of. on: push: - branches: ['dev'] + branches: ['main'] jobs: build: @@ -16,7 +15,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch dev --depth 1 \ + git clone --branch main --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -36,7 +35,7 @@ jobs: # what's already installed and bakery would correctly refuse it. LATEST_TAG="$(git ls-remote --tags --refs \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" if [ -n "${LATEST_TAG}" ]; then CUR="${LATEST_TAG}" else @@ -75,6 +74,6 @@ jobs: # mktemp: a fixed clone path races when multiple repos' dev/beta # workflows run close together on the same self-hosted runner. ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index 01e1dad..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Mirror to GitHub - -on: - push: - branches: ['**'] - tags: ['**'] - -jobs: - mirror: - runs-on: [self-hosted, hestia] - steps: - - name: Mirror to GitHub - run: | - set -euo pipefail - git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git - cd repo.git - git push --prune \ - "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadpaper.git" \ - '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/rc-release.yml similarity index 58% rename from .forgejo/workflows/beta-release.yml rename to .forgejo/workflows/rc-release.yml index 229f035..3146063 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/rc-release.yml @@ -1,22 +1,23 @@ -name: beta release +name: beta (rc) release -# Publishes a beta-track build on every push to `beta` — a frozen -# stabilization branch cut from `dev` when ready to stabilize; only -# fix/ branches merged into `beta` should land here afterward. -# See bread-ecosystem's docs/release-channels.md for the three-track policy. +# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag +# pushed to `main` — there is no separate `beta` branch; "freezing" is +# just pausing pushes to main while an RC gets tested. See +# bread-ecosystem's docs/release-channels.md for the release-track policy. on: push: - branches: ['beta'] + tags: ['v*'] jobs: build: + if: ${{ contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch beta --depth 1 \ + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -25,31 +26,10 @@ jobs: - name: test run: cd src && cargo test --release --locked - - name: compute beta version - run: | - set -euo pipefail - cd src - # Base the beta version off the latest published stable tag, - # not Cargo.toml — Cargo.toml can go stale relative to the last - # real release (seen in practice: breadbox/breadpad/breadcrumbs/ - # breadpaper), which would make a beta build sort as OLDER than - # what's already installed and bakery would correctly refuse it. - LATEST_TAG="$(git ls-remote --tags --refs \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" - if [ -n "${LATEST_TAG}" ]; then - CUR="${LATEST_TAG}" - else - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" - fi - IFS='.' read -r MA MI PA <<< "${CUR}" - SHA="$(git rev-parse --short HEAD)" - TS="$(date -u +%Y%m%d%H%M%S)" - echo "VERSION=${MA}.${MI}.$((PA + 1))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" - - name: prepare artifacts run: | set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="/srv/breadway-dl/beta/breadpaper/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/breadpaper" "${PKG_DIR}/breadpaper-x86_64" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 0c45193..9785dfd 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -11,6 +11,7 @@ env: jobs: build: + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: hestia defaults: run: From ceb6fbb12f545b2a784f1e69f33dbce691abcf14 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:08:41 +0800 Subject: [PATCH 10/21] CONTRIBUTING.md: document single-trunk + RC-tag release model --- CONTRIBUTING.md | 71 ++++++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 39 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8262426..e2d9ceb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,16 +7,10 @@ workflow as every other ecosystem product. ## Branches -- **`main`** — release branch, always tag-ready. Nothing is committed to it - directly; it only moves forward via a `beta` merge (see below). -- **`dev`** — integration branch. All day-to-day work lands here first. - Every push to `dev` automatically builds and publishes a **dev-track** - build (see Tracks below) — use this to test your change in a real install - before it goes any further. -- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. - Every push to `beta` automatically builds and publishes a **beta-track** - build. While a freeze is active, only fixes for issues found *in that - freeze* should land on `beta`. +There is one long-lived branch: **`main`**. All day-to-day work lands here. +Every push to `main` automatically builds and publishes a **dev-track** +build (see Tracks below) — a real install you can test before cutting +anything more formal. New work — features and bug fixes alike — goes on a short-lived branch: @@ -25,28 +19,26 @@ feature/ fix/ ``` -Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing -something reported against an active `beta` freeze, branch off `beta` -instead, merge the fix there to unblock testers, and also forward the same -fix into `dev` so it doesn't quietly reappear next cycle. +Branch off `main`, open a PR/push back into `main` when ready. Short-lived +branches get deleted on merge — they never accumulate the kind of drift a +second long-lived branch does. ## The release cycle -1. Work accumulates on `dev` via `feature/x` / `fix/x` branches. Each push +There's no separate `beta` or release branch — "stable" and "beta" are both +just **tags** on `main`, not branches that need to be kept in sync: + +1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push auto-publishes a dev build — install it with `bakery track set dev` and - `bakery update --all`, then report or fix anything broken with another - push to `dev`. -2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut - fresh from `dev`'s current tip. This freezes it as the stabilization - target — `dev` keeps moving independently starting the next cycle. -3. `beta` is open for anyone to test: `bakery track set beta` and - `bakery update --all`. **File issues against anything you find on this - repo's Forgejo issue tracker.** Fixes land via `fix/` branches - merged into `beta`. -4. Once `beta` has gone roughly **a month** without new issues, it's merged - into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the - stable release build. `beta` is then reset from `dev` to start the next - cycle. + `bakery update --all`, then fix anything broken with another push. +2. When you want to stabilize before a real release, tag a release + candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to + both remotes). That tag alone triggers a beta-track build — + "freezing" is just pausing pushes to `main` while you test it, not a + branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes. +3. Once an RC has gone without issues, tag the real release: + `git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the + signed stable release build. ## Tracks, from a user's perspective @@ -58,14 +50,15 @@ bakery update --all # pull the latest build on your current track | Track | What it is | Published from | |--------|-----------|-----------------| -| `stable` | The last tagged release | `main`, on a `vX.Y.Z` tag push | -| `beta` | Current stabilization freeze | `beta`, on every push | -| `dev` | Bleeding edge | `dev`, on every push | +| `stable` | The last tagged release | a `vX.Y.Z` tag | +| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag | +| `dev` | Bleeding edge | `main`, on every push | -Dev/beta versions are auto-computed (`X.Y.Z-dev.+` / -`-beta.…`) from the latest published stable tag, so they always sort as -newer than what you have installed — no manual version bumping needed when -pushing to `dev` or `beta`. +Dev versions are auto-computed (`X.Y.Z-dev.+`) from the +latest published stable tag, so they always sort as newer than what you +have installed — no manual version bumping needed. Beta versions are just +the RC tag itself (already valid semver, already sorts below the real +release it's a candidate for). ## Local development @@ -76,10 +69,10 @@ cargo test --release ## CI -- `dev-release.yml` — triggered on push to `dev`. -- `beta-release.yml` — triggered on push to `beta`. -- `release.yml` — triggered on a `v*` tag push, cuts the actual stable release. -- `package.yml` — publishes to the `[breadway]` pacman repo, also tag-triggered. +- `dev-release.yml` — triggered on push to `main`. +- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push. +- `release.yml` — triggered on any other `v*` tag push, cuts the actual + stable release. All CI runs on a self-hosted runner; nothing runs automatically on plain commits or PRs beyond the track builds above. See From 3eff1e5972c169d9094637f3e91777812ac848ae Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 5 Aug 2026 19:14:49 +0800 Subject: [PATCH 11/21] ci: build inside bread-ecosystem's shared Arch container Ports breadpaper onto bread-ecosystem's shared, pinned Arch build image/script instead of installing toolchain/libraries directly on the bare self-hosted runner. Follows the pattern proven in breadpad: ci/build.sh clones bread-ecosystem pinned to the sha in ci/bread-ecosystem.rev and delegates to its ci/build.sh. breadpaper needs nothing beyond the shared image's base package set (only clap/anyhow as deps, no image-processing or GTK crates), so no ci/deps.txt is added. Also adds check.yml: clippy + test on feature/**/fix/** pushes, as a fast pre-release-build signal, matching breadpaper's own build/test flags (--release --locked, no --workspace since this isn't a workspace). --- .forgejo/workflows/check.yml | 24 ++++++++++++++++++++++++ .forgejo/workflows/dev-release.yml | 4 ++-- .forgejo/workflows/rc-release.yml | 4 ++-- .forgejo/workflows/release.yml | 4 ++-- ci/bread-ecosystem.rev | 1 + ci/build.sh | 21 +++++++++++++++++++++ 6 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 .forgejo/workflows/check.yml create mode 100644 ci/bread-ecosystem.rev create mode 100755 ci/build.sh diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml new file mode 100644 index 0000000..38e3b07 --- /dev/null +++ b/.forgejo/workflows/check.yml @@ -0,0 +1,24 @@ +name: check + +# Fast-fail lint/test on short-lived work branches, before it ever reaches +# main and triggers a dev-track release build. +on: + push: + branches: ['feature/**', 'fix/**'] + +jobs: + check: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: clippy + run: cd src && bash ci/build.sh cargo clippy --all-targets --locked -- -D warnings + + - name: test + run: cd src && bash ci/build.sh cargo test --release --locked diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index da8d667..4398649 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -19,10 +19,10 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: test - run: cd src && cargo test --release --locked + run: cd src && bash ci/build.sh cargo test --release --locked - name: compute dev version run: | diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml index 3146063..3c1646b 100644 --- a/.forgejo/workflows/rc-release.yml +++ b/.forgejo/workflows/rc-release.yml @@ -21,10 +21,10 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: test - run: cd src && cargo test --release --locked + run: cd src && bash ci/build.sh cargo test --release --locked - name: prepare artifacts run: | diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 9785dfd..96877ef 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -26,10 +26,10 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /tmp/breadpaper-build - name: build - run: /home/breadway/.cargo/bin/cargo build --release --locked + run: bash ci/build.sh cargo build --release --locked - name: test - run: /home/breadway/.cargo/bin/cargo test --release --locked + run: bash ci/build.sh cargo test --release --locked - name: prepare artifacts run: | diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev new file mode 100644 index 0000000..34e7aa9 --- /dev/null +++ b/ci/bread-ecosystem.rev @@ -0,0 +1 @@ +147cfbbf96ae4b171027defa1130d2caddb934b1 diff --git a/ci/build.sh b/ci/build.sh new file mode 100755 index 0000000..8a880a0 --- /dev/null +++ b/ci/build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Delegates to bread-ecosystem's shared CI build image/script, pinned to +# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI +# files now affect every product's release pipeline, so bumping the pin +# is a deliberate act instead of silent drift (see the bread-theme test +# that broke here for exactly that reason, before it was pinned by rev). +# +# Usage: ci/build.sh cargo build --release --locked +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")" + +CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}" +if [ ! -d "$CACHE_DIR" ]; then + rm -rf /tmp/bread-ecosystem-ci-* + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR" + git -C "$CACHE_DIR" checkout --quiet "$REV" +fi + +bash "${CACHE_DIR}/ci/build.sh" breadpaper "$ROOT" "$@" From 70c2dd3c6ac17aad051340b4080293876ca68c4f Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 21:34:55 +0800 Subject: [PATCH 12/21] bakery.toml: bread-theme + awww/pywal, not bread The setter shells out to awww, wal, and bread-theme reload. It never talks to breadd. bakery.toml listed bread as a dep and treated awww as optional. Add a short README: this is a setter, not a library or slideshow daemon. Wallpaper library UI lives in bos-settings. --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ bakery.toml | 6 +++--- 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4b724b --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# breadpaper + +Wallpaper setter for the bread desktop. One command sets the wallpaper +via [awww](https://github.com/heywoodlh/awww), generates a palette with +[pywal](https://github.com/dylanaraps/pywal) (`wal`), and runs +`bread-theme reload`. + +It is not a wallpaper library, a slideshow daemon, or a GUI. Browsing +`~/Pictures/Backgrounds` and picking an image lives in +[bos-settings](https://git.breadway.dev/Breadway/bos-settings). + +## Dependencies + +Must be on `$PATH` for `set`: + +- `awww` — Wayland wallpaper (`awww img`) +- `wal` — palette generation (`python-pywal`) +- `bread-theme` — theme reload (bakery package, not `breadd`) + +`get` only reads the path pywal stored at `~/.cache/wal/wal`. + +## Install + +``` +bakery install breadpaper +``` + +From source: + +``` +cargo build --release +install -Dm755 target/release/breadpaper ~/.local/bin/breadpaper +``` + +## Usage + +``` +breadpaper # shorthand for `set` +breadpaper set # awww + wal + bread-theme reload +breadpaper get # print the current wallpaper path +``` + +Supported formats: `png`, `jpg`, `jpeg`, `webp`, `gif`, `bmp`. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/bakery.toml b/bakery.toml index 6889a7d..c946a46 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,9 +1,9 @@ name = "breadpaper" description = "Wallpaper manager for the bread desktop — sets awww wallpaper, generates pywal palette, reloads bread themes" binaries = ["breadpaper"] -system_deps = ["python-pywal"] -optional_system_deps = ["awww"] -bread_deps = ["bread"] +system_deps = ["awww", "python-pywal"] +optional_system_deps = [] +bread_deps = ["bread-theme"] license_file = "LICENSE" [install] From 7a3a54c6f025df9707899872c136c67023cc2dd2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:03:30 +0800 Subject: [PATCH 13/21] Track AGENTS.md --- .gitignore | 1 - AGENTS.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index e96a201..e412023 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ /target # Local hygiene notes (not for commit) -CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..81e8786 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# AGENTS.md — Repo hygiene + +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 `main` model, `feature/x`/`fix/x` branch naming, RC-tag-driven +beta releases, etc). Don't improvise a different workflow. There is no +`dev` or `beta` branch — that three-branch model was retired. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push both when publishing. + +## CI +- `check.yml` — clippy + test, triggers on push to `feature/**`/`fix/**`. +- `dev-release.yml` — triggers on push to `main`. +- `rc-release.yml` — triggers on `vX.Y.Z-rc.N` tag push. +- `release.yml` — triggers on any other `v*` tag push. + +All four run on a self-hosted runner (`hestia`) inside a pinned Arch +container — not the host's native environment. The Containerfile/build +script are shared across bread-ecosystem products and live in +`bread-ecosystem/ci/`; this repo's `ci/build.sh` clones that repo at the +sha in `ci/bread-ecosystem.rev` (deliberately pinned, not `main`) and +delegates to it. Nothing runs automatically on plain commits or PRs +beyond what's listed. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. From 54210ddc9352ce60200695ff82d99bda4509c5ae Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:15:29 +0800 Subject: [PATCH 14/21] Emit bread.paper.changed after a successful set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Link bread-utils v0.7.1 (bread-client) and publish { "path": "" } once awww + wal + bread-theme reload all succeed. Silent no-op if breadd is down. breadpaper is CLI-only — no watch subscriber; modules should bread.exec ("breadpaper set …"). See EVENTS.md. --- Cargo.lock | 377 ++++++++++++++++++++++++++++++++++++++++++++++++++--- Cargo.toml | 2 + EVENTS.md | 57 ++++++++ README.md | 7 + src/lib.rs | 29 ++++- 5 files changed, 452 insertions(+), 20 deletions(-) create mode 100644 EVENTS.md diff --git a/Cargo.lock b/Cargo.lock index f145084..a44fd0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,7 +38,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,28 +49,58 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bread-shared" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +dependencies = [ + "dirs", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "bread-utils" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +dependencies = [ + "bread-shared", + "dirs", + "serde", + "serde_json", +] [[package]] name = "breadpaper" version = "0.1.0" dependencies = [ "anyhow", + "bread-utils", "clap", + "serde_json", ] [[package]] -name = "clap" -version = "4.6.1" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -78,9 +108,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -90,14 +120,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -112,18 +142,99 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -131,23 +242,92 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "option-ext" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "strsim" version = "0.11.1" @@ -156,15 +336,87 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -177,12 +429,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -191,3 +458,75 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index da29e25..ff77e05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,3 +8,5 @@ license = "MIT" [dependencies] clap = { version = "4", features = ["derive"] } anyhow = "1" +serde_json = "1" +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["bread-client"] } diff --git a/EVENTS.md b/EVENTS.md new file mode 100644 index 0000000..cf531a0 --- /dev/null +++ b/EVENTS.md @@ -0,0 +1,57 @@ +# breadpaper — bread event integration + +breadpaper is a one-shot CLI wallpaper setter: it works exactly the same +with or without `breadd` running. When breadd *is* present, each successful +`breadpaper set` (or the bare-path shorthand) publishes one event into the +shared bread automation fabric. See the parent `bread` repo's +`Documentation.md` — specifically its "Namespaces" and "Integrating a +bread\* app" sections — for the general convention this follows. + +App id: **`paper`**. Transport: `bread-utils`'s `bread_client` module +(feature `bread-client`) — the CLI links it directly and uses +`BreadClient::connect("paper")` + `emit` only. v0.7.1 has no `command()` +helper, and breadpaper has no long-running process that could hold a +`subscribe` open. + +There is no `breadpaper` daemon and no `watch` subcommand. A `bread-emit +bread.command.paper.set` (or any other `bread.command.paper.*`) with no +subscriber is a silent no-op — that is the documented bread convention, +not a breadpaper bug. Modules that want to change the wallpaper should +shell out: + +```lua +bread.exec("breadpaper set /path/to/image.png") +``` + +The one-shot process still emits `bread.paper.changed` on success, so a +workflow can `bread.wait("bread.paper.changed", …)` for the real outcome +instead of assuming the exec finished the set. + +## Events published (`bread.paper.*`) + +| Event | Data | When | +|-------|------|------| +| `bread.paper.changed` | `{ "path": "" }` | After a successful `set` (awww + wal + `bread-theme reload`). `path` is the canonical absolute path that was applied. Not emitted on `get`, and not emitted if any of the three steps fail. | + +## Commands honored (`bread.command.paper.*`) + +None, because there is nobody listening. + +| Verb | Data | Status | +|------|------|--------| +| `set` | `{ "path": "..." }` | **Not subscribed.** The same work is `bread.exec("breadpaper set …")`. A future `breadpaper watch` (or a service-mode of this binary) could honor `bread.command.paper.set` and emit `bread.paper.set.done` / `.failed`; that is deliberately not added here — a long-running process whose only job is to re-exec the existing one-shot CLI is not worth the extra surface. | + +### Not implemented: slideshow / library / random / next + +breadpaper is not a wallpaper library, a slideshow daemon, or a picker. +Browsing `~/Pictures/Backgrounds` lives in bos-settings. Do not invent +`bread.command.paper.next` / `.random` / `.cycle` (or matching events) +ahead of a real product feature. + +## Fail-safe behavior + +- If breadd isn't installed or isn't running, `emit` is a silent no-op + (`BreadClient::emit` never blocks or errors the caller) — breadpaper + still sets the wallpaper, generates the palette, and reloads themes. +- There is no command subscription to reconnect, because there is no + long-running subscriber. diff --git a/README.md b/README.md index d4b724b..3ab2b86 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,13 @@ breadpaper get # print the current wallpaper path Supported formats: `png`, `jpg`, `jpeg`, `webp`, `gif`, `bmp`. +## Bread events + +After a successful `set`, breadpaper emits `bread.paper.changed` if +`breadd` is running (silent no-op if it isn't). There is no daemon and +no command subscription — Lua modules should `bread.exec("breadpaper set …")`. +See [EVENTS.md](EVENTS.md). + ## License MIT — see [LICENSE](LICENSE). diff --git a/src/lib.rs b/src/lib.rs index f990b9e..c9fa141 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,12 @@ mod wallpaper; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result, bail}; +use anyhow::{bail, Context, Result}; +use bread_utils::bread_client::BreadClient; + +/// App id in bread's sibling-app registry (`KNOWN_APPS`). Events publish as +/// `bread.paper.*`. See `EVENTS.md`. +const APP_ID: &str = "paper"; const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif", "bmp"]; @@ -13,9 +18,19 @@ pub fn set(path: &Path) -> Result<()> { apply_wallpaper(&path)?; generate_palette(&path)?; reload_theme()?; + emit_changed(&path); Ok(()) } +/// Fire-and-forget `bread.paper.changed`. Silent no-op if breadd is down +/// (`BreadClient::emit` never blocks or errors the caller). +fn emit_changed(path: &Path) { + BreadClient::connect(APP_ID).emit( + "bread.paper.changed", + serde_json::json!({ "path": path.to_string_lossy() }), + ); +} + pub fn get() -> Result { let home = std::env::var("HOME").context("HOME not set")?; let wal_file = PathBuf::from(home).join(".cache/wal/wal"); @@ -59,3 +74,15 @@ fn validate(path: &Path) -> Result { Ok(canonical) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emit_changed_is_silent_without_breadd() { + // BreadClient::emit must never panic or error just because the + // socket is missing — this is the fail-silent contract. + emit_changed(Path::new("/tmp/wallpaper.png")); + } +} From 0283ac4e81a14e9d08a95712119a74cac96c5905 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:47:12 +0800 Subject: [PATCH 15/21] Honor bread.command.paper.set via breadpaper listen Pin bread-utils to v0.7.2 (command/subscribe). Add a long-running listen subcommand that subscribes to bread.command.paper.**, calls set() on set, and emits bread.paper.set.done / .failed. Fail-silent if breadd is down. No slideshow verbs. See EVENTS.md. --- Cargo.lock | 4 +-- Cargo.toml | 2 +- EVENTS.md | 54 ++++++++++++++++++------------- README.md | 8 +++-- src/lib.rs | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 3 ++ 6 files changed, 133 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a44fd0c..7429009 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,8 +71,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "bread-shared", "dirs", diff --git a/Cargo.toml b/Cargo.toml index ff77e05..a1f8abe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,4 +9,4 @@ license = "MIT" clap = { version = "4", features = ["derive"] } anyhow = "1" serde_json = "1" -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["bread-client"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } diff --git a/EVENTS.md b/EVENTS.md index cf531a0..b4cd0f1 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -1,57 +1,67 @@ # breadpaper — bread event integration -breadpaper is a one-shot CLI wallpaper setter: it works exactly the same -with or without `breadd` running. When breadd *is* present, each successful -`breadpaper set` (or the bare-path shorthand) publishes one event into the -shared bread automation fabric. See the parent `bread` repo's +breadpaper is a wallpaper setter: it works exactly the same with or +without `breadd` running. When breadd *is* present, a successful +`breadpaper set` (or the bare-path shorthand) publishes `bread.paper.changed` +into the shared bread automation fabric, and `breadpaper listen` honors +`bread.command.paper.set`. See the parent `bread` repo's `Documentation.md` — specifically its "Namespaces" and "Integrating a bread\* app" sections — for the general convention this follows. App id: **`paper`**. Transport: `bread-utils`'s `bread_client` module -(feature `bread-client`) — the CLI links it directly and uses -`BreadClient::connect("paper")` + `emit` only. v0.7.1 has no `command()` -helper, and breadpaper has no long-running process that could hold a -`subscribe` open. +(feature `bread-client`) — the CLI links it directly. One-shot +`set`/`get` use `BreadClient::connect("paper")` + `emit` only. The +long-running `listen` subcommand holds a `subscribe` open. -There is no `breadpaper` daemon and no `watch` subcommand. A `bread-emit -bread.command.paper.set` (or any other `bread.command.paper.*`) with no -subscriber is a silent no-op — that is the documented bread convention, -not a breadpaper bug. Modules that want to change the wallpaper should +`breadpaper listen` is fail-silent if breadd is down: `subscribe` +reconnects with backoff and simply delivers nothing until the daemon +comes back. The one-shot `set`/`get` path does not require `listen`. +Modules that want to change the wallpaper without a listener can still shell out: ```lua bread.exec("breadpaper set /path/to/image.png") ``` -The one-shot process still emits `bread.paper.changed` on success, so a -workflow can `bread.wait("bread.paper.changed", …)` for the real outcome -instead of assuming the exec finished the set. +A workflow that publishes the command instead should wait for the +confirmation, not assume the emit finished the set: + +```lua +bread.emit("bread.command.paper.set", { path = "/path/to/image.png" }) +bread.wait("bread.paper.set.done", { timeout = 10000 }) +``` ## Events published (`bread.paper.*`) | Event | Data | When | |-------|------|------| -| `bread.paper.changed` | `{ "path": "" }` | After a successful `set` (awww + wal + `bread-theme reload`). `path` is the canonical absolute path that was applied. Not emitted on `get`, and not emitted if any of the three steps fail. | +| `bread.paper.changed` | `{ "path": "" }` | After a successful `set` (awww + wal + `bread-theme reload`), including when `listen` honors `bread.command.paper.set`. `path` is the canonical absolute path that was applied. Not emitted on `get`, and not emitted if any of the three steps fail. | +| `bread.paper.set.done` | `{ "path": "" }` | `bread.command.paper.set` was received and `set()` succeeded. `path` is the canonical absolute path that was applied. Not emitted by the one-shot CLI `set` — that path only publishes `changed`. | +| `bread.paper.set.failed` | `{ "error": "", "path"?: "" }` | `bread.command.paper.set` was received but `set()` failed, or `data.path` was missing/not a string. `path` is the requested (not canonical) path when one was supplied. | ## Commands honored (`bread.command.paper.*`) -None, because there is nobody listening. +Honored only while `breadpaper listen` is running. A +`bread-emit bread.command.paper.set` with no listener is a silent no-op +— that is the documented bread convention, not a breadpaper bug. -| Verb | Data | Status | +| Verb | Data | Effect | |------|------|--------| -| `set` | `{ "path": "..." }` | **Not subscribed.** The same work is `bread.exec("breadpaper set …")`. A future `breadpaper watch` (or a service-mode of this binary) could honor `bread.command.paper.set` and emit `bread.paper.set.done` / `.failed`; that is deliberately not added here — a long-running process whose only job is to re-exec the existing one-shot CLI is not worth the extra surface. | +| `set` | `{ "path": "..." }` | Calls the existing `set()` (awww + wal + `bread-theme reload`). Emits `bread.paper.set.done` / `.failed`. A successful set also emits `bread.paper.changed`. | ### Not implemented: slideshow / library / random / next breadpaper is not a wallpaper library, a slideshow daemon, or a picker. Browsing `~/Pictures/Backgrounds` lives in bos-settings. Do not invent `bread.command.paper.next` / `.random` / `.cycle` (or matching events) -ahead of a real product feature. +ahead of a real product feature. Unrecognized verbs are ignored. ## Fail-safe behavior - If breadd isn't installed or isn't running, `emit` is a silent no-op (`BreadClient::emit` never blocks or errors the caller) — breadpaper still sets the wallpaper, generates the palette, and reloads themes. -- There is no command subscription to reconnect, because there is no - long-running subscriber. +- `breadpaper listen` does not exit if breadd is down. The command + subscription reconnects automatically (`BreadClient::subscribe`'s + background thread has its own backoff loop); no restart of `listen` + is needed once breadd returns. diff --git a/README.md b/README.md index 3ab2b86..d0ae2de 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ install -Dm755 target/release/breadpaper ~/.local/bin/breadpaper breadpaper # shorthand for `set` breadpaper set # awww + wal + bread-theme reload breadpaper get # print the current wallpaper path +breadpaper listen # honor bread.command.paper.set (fail-silent if breadd is down) ``` Supported formats: `png`, `jpg`, `jpeg`, `webp`, `gif`, `bmp`. @@ -45,9 +46,10 @@ Supported formats: `png`, `jpg`, `jpeg`, `webp`, `gif`, `bmp`. ## Bread events After a successful `set`, breadpaper emits `bread.paper.changed` if -`breadd` is running (silent no-op if it isn't). There is no daemon and -no command subscription — Lua modules should `bread.exec("breadpaper set …")`. -See [EVENTS.md](EVENTS.md). +`breadd` is running (silent no-op if it isn't). `breadpaper listen` is +the optional long-running subscriber for `bread.command.paper.set`; it +does not start by itself. Lua modules can still +`bread.exec("breadpaper set …")`. See [EVENTS.md](EVENTS.md). ## License diff --git a/src/lib.rs b/src/lib.rs index c9fa141..f846d67 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,9 +3,11 @@ mod theme; mod wallpaper; use std::path::{Path, PathBuf}; +use std::thread; use anyhow::{bail, Context, Result}; -use bread_utils::bread_client::BreadClient; +use bread_utils::bread_client::{BreadClient, BreadEvent}; +use serde_json::{json, Value}; /// App id in bread's sibling-app registry (`KNOWN_APPS`). Events publish as /// `bread.paper.*`. See `EVENTS.md`. @@ -22,15 +24,65 @@ pub fn set(path: &Path) -> Result<()> { Ok(()) } +/// Honor `bread.command.paper.*` until killed. Subscribe reconnects with +/// backoff if breadd is down or restarts — this never errors the caller. +pub fn listen() -> Result<()> { + let client = BreadClient::connect(APP_ID); + let _subscription = client.subscribe("bread.command.paper.**", handle_command); + loop { + thread::park(); + } +} + /// Fire-and-forget `bread.paper.changed`. Silent no-op if breadd is down /// (`BreadClient::emit` never blocks or errors the caller). fn emit_changed(path: &Path) { BreadClient::connect(APP_ID).emit( "bread.paper.changed", - serde_json::json!({ "path": path.to_string_lossy() }), + json!({ "path": path.to_string_lossy() }), ); } +fn handle_command(event: BreadEvent) { + let Some(verb) = event.event.strip_prefix("bread.command.paper.") else { + return; + }; + match verb { + "set" => handle_set(&event.data), + other => { + eprintln!("breadpaper: ignoring unrecognized command verb '{other}'"); + } + } +} + +fn handle_set(data: &Value) { + let client = BreadClient::connect(APP_ID); + let Some(path_str) = data.get("path").and_then(Value::as_str) else { + client.emit( + "bread.paper.set.failed", + json!({ "error": "missing string \"path\"" }), + ); + return; + }; + let path = Path::new(path_str); + match set(path) { + Ok(()) => { + let applied = path + .canonicalize() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| path_str.to_string()); + client.emit("bread.paper.set.done", json!({ "path": applied })); + } + Err(e) => { + eprintln!("breadpaper: bread.command.paper.set failed: {e:#}"); + client.emit( + "bread.paper.set.failed", + json!({ "error": format!("{e:#}"), "path": path_str }), + ); + } + } +} + pub fn get() -> Result { let home = std::env::var("HOME").context("HOME not set")?; let wal_file = PathBuf::from(home).join(".cache/wal/wal"); @@ -85,4 +137,40 @@ mod tests { // socket is missing — this is the fail-silent contract. emit_changed(Path::new("/tmp/wallpaper.png")); } + + #[test] + fn subscribe_is_silent_without_breadd() { + let client = BreadClient::connect(APP_ID); + let sub = client.subscribe("bread.command.paper.**", |_| {}); + drop(sub); + } + + #[test] + fn handle_command_ignores_unrecognized_verb() { + handle_command(BreadEvent { + event: "bread.command.paper.next".into(), + timestamp: 0, + data: json!({}), + }); + } + + #[test] + fn handle_command_ignores_events_outside_its_own_command_namespace() { + handle_command(BreadEvent { + event: "bread.command.clip.clear".into(), + timestamp: 0, + data: json!({}), + }); + handle_command(BreadEvent { + event: "bread.paper.changed".into(), + timestamp: 0, + data: json!({ "path": "/tmp/wallpaper.png" }), + }); + } + + #[test] + fn handle_set_missing_path_is_silent_without_breadd() { + handle_set(&json!({})); + handle_set(&json!({ "path": 1 })); + } } diff --git a/src/main.rs b/src/main.rs index d108e3d..959c184 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,8 @@ enum Command { }, /// Print the current wallpaper path Get, + /// Honor bread.command.paper.set until killed + Listen, } fn main() { @@ -28,6 +30,7 @@ fn main() { let result = match (cli.command, cli.path) { (Some(Command::Set { path }), _) | (None, Some(path)) => breadpaper::set(&path), + (Some(Command::Listen), _) => breadpaper::listen(), (Some(Command::Get), _) | (None, None) => { breadpaper::get().map(|p| println!("{}", p.display())) } From 01c7e5b73f7fb42330064b4d9b9563de102e9b75 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:05:47 +0800 Subject: [PATCH 16/21] Bump version to v0.1.12 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7429009..837cd66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,7 +82,7 @@ dependencies = [ [[package]] name = "breadpaper" -version = "0.1.0" +version = "0.1.12" dependencies = [ "anyhow", "bread-utils", diff --git a/Cargo.toml b/Cargo.toml index a1f8abe..1efe23d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadpaper" -version = "0.1.0" +version = "0.1.12" edition = "2024" description = "Wallpaper manager for the bread desktop" license = "MIT" From 1efc7219908026d7aa4103394205d6c7b4bb0c1c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:26:50 +0800 Subject: [PATCH 17/21] Add GTK wallpaper library Scan ~/Pictures/Wallpapers and /usr/share/backgrounds/bos (configurable) and open a bread-theme GTK picker via `breadpaper library` (alias browse). Clicking a thumbnail runs the existing set path. listen honors bread.command.paper.library by spawning that picker. --- .forgejo/workflows/dev-release.yml | 1 + .forgejo/workflows/rc-release.yml | 1 + .forgejo/workflows/release.yml | 1 + Cargo.lock | 565 ++++++++++++++++++++++++++++- Cargo.toml | 4 + EVENTS.md | 18 +- README.md | 40 +- bakery.toml | 3 +- config.example.toml | 6 + packaging/breadpaper.desktop | 9 + src/config.rs | 252 +++++++++++++ src/lib.rs | 69 +++- src/library.rs | 168 +++++++++ src/main.rs | 20 +- src/ui.rs | 272 ++++++++++++++ 15 files changed, 1398 insertions(+), 31 deletions(-) create mode 100644 config.example.toml create mode 100644 packaging/breadpaper.desktop create mode 100644 src/config.rs create mode 100644 src/library.rs create mode 100644 src/ui.rs diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 4398649..2ffbe57 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -56,6 +56,7 @@ jobs: sha256sum "${PKG_DIR}/breadpaper-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/breadpaper-x86_64.sha256" cp src/LICENSE "${PKG_DIR}/" + cp src/packaging/breadpaper.desktop "${PKG_DIR}/" cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadpaper/latest" diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml index 3c1646b..6a588e1 100644 --- a/.forgejo/workflows/rc-release.yml +++ b/.forgejo/workflows/rc-release.yml @@ -37,6 +37,7 @@ jobs: sha256sum "${PKG_DIR}/breadpaper-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/breadpaper-x86_64.sha256" cp src/LICENSE "${PKG_DIR}/" + cp src/packaging/breadpaper.desktop "${PKG_DIR}/" cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadpaper/latest" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 96877ef..0d3f13e 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -41,6 +41,7 @@ jobs: sha256sum "${PKG_DIR}/breadpaper-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/breadpaper-x86_64.sha256" cp LICENSE "${PKG_DIR}/" + cp packaging/breadpaper.desktop "${PKG_DIR}/" cp bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "${DL_DIR}/breadpaper/latest" diff --git a/Cargo.lock b/Cargo.lock index 837cd66..5052a56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,6 +58,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "bread-shared" version = "0.7.0" @@ -66,7 +78,18 @@ dependencies = [ "dirs", "serde", "serde_json", - "toml", + "toml 0.8.23", +] + +[[package]] +name = "bread-theme" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +dependencies = [ + "dirs", + "gtk4", + "serde", + "serde_json", ] [[package]] @@ -85,9 +108,46 @@ name = "breadpaper" version = "0.1.12" dependencies = [ "anyhow", + "bread-theme", "bread-utils", "clap", + "gtk4", + "serde", "serde_json", + "toml 0.8.23", +] + +[[package]] +name = "cairo-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon", ] [[package]] @@ -169,6 +229,135 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -180,6 +369,194 @@ dependencies = [ "wasi", ] +[[package]] +name = "gio" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.61.2", +] + +[[package]] +name = "glib" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff" +dependencies = [ + "glib", + "graphene-sys", +] + +[[package]] +name = "graphene-sys" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -235,6 +612,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -247,6 +633,50 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "pango" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c" +dependencies = [ + "gio", + "glib", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -276,6 +706,21 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -328,6 +773,27 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + [[package]] name = "strsim" version = "0.11.1" @@ -356,6 +822,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 1.1.4+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "thiserror" version = "1.0.69" @@ -383,9 +868,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", ] [[package]] @@ -397,6 +897,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -405,10 +914,31 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -417,6 +947,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -429,6 +965,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -525,6 +1067,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 1efe23d..f89a4c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,5 +8,9 @@ license = "MIT" [dependencies] clap = { version = "4", features = ["derive"] } anyhow = "1" +serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" +gtk4 = { version = "0.11", features = ["v4_12"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } diff --git a/EVENTS.md b/EVENTS.md index b4cd0f1..6922dd6 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -4,7 +4,7 @@ breadpaper is a wallpaper setter: it works exactly the same with or without `breadd` running. When breadd *is* present, a successful `breadpaper set` (or the bare-path shorthand) publishes `bread.paper.changed` into the shared bread automation fabric, and `breadpaper listen` honors -`bread.command.paper.set`. See the parent `bread` repo's +`bread.command.paper.set` and `bread.command.paper.library`. See the parent `bread` repo's `Documentation.md` — specifically its "Namespaces" and "Integrating a bread\* app" sections — for the general convention this follows. @@ -15,9 +15,9 @@ long-running `listen` subcommand holds a `subscribe` open. `breadpaper listen` is fail-silent if breadd is down: `subscribe` reconnects with backoff and simply delivers nothing until the daemon -comes back. The one-shot `set`/`get` path does not require `listen`. -Modules that want to change the wallpaper without a listener can still -shell out: +comes back. The one-shot `set`/`get`/`library` path does not require +`listen`. Modules that want to change the wallpaper without a listener +can still shell out: ```lua bread.exec("breadpaper set /path/to/image.png") @@ -38,6 +38,8 @@ bread.wait("bread.paper.set.done", { timeout = 10000 }) | `bread.paper.changed` | `{ "path": "" }` | After a successful `set` (awww + wal + `bread-theme reload`), including when `listen` honors `bread.command.paper.set`. `path` is the canonical absolute path that was applied. Not emitted on `get`, and not emitted if any of the three steps fail. | | `bread.paper.set.done` | `{ "path": "" }` | `bread.command.paper.set` was received and `set()` succeeded. `path` is the canonical absolute path that was applied. Not emitted by the one-shot CLI `set` — that path only publishes `changed`. | | `bread.paper.set.failed` | `{ "error": "", "path"?: "" }` | `bread.command.paper.set` was received but `set()` failed, or `data.path` was missing/not a string. `path` is the requested (not canonical) path when one was supplied. | +| `bread.paper.library.done` | `{}` | `bread.command.paper.library` was received and a `breadpaper library` process was started. Not emitted by the one-shot CLI `library` / `browse`. | +| `bread.paper.library.failed` | `{ "error": "" }` | `bread.command.paper.library` was received but the library process could not be spawned. | ## Commands honored (`bread.command.paper.*`) @@ -48,13 +50,13 @@ Honored only while `breadpaper listen` is running. A | Verb | Data | Effect | |------|------|--------| | `set` | `{ "path": "..." }` | Calls the existing `set()` (awww + wal + `bread-theme reload`). Emits `bread.paper.set.done` / `.failed`. A successful set also emits `bread.paper.changed`. | +| `library` | `{}` | Spawns `breadpaper library` (GTK picker). Emits `bread.paper.library.done` once the process is started, or `bread.paper.library.failed` if the spawn fails. Clicking a thumbnail in that window is a normal `set` and publishes `bread.paper.changed`. | -### Not implemented: slideshow / library / random / next +### Not implemented: slideshow / random / next -breadpaper is not a wallpaper library, a slideshow daemon, or a picker. -Browsing `~/Pictures/Backgrounds` lives in bos-settings. Do not invent +`breadpaper library` / `browse` is the in-app picker. Do not invent `bread.command.paper.next` / `.random` / `.cycle` (or matching events) -ahead of a real product feature. Unrecognized verbs are ignored. +ahead of a real slideshow feature. Unrecognized verbs are ignored. ## Fail-safe behavior diff --git a/README.md b/README.md index d0ae2de..7064d1d 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ via [awww](https://github.com/heywoodlh/awww), generates a palette with [pywal](https://github.com/dylanaraps/pywal) (`wal`), and runs `bread-theme reload`. -It is not a wallpaper library, a slideshow daemon, or a GUI. Browsing -`~/Pictures/Backgrounds` and picking an image lives in -[bos-settings](https://git.breadway.dev/Breadway/bos-settings). +`set` / `get` stay one-shot CLI. `breadpaper library` (alias `browse`) +opens a GTK picker over the wallpaper directories. It is not a slideshow +daemon. ## Dependencies @@ -17,7 +17,8 @@ Must be on `$PATH` for `set`: - `wal` — palette generation (`python-pywal`) - `bread-theme` — theme reload (bakery package, not `breadd`) -`get` only reads the path pywal stored at `~/.cache/wal/wal`. +`library` also needs GTK4 (the window loads `bread-theme`'s shared +stylesheet). `get` only reads the path pywal stored at `~/.cache/wal/wal`. ## Install @@ -38,18 +39,41 @@ install -Dm755 target/release/breadpaper ~/.local/bin/breadpaper breadpaper # shorthand for `set` breadpaper set # awww + wal + bread-theme reload breadpaper get # print the current wallpaper path -breadpaper listen # honor bread.command.paper.set (fail-silent if breadd is down) +breadpaper library # GTK picker (alias: browse) +breadpaper library --dir PATH # also scan PATH (repeatable) +breadpaper listen # honor bread.command.paper.set / .library ``` Supported formats: `png`, `jpg`, `jpeg`, `webp`, `gif`, `bmp`. +## Library + +`breadpaper library` scans these directories (missing ones are skipped): + +1. `~/Pictures/Wallpapers` +2. `/usr/share/backgrounds/bos` + +Override the list in `~/.config/breadpaper/config.toml`: + +```toml +library_dirs = [ + "~/Pictures/Wallpapers", + "/usr/share/backgrounds/bos", +] +``` + +`BREADPAPER_LIBRARY_DIRS` (colon-separated) overrides the file. `--dir` +appends extra roots for that invocation. Clicking a thumbnail runs the +same `set` path as the CLI. + ## Bread events After a successful `set`, breadpaper emits `bread.paper.changed` if `breadd` is running (silent no-op if it isn't). `breadpaper listen` is -the optional long-running subscriber for `bread.command.paper.set`; it -does not start by itself. Lua modules can still -`bread.exec("breadpaper set …")`. See [EVENTS.md](EVENTS.md). +the optional long-running subscriber for `bread.command.paper.set` and +`bread.command.paper.library`; it does not start by itself. Lua modules +can still `bread.exec("breadpaper set …")` or +`bread.exec("breadpaper library")`. See [EVENTS.md](EVENTS.md). ## License diff --git a/bakery.toml b/bakery.toml index c946a46..d003e38 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,10 +1,11 @@ name = "breadpaper" description = "Wallpaper manager for the bread desktop — sets awww wallpaper, generates pywal palette, reloads bread themes" binaries = ["breadpaper"] -system_deps = ["awww", "python-pywal"] +system_deps = ["awww", "python-pywal", "gtk4"] optional_system_deps = [] bread_deps = ["bread-theme"] license_file = "LICENSE" +desktop_file = "breadpaper.desktop" [install] post_install = [] diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..a447d7c --- /dev/null +++ b/config.example.toml @@ -0,0 +1,6 @@ +# ~/.config/breadpaper/config.toml +# Missing directories are skipped. An empty list uses the built-in defaults. +library_dirs = [ + "~/Pictures/Wallpapers", + "/usr/share/backgrounds/bos", +] diff --git a/packaging/breadpaper.desktop b/packaging/breadpaper.desktop new file mode 100644 index 0000000..86ccccd --- /dev/null +++ b/packaging/breadpaper.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=Wallpapers +Comment=Browse and apply wallpapers on the Bread desktop +Exec=breadpaper library +Icon=preferences-desktop-wallpaper +Terminal=false +Type=Application +Categories=Settings;DesktopSettings; +StartupWMClass=com.breadway.breadpaper diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..b89bd08 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,252 @@ +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +/// User library directory used when no config file is present. +pub const DEFAULT_USER_LIBRARY: &str = "Pictures/Wallpapers"; + +/// Packaged BOS backgrounds, scanned when the directory exists. +pub const DEFAULT_SYSTEM_LIBRARY: &str = "/usr/share/backgrounds/bos"; + +/// Colon-separated override of [`Config::library_dirs`]. Empty means "use +/// the config file / defaults". +pub const LIBRARY_DIRS_ENV: &str = "BREADPAPER_LIBRARY_DIRS"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + pub library_dirs: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct ConfigFile { + #[serde(default)] + library_dirs: Vec, +} + +impl Default for Config { + fn default() -> Self { + Self { + library_dirs: default_library_dirs(), + } + } +} + +impl Config { + pub fn path() -> PathBuf { + bread_utils::xdg::config_dir("breadpaper").join("config.toml") + } + + pub fn load() -> Self { + Self::load_from(&Self::path()) + } + + pub fn load_from(path: &Path) -> Self { + let mut cfg = match std::fs::read_to_string(path) { + Ok(text) => match toml::from_str::(&text) { + Ok(parsed) if !parsed.library_dirs.is_empty() => Self { + library_dirs: parsed.library_dirs, + }, + Ok(_) => Self::default(), + Err(e) => { + eprintln!( + "breadpaper: {} failed to parse ({e}); using defaults", + path.display() + ); + Self::default() + } + }, + Err(_) => Self::default(), + }; + + if let Some(dirs) = env_library_dirs() { + cfg.library_dirs = dirs; + } + + cfg.library_dirs = cfg + .library_dirs + .into_iter() + .map(expand_tilde) + .filter(|p| !p.as_os_str().is_empty()) + .collect(); + cfg + } + + pub fn with_extra_dirs(mut self, extra: impl IntoIterator) -> Self { + self.library_dirs + .extend(extra.into_iter().map(expand_tilde)); + self + } +} + +pub fn default_library_dirs() -> Vec { + vec![ + bread_utils::xdg::home_dir().join(DEFAULT_USER_LIBRARY), + PathBuf::from(DEFAULT_SYSTEM_LIBRARY), + ] +} + +pub fn expand_tilde(path: PathBuf) -> PathBuf { + let Some(s) = path.to_str() else { + return path; + }; + if s == "~" { + return bread_utils::xdg::home_dir(); + } + if let Some(rest) = s.strip_prefix("~/") { + return bread_utils::xdg::home_dir().join(rest); + } + path +} + +fn env_library_dirs() -> Option> { + let raw = std::env::var(LIBRARY_DIRS_ENV).ok()?; + if raw.is_empty() { + return None; + } + let dirs: Vec = raw + .split(':') + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .collect(); + if dirs.is_empty() { None } else { Some(dirs) } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) + } + + fn tmp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "breadpaper-config-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn default_dirs_are_pictures_wallpapers_and_bos_backgrounds() { + let dirs = Config::default().library_dirs; + assert!( + dirs.iter().any(|d| d.ends_with(DEFAULT_USER_LIBRARY)), + "missing ~/{DEFAULT_USER_LIBRARY} in {dirs:?}" + ); + assert!( + dirs.iter().any(|d| d == Path::new(DEFAULT_SYSTEM_LIBRARY)), + "missing {DEFAULT_SYSTEM_LIBRARY} in {dirs:?}" + ); + } + + #[test] + fn expand_tilde_prefix() { + let home = bread_utils::xdg::home_dir(); + assert_eq!( + expand_tilde(PathBuf::from("~/Pictures/Wallpapers")), + home.join("Pictures/Wallpapers") + ); + assert_eq!(expand_tilde(PathBuf::from("~")), home); + let abs = PathBuf::from("/usr/share/backgrounds/bos"); + assert_eq!(expand_tilde(abs.clone()), abs); + } + + #[test] + fn load_from_missing_file_uses_defaults() { + let _lock = env_lock(); + let prev = std::env::var_os(LIBRARY_DIRS_ENV); + unsafe { std::env::remove_var(LIBRARY_DIRS_ENV) }; + let cfg = Config::load_from(&PathBuf::from("/no/such/breadpaper-config.toml")); + if let Some(v) = prev { + unsafe { std::env::set_var(LIBRARY_DIRS_ENV, v) }; + } + assert_eq!(cfg.library_dirs, default_library_dirs()); + } + + #[test] + fn load_from_parses_library_dirs_and_expands_tilde() { + let _lock = env_lock(); + let prev = std::env::var_os(LIBRARY_DIRS_ENV); + unsafe { std::env::remove_var(LIBRARY_DIRS_ENV) }; + + let dir = tmp_dir("parse"); + let path = dir.join("config.toml"); + std::fs::write( + &path, + "library_dirs = [\"~/custom/walls\", \"/opt/walls\"]\n", + ) + .unwrap(); + let cfg = Config::load_from(&path); + if let Some(v) = prev { + unsafe { std::env::set_var(LIBRARY_DIRS_ENV, v) }; + } + let _ = std::fs::remove_dir_all(&dir); + assert_eq!( + cfg.library_dirs, + vec![ + bread_utils::xdg::home_dir().join("custom/walls"), + PathBuf::from("/opt/walls"), + ] + ); + } + + #[test] + fn empty_library_dirs_key_falls_back_to_defaults() { + let _lock = env_lock(); + let prev = std::env::var_os(LIBRARY_DIRS_ENV); + unsafe { std::env::remove_var(LIBRARY_DIRS_ENV) }; + + let dir = tmp_dir("empty"); + let path = dir.join("config.toml"); + std::fs::write(&path, "library_dirs = []\n").unwrap(); + let cfg = Config::load_from(&path); + if let Some(v) = prev { + unsafe { std::env::set_var(LIBRARY_DIRS_ENV, v) }; + } + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(cfg.library_dirs, default_library_dirs()); + } + + #[test] + fn env_overrides_config_file() { + let _lock = env_lock(); + let prev = std::env::var_os(LIBRARY_DIRS_ENV); + unsafe { std::env::set_var(LIBRARY_DIRS_ENV, "/tmp/a:/tmp/b") }; + + let dir = tmp_dir("env"); + let path = dir.join("config.toml"); + std::fs::write(&path, "library_dirs = [\"/from/file\"]\n").unwrap(); + let cfg = Config::load_from(&path); + match prev { + Some(v) => unsafe { std::env::set_var(LIBRARY_DIRS_ENV, v) }, + None => unsafe { std::env::remove_var(LIBRARY_DIRS_ENV) }, + } + let _ = std::fs::remove_dir_all(&dir); + assert_eq!( + cfg.library_dirs, + vec![PathBuf::from("/tmp/a"), PathBuf::from("/tmp/b")] + ); + } + + #[test] + fn with_extra_dirs_appends() { + let cfg = Config { + library_dirs: vec![PathBuf::from("/a")], + } + .with_extra_dirs([PathBuf::from("/b")]); + assert_eq!( + cfg.library_dirs, + vec![PathBuf::from("/a"), PathBuf::from("/b")] + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index f846d67..26be507 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,21 @@ +mod config; +mod library; mod pywal; mod theme; +mod ui; mod wallpaper; use std::path::{Path, PathBuf}; +#[cfg(not(test))] +use std::process::{Command, Stdio}; use std::thread; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use bread_utils::bread_client::{BreadClient, BreadEvent}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; + +pub use config::{Config, DEFAULT_SYSTEM_LIBRARY, DEFAULT_USER_LIBRARY}; +pub use library::{Wallpaper, scan}; /// App id in bread's sibling-app registry (`KNOWN_APPS`). Events publish as /// `bread.paper.*`. See `EVENTS.md`. @@ -15,6 +23,13 @@ const APP_ID: &str = "paper"; const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif", "bmp"]; +/// Open the GTK wallpaper library. Extra dirs are appended to the configured +/// scan list (`~/.config/breadpaper/config.toml`, then defaults). +pub fn library(extra_dirs: impl IntoIterator) -> Result<()> { + let cfg = Config::load().with_extra_dirs(extra_dirs); + ui::run(cfg.library_dirs) +} + pub fn set(path: &Path) -> Result<()> { let path = validate(path)?; apply_wallpaper(&path)?; @@ -49,6 +64,7 @@ fn handle_command(event: BreadEvent) { }; match verb { "set" => handle_set(&event.data), + "library" => handle_library(), other => { eprintln!("breadpaper: ignoring unrecognized command verb '{other}'"); } @@ -83,6 +99,46 @@ fn handle_set(data: &Value) { } } +fn handle_library() { + let client = BreadClient::connect(APP_ID); + match open_library() { + Ok(()) => client.emit("bread.paper.library.done", json!({})), + Err(e) => { + eprintln!("breadpaper: bread.command.paper.library failed: {e:#}"); + client.emit( + "bread.paper.library.failed", + json!({ "error": format!("{e:#}") }), + ); + } + } +} + +/// Spawn a one-shot `breadpaper library` so the listen loop can stay a +/// park() thread. GTK needs its own process (and argv) — mixing it into +/// `listen` would steal the main thread. +fn open_library() -> Result<()> { + spawn_library() +} + +#[cfg(not(test))] +fn spawn_library() -> Result<()> { + let exe = std::env::current_exe().context("cannot resolve breadpaper executable")?; + Command::new(exe) + .arg("library") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .context("failed to spawn breadpaper library")?; + Ok(()) +} + +#[cfg(test)] +fn spawn_library() -> Result<()> { + // cargo test's current_exe is the test harness, not breadpaper. + Ok(()) +} + pub fn get() -> Result { let home = std::env::var("HOME").context("HOME not set")?; let wal_file = PathBuf::from(home).join(".cache/wal/wal"); @@ -173,4 +229,13 @@ mod tests { handle_set(&json!({})); handle_set(&json!({ "path": 1 })); } + + #[test] + fn handle_command_library_is_silent_without_breadd() { + handle_command(BreadEvent { + event: "bread.command.paper.library".into(), + timestamp: 0, + data: json!({}), + }); + } } diff --git a/src/library.rs b/src/library.rs new file mode 100644 index 0000000..cc1adff --- /dev/null +++ b/src/library.rs @@ -0,0 +1,168 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::IMAGE_EXTENSIONS; + +/// Caps how many files the picker ever lists. The library is organized in +/// subfolders (show/series), so the walk is recursive — without a bound a +/// huge Pictures tree would stall the window. +pub const MAX_LIBRARY_ITEMS: usize = 200; +pub const MAX_SCAN_DEPTH: usize = 4; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Wallpaper { + pub path: PathBuf, + pub name: String, +} + +pub fn is_wallpaper_file(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| { + IMAGE_EXTENSIONS + .iter() + .any(|ext| ext.eq_ignore_ascii_case(e)) + }) + .unwrap_or(false) +} + +/// Recursively collect images under `dirs`. Missing directories are skipped. +/// Results are sorted by filename (case-insensitive), then full path. +pub fn scan(dirs: &[PathBuf]) -> Vec { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for dir in dirs { + if !dir.is_dir() { + continue; + } + walk(dir, MAX_SCAN_DEPTH, &mut out, &mut seen); + if out.len() >= MAX_LIBRARY_ITEMS { + break; + } + } + out.sort_by(|a, b| { + a.name + .to_lowercase() + .cmp(&b.name.to_lowercase()) + .then_with(|| a.path.cmp(&b.path)) + }); + out +} + +fn walk(dir: &Path, depth: usize, out: &mut Vec, seen: &mut HashSet) { + if depth == 0 || out.len() >= MAX_LIBRARY_ITEMS { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + let mut entries: Vec<_> = entries.flatten().collect(); + entries.sort_by_key(|e| e.file_name()); + for entry in entries { + if out.len() >= MAX_LIBRARY_ITEMS { + return; + } + let path = entry.path(); + let name = entry.file_name(); + if name.to_string_lossy().starts_with('.') { + continue; + } + if path.is_dir() { + walk(&path, depth - 1, out, seen); + continue; + } + if !is_wallpaper_file(&path) { + continue; + } + let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); + if !seen.insert(canonical.clone()) { + continue; + } + out.push(Wallpaper { + name: path + .file_stem() + .or_else(|| path.file_name()) + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(), + path: canonical, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "breadpaper-scan-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn touch(path: &Path) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, []).unwrap(); + } + + #[test] + fn is_wallpaper_file_accepts_known_extensions() { + assert!(is_wallpaper_file(Path::new("a.PNG"))); + assert!(is_wallpaper_file(Path::new("b.jpeg"))); + assert!(is_wallpaper_file(Path::new("c.webp"))); + assert!(!is_wallpaper_file(Path::new("d.txt"))); + assert!(!is_wallpaper_file(Path::new("noext"))); + } + + #[test] + fn scan_skips_missing_dirs() { + assert!(scan(&[PathBuf::from("/no/such/breadpaper-walls")]).is_empty()); + } + + #[test] + fn scan_finds_images_and_ignores_other_files() { + let dir = tmp_dir("find"); + touch(&dir.join("keep.png")); + touch(&dir.join("notes.txt")); + touch(&dir.join(".hidden.jpg")); + touch(&dir.join("nested").join("deep.jpg")); + let found = scan(std::slice::from_ref(&dir)); + let names: Vec<_> = found.iter().map(|w| w.name.as_str()).collect(); + assert!(names.contains(&"keep"), "{names:?}"); + assert!(names.contains(&"deep"), "{names:?}"); + assert!( + !names + .iter() + .any(|n| n.contains("notes") || n.contains("hidden")) + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn scan_dedups_the_same_file_via_two_roots() { + let dir = tmp_dir("dedup"); + touch(&dir.join("one.png")); + let found = scan(&[dir.clone(), dir.clone()]); + assert_eq!(found.len(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn scan_respects_item_cap() { + let dir = tmp_dir("cap"); + for i in 0..(MAX_LIBRARY_ITEMS + 10) { + touch(&dir.join(format!("{i:04}.png"))); + } + let found = scan(std::slice::from_ref(&dir)); + assert_eq!(found.len(), MAX_LIBRARY_ITEMS); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/main.rs b/src/main.rs index 959c184..8069a89 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,11 @@ use std::process; use clap::{Parser, Subcommand}; #[derive(Parser)] -#[command(name = "breadpaper", version, about = "Wallpaper manager for the bread desktop")] +#[command( + name = "breadpaper", + version, + about = "Wallpaper manager for the bread desktop" +)] struct Cli { /// Image file to set as wallpaper (shorthand for `set`) path: Option, @@ -16,13 +20,18 @@ struct Cli { #[derive(Subcommand)] enum Command { /// Set wallpaper, generate pywal palette, and reload bread themes - Set { - path: PathBuf, - }, + Set { path: PathBuf }, /// Print the current wallpaper path Get, - /// Honor bread.command.paper.set until killed + /// Honor bread.command.paper.set / .library until killed Listen, + /// Open the wallpaper library (alias: browse) + #[command(visible_alias = "browse")] + Library { + /// Extra directory to scan (repeatable; added to configured dirs) + #[arg(short, long = "dir", value_name = "DIR")] + dirs: Vec, + }, } fn main() { @@ -31,6 +40,7 @@ fn main() { let result = match (cli.command, cli.path) { (Some(Command::Set { path }), _) | (None, Some(path)) => breadpaper::set(&path), (Some(Command::Listen), _) => breadpaper::listen(), + (Some(Command::Library { dirs }), _) => breadpaper::library(dirs), (Some(Command::Get), _) | (None, None) => { breadpaper::get().map(|p| println!("{}", p.display())) } diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..5193541 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,272 @@ +use std::cell::RefCell; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use anyhow::Result; +use gtk4::gdk_pixbuf::Pixbuf; +use gtk4::gio::ApplicationFlags; +use gtk4::prelude::*; +use gtk4::{ + Align, Application, ApplicationWindow, Box as GBox, Button, ContentFit, CssProvider, FlowBox, + FlowBoxChild, HeaderBar, Label, Orientation, Picture, PolicyType, ScrolledWindow, + SelectionMode, Stack, +}; + +use crate::library::{self, Wallpaper}; + +const APP_ID: &str = "com.breadway.breadpaper"; +const THUMB_W: i32 = 240; +const THUMB_H: i32 = 135; + +const APP_CSS: &str = "\ +headerbar {\ + background-color: @bg; color: @on-bg; box-shadow: none;\ + border-bottom: 1px solid alpha(@on-bg, 0.08);\ +}\n\ +.library-chrome { padding: 12px 16px 8px 16px; }\n\ +.library-grid { padding: 8px 12px 16px 12px; }\n\ +.library-empty { padding: 32px 24px; }\n\ +.wallpaper-tile {\ + padding: 0; background-color: @surface; color: @on-surface;\ + border-radius: 8px;\ +}\n\ +.wallpaper-tile:hover { background-color: alpha(@on-surface, 0.14); }\n\ +.wallpaper-tile.current { box-shadow: inset 0 0 0 2px @accent; }\n\ +.wallpaper-name { padding: 8px 10px; font-size: 12px; }\n\ +"; + +thread_local! { + static APP_PROVIDER: RefCell> = const { RefCell::new(None) }; +} + +pub fn run(dirs: Vec) -> Result<()> { + let app = Application::builder() + .application_id(APP_ID) + .flags(ApplicationFlags::empty()) + .build(); + + app.connect_activate(move |app| present(app, dirs.clone())); + // Clap already consumed argv; do not let GApplication re-parse `library --dir`. + let _ = app.run_with_args(&["breadpaper"]); + Ok(()) +} + +fn present(app: &Application, dirs: Vec) { + bread_theme::gtk::apply_shared(); + APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell)); + + let window = ApplicationWindow::builder() + .application(app) + .title("Wallpapers") + .default_width(960) + .default_height(640) + .build(); + + let header = HeaderBar::new(); + window.set_titlebar(Some(&header)); + + let refresh = Button::with_label("Refresh"); + header.pack_end(&refresh); + + let root = GBox::new(Orientation::Vertical, 0); + + let chrome = GBox::new(Orientation::Vertical, 6); + chrome.add_css_class("library-chrome"); + + let summary = Label::new(None); + summary.set_xalign(0.0); + summary.set_wrap(true); + summary.add_css_class("dim"); + chrome.append(&summary); + + let status = Label::new(Some("Click a wallpaper to apply it.")); + status.set_xalign(0.0); + status.set_wrap(true); + chrome.append(&status); + root.append(&chrome); + + let flow = FlowBox::new(); + flow.set_selection_mode(SelectionMode::None); + flow.set_homogeneous(true); + flow.set_max_children_per_line(6); + flow.set_min_children_per_line(2); + flow.set_row_spacing(12); + flow.set_column_spacing(12); + flow.set_halign(Align::Fill); + flow.add_css_class("library-grid"); + + let scrolled = ScrolledWindow::builder() + .hscrollbar_policy(PolicyType::Never) + .vscrollbar_policy(PolicyType::Automatic) + .vexpand(true) + .hexpand(true) + .child(&flow) + .build(); + + let empty = Label::new(None); + empty.set_wrap(true); + empty.set_justify(gtk4::Justification::Center); + empty.add_css_class("dim"); + empty.add_css_class("library-empty"); + empty.set_hexpand(true); + empty.set_vexpand(true); + + let stack = Stack::new(); + stack.set_vexpand(true); + stack.add_named(&scrolled, Some("grid")); + stack.add_named(&empty, Some("empty")); + root.append(&stack); + + window.set_child(Some(&root)); + + let dirs = Rc::new(dirs); + let reload = { + let dirs = dirs.clone(); + let flow = flow.clone(); + let summary = summary.clone(); + let status = status.clone(); + let stack = stack.clone(); + let empty = empty.clone(); + Rc::new(move || { + let papers = library::scan(&dirs); + summary.set_text(&dirs_summary(&dirs, papers.len())); + empty.set_text(&empty_message(&dirs)); + if papers.is_empty() { + stack.set_visible_child_name("empty"); + } else { + stack.set_visible_child_name("grid"); + } + fill_grid(&flow, &papers, &status); + }) + }; + + reload(); + { + let reload = reload.clone(); + refresh.connect_clicked(move |_| reload()); + } + + window.present(); +} + +fn fill_grid(flow: &FlowBox, papers: &[Wallpaper], status: &Label) { + while let Some(child) = flow.first_child() { + flow.remove(&child); + } + let current = crate::get().ok(); + for paper in papers { + let is_current = current.as_deref() == Some(paper.path.as_path()); + flow.insert(&tile(paper, is_current, flow, status), -1); + } +} + +fn tile(paper: &Wallpaper, is_current: bool, flow: &FlowBox, status: &Label) -> Button { + let btn = Button::new(); + btn.add_css_class("wallpaper-tile"); + btn.set_widget_name(&paper.path.to_string_lossy()); + btn.set_tooltip_text(Some(&paper.path.to_string_lossy())); + if is_current { + btn.add_css_class("current"); + } + + let col = GBox::new(Orientation::Vertical, 0); + col.append(&thumbnail(&paper.path)); + + let name = Label::new(Some(&paper.name)); + name.add_css_class("wallpaper-name"); + name.set_xalign(0.0); + name.set_ellipsize(gtk4::pango::EllipsizeMode::End); + name.set_max_width_chars(24); + col.append(&name); + btn.set_child(Some(&col)); + + let path = paper.path.clone(); + let pretty = paper.name.clone(); + let status = status.clone(); + let flow = flow.clone(); + btn.connect_clicked(move |clicked| { + if !clicked.is_sensitive() { + return; + } + clicked.set_sensitive(false); + status.set_text(&format!("Applying {pretty}…")); + let path = path.clone(); + let pretty = pretty.clone(); + let status = status.clone(); + let flow = flow.clone(); + let clicked = clicked.clone(); + gtk4::glib::spawn_future_local(async move { + let path_thread = path.clone(); + let result = gtk4::gio::spawn_blocking(move || crate::set(&path_thread)).await; + clicked.set_sensitive(true); + match result { + Ok(Ok(())) => { + status.set_text(&format!("Applied {pretty}")); + mark_current(&flow, &path); + } + Ok(Err(e)) => status.set_text(&format!("{e:#}")), + Err(_) => status.set_text("Failed to apply wallpaper"), + } + }); + }); + + btn +} + +fn thumbnail(path: &Path) -> Picture { + let picture = match Pixbuf::from_file_at_scale(path, THUMB_W, THUMB_H, true) { + Ok(pb) => Picture::for_paintable(>k4::gdk::Texture::for_pixbuf(&pb)), + Err(_) => Picture::for_filename(path), + }; + picture.set_content_fit(ContentFit::Cover); + picture.set_size_request(THUMB_W, THUMB_H); + picture.set_can_shrink(true); + picture.set_hexpand(true); + picture +} + +fn mark_current(flow: &FlowBox, current: &Path) { + let current = current.to_string_lossy(); + let mut i = 0; + while let Some(wrapper) = flow.child_at_index(i) { + if let Some(btn) = wrapper + .downcast_ref::() + .and_then(|c| c.child()) + .and_then(|w| w.downcast::