Compare commits

...

22 commits

Author SHA1 Message Date
Breadway
4e88979378 Bump version to v0.1.14
Some checks failed
beta (rc) release / build (push) Has been skipped
dev release / build (push) Failing after 10s
release / build (push) Successful in 34s
2026-08-16 14:08:37 +08:00
Breadway
2c01dfab3e Pin bread-theme to v0.7.4 for per-output palettes
All checks were successful
dev release / build (push) Successful in 47s
2026-08-16 13:23:05 +08:00
Breadway
8d8be6ebb1 Add per-output wallpaper set, persist, and theme
Two monitors can keep different wallpapers. `set --output` targets one
awww output, writes that output's bread-theme files, and updates
~/.config/breadpaper/current.json without clobbering the other. Global
set still runs wal + bread-theme reload and stamps every known output.
`apply` and listen-on-monitor-connect restore the saved map. The library
picker binds to its monitor and applies to that output.
2026-08-16 13:06:17 +08:00
Breadway
5bc879d70e CI: run stable release on hestia and refuse unsigned bakery index
All checks were successful
check / check (push) Successful in 1m7s
dev release / build (push) Successful in 5m10s
beta (rc) release / build (push) Has been skipped
release / build (push) Successful in 42s
2026-08-16 00:50:22 +08:00
Breadway
1efc721990 Add GTK wallpaper library
All checks were successful
check / check (push) Successful in 2m28s
dev release / build (push) Successful in 49s
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.
2026-08-16 00:26:50 +08:00
Breadway
01c7e5b73f Bump version to v0.1.12
All checks were successful
beta (rc) release / build (push) Has been skipped
release / build (push) Successful in 37s
dev release / build (push) Successful in 47s
2026-08-15 23:05:47 +08:00
Breadway
0283ac4e81 Honor bread.command.paper.set via breadpaper listen
All checks were successful
dev release / build (push) Successful in 21s
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.
2026-08-15 22:47:12 +08:00
Breadway
54210ddc93 Emit bread.paper.changed after a successful set
All checks were successful
check / check (push) Successful in 23s
dev release / build (push) Successful in 25s
Link bread-utils v0.7.1 (bread-client) and publish
{ "path": "<wallpaper>" } 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.
2026-08-15 22:15:29 +08:00
Breadway
7a3a54c6f0 Track AGENTS.md
All checks were successful
check / check (push) Successful in 26s
2026-08-15 22:03:30 +08:00
Breadway
70c2dd3c6a bakery.toml: bread-theme + awww/pywal, not bread
All checks were successful
check / check (push) Successful in 8s
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.
2026-08-15 21:34:55 +08:00
a93e8b1793 Merge pull request 'ci: build inside bread-ecosystem's shared Arch container' (#1) from feature/shared-ci into main
All checks were successful
dev release / build (push) Successful in 12s
check / check (push) Successful in 5s
Reviewed-on: #1
2026-08-05 19:19:29 +08:00
Breadway
3eff1e5972 ci: build inside bread-ecosystem's shared Arch container
All checks were successful
check / check (push) Successful in 4s
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).
2026-08-05 19:14:49 +08:00
Breadway
ceb6fbb12f CONTRIBUTING.md: document single-trunk + RC-tag release model
All checks were successful
dev release / build (push) Successful in 49s
2026-07-31 11:08:41 +08:00
Breadway
d329839af2 CI: single-trunk model — dev triggers on main, beta becomes RC-tag-triggered
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.
2026-07-31 11:05:29 +08:00
Breadway
3b7434f2c9 Drop pacman packaging, bakery-only distribution
Some checks failed
dev release / build (push) Successful in 24s
Mirror to GitHub / mirror (push) Failing after 1s
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.
2026-07-23 10:25:12 +08:00
Breadway
61775c91a6 docs: add CONTRIBUTING.md
Some checks failed
Mirror to GitHub / mirror (push) Failing after 4s
dev release / build (push) Successful in 10s
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.
2026-07-22 19:41:10 +08:00
Breadway
eb8667991d ci: make beta a branch-triggered freeze track, not a one-off tag
Some checks failed
dev release / build (push) Failing after 12s
beta release / build (push) Successful in 19s
Mirror to GitHub / mirror (push) Failing after 6s
Beta is now a real stabilization branch: publishes on every push to
`beta` (mirroring dev's model, auto-versioned X.Y.Z-beta.<ts>+<sha>,
base version from the latest published tag) instead of a manual
beta-v* tag. Fixes made during the freeze land via fix/<issue> 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.
2026-07-22 18:37:32 +08:00
Breadway
fa3ccacca5 ci: base dev version on the latest published tag, not Cargo.toml
Some checks failed
dev release / build (push) Successful in 20s
Mirror to GitHub / mirror (push) Failing after 2s
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.
2026-07-22 13:52:32 +08:00
Breadway
e77da145d9 random commit message, read it yourself
Some checks failed
dev release / build (push) Successful in 23s
Mirror to GitHub / mirror (push) Failing after 1s
2026-07-22 11:56:44 +08:00
Breadway
2d652f2bbf ci: use a unique temp dir for the bread-ecosystem clone in dev/beta CI
Some checks failed
dev release / build (push) Successful in 21s
Mirror to GitHub / mirror (push) Failing after 1s
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.
2026-07-22 10:24:33 +08:00
Breadway
25a3175776 ci: retrigger dev-track build now that BAKERY_MINISIGN_SEC_KEY_PATH is set
Some checks failed
dev release / build (push) Successful in 30s
Mirror to GitHub / mirror (push) Failing after 2s
2026-07-22 10:10:10 +08:00
Breadway
c10bad3877 ci: add dev/beta build track workflows
Some checks failed
dev release / build (push) Failing after 11s
Mirror to GitHub / mirror (push) Failing after 2s
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.
2026-07-22 09:58:26 +08:00
27 changed files with 2758 additions and 156 deletions

