diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml deleted file mode 100644 index 38e3b07..0000000 --- a/.forgejo/workflows/check.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: check - -# Fast-fail lint/test on short-lived work branches, before it ever reaches -# main and triggers a dev-track release build. -on: - push: - branches: ['feature/**', 'fix/**'] - -jobs: - check: - runs-on: [self-hosted, hestia] - steps: - - name: checkout - run: | - set -euo pipefail - rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: clippy - run: cd src && bash ci/build.sh cargo clippy --all-targets --locked -- -D warnings - - - name: test - run: cd src && bash ci/build.sh cargo test --release --locked diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml deleted file mode 100644 index 2ffbe57..0000000 --- a/.forgejo/workflows/dev-release.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: dev release - -# Publishes a dev-track build on every push to `main` (the trunk -# branch — there is no separate `dev` branch). See bread-ecosystem's -# docs/release-channels.md for the release-track policy this is part of. -on: - push: - branches: ['main'] - -jobs: - build: - runs-on: [self-hosted, hestia] - steps: - - name: checkout - run: | - set -euo pipefail - rm -rf src && mkdir src - git clone --branch main --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: build - run: cd src && bash ci/build.sh cargo build --release --locked - - - name: 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}" diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..01e1dad --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -0,0 +1,19 @@ +name: Mirror to GitHub + +on: + push: + branches: ['**'] + tags: ['**'] + +jobs: + mirror: + runs-on: [self-hosted, hestia] + steps: + - name: Mirror to GitHub + run: | + set -euo pipefail + git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git + cd repo.git + git push --prune \ + "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadpaper.git" \ + '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml new file mode 100644 index 0000000..1a26be9 --- /dev/null +++ b/.forgejo/workflows/package.yml @@ -0,0 +1,37 @@ +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/rc-release.yml b/.forgejo/workflows/rc-release.yml deleted file mode 100644 index 6a588e1..0000000 --- a/.forgejo/workflows/rc-release.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: beta (rc) release - -# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag -# pushed to `main` — there is no separate `beta` branch; "freezing" is -# just pausing pushes to main while an RC gets tested. See -# bread-ecosystem's docs/release-channels.md for the release-track policy. -on: - push: - tags: ['v*'] - -jobs: - build: - if: ${{ contains(github.ref_name, '-rc.') }} - runs-on: [self-hosted, hestia] - steps: - - name: checkout - run: | - set -euo pipefail - rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: build - run: cd src && bash ci/build.sh cargo build --release --locked - - - name: 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}" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 0d5451e..ff3f0c2 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -4,57 +4,50 @@ on: push: 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: build: - if: ${{ !contains(github.ref_name, '-rc.') }} - runs-on: [self-hosted, hestia] + runs-on: hestia + defaults: + run: + working-directory: /tmp/breadpaper-build + steps: - name: checkout + working-directory: /tmp run: | - set -euo pipefail - rm -rf src && mkdir src + rm -rf /tmp/breadpaper-build git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /tmp/breadpaper-build - name: build - run: | - set -euo pipefail - if [ ! -f src/ci/build.sh ]; then - echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper" - exit 1 - fi - cd src && bash ci/build.sh cargo build --release --locked || { - echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked." - exit 1 - } + run: /home/breadway/.cargo/bin/cargo build --release --locked - name: test - run: cd src && bash ci/build.sh cargo test --release --locked + run: /home/breadway/.cargo/bin/cargo test --release --locked - name: prepare artifacts run: | - set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" - PKG_DIR="/srv/breadway-dl/breadpaper/${VERSION}" + PKG_DIR="${DL_DIR}/breadpaper/${VERSION}" mkdir -p "${PKG_DIR}" - cp src/target/release/breadpaper "${PKG_DIR}/breadpaper-x86_64" + cp 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/breadpaper/latest" + cp bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "${DL_DIR}/breadpaper/latest" + + - name: ensure bread-ecosystem + working-directory: /tmp + run: | + rm -rf "${ECOSYSTEM_DIR}" + git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" - name: regenerate index.json - env: - MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} - run: | - set -euo pipefail - if [ -z "${MINISIGN_SEC_KEY:-}" ]; then - echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)" - exit 1 - fi - rm -rf /tmp/bread-ecosystem-ci - git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci - bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + working-directory: /tmp + run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 5abaed1..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - RUSTFLAGS: "-D warnings" - -jobs: - test: - name: fmt · clippy · test · build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Format check - run: cargo fmt --all -- --check - - - name: Clippy - run: cargo clippy --all-targets -- -D warnings - - - name: Test - run: cargo test --all --verbose - - - name: Release build - run: cargo build --release --verbose diff --git a/.gitignore b/.gitignore index e412023..ea8c4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1 @@ /target - -# Local hygiene notes (not for commit) diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 81e8786..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,28 +0,0 @@ -# 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index e2d9ceb..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,84 +0,0 @@ -# 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/ -fix/ -``` - -Branch off `main`, open a PR/push back into `main` when ready. Short-lived -branches get deleted on merge — they never accumulate the kind of drift a -second long-lived branch does. - -## The release cycle - -There's no separate `beta` or release branch — "stable" and "beta" are both -just **tags** on `main`, not branches that need to be kept in sync: - -1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push - auto-publishes a dev build — install it with `bakery track set dev` and - `bakery update --all`, then fix anything broken with another push. -2. When you want to stabilize before a real release, tag a release - candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to - both remotes). That tag alone triggers a beta-track build — - "freezing" is just pausing pushes to `main` while you test it, not a - branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes. -3. Once an RC has gone without issues, tag the real release: - `git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the - signed stable release build. - -## Tracks, from a user's perspective - -``` -bakery track show # what you're currently on (defaults to stable) -bakery track set dev # or beta, or stable -bakery update --all # pull the latest build on your current track -``` - -| Track | What it is | Published from | -|--------|-----------|-----------------| -| `stable` | The last tagged release | a `vX.Y.Z` tag | -| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag | -| `dev` | Bleeding edge | `main`, on every push | - -Dev versions are auto-computed (`X.Y.Z-dev.+`) from the -latest published stable tag, so they always sort as newer than what you -have installed — no manual version bumping needed. Beta versions are just -the RC tag itself (already valid semver, already sorts below the real -release it's a candidate for). - -## Local development - -```sh -cargo build --release -cargo test --release -``` - -## CI - -- `dev-release.yml` — triggered on push to `main`. -- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push. -- `release.yml` — triggered on any other `v*` tag push, cuts the actual - stable release. - -All CI runs on a self-hosted runner; nothing runs automatically on plain -commits or PRs beyond the track builds above. See -[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md) -for the full policy, including how a new product gets wired onto these tracks. - -## Questions - -Open an issue on this repo's Forgejo tracker. diff --git a/Cargo.lock b/Cargo.lock index cf5aeb6..2ff53af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,50 +54,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.104" +version = "1.0.103" 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" -source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" -dependencies = [ - "dirs", - "serde", - "serde_json", - "toml 0.8.23", -] - -[[package]] -name = "bread-theme" -version = "0.7.4" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" -dependencies = [ - "dirs", - "gtk4", - "serde", - "serde_json", -] +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "bread-utils" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.3.0" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" dependencies = [ - "bread-shared", "dirs", "serde", "serde_json", @@ -105,49 +70,11 @@ dependencies = [ [[package]] name = "breadpaper" -version = "0.1.14" +version = "0.1.11" 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]] @@ -158,9 +85,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.6" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", "clap_derive", @@ -168,9 +95,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -180,14 +107,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] @@ -223,141 +150,6 @@ dependencies = [ "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 = "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" @@ -369,216 +161,12 @@ 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" -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" @@ -593,15 +181,15 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -612,15 +200,6 @@ 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" @@ -633,64 +212,20 @@ 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" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.47" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -706,26 +241,11 @@ 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" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -733,29 +253,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -764,36 +284,6 @@ dependencies = [ "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 = "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" @@ -811,36 +301,6 @@ dependencies = [ "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 = "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" @@ -858,101 +318,9 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - -[[package]] -name = "toml" -version = "1.1.4+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" -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]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - -[[package]] -name = "toml_edit" -version = "0.25.13+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_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]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "toml_writer" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -965,12 +333,6 @@ 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" @@ -1058,24 +420,6 @@ 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 = "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 b2bea93..1a6a8d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadpaper" -version = "0.1.14" +version = "0.1.11" edition = "2024" description = "Wallpaper manager for the bread desktop" license = "MIT" @@ -8,9 +8,4 @@ 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.4", features = ["gtk"] } -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" } diff --git a/EVENTS.md b/EVENTS.md deleted file mode 100644 index 37260ba..0000000 --- a/EVENTS.md +++ /dev/null @@ -1,70 +0,0 @@ -# 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": "", "output"?: "" }` | 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": "", "output"?: "" }` | `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": "", "path"?: "", "output"?: "" }` | `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": "" }` | `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. diff --git a/README.md b/README.md index f49b156..dc43320 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,52 @@ # 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. +Wallpaper manager for the bread desktop. Sets a wallpaper via [awww](https://github.com/heywoodlh/awww), generates a colour palette with [pywal](https://github.com/dylanaraps/pywal), and reloads bread themes in one command. ## Dependencies -Must be on `$PATH` for `set`: +Runtime: -- `awww` — Wayland wallpaper (`awww img`) -- `wal` — palette generation (`python-pywal`) -- `bread-theme` — theme reload (bakery package, not `breadd`) +- `awww` — Wayland wallpaper daemon +- `python-pywal` — colour palette generation +- `bread-theme` — theme reload (part of the bread ecosystem) -`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`. +Build: + +- Rust 1.85+ (edition 2024) / cargo ## Install +**From the bakery (recommended on bread OS):** + ``` bakery install breadpaper ``` -From source: +**From source:** ``` cargo build --release install -Dm755 target/release/breadpaper ~/.local/bin/breadpaper ``` +**Arch Linux (PKGBUILD):** + +``` +cd packaging/arch +makepkg -si +``` + ## Usage ``` -breadpaper # shorthand for `set` (all outputs) -breadpaper --output NAME # one output -breadpaper set # awww + wal + bread-theme reload (all) -breadpaper set --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 +breadpaper # set wallpaper (shorthand for `set`) +breadpaper set # set wallpaper, generate palette, reload themes +breadpaper get # print the current wallpaper path ``` 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). +The current wallpaper path is stored by pywal at `~/.cache/wal/wal`. ## License diff --git a/bakery.toml b/bakery.toml index d003e38..2253fa9 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,11 +1,9 @@ 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", "gtk4"] -optional_system_deps = [] -bread_deps = ["bread-theme"] -license_file = "LICENSE" -desktop_file = "breadpaper.desktop" +system_deps = ["python-pywal"] +optional_system_deps = ["awww"] +bread_deps = ["bread"] [install] post_install = [] diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev deleted file mode 100644 index 34e7aa9..0000000 --- a/ci/bread-ecosystem.rev +++ /dev/null @@ -1 +0,0 @@ -147cfbbf96ae4b171027defa1130d2caddb934b1 diff --git a/ci/build.sh b/ci/build.sh deleted file mode 100755 index 8a880a0..0000000 --- a/ci/build.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -# Delegates to bread-ecosystem's shared CI build image/script, pinned to -# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI -# files now affect every product's release pipeline, so bumping the pin -# is a deliberate act instead of silent drift (see the bread-theme test -# that broke here for exactly that reason, before it was pinned by rev). -# -# Usage: ci/build.sh cargo build --release --locked -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")" - -CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}" -if [ ! -d "$CACHE_DIR" ]; then - rm -rf /tmp/bread-ecosystem-ci-* - git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR" - git -C "$CACHE_DIR" checkout --quiet "$REV" -fi - -bash "${CACHE_DIR}/ci/build.sh" breadpaper "$ROOT" "$@" diff --git a/config.example.toml b/config.example.toml deleted file mode 100644 index a447d7c..0000000 --- a/config.example.toml +++ /dev/null @@ -1,6 +0,0 @@ -# ~/.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/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..bf377ff --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,34 @@ +# Maintainer: Breadway + +pkgname=breadpaper +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Wallpaper manager for the bread desktop" +arch=('x86_64') +url="https://git.breadway.dev/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" +} diff --git a/packaging/breadpaper.desktop b/packaging/breadpaper.desktop deleted file mode 100644 index 86ccccd..0000000 --- a/packaging/breadpaper.desktop +++ /dev/null @@ -1,9 +0,0 @@ -[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 deleted file mode 100644 index b89bd08..0000000 --- a/src/config.rs +++ /dev/null @@ -1,252 +0,0 @@ -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/current.rs b/src/current.rs deleted file mode 100644 index f39d680..0000000 --- a/src/current.rs +++ /dev/null @@ -1,126 +0,0 @@ -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, -} - -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, path: impl Into) { - 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 { - &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); - } -} diff --git a/src/lib.rs b/src/lib.rs index 60d1c9b..f990b9e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,249 +1,18 @@ -mod config; -mod current; -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::{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"]; -/// 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) -} - -/// Set wallpaper + global pywal palette on every live output. pub fn set(path: &Path) -> Result<()> { let path = validate(path)?; apply_wallpaper(&path)?; generate_palette(&path)?; 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 = 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(()) } @@ -257,14 +26,6 @@ pub fn get() -> Result { Ok(PathBuf::from(contents.trim())) } -/// Wallpaper path last persisted for `output` in current.json. -pub fn get_on(output: &str) -> Result { - 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<()> { wallpaper::apply(path) } @@ -298,132 +59,3 @@ fn validate(path: &Path) -> Result { Ok(canonical) } - -fn live_outputs() -> Vec { - if let Some(names) = hypr_output_names() { - return names; - } - wallpaper::query_outputs() -} - -fn hypr_output_names() -> Option> { - let v = bread_utils::hypr::request_json("j/monitors")?; - let names: Vec = 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); - } -} diff --git a/src/library.rs b/src/library.rs deleted file mode 100644 index cc1adff..0000000 --- a/src/library.rs +++ /dev/null @@ -1,168 +0,0 @@ -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 899386f..7616f31 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,10 +13,6 @@ struct Cli { /// Image file to set as wallpaper (shorthand for `set`) path: Option, - /// Restrict set/get to one compositor output - #[arg(long, value_name = "NAME", global = true)] - output: Option, - #[command(subcommand)] command: Option, } @@ -27,34 +23,16 @@ enum Command { Set { path: PathBuf }, /// Print the current wallpaper path 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, - }, } fn main() { let cli = Cli::parse(); let result = match (cli.command, cli.path) { - (Some(Command::Set { path }), _) | (None, Some(path)) => match cli.output.as_deref() { - Some(output) => breadpaper::set_on(&path, output), - 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())), - }, + (Some(Command::Set { path }), _) | (None, Some(path)) => breadpaper::set(&path), + (Some(Command::Get), _) | (None, None) => { + breadpaper::get().map(|p| println!("{}", p.display())) + } }; if let Err(e) = result { diff --git a/src/pywal.rs b/src/pywal.rs index ed78f80..c6445a0 100644 --- a/src/pywal.rs +++ b/src/pywal.rs @@ -1,18 +1,19 @@ use std::path::Path; -use std::process::Command; +use std::time::Duration; -use anyhow::{Context, Result, bail}; +use anyhow::{Result, bail}; pub fn generate(path: &Path) -> Result<()> { - let status = Command::new("wal") - .arg("-i") - .arg(path) - .arg("-n") // skip wal's own wallpaper backend; awww already set it - .status() - .context("failed to run wal — is python-pywal installed?")?; - - if !status.success() { - bail!("wal exited with {}", status); + // Was a bare Command::new("wal").status() with no timeout — pywal can + // hang on a corrupt/huge image; this used to be able to wedge the + // caller indefinitely. + let out = bread_utils::proc::run( + "wal", + &["-i", &path.to_string_lossy(), "-n"], // -n: skip wal's own wallpaper backend; awww already set it + Duration::from_secs(30), + ); + if !out.success { + bail!("wal failed (is python-pywal installed?): {}", out.stderr.trim()); } Ok(()) } diff --git a/src/theme.rs b/src/theme.rs index a104e17..fc597ae 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,30 +1,12 @@ -use std::path::Path; -use std::process::Command; +use std::time::Duration; -use anyhow::{Context, Result, bail}; -use bread_theme::Palette; +use anyhow::{Result, bail}; pub fn reload() -> Result<()> { - let status = Command::new("bread-theme") - .arg("reload") - .status() - .context("failed to run bread-theme — is it installed?")?; - - if !status.success() { - bail!("bread-theme reload exited with {}", status); + // Was a bare Command::new("bread-theme").status() with no timeout. + let out = bread_utils::proc::run("bread-theme", &["reload"], Duration::from_secs(5)); + if !out.success { + bail!("bread-theme reload failed (is it installed?): {}", out.stderr.trim()); } Ok(()) } - -/// Per-output palette + bread-theme files. Does not run `wal -i`. -pub fn generate_for_output(output: &str, path: &Path) -> Result { - 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(|_| ()) -} diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index 01e5365..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,300 +0,0 @@ -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)); - 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) { - 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) -> Option { - 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) -> Option { - 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(>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::