View file

@ -0,0 +1,24 @@
name: check
# Fast-fail lint/test on short-lived work branches, before it ever reaches
# main and triggers a dev-track release build.
on:
push:
branches: ['feature/**', 'fix/**']
jobs:
check:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: clippy
run: cd src && bash ci/build.sh cargo clippy --all-targets --locked -- -D warnings
- name: test
run: cd src && bash ci/build.sh cargo test --release --locked

View file

@ -0,0 +1,80 @@
name: dev release
# Publishes a dev-track build on every push to `main` (the trunk
# branch — there is no separate `dev` branch). See bread-ecosystem's
# docs/release-channels.md for the release-track policy this is part of.
on:
push:
branches: ['main']
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch main --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && bash ci/build.sh cargo build --release --locked
- name: test
run: cd src && bash ci/build.sh cargo test --release --locked
- name: compute dev version
run: |
set -euo pipefail
cd src
# Base the dev version off the latest published stable tag,
# not Cargo.toml — Cargo.toml can go stale relative to the last
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
# breadpaper), which would make a dev build sort as OLDER than
# what's already installed and bakery would correctly refuse it.
LATEST_TAG="$(git ls-remote --tags --refs \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
if [ -n "${LATEST_TAG}" ]; then
CUR="${LATEST_TAG}"
else
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
fi
IFS='.' read -r MA MI PA <<< "${CUR}"
SHA="$(git rev-parse --short HEAD)"
TS="$(date -u +%Y%m%d%H%M%S)"
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
- name: prepare artifacts
run: |
set -euo pipefail
PKG_DIR="/srv/breadway-dl/dev/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/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"
# No GitHub Release upload — dev, like the other non-stable track,
# is only distributed via dl.breadway.dev/dev/.
- name: regenerate dev index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
# mktemp: a fixed clone path races when multiple repos' dev/beta
# workflows run close together on the same self-hosted runner.
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -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/*'

View file

@ -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"

View file

@ -0,0 +1,61 @@
name: beta (rc) release
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag
# pushed to `main` — there is no separate `beta` branch; "freezing" is
# just pausing pushes to main while an RC gets tested. See
# bread-ecosystem's docs/release-channels.md for the release-track policy.
on:
push:
tags: ['v*']
jobs:
build:
if: ${{ contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && bash ci/build.sh cargo build --release --locked
- name: test
run: cd src && bash ci/build.sh cargo test --release --locked
- 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"
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/packaging/breadpaper.desktop "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadpaper/latest"
# No GitHub Release upload — beta, like dev, is only distributed via
# dl.breadway.dev/beta/.
- name: regenerate beta index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
# mktemp: a fixed clone path races when multiple repos' dev/beta
# workflows run close together on the same self-hosted runner.
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -4,50 +4,57 @@ on:
push: push:
tags: ["v*"] tags: ["v*"]
env:
DL_DIR: /srv/breadway-dl
ECOSYSTEM_DIR: /tmp/bread-ecosystem-ci
PATH: /home/breadway/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
jobs: jobs:
build: build:
runs-on: hestia if: ${{ !contains(github.ref_name, '-rc.') }}
defaults: runs-on: [self-hosted, hestia]
run:
working-directory: /tmp/breadpaper-build
steps: steps:
- name: checkout - name: checkout
working-directory: /tmp
run: | run: |
rm -rf /tmp/breadpaper-build set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /tmp/breadpaper-build "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build - name: build
run: /home/breadway/.cargo/bin/cargo build --release --locked run: |
set -euo pipefail
if [ ! -f src/ci/build.sh ]; then
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
exit 1
fi
cd src && bash ci/build.sh cargo build --release --locked || {
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
exit 1
}
- name: test - name: test
run: /home/breadway/.cargo/bin/cargo test --release --locked run: cd src && bash ci/build.sh cargo test --release --locked
- name: prepare artifacts - name: prepare artifacts
run: | run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}" VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="${DL_DIR}/breadpaper/${VERSION}" PKG_DIR="/srv/breadway-dl/breadpaper/${VERSION}"
mkdir -p "${PKG_DIR}" mkdir -p "${PKG_DIR}"
cp target/release/breadpaper "${PKG_DIR}/breadpaper-x86_64" cp src/target/release/breadpaper "${PKG_DIR}/breadpaper-x86_64"
strip "${PKG_DIR}/breadpaper-x86_64" strip "${PKG_DIR}/breadpaper-x86_64"
sha256sum "${PKG_DIR}/breadpaper-x86_64" | awk '{print $1}' \ sha256sum "${PKG_DIR}/breadpaper-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/breadpaper-x86_64.sha256" > "${PKG_DIR}/breadpaper-x86_64.sha256"
cp bakery.toml "${PKG_DIR}/bakery.toml" cp src/LICENSE "${PKG_DIR}/"
ln -sfn "${VERSION}" "${DL_DIR}/breadpaper/latest" cp src/packaging/breadpaper.desktop "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
- name: ensure bread-ecosystem ln -sfn "${VERSION}" "/srv/breadway-dl/breadpaper/latest"
working-directory: /tmp
run: |
rm -rf "${ECOSYSTEM_DIR}"
git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}"
- name: regenerate index.json - name: regenerate index.json
working-directory: /tmp env:
run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh

2
.gitignore vendored
View file

@ -1 +1,3 @@
/target /target
# Local hygiene notes (not for commit)

28
AGENTS.md Normal file
View file

@ -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.

84
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,84 @@
# 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
There is one long-lived branch: **`main`**. All day-to-day work lands here.
Every push to `main` automatically builds and publishes a **dev-track**
build (see Tracks below) — a real install you can test before cutting
anything more formal.
New work — features and bug fixes alike — goes on a short-lived branch:
```
feature/<short-name>
fix/<issue-number-or-short-name>
```
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
branches get deleted on merge — they never accumulate the kind of drift a
second long-lived branch does.
## The release cycle
There's no separate `beta` or release branch — "stable" and "beta" are both
just **tags** on `main`, not branches that need to be kept in sync:
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
auto-publishes a dev build — install it with `bakery track set dev` and
`bakery update --all`, then fix anything broken with another push.
2. When you want to stabilize before a real release, tag a release
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
both remotes). That tag alone triggers a beta-track build —
"freezing" is just pausing pushes to `main` while you test it, not a
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
3. Once an RC has gone without issues, tag the real release:
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
signed stable release build.
## Tracks, from a user's perspective
```
bakery track show # what you're currently on (defaults to stable)
bakery track set dev # or beta, or stable
bakery update --all # pull the latest build on your current track
```
| Track | What it is | Published from |
|--------|-----------|-----------------|
| `stable` | The last tagged release | a `vX.Y.Z` tag |
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
| `dev` | Bleeding edge | `main`, on every push |
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
latest published stable tag, so they always sort as newer than what you
have installed — no manual version bumping needed. Beta versions are just
the RC tag itself (already valid semver, already sorts below the real
release it's a candidate for).
## Local development
```sh
cargo build --release
cargo test --release
```
## CI
- `dev-release.yml` — triggered on push to `main`.
- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push.
- `release.yml` — triggered on any other `v*` tag push, cuts the actual
stable release.
All CI runs on a self-hosted runner; nothing runs automatically on plain
commits or PRs beyond the track builds above. See
[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md)
for the full policy, including how a new product gets wired onto these tracks.
## Questions
Open an issue on this repo's Forgejo tracker.

936
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "breadpaper" name = "breadpaper"
version = "0.1.0" version = "0.1.14"
edition = "2024" edition = "2024"
description = "Wallpaper manager for the bread desktop" description = "Wallpaper manager for the bread desktop"
license = "MIT" license = "MIT"
@ -8,3 +8,9 @@ license = "MIT"
[dependencies] [dependencies]
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
anyhow = "1" 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.4", features = ["gtk"] }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }

70
EVENTS.md Normal file
View file

@ -0,0 +1,70 @@
# breadpaper — bread event integration
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` 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.
App id: **`paper`**. Transport: `bread-utils`'s `bread_client` module
(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.
`breadpaper listen` is fail-silent if breadd is down: `subscribe`
reconnects with backoff and simply delivers nothing until the daemon
comes back. It also honors `bread.monitor.connected` by re-applying
`~/.config/breadpaper/current.json`. The one-shot `set`/`get`/`library`
/`apply` 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")
```
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": "<wallpaper>", "output"?: "<name>" }` | After a successful `set` / `set --output` (awww + palette + theme write), including when `listen` honors `bread.command.paper.set` or restores `current.json` after `bread.monitor.connected`. `path` is the canonical absolute path that was applied. `output` is the compositor output name when only one monitor was targeted; omit (or `null`) means every output. Not emitted on `get`, and not emitted if the set fails. |
| `bread.paper.set.done` | `{ "path": "<wallpaper>", "output"?: "<name>" }` | `bread.command.paper.set` was received and `set()` / `set_on()` succeeded. `path` is the canonical absolute path that was applied. `output` is present when the command targeted one monitor. Not emitted by the one-shot CLI `set` — that path only publishes `changed`. |
| `bread.paper.set.failed` | `{ "error": "<message>", "path"?: "<requested>", "output"?: "<name>" }` | `bread.command.paper.set` was received but `set()` / `set_on()` failed, or `data.path` was missing/not a string. `path` is the requested (not canonical) path when one was supplied. `output` is echoed when the command included one. |
| `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": "<message>" }` | `bread.command.paper.library` was received but the library process could not be spawned. |
## Commands honored (`bread.command.paper.*`)
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 | Effect |
|------|------|--------|
| `set` | `{ "path": "...", "output"?: "..." }` | Missing `output` calls `set()` (all live outputs, global `wal -i`, `bread-theme reload`). A string `output` calls `set_on()` (that monitor only; no global wal unless it is the focused Hyprland monitor). 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 applies to the picker's output and publishes `bread.paper.changed`. |
### Not implemented: slideshow / random / next
`breadpaper library` / `browse` is the in-app picker. Do not invent
`bread.command.paper.next` / `.random` / `.cycle` (or matching events)
ahead of a real slideshow 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.
- `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.

89
README.md Normal file
View file

@ -0,0 +1,89 @@
# 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`. Two monitors can keep different wallpapers (and
per-output bread-theme files); the last path per output is stored in
`~/.config/breadpaper/current.json`.
`set` / `get` / `apply` stay one-shot CLI. `breadpaper library` (alias
`browse`) opens a GTK picker over the wallpaper directories. It is not
a slideshow daemon.
## 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`)
`library` also needs GTK4 (the window loads `bread-theme`'s shared
stylesheet and follows the monitor it sits on). `get` without
`--output` still reads the path pywal stored at `~/.cache/wal/wal`.
`get --output NAME` reads `~/.config/breadpaper/current.json`.
## Install
```
bakery install breadpaper
```
From source:
```
cargo build --release
install -Dm755 target/release/breadpaper ~/.local/bin/breadpaper
```
## Usage
```
breadpaper <path> # shorthand for `set` (all outputs)
breadpaper <path> --output NAME # one output
breadpaper set <path> # awww + wal + bread-theme reload (all)
breadpaper set <path> --output NAME
breadpaper get # print ~/.cache/wal/wal
breadpaper get --output NAME # path from current.json
breadpaper apply # restore current.json
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 applies
to the monitor the picker is on (`set --output`); if the output cannot
be resolved it falls back to all outputs.
## 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` 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
MIT — see [LICENSE](LICENSE).

View file

@ -1,9 +1,11 @@
name = "breadpaper" name = "breadpaper"
description = "Wallpaper manager for the bread desktop — sets awww wallpaper, generates pywal palette, reloads bread themes" description = "Wallpaper manager for the bread desktop — sets awww wallpaper, generates pywal palette, reloads bread themes"
binaries = ["breadpaper"] binaries = ["breadpaper"]
system_deps = ["python-pywal"] system_deps = ["awww", "python-pywal", "gtk4"]
optional_system_deps = ["awww"] optional_system_deps = []
bread_deps = ["bread"] bread_deps = ["bread-theme"]
license_file = "LICENSE"
desktop_file = "breadpaper.desktop"
[install] [install]
post_install = [] post_install = []

1
ci/bread-ecosystem.rev Normal file
View file

@ -0,0 +1 @@
147cfbbf96ae4b171027defa1130d2caddb934b1

21
ci/build.sh Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Delegates to bread-ecosystem's shared CI build image/script, pinned to
# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI
# files now affect every product's release pipeline, so bumping the pin
# is a deliberate act instead of silent drift (see the bread-theme test
# that broke here for exactly that reason, before it was pinned by rev).
#
# Usage: ci/build.sh cargo build --release --locked
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")"
CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}"
if [ ! -d "$CACHE_DIR" ]; then
rm -rf /tmp/bread-ecosystem-ci-*
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR"
git -C "$CACHE_DIR" checkout --quiet "$REV"
fi
bash "${CACHE_DIR}/ci/build.sh" breadpaper "$ROOT" "$@"

6
config.example.toml Normal file
View file

@ -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",
]

View file

@ -1,34 +0,0 @@
# Maintainer: Breadway <rileyhorsham@gmail.com>
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"
}

View file

@ -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

252
src/config.rs Normal file
View file

@ -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<PathBuf>,
}
#[derive(Debug, Default, Deserialize)]
struct ConfigFile {
#[serde(default)]
library_dirs: Vec<PathBuf>,
}
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::<ConfigFile>(&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<Item = PathBuf>) -> Self {
self.library_dirs
.extend(extra.into_iter().map(expand_tilde));
self
}
}
pub fn default_library_dirs() -> Vec<PathBuf> {
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<Vec<PathBuf>> {
let raw = std::env::var(LIBRARY_DIRS_ENV).ok()?;
if raw.is_empty() {
return None;
}
let dirs: Vec<PathBuf> = 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<Mutex<()>> = 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")]
);
}
}

126
src/current.rs Normal file
View file

@ -0,0 +1,126 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
/// Persisted wallpaper path per output (`~/.config/breadpaper/current.json`).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Current {
#[serde(default)]
outputs: BTreeMap<String, PathBuf>,
}
impl Current {
pub fn path() -> PathBuf {
bread_utils::xdg::config_dir("breadpaper").join("current.json")
}
pub fn load() -> Self {
Self::load_from(&Self::path())
}
/// Missing or unreadable file => empty map.
pub fn load_from(path: &Path) -> Self {
let Ok(text) = std::fs::read_to_string(path) else {
return Self::default();
};
serde_json::from_str(&text).unwrap_or_default()
}
pub fn save(&self) -> Result<()> {
self.save_to(&Self::path())
}
pub fn save_to(&self, path: &Path) -> Result<()> {
let text = serde_json::to_string_pretty(self).context("serialize current.json")?;
let text = format!("{text}\n");
bread_utils::atomic::write_atomic(path, &text, None)
.with_context(|| format!("write {}", path.display()))
}
pub fn set_output(&mut self, output: impl Into<String>, path: impl Into<PathBuf>) {
self.outputs.insert(output.into(), path.into());
}
pub fn get_output(&self, output: &str) -> Option<&Path> {
self.outputs.get(output).map(PathBuf::as_path)
}
pub fn all(&self) -> &BTreeMap<String, PathBuf> {
&self.outputs
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"breadpaper-current-{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 missing_file_is_empty_map() {
let dir = tmp_dir("missing");
let path = dir.join("current.json");
let cur = Current::load_from(&path);
assert!(cur.all().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn roundtrip_pretty_json() {
let dir = tmp_dir("roundtrip");
let path = dir.join("nested").join("current.json");
let mut cur = Current::default();
cur.set_output("eDP-1", "/abs/path/a.png");
cur.set_output("HDMI-A-1", "/abs/path/b.png");
cur.save_to(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("\n \"outputs\""));
assert!(text.contains("\n \"HDMI-A-1\""));
assert!(text.contains("\n \"eDP-1\""));
let loaded = Current::load_from(&path);
assert_eq!(
loaded.get_output("eDP-1"),
Some(Path::new("/abs/path/a.png"))
);
assert_eq!(
loaded.get_output("HDMI-A-1"),
Some(Path::new("/abs/path/b.png"))
);
assert_eq!(loaded.all().len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn set_one_output_does_not_drop_others() {
let dir = tmp_dir("keep");
let path = dir.join("current.json");
let mut cur = Current::default();
cur.set_output("eDP-1", "/abs/a.png");
cur.set_output("HDMI-A-1", "/abs/b.png");
cur.save_to(&path).unwrap();
let mut cur = Current::load_from(&path);
cur.set_output("eDP-1", "/abs/c.png");
cur.save_to(&path).unwrap();
let loaded = Current::load_from(&path);
assert_eq!(loaded.get_output("eDP-1"), Some(Path::new("/abs/c.png")));
assert_eq!(loaded.get_output("HDMI-A-1"), Some(Path::new("/abs/b.png")));
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -1,18 +1,249 @@
mod config;
mod current;
mod library;
mod pywal; mod pywal;
mod theme; mod theme;
mod ui;
mod wallpaper; mod wallpaper;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[cfg(not(test))]
use std::process::{Command, Stdio};
use std::thread;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use bread_utils::bread_client::{BreadClient, BreadEvent};
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`.
const APP_ID: &str = "paper";
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif", "bmp"]; 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<Item = PathBuf>) -> Result<()> {
let cfg = Config::load().with_extra_dirs(extra_dirs);
ui::run(cfg.library_dirs)
}
/// Set wallpaper + global pywal palette on every live output.
pub fn set(path: &Path) -> Result<()> { pub fn set(path: &Path) -> Result<()> {
let path = validate(path)?; let path = validate(path)?;
apply_wallpaper(&path)?; apply_wallpaper(&path)?;
generate_palette(&path)?; generate_palette(&path)?;
reload_theme()?; reload_theme()?;
let mut cur = current::Current::load();
if cur.all().is_empty() {
let live = live_outputs();
if live.is_empty() {
cur.set_output("*", path.clone());
} else {
for output in live {
cur.set_output(output, path.clone());
}
}
} else {
let keys: Vec<String> = cur.all().keys().cloned().collect();
for output in keys {
cur.set_output(output, path.clone());
}
}
cur.save()?;
for output in cur.all().keys() {
if output != "*" {
theme::generate_for_output(output, &path)?;
}
}
emit_changed(&path, None);
Ok(())
}
/// Set wallpaper + per-output theme on a single compositor output.
///
/// Does not run global `wal -i`. If `output` is the focused Hyprland
/// monitor, the shared stylesheet is updated from that output's palette
/// so unbound apps match the focused screen.
pub fn set_on(path: &Path, output: &str) -> Result<()> {
if output.is_empty() {
bail!("output name is empty");
}
let path = validate(path)?;
wallpaper::apply_on(&path, output)?;
let palette = theme::generate_for_output(output, &path)?;
let mut cur = current::Current::load();
cur.set_output(output, path.clone());
cur.save()?;
if is_focused_output(output) {
theme::write_shared_from(&palette)?;
}
emit_changed(&path, Some(output));
Ok(())
}
/// Re-apply every wallpaper + per-output theme stored in current.json.
pub fn apply_saved() -> Result<()> {
let cur = current::Current::load();
let mut first_err = None;
for (output, path) in cur.all() {
let result = restore_one(output, path);
match result {
Ok(()) => {
let name = (output != "*").then_some(output.as_str());
emit_changed(path, name);
}
Err(e) => {
eprintln!("breadpaper: apply {output}: {e:#}");
if first_err.is_none() {
first_err = Some(e);
}
}
}
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
fn restore_one(output: &str, path: &Path) -> Result<()> {
if output == "*" {
wallpaper::apply(path)?;
return Ok(());
}
wallpaper::apply_on(path, output)?;
let palette = theme::generate_for_output(output, path)?;
if is_focused_output(output) {
theme::write_shared_from(&palette)?;
}
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);
let _monitors = client.subscribe("bread.monitor.connected", |_| {
if let Err(e) = apply_saved() {
eprintln!("breadpaper: apply_saved on monitor connect failed: {e:#}");
}
});
loop {
thread::park();
}
}
/// Fire-and-forget `bread.paper.changed`. Silent no-op if breadd is down
/// (`BreadClient::emit` never blocks or errors the caller).
///
/// `output` is `None` when the wallpaper was applied to every output.
fn emit_changed(path: &Path, output: Option<&str>) {
let mut data = json!({ "path": path.to_string_lossy() });
if let Some(name) = output {
data["output"] = json!(name);
}
BreadClient::connect(APP_ID).emit("bread.paper.changed", data);
}
fn handle_command(event: BreadEvent) {
let Some(verb) = event.event.strip_prefix("bread.command.paper.") else {
return;
};
match verb {
"set" => handle_set(&event.data),
"library" => handle_library(),
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 output = data.get("output").and_then(Value::as_str);
let path = Path::new(path_str);
let result = match output {
Some(name) => set_on(path, name),
None => set(path),
};
match result {
Ok(()) => {
let applied = path
.canonicalize()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| path_str.to_string());
let mut payload = json!({ "path": applied });
if let Some(name) = output {
payload["output"] = json!(name);
}
client.emit("bread.paper.set.done", payload);
}
Err(e) => {
eprintln!("breadpaper: bread.command.paper.set failed: {e:#}");
let mut payload = json!({ "error": format!("{e:#}"), "path": path_str });
if let Some(name) = output {
payload["output"] = json!(name);
}
client.emit("bread.paper.set.failed", payload);
}
}
}
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(()) Ok(())
} }
@ -26,6 +257,14 @@ pub fn get() -> Result<PathBuf> {
Ok(PathBuf::from(contents.trim())) Ok(PathBuf::from(contents.trim()))
} }
/// Wallpaper path last persisted for `output` in current.json.
pub fn get_on(output: &str) -> Result<PathBuf> {
current::Current::load()
.get_output(output)
.map(Path::to_path_buf)
.with_context(|| format!("no wallpaper saved for output {output}"))
}
pub fn apply_wallpaper(path: &Path) -> Result<()> { pub fn apply_wallpaper(path: &Path) -> Result<()> {
wallpaper::apply(path) wallpaper::apply(path)
} }
@ -59,3 +298,132 @@ fn validate(path: &Path) -> Result<PathBuf> {
Ok(canonical) Ok(canonical)
} }
fn live_outputs() -> Vec<String> {
if let Some(names) = hypr_output_names() {
return names;
}
wallpaper::query_outputs()
}
fn hypr_output_names() -> Option<Vec<String>> {
let v = bread_utils::hypr::request_json("j/monitors")?;
let names: Vec<String> = v
.as_array()?
.iter()
.filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(str::to_string))
.collect();
if names.is_empty() { None } else { Some(names) }
}
fn is_focused_output(output: &str) -> bool {
bread_utils::hypr::focused_monitor()
.map(|m| m.name == output)
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"breadpaper-lib-{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 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"), None);
emit_changed(Path::new("/tmp/wallpaper.png"), Some("eDP-1"));
}
#[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 }));
}
#[test]
fn handle_set_with_output_vs_without_is_silent_without_breadd() {
// Missing files fail in validate — never reaches awww/wal.
handle_set(&json!({ "path": "/no/such/breadpaper-wallpaper.png" }));
handle_set(&json!({
"path": "/no/such/breadpaper-wallpaper.png",
"output": "eDP-1"
}));
}
#[test]
fn handle_command_library_is_silent_without_breadd() {
handle_command(BreadEvent {
event: "bread.command.paper.library".into(),
timestamp: 0,
data: json!({}),
});
}
#[test]
fn validate_rejects_bad_extensions() {
let dir = tmp_dir("validate");
let txt = dir.join("notes.txt");
std::fs::write(&txt, b"x").unwrap();
assert!(validate(&txt).is_err());
let png = dir.join("ok.png");
std::fs::write(&png, b"x").unwrap();
assert_eq!(validate(&png).unwrap(), png.canonicalize().unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn handle_set_bad_extension_with_output_is_silent_without_breadd() {
let dir = tmp_dir("bad-ext");
let txt = dir.join("notes.txt");
std::fs::write(&txt, b"x").unwrap();
handle_set(&json!({
"path": txt.to_string_lossy(),
"output": "HDMI-A-1"
}));
let _ = std::fs::remove_dir_all(&dir);
}
}

168
src/library.rs Normal file
View file

@ -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<Wallpaper> {
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<Wallpaper>, seen: &mut HashSet<PathBuf>) {
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);
}
}

View file

@ -4,11 +4,19 @@ use std::process;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
#[derive(Parser)] #[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 { struct Cli {
/// Image file to set as wallpaper (shorthand for `set`) /// Image file to set as wallpaper (shorthand for `set`)
path: Option<PathBuf>, path: Option<PathBuf>,
/// Restrict set/get to one compositor output
#[arg(long, value_name = "NAME", global = true)]
output: Option<String>,
#[command(subcommand)] #[command(subcommand)]
command: Option<Command>, command: Option<Command>,
} }
@ -16,21 +24,37 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Command { enum Command {
/// Set wallpaper, generate pywal palette, and reload bread themes /// Set wallpaper, generate pywal palette, and reload bread themes
Set { Set { path: PathBuf },
path: PathBuf,
},
/// Print the current wallpaper path /// Print the current wallpaper path
Get, Get,
/// Re-apply wallpapers and per-output themes from current.json
Apply,
/// 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<PathBuf>,
},
} }
fn main() { fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
let result = match (cli.command, cli.path) { let result = match (cli.command, cli.path) {
(Some(Command::Set { path }), _) | (None, Some(path)) => breadpaper::set(&path), (Some(Command::Set { path }), _) | (None, Some(path)) => match cli.output.as_deref() {
(Some(Command::Get), _) | (None, None) => { Some(output) => breadpaper::set_on(&path, output),
breadpaper::get().map(|p| println!("{}", p.display())) None => breadpaper::set(&path),
} },
(Some(Command::Listen), _) => breadpaper::listen(),
(Some(Command::Library { dirs }), _) => breadpaper::library(dirs),
(Some(Command::Apply), _) => breadpaper::apply_saved(),
(Some(Command::Get), _) | (None, None) => match cli.output.as_deref() {
Some(output) => breadpaper::get_on(output).map(|p| println!("{}", p.display())),
None => breadpaper::get().map(|p| println!("{}", p.display())),
},
}; };
if let Err(e) = result { if let Err(e) = result {

View file

@ -1,6 +1,8 @@
use std::path::Path;
use std::process::Command; use std::process::Command;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use bread_theme::Palette;
pub fn reload() -> Result<()> { pub fn reload() -> Result<()> {
let status = Command::new("bread-theme") let status = Command::new("bread-theme")
@ -13,3 +15,16 @@ pub fn reload() -> Result<()> {
} }
Ok(()) Ok(())
} }
/// Per-output palette + bread-theme files. Does not run `wal -i`.
pub fn generate_for_output(output: &str, path: &Path) -> Result<Palette> {
bread_theme::generate_output(output, path)
.with_context(|| format!("bread-theme generate_output({output}, {})", path.display()))?;
Ok(bread_theme::load_palette_for(output))
}
pub fn write_shared_from(palette: &Palette) -> Result<()> {
bread_theme::write_shared_css_from(palette)
.context("bread-theme write_shared_css_from")
.map(|_| ())
}

300
src/ui.rs Normal file
View file

@ -0,0 +1,300 @@
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<Option<CssProvider>> = const { RefCell::new(None) };
}
pub fn run(dirs: Vec<PathBuf>) -> 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<PathBuf>) {
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));
bread_theme::gtk::bind_window_auto(&window);
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();
let window = window.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, &window);
})
};
window.present();
reload();
{
let reload = reload.clone();
refresh.connect_clicked(move |_| reload());
}
}
fn fill_grid(flow: &FlowBox, papers: &[Wallpaper], status: &Label, host: &impl IsA<gtk4::Widget>) {
while let Some(child) = flow.first_child() {
flow.remove(&child);
}
let current = current_path_for(host);
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 current_path_for(widget: &impl IsA<gtk4::Widget>) -> Option<PathBuf> {
target_output(widget)
.and_then(|output| {
crate::current::Current::load()
.get_output(&output)
.map(Path::to_path_buf)
})
.or_else(|| crate::get().ok())
}
fn target_output(widget: &impl IsA<gtk4::Widget>) -> Option<String> {
bread_theme::gtk::output_for_widget(widget)
.or_else(|| bread_utils::hypr::focused_monitor().map(|m| m.name))
}
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);
let output = target_output(clicked);
status.set_text(&match output.as_deref() {
Some(name) => format!("Applying {pretty} on {name}"),
None => 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 output_thread = output.clone();
let result = gtk4::gio::spawn_blocking(move || match output_thread.as_deref() {
Some(name) => crate::set_on(&path_thread, name),
None => crate::set(&path_thread),
})
.await;
clicked.set_sensitive(true);
match result {
Ok(Ok(())) => {
status.set_text(&match output.as_deref() {
Some(name) => format!("Applied {pretty} on {name}"),
None => 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(&gtk4::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::<FlowBoxChild>()
.and_then(|c| c.child())
.and_then(|w| w.downcast::<Button>().ok())
{
if btn.widget_name() == current.as_ref() {
btn.add_css_class("current");
} else {
btn.remove_css_class("current");
}
}
i += 1;
}
}
fn dirs_summary(dirs: &[PathBuf], count: usize) -> String {
let listed = dirs
.iter()
.map(|d| {
if d.is_dir() {
d.display().to_string()
} else {
format!("{} (missing)", d.display())
}
})
.collect::<Vec<_>>()
.join(" · ");
format!("{count} wallpaper(s) · {listed}")
}
fn empty_message(dirs: &[PathBuf]) -> String {
let listed = dirs
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join("\n");
format!(
"No wallpapers found.\nAdd png/jpg/webp/gif/bmp files under:\n{listed}\n\nOr set library_dirs in {}",
crate::config::Config::path().display()
)
}

View file

@ -4,14 +4,73 @@ use std::process::Command;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
pub fn apply(path: &Path) -> Result<()> { pub fn apply(path: &Path) -> Result<()> {
let status = Command::new("awww") run_awww(Command::new("awww").arg("img").arg(path))
.arg("img") }
.arg(path)
pub fn apply_on(path: &Path, output: &str) -> Result<()> {
run_awww(
Command::new("awww")
.arg("img")
.arg(path)
.arg("--outputs")
.arg(output),
)
}
/// Output names from `awww query`. Empty if the daemon isn't running.
pub fn query_outputs() -> Vec<String> {
let Ok(out) = Command::new("awww").arg("query").output() else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
parse_awww_query(&String::from_utf8_lossy(&out.stdout))
}
fn run_awww(cmd: &mut Command) -> Result<()> {
let status = cmd
.status() .status()
.context("failed to run awww — is awww-daemon running?")?; .context("failed to run awww — is awww-daemon running?")?;
if !status.success() { if !status.success() {
bail!("awww img exited with {}", status); bail!("awww img exited with {}", status);
} }
Ok(()) Ok(())
} }
fn parse_awww_query(stdout: &str) -> Vec<String> {
stdout.lines().filter_map(parse_awww_query_line).collect()
}
/// `awww query` lines look like `: eDP-1: 1920x1080, scale: 1, ...`.
fn parse_awww_query_line(line: &str) -> Option<String> {
let rest = line.trim().strip_prefix(':').unwrap_or(line).trim();
let name = rest.split(':').next()?.trim();
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_awww_query_names() {
let sample = "\
: eDP-1: 1920x1080, scale: 1, currently displaying: image: /a.png
: HDMI-A-1: 2560x1440, scale: 1, currently displaying: image: /b.png
";
assert_eq!(
parse_awww_query(sample),
vec!["eDP-1".to_string(), "HDMI-A-1".to_string()]
);
}
#[test]
fn parse_awww_query_skips_blank() {
assert!(parse_awww_query("\n \n").is_empty());
}
}