From a86d3b5ee493b5e7535b88a73bd1fe368bd86d81 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 21 Jul 2026 19:17:57 +0800 Subject: [PATCH 01/20] ci: remove GitHub push-mirror workflow --- .forgejo/workflows/mirror.yml | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 .forgejo/workflows/mirror.yml diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index 7603cc8..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Mirror to GitHub - -on: - push: - branches: ['**'] - tags: ['**'] - -jobs: - mirror: - runs-on: [self-hosted, hestia] - steps: - - name: Mirror to GitHub - run: | - set -euo pipefail - git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git - cd repo.git - git push --prune \ - "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadmon.git" \ - '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' From f2c1e5595629a4e977e3ac23b0109cf86c978916 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 09:56:49 +0800 Subject: [PATCH 02/20] ci: add dev/beta build track workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Also creates the dev branch for this repo, which didn't exist before — see bread-ecosystem/docs/release-channels.md for the three-track policy. --- .forgejo/workflows/beta-release.yml | 53 ++++++++++++++++++++++++ .forgejo/workflows/dev-release.yml | 62 +++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 .forgejo/workflows/beta-release.yml create mode 100644 .forgejo/workflows/dev-release.yml diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml new file mode 100644 index 0000000..94586b8 --- /dev/null +++ b/.forgejo/workflows/beta-release.yml @@ -0,0 +1,53 @@ +name: beta release + +# Publishes a beta-track build when a `beta-v*` tag is pushed — +# separate from release.yml's tag-triggered stable releases. See +# bread-ecosystem's docs/release-channels.md for the three-track policy +# this is part of. +on: + push: + tags: ['beta-v*'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#beta-v}" + PKG_DIR="/srv/breadway-dl/beta/breadmon/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/breadmon" "${PKG_DIR}/breadmon-x86_64" + strip "${PKG_DIR}/breadmon-x86_64" + sha256sum "${PKG_DIR}/breadmon-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/breadmon-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadmon/latest" + + # No GitHub Release upload — beta, like the other non-stable track, + # is only distributed via dl.breadway.dev/beta/. + - name: regenerate beta index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)" + exit 1 + fi + rm -rf /tmp/bread-ecosystem-ci + # --branch dev: the TRACK-aware gen-index.sh isn't merged to + # bread-ecosystem's main yet. + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci + TRACK=beta bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml new file mode 100644 index 0000000..9212603 --- /dev/null +++ b/.forgejo/workflows/dev-release.yml @@ -0,0 +1,62 @@ +name: dev release + +# Publishes a dev-track build on every push to `dev` — +# separate from release.yml's tag-triggered stable releases. See +# bread-ecosystem's docs/release-channels.md for the three-track policy +# this is part of. +on: + push: + branches: ['dev'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch dev --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: compute dev version + run: | + set -euo pipefail + cd src + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + IFS='.' read -r MA MI PA <<< "${CUR}" + SHA="$(git rev-parse --short HEAD)" + TS="$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV" + + - name: prepare artifacts + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/breadmon/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/breadmon" "${PKG_DIR}/breadmon-x86_64" + strip "${PKG_DIR}/breadmon-x86_64" + sha256sum "${PKG_DIR}/breadmon-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/breadmon-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadmon/latest" + + # No GitHub Release upload — dev, like the other non-stable track, + # is only distributed via dl.breadway.dev/dev/. + - name: regenerate dev index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)" + exit 1 + fi + rm -rf /tmp/bread-ecosystem-ci + # --branch dev: the TRACK-aware gen-index.sh isn't merged to + # bread-ecosystem's main yet. + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci + TRACK=dev bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh From e088ab54ee47e7dde06b9d15cc920cf0f55be527 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 10:10:13 +0800 Subject: [PATCH 03/20] ci: retrigger dev-track build now that BAKERY_MINISIGN_SEC_KEY_PATH is set From 79ef7b6927cfc30d0b1ef2ded28d8231b17632f5 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 10:24:38 +0800 Subject: [PATCH 04/20] ci: use a unique temp dir for the bread-ecosystem clone in dev/beta CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixed /tmp/bread-ecosystem-ci path races when multiple repos' dev/beta workflows run close together on the same self-hosted runner — one job's rm -rf/clone can stomp another's in-progress checkout, causing the regenerate-index step to fail intermittently. Switch to mktemp -d. --- .forgejo/workflows/beta-release.yml | 12 +++++++----- .forgejo/workflows/dev-release.yml | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index 94586b8..e90f57e 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -46,8 +46,10 @@ jobs: echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)" exit 1 fi - rm -rf /tmp/bread-ecosystem-ci - # --branch dev: the TRACK-aware gen-index.sh isn't merged to - # bread-ecosystem's main yet. - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci - TRACK=beta bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true + # mktemp: a fixed clone path races when multiple repos' dev/beta + # workflows run close together on the same self-hosted runner. + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 9212603..ba68eca 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -55,8 +55,10 @@ jobs: echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)" exit 1 fi - rm -rf /tmp/bread-ecosystem-ci - # --branch dev: the TRACK-aware gen-index.sh isn't merged to - # bread-ecosystem's main yet. - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci - TRACK=dev bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true + # mktemp: a fixed clone path races when multiple repos' dev/beta + # workflows run close together on the same self-hosted runner. + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" From c2e650730b4dc0cd905b8f8aa0f3e182840afc19 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 11:55:43 +0800 Subject: [PATCH 05/20] random commit message, read it yourself --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 36f7f5b..8d2803a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ logs/ # Runtime files *.sock *.pid + +# Local hygiene notes (not for commit) +CLAUDE.md From db37668a0367dd0e4f879293fa1ca9102784f71d Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 13:52:37 +0800 Subject: [PATCH 06/20] ci: base dev version on the latest published tag, not Cargo.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo.toml can drift stale relative to the actual last release (observed on breadbox/breadpad/breadcrumbs/breadpaper), which made the auto-bumped dev version sort as OLDER than what's already installed — bakery's semver check correctly refused those "updates". Deriving the base version from git ls-remote --tags instead is self-healing regardless of Cargo.toml drift, with a Cargo.toml fallback only for a repo with no tags yet. --- .forgejo/workflows/dev-release.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index ba68eca..0d5a8d0 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -26,7 +26,19 @@ jobs: run: | set -euo pipefail cd src - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + # Base the dev version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a dev build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi IFS='.' read -r MA MI PA <<< "${CUR}" SHA="$(git rev-parse --short HEAD)" TS="$(date -u +%Y%m%d%H%M%S)" From dec791b73fad25000ea8de91e009388599fef63a Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 18:37:41 +0800 Subject: [PATCH 07/20] ci: make beta a branch-triggered freeze track, not a one-off tag Beta is now a real stabilization branch: publishes on every push to `beta` (mirroring dev's model, auto-versioned X.Y.Z-beta.+, base version from the latest published tag) instead of a manual beta-v* tag. Fixes made during the freeze land via fix/ branches merged into `beta` directly. The gen-index.sh clone for beta pulls bread-ecosystem's default branch (main) rather than pinning to dev, since beta is the more stable track and main now carries the TRACK-aware script. --- .forgejo/workflows/beta-release.yml | 41 ++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index e90f57e..d885ca5 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -1,12 +1,12 @@ name: beta release -# Publishes a beta-track build when a `beta-v*` tag is pushed — -# separate from release.yml's tag-triggered stable releases. See -# bread-ecosystem's docs/release-channels.md for the three-track policy -# this is part of. +# Publishes a beta-track build on every push to `beta` — a frozen +# stabilization branch cut from `dev` when ready to stabilize; only +# fix/ branches merged into `beta` should land here afterward. +# See bread-ecosystem's docs/release-channels.md for the three-track policy. on: push: - tags: ['beta-v*'] + branches: ['beta'] jobs: build: @@ -16,16 +16,37 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + git clone --branch beta --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build run: cd src && cargo build --release --locked + - name: compute beta version + run: | + set -euo pipefail + cd src + # Base the beta version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a beta build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi + IFS='.' read -r MA MI PA <<< "${CUR}" + SHA="$(git rev-parse --short HEAD)" + TS="$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=${MA}.${MI}.$((PA + 1))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" + - name: prepare artifacts run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#beta-v}" PKG_DIR="/srv/breadway-dl/beta/breadmon/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/breadmon" "${PKG_DIR}/breadmon-x86_64" @@ -35,8 +56,8 @@ jobs: cp src/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadmon/latest" - # No GitHub Release upload — beta, like the other non-stable track, - # is only distributed via dl.breadway.dev/beta/. + # No GitHub Release upload — beta, like dev, is only distributed via + # dl.breadway.dev/beta/. - name: regenerate beta index.json env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} @@ -50,6 +71,6 @@ jobs: # mktemp: a fixed clone path races when multiple repos' dev/beta # workflows run close together on the same self-hosted runner. ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" rm -rf "${ECOSYSTEM_CI_DIR}" From 96de82920167180bd0f40813748726137f3ad535 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 19:41:19 +0800 Subject: [PATCH 08/20] docs: add CONTRIBUTING.md Documents the dev/beta/main branch and release-track workflow shared across the bread ecosystem. See bread-ecosystem's docs/release-channels.md for the full policy this implements. --- CONTRIBUTING.md | 90 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ac62569 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,90 @@ +# Contributing + +`breadmon` — Terminal UI monitor manager for Hyprland. + +Part of the bread ecosystem; this repo follows the same branch/release +workflow as every other ecosystem product. + +## Branches + +- **`main`** — release branch, always tag-ready. Nothing is committed to it + directly; it only moves forward via a `beta` merge (see below). +- **`dev`** — integration branch. All day-to-day work lands here first. + Every push to `dev` automatically builds and publishes a **dev-track** + build (see Tracks below) — use this to test your change in a real install + before it goes any further. +- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. + Every push to `beta` automatically builds and publishes a **beta-track** + build. While a freeze is active, only fixes for issues found *in that + freeze* should land on `beta`. + +New work — features and bug fixes alike — goes on a short-lived branch: + +``` +feature/ +fix/ +``` + +Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing +something reported against an active `beta` freeze, branch off `beta` +instead, merge the fix there to unblock testers, and also forward the same +fix into `dev` so it doesn't quietly reappear next cycle. + +## The release cycle + +1. Work accumulates on `dev` via `feature/x` / `fix/x` branches. Each push + auto-publishes a dev build — install it with `bakery track set dev` and + `bakery update --all`, then report or fix anything broken with another + push to `dev`. +2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut + fresh from `dev`'s current tip. This freezes it as the stabilization + target — `dev` keeps moving independently starting the next cycle. +3. `beta` is open for anyone to test: `bakery track set beta` and + `bakery update --all`. **File issues against anything you find on this + repo's Forgejo issue tracker.** Fixes land via `fix/` branches + merged into `beta`. +4. Once `beta` has gone roughly **a month** without new issues, it's merged + into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the + stable release build. `beta` is then reset from `dev` to start the next + cycle. + +## Tracks, from a user's perspective + +``` +bakery track show # what you're currently on (defaults to stable) +bakery track set dev # or beta, or stable +bakery update --all # pull the latest build on your current track +``` + +| Track | What it is | Published from | +|--------|-----------|-----------------| +| `stable` | The last tagged release | `main`, on a `vX.Y.Z` tag push | +| `beta` | Current stabilization freeze | `beta`, on every push | +| `dev` | Bleeding edge | `dev`, on every push | + +Dev/beta versions are auto-computed (`X.Y.Z-dev.+` / +`-beta.…`) from the latest published stable tag, so they always sort as +newer than what you have installed — no manual version bumping needed when +pushing to `dev` or `beta`. + +## Local development + +```sh +cargo build --release +cargo test --release +``` + +## CI + +- `dev-release.yml` — triggered on push to `dev`. +- `beta-release.yml` — triggered on push to `beta`. +- `release.yml` — triggered on a `v*` tag push, cuts the actual stable release. + +All CI runs on a self-hosted runner; nothing runs automatically on plain +commits or PRs beyond the track builds above. See +[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md) +for the full policy, including how a new product gets wired onto these tracks. + +## Questions + +Open an issue on this repo's Forgejo tracker. From b131a47c7de6d637e0f2e090d856dae3133ab03b Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:05:29 +0800 Subject: [PATCH 09/20] =?UTF-8?q?CI:=20single-trunk=20model=20=E2=80=94=20?= =?UTF-8?q?dev=20triggers=20on=20main,=20beta=20becomes=20RC-tag-triggered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dev/beta branch split with one trunk (main): dev-track builds still publish on every push, but the beta track now publishes from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained beta branch. Removes the branch nobody reliably kept in sync. --- .forgejo/workflows/dev-release.yml | 15 ++++---- .../{beta-release.yml => rc-release.yml} | 38 +++++-------------- .forgejo/workflows/release.yml | 1 + 3 files changed, 17 insertions(+), 37 deletions(-) rename .forgejo/workflows/{beta-release.yml => rc-release.yml} (57%) diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 0d5a8d0..1fbbcd9 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -1,12 +1,11 @@ name: dev release -# Publishes a dev-track build on every push to `dev` — -# separate from release.yml's tag-triggered stable releases. See -# bread-ecosystem's docs/release-channels.md for the three-track policy -# this is part of. +# Publishes a dev-track build on every push to `main` (the trunk +# branch — there is no separate `dev` branch). See bread-ecosystem's +# docs/release-channels.md for the release-track policy this is part of. on: push: - branches: ['dev'] + branches: ['main'] jobs: build: @@ -16,7 +15,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch dev --depth 1 \ + git clone --branch main --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -33,7 +32,7 @@ jobs: # what's already installed and bakery would correctly refuse it. LATEST_TAG="$(git ls-remote --tags --refs \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" if [ -n "${LATEST_TAG}" ]; then CUR="${LATEST_TAG}" else @@ -71,6 +70,6 @@ jobs: # mktemp: a fixed clone path races when multiple repos' dev/beta # workflows run close together on the same self-hosted runner. ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" - git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/rc-release.yml similarity index 57% rename from .forgejo/workflows/beta-release.yml rename to .forgejo/workflows/rc-release.yml index d885ca5..b44f054 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/rc-release.yml @@ -1,52 +1,32 @@ -name: beta release +name: beta (rc) release -# Publishes a beta-track build on every push to `beta` — a frozen -# stabilization branch cut from `dev` when ready to stabilize; only -# fix/ branches merged into `beta` should land here afterward. -# See bread-ecosystem's docs/release-channels.md for the three-track policy. +# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag +# pushed to `main` — there is no separate `beta` branch; "freezing" is +# just pausing pushes to main while an RC gets tested. See +# bread-ecosystem's docs/release-channels.md for the release-track policy. on: push: - branches: ['beta'] + tags: ['v*'] jobs: build: + if: ${{ contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch beta --depth 1 \ + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build run: cd src && cargo build --release --locked - - name: compute beta version - run: | - set -euo pipefail - cd src - # Base the beta version off the latest published stable tag, - # not Cargo.toml — Cargo.toml can go stale relative to the last - # real release (seen in practice: breadbox/breadpad/breadcrumbs/ - # breadpaper), which would make a beta build sort as OLDER than - # what's already installed and bakery would correctly refuse it. - LATEST_TAG="$(git ls-remote --tags --refs \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" - if [ -n "${LATEST_TAG}" ]; then - CUR="${LATEST_TAG}" - else - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" - fi - IFS='.' read -r MA MI PA <<< "${CUR}" - SHA="$(git rev-parse --short HEAD)" - TS="$(date -u +%Y%m%d%H%M%S)" - echo "VERSION=${MA}.${MI}.$((PA + 1))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" - - name: prepare artifacts run: | set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="/srv/breadway-dl/beta/breadmon/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/breadmon" "${PKG_DIR}/breadmon-x86_64" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 7903d69..f584b3a 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,6 +6,7 @@ on: jobs: build: + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout From c692b597b3e3790679825071b0c80fdad732517c Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:08:41 +0800 Subject: [PATCH 10/20] CONTRIBUTING.md: document single-trunk + RC-tag release model --- CONTRIBUTING.md | 70 ++++++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac62569..370bfa2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,16 +7,10 @@ workflow as every other ecosystem product. ## Branches -- **`main`** — release branch, always tag-ready. Nothing is committed to it - directly; it only moves forward via a `beta` merge (see below). -- **`dev`** — integration branch. All day-to-day work lands here first. - Every push to `dev` automatically builds and publishes a **dev-track** - build (see Tracks below) — use this to test your change in a real install - before it goes any further. -- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. - Every push to `beta` automatically builds and publishes a **beta-track** - build. While a freeze is active, only fixes for issues found *in that - freeze* should land on `beta`. +There is one long-lived branch: **`main`**. All day-to-day work lands here. +Every push to `main` automatically builds and publishes a **dev-track** +build (see Tracks below) — a real install you can test before cutting +anything more formal. New work — features and bug fixes alike — goes on a short-lived branch: @@ -25,28 +19,26 @@ feature/ fix/ ``` -Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing -something reported against an active `beta` freeze, branch off `beta` -instead, merge the fix there to unblock testers, and also forward the same -fix into `dev` so it doesn't quietly reappear next cycle. +Branch off `main`, open a PR/push back into `main` when ready. Short-lived +branches get deleted on merge — they never accumulate the kind of drift a +second long-lived branch does. ## The release cycle -1. Work accumulates on `dev` via `feature/x` / `fix/x` branches. Each push +There's no separate `beta` or release branch — "stable" and "beta" are both +just **tags** on `main`, not branches that need to be kept in sync: + +1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push auto-publishes a dev build — install it with `bakery track set dev` and - `bakery update --all`, then report or fix anything broken with another - push to `dev`. -2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut - fresh from `dev`'s current tip. This freezes it as the stabilization - target — `dev` keeps moving independently starting the next cycle. -3. `beta` is open for anyone to test: `bakery track set beta` and - `bakery update --all`. **File issues against anything you find on this - repo's Forgejo issue tracker.** Fixes land via `fix/` branches - merged into `beta`. -4. Once `beta` has gone roughly **a month** without new issues, it's merged - into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the - stable release build. `beta` is then reset from `dev` to start the next - cycle. + `bakery update --all`, then fix anything broken with another push. +2. When you want to stabilize before a real release, tag a release + candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to + both remotes). That tag alone triggers a beta-track build — + "freezing" is just pausing pushes to `main` while you test it, not a + branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes. +3. Once an RC has gone without issues, tag the real release: + `git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the + signed stable release build. ## Tracks, from a user's perspective @@ -58,14 +50,15 @@ bakery update --all # pull the latest build on your current track | Track | What it is | Published from | |--------|-----------|-----------------| -| `stable` | The last tagged release | `main`, on a `vX.Y.Z` tag push | -| `beta` | Current stabilization freeze | `beta`, on every push | -| `dev` | Bleeding edge | `dev`, on every push | +| `stable` | The last tagged release | a `vX.Y.Z` tag | +| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag | +| `dev` | Bleeding edge | `main`, on every push | -Dev/beta versions are auto-computed (`X.Y.Z-dev.+` / -`-beta.…`) from the latest published stable tag, so they always sort as -newer than what you have installed — no manual version bumping needed when -pushing to `dev` or `beta`. +Dev versions are auto-computed (`X.Y.Z-dev.+`) from the +latest published stable tag, so they always sort as newer than what you +have installed — no manual version bumping needed. Beta versions are just +the RC tag itself (already valid semver, already sorts below the real +release it's a candidate for). ## Local development @@ -76,9 +69,10 @@ cargo test --release ## CI -- `dev-release.yml` — triggered on push to `dev`. -- `beta-release.yml` — triggered on push to `beta`. -- `release.yml` — triggered on a `v*` tag push, cuts the actual stable release. +- `dev-release.yml` — triggered on push to `main`. +- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push. +- `release.yml` — triggered on any other `v*` tag push, cuts the actual + stable release. All CI runs on a self-hosted runner; nothing runs automatically on plain commits or PRs beyond the track builds above. See From d73eacf40fcae001af971a179313f1d03d225f5d Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 5 Aug 2026 14:02:47 +0800 Subject: [PATCH 11/20] ci: build against bread-ecosystem's shared Arch CI image, add check.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as breadpad: build inside the shared pinned Arch container (bread-ecosystem/ci/, cloned at the sha in ci/bread-ecosystem.rev) instead of building natively against whatever's on the runner host. Adds check.yml (clippy + test on feature/**/fix/**) as a fast-fail gate before anything reaches main. Turning on clippy -D warnings for the first time surfaced 10 pre-existing warnings (int_plus_one, ptr_arg on &mut Vec params, collapsible_if, collapsible_match) across layout.rs, mirror.rs, profile.rs, and the config/mirror TUI views — all fixed exactly per clippy's suggested diffs, verified behavior-preserving (the mirror_view.rs collapse in particular: confirmed the "do nothing" fallthrough when mirror.result is None is unchanged, since that was already the fallthrough behavior of the original nested if with no matching else on the outer condition). Verified locally: build, clippy, and test all pass through the new container path. --- .forgejo/workflows/check.yml | 24 ++++++++++++++++ .forgejo/workflows/dev-release.yml | 2 +- .forgejo/workflows/rc-release.yml | 2 +- .forgejo/workflows/release.yml | 2 +- ci/bread-ecosystem.rev | 1 + ci/build.sh | 20 ++++++++++++++ src/layout.rs | 4 +-- src/mirror.rs | 5 ++-- src/profile.rs | 2 +- src/ui/config_view.rs | 40 +++++++++++++-------------- src/ui/layout_view.rs | 4 +-- src/ui/mirror_view.rs | 44 +++++++++++++++--------------- 12 files changed, 97 insertions(+), 53 deletions(-) create mode 100644 .forgejo/workflows/check.yml create mode 100644 ci/bread-ecosystem.rev create mode 100755 ci/build.sh diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml new file mode 100644 index 0000000..b547c34 --- /dev/null +++ b/.forgejo/workflows/check.yml @@ -0,0 +1,24 @@ +name: check + +# Fast-fail lint/test on short-lived work branches, before it ever reaches +# main and triggers a dev-track release build. +on: + push: + branches: ['feature/**', 'fix/**'] + +jobs: + check: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: clippy + run: cd src && bash ci/build.sh cargo clippy --workspace --all-targets --locked -- -D warnings + + - name: test + run: cd src && bash ci/build.sh cargo test --workspace --locked diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 1fbbcd9..3087715 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -19,7 +19,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: compute dev version run: | diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml index b44f054..fd6ba9a 100644 --- a/.forgejo/workflows/rc-release.yml +++ b/.forgejo/workflows/rc-release.yml @@ -21,7 +21,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: prepare artifacts run: | diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index f584b3a..b3b2164 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -17,7 +17,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: cd src && bash ci/build.sh cargo build --release --locked - name: prepare artifacts run: | diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev new file mode 100644 index 0000000..474f1fd --- /dev/null +++ b/ci/bread-ecosystem.rev @@ -0,0 +1 @@ +620c5a1317a6b57276eabca961facdb78bf510db diff --git a/ci/build.sh b/ci/build.sh new file mode 100755 index 0000000..7582664 --- /dev/null +++ b/ci/build.sh @@ -0,0 +1,20 @@ +#!/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. +# +# 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" breadmon "$ROOT" "$@" diff --git a/src/layout.rs b/src/layout.rs index c19923a..0b6e835 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -103,7 +103,7 @@ pub fn snap_position( } /// Move the selected monitor by (dx, dy) pixels, then snap. -pub fn move_selected(state: &LayoutState, monitors: &mut Vec, dx: i32, dy: i32) { +pub fn move_selected(state: &LayoutState, monitors: &mut [Monitor], dx: i32, dy: i32) { let idx = state.selected; if idx >= monitors.len() { return; @@ -116,7 +116,7 @@ pub fn move_selected(state: &LayoutState, monitors: &mut Vec, dx: i32, } /// Place monitors in a left-to-right row with no gaps. -pub fn auto_arrange(monitors: &mut Vec) { +pub fn auto_arrange(monitors: &mut [Monitor]) { let mut cursor = 0i32; for m in monitors.iter_mut() { m.x = cursor; diff --git a/src/mirror.rs b/src/mirror.rs index 6485a58..2e46367 100644 --- a/src/mirror.rs +++ b/src/mirror.rs @@ -62,11 +62,10 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option Profile { /// Apply a profile's settings onto a list of live monitors (matched by name). /// Monitors not in the profile are left unchanged. -pub fn apply_to_monitors(profile: &Profile, monitors: &mut Vec) { +pub fn apply_to_monitors(profile: &Profile, monitors: &mut [Monitor]) { for pm in &profile.monitors { if let Some(m) = monitors.iter_mut().find(|m| m.name == pm.name) { if let Some(mode) = Mode::parse(&format!("{}Hz", pm.mode)) { diff --git a/src/ui/config_view.rs b/src/ui/config_view.rs index 4ec4026..d3a3352 100644 --- a/src/ui/config_view.rs +++ b/src/ui/config_view.rs @@ -242,14 +242,14 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { state.dirty = true; } } - KeyCode::Char('l') | KeyCode::Right => { - if state.config.res_idx + 1 < state.config.resolutions.len() { - state.config.res_idx += 1; - let m = &state.monitors[idx]; - state.config.update_refreshes(m); - sync_mode_to_monitor(state, idx); - state.dirty = true; - } + KeyCode::Char('l') | KeyCode::Right + if state.config.res_idx + 1 < state.config.resolutions.len() => + { + state.config.res_idx += 1; + let m = &state.monitors[idx]; + state.config.update_refreshes(m); + sync_mode_to_monitor(state, idx); + state.dirty = true; } _ => {} }, @@ -261,12 +261,12 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { state.dirty = true; } } - KeyCode::Char('l') | KeyCode::Right => { - if state.config.refresh_idx + 1 < state.config.refreshes.len() { - state.config.refresh_idx += 1; - sync_mode_to_monitor(state, idx); - state.dirty = true; - } + KeyCode::Char('l') | KeyCode::Right + if state.config.refresh_idx + 1 < state.config.refreshes.len() => + { + state.config.refresh_idx += 1; + sync_mode_to_monitor(state, idx); + state.dirty = true; } _ => {} }, @@ -335,12 +335,12 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { state.dirty = true; } } - KeyCode::Char('l') | KeyCode::Right => { - if state.config.mirror_idx + 1 < state.config.mirror_options.len() { - state.config.mirror_idx += 1; - sync_mirror_to_monitor(state, idx); - state.dirty = true; - } + KeyCode::Char('l') | KeyCode::Right + if state.config.mirror_idx + 1 < state.config.mirror_options.len() => + { + state.config.mirror_idx += 1; + sync_mirror_to_monitor(state, idx); + state.dirty = true; } _ => {} }, diff --git a/src/ui/layout_view.rs b/src/ui/layout_view.rs index ffa7131..c64d1e3 100644 --- a/src/ui/layout_view.rs +++ b/src/ui/layout_view.rs @@ -259,9 +259,9 @@ pub fn canvas_area(terminal_size: (u16, u16)) -> Rect { } fn in_canvas(col: u16, row: u16, canvas: Rect) -> bool { - col >= canvas.x + 1 + col > canvas.x && col < canvas.x + canvas.width.saturating_sub(1) - && row >= canvas.y + 1 + && row > canvas.y && row < canvas.y + canvas.height.saturating_sub(1) } diff --git a/src/ui/mirror_view.rs b/src/ui/mirror_view.rs index b593dd1..57f0d9d 100644 --- a/src/ui/mirror_view.rs +++ b/src/ui/mirror_view.rs @@ -210,33 +210,33 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { } } } - r if r >= 7 => { + r if r >= 7 // Result panel: Apply is on the line with buttons. // Rough column check: col < 20 = Apply, col >= 20 = Cancel - if state.mirror.result.is_some() { - let col = event.column; - if col < 20 { - // Activate Apply - state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); - if let Some(result) = state.mirror.result.clone() { - state.push_undo(); - let src_name = state.monitors[state.mirror.source_idx].name.clone(); - let tgt_idx = state.mirror.target_idx; - state.monitors[tgt_idx].active_mode = result.mirror_mode.clone(); - state.monitors[tgt_idx].mirror_of = Some(src_name.clone()); - state.dirty = true; - state.mirror.result = None; - state.mirror.focused = 0; - state.set_status( - format!("Mirror set: {} → {} at {}", src_name, state.monitors[tgt_idx].name, result.mirror_mode), - crate::ui::StatusLevel::Success, - ); - } - } else { - // Cancel + && state.mirror.result.is_some() => + { + let col = event.column; + if col < 20 { + // Activate Apply + state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); + if let Some(result) = state.mirror.result.clone() { + state.push_undo(); + let src_name = state.monitors[state.mirror.source_idx].name.clone(); + let tgt_idx = state.mirror.target_idx; + state.monitors[tgt_idx].active_mode = result.mirror_mode.clone(); + state.monitors[tgt_idx].mirror_of = Some(src_name.clone()); + state.dirty = true; state.mirror.result = None; state.mirror.focused = 0; + state.set_status( + format!("Mirror set: {} → {} at {}", src_name, state.monitors[tgt_idx].name, result.mirror_mode), + crate::ui::StatusLevel::Success, + ); } + } else { + // Cancel + state.mirror.result = None; + state.mirror.focused = 0; } } _ => {} From 20d4825cf0197b77b08ea66e5e1aa77e616928b7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 21:35:35 +0800 Subject: [PATCH 12/20] deps: pin bread-utils to bread-ecosystem v0.7.1 Replace the old `bread modules install` docs with `bakery install breadmon`. Spell out that `hyprctl eval 'hl.monitor({...})'` is BOS-patched Hyprland only, and that breadmon is not the bos-settings Display panel (which edits monitors.json). --- Cargo.lock | 4 ++-- Cargo.toml | 3 +-- README.md | 26 ++++++++++++++++---------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 67a5c2c..1ac0ca2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,8 +22,8 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-utils" -version = "0.3.0" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ "dirs", "serde", diff --git a/Cargo.toml b/Cargo.toml index 6cc904c..0e635ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,8 +19,7 @@ toml = "0.8" anyhow = "1" dirs = "5" futures = "0.3" -# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } [profile.release] lto = "thin" diff --git a/README.md b/README.md index a0d2396..d4ff828 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,24 @@ # breadmon -A terminal UI monitor manager for Hyprland. Lets you position, configure, and mirror displays interactively, then apply changes live via `hyprctl`. +A terminal UI monitor manager for Hyprland. Lets you position, configure, and mirror displays interactively, then apply a live layout on [BOS](https://git.breadway.dev/breadway/bos)-patched Hyprland. + +breadmon is **not** the Display panel in `bos-settings`. That panel edits `~/.config/hypr/monitors.json` (Hyprland's login/reload layout store). breadmon keeps its own named snapshots as TOML under `~/.config/breadmon/profiles/` and applies them live through BOS Hyprland. The two stores are independent — changing one does not update the other. ## Requirements -- **[BOS (Bread OS)](https://git.breadway.dev/breadway/bos)'s patched Hyprland build.** Applying changes (the `a` key / Global keys "Apply") runs `hyprctl eval` with a `hl.monitor({...})` Lua call — a BOS-specific extension that does not exist on vanilla/upstream Hyprland. On a non-BOS Hyprland install, `hyprctl eval` itself is not a recognized request, and breadmon will fail to apply with an explicit error explaining this instead of the raw hyprctl response. Everything else in the TUI (viewing/arranging/saving profiles) works regardless; only the live-apply step needs BOS. +- **[BOS (Bread OS)](https://git.breadway.dev/breadway/bos)'s patched Hyprland build** for live apply. The `a` key runs `hyprctl eval 'hl.monitor({...})'` — a BOS-specific Lua extension. Vanilla/upstream Hyprland has no `eval` request and no `hl.monitor()`, so apply will fail there with an explicit error instead of the raw hyprctl response. Viewing, arranging, and saving profiles work on any Hyprland; only live apply needs BOS. - The `hyprctl` binary must be on `PATH` - Rust toolchain (to build from source) -## Build +## Install + +Via [bakery](https://git.breadway.dev/Breadway/bread-ecosystem), the bread ecosystem package manager: + +``` +bakery install breadmon +``` + +Or build from source: ``` cargo build --release @@ -16,12 +26,6 @@ cargo build --release The binary is written to `target/release/breadmon`. -If you use the bread ecosystem, `bakery` can install it instead: - -``` -bread modules install /path/to/breadmon -``` - ## Usage ``` @@ -91,7 +95,7 @@ Profiles are saved to `~/.config/breadmon/profiles/`. | Key | Action | |-----|--------| -| `a` | Apply current configuration via `hyprctl` | +| `a` | Apply current configuration via `hyprctl eval 'hl.monitor({...})'` (BOS-patched Hyprland only) | | `s` | Save current configuration as a profile | | `r` | Refresh monitor list from Hyprland | | `Ctrl+Z` | Undo last change (up to 20 steps) | @@ -102,3 +106,5 @@ breadmon also listens on Hyprland's event socket and reloads the monitor list au ## Config Profiles are plain TOML files under `~/.config/breadmon/profiles/`. Each file records the monitor name, mode, position, scale, transform, VRR, DPMS, and mirror source. They are created and managed through the Profiles tab; there is no hand-written config file. + +This is not `~/.config/hypr/monitors.json`. That file is the persistent Hyprland layout edited by the `bos-settings` Display panel and applied on login/reload. breadmon never reads or writes it. From 7aa95e1ffa13fa2df721d070af65b456eb95edb4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:03:30 +0800 Subject: [PATCH 13/20] Track AGENTS.md --- .gitignore | 1 - AGENTS.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index 8d2803a..0a33787 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,3 @@ logs/ *.pid # Local hygiene notes (not for commit) -CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e911ad9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# 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. The short version: +there is one long-lived branch, `main` — no `dev` or `beta` branch exists. +`main` auto-publishes a dev-track build on every push. "Beta" and "stable" +are both just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a +beta-track build, push a plain `vX.Y.Z` tag to cut the signed stable +release. "Freezing" for stabilization means pausing pushes to `main`, not +moving a branch. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push both when publishing. + +## CI +- `check.yml` — clippy + test, triggers on push to `feature/**`/`fix/**`. +- `dev-release.yml` — triggers on push to `main`. +- `rc-release.yml` — triggers on `vX.Y.Z-rc.N` tag push. +- `release.yml` — triggers on any other `v*` tag push. + +All four run on a self-hosted runner (`hestia`) inside a pinned Arch +container — not the host's native environment. The Containerfile/build +script are shared across bread-ecosystem products and live in +`bread-ecosystem/ci/`; this repo's `ci/build.sh` clones that repo at the +sha in `ci/bread-ecosystem.rev` (deliberately pinned, not `main`) and +delegates to it. Nothing runs automatically on plain commits or PRs +beyond what's listed. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. From aec2fa52907c1e0de4cb40e9c0e17057ffaca6a5 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:16:36 +0800 Subject: [PATCH 14/20] Wire breadmon into the bread event fabric (app id mon) After a successful hyprctl apply, publish bread.mon.applied { "profile": }. BreadClient is fail-silent: if breadd is down, apply behaves exactly as before. Document the contract in EVENTS.md. --- Cargo.lock | 12 +++++++++++ Cargo.toml | 2 +- EVENTS.md | 44 +++++++++++++++++++++++++++++++++++++++++ README.md | 4 ++++ src/bread_events.rs | 43 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 4 ++++ src/ui/config_view.rs | 26 ++++++++++++------------ src/ui/layout_view.rs | 12 +++++------ src/ui/mirror_view.rs | 4 ++-- src/ui/mod.rs | 15 +++++++++++++- src/ui/profiles_view.rs | 2 ++ 11 files changed, 145 insertions(+), 23 deletions(-) create mode 100644 EVENTS.md create mode 100644 src/bread_events.rs diff --git a/Cargo.lock b/Cargo.lock index 1ac0ca2..d56164e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,11 +20,23 @@ 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", +] + [[package]] name = "bread-utils" version = "0.3.1" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ + "bread-shared", "dirs", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 0e635ab..b2da7f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ toml = "0.8" anyhow = "1" dirs = "5" futures = "0.3" -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["bread-client"] } [profile.release] lto = "thin" diff --git a/EVENTS.md b/EVENTS.md new file mode 100644 index 0000000..1b92496 --- /dev/null +++ b/EVENTS.md @@ -0,0 +1,44 @@ +# breadmon — bread event integration + +breadmon is a standalone TUI monitor manager: it works exactly the same +with or without `breadd` running. When breadd *is* present, a successful +live apply publishes an event into the shared bread automation fabric. +See the parent `bread` repo's `Documentation.md` — specifically its +"Namespaces" and "Integrating a bread\* app" sections — for the general +convention this follows. + +App id: **`mon`**. Transport: `bread-utils`'s `bread_client` module +(feature `bread-client`) — the TUI links it directly and emits from the +same process that ran `hyprctl eval`. Each `emit` is its own short-lived +connection (`BreadClient::emit` never blocks or errors the caller). + +This event is about breadmon's own live apply (`hyprctl eval +'hl.monitor({...})'` on [BOS](https://git.breadway.dev/breadway/bos)-patched +Hyprland). It is **not** fired by the `bos-settings` Display panel, which +writes `~/.config/hypr/monitors.json` and is a separate store breadmon +never reads or writes. Vanilla/upstream Hyprland has no `eval` request +and no `hl.monitor()`, so apply fails there and this event is not +published. + +## Events published (`bread.mon.*`) + +| Event | Data | When | +|-------|------|------| +| `bread.mon.applied` | `{ "profile": }` | After `hyprctl eval 'hl.monitor({...})'` succeeds. `profile` is the named snapshot that was just applied (the last loaded or saved profile this session, if the layout was not edited after that), or `null` for an ad-hoc layout. Not emitted when apply fails. | + +## Commands honored (`bread.command.mon.*`) + +None. breadmon is an interactive TUI, not a long-running daemon — a +command subscription would only be live while the TUI is open, which is +a poor control surface. Apply, load, and save stay keyboard-driven. +If/when breadmon grows a headless apply path, the corresponding +`bread.command.mon.apply` verb should be added at the same time, not +stubbed out ahead of it. + +## 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) — breadmon's + actual apply / profile / TUI functionality is entirely unaffected. +- There is no command subscription, so a breadd restart has nothing to + reconnect. diff --git a/README.md b/README.md index d4ff828..b15936c 100644 --- a/README.md +++ b/README.md @@ -108,3 +108,7 @@ breadmon also listens on Hyprland's event socket and reloads the monitor list au Profiles are plain TOML files under `~/.config/breadmon/profiles/`. Each file records the monitor name, mode, position, scale, transform, VRR, DPMS, and mirror source. They are created and managed through the Profiles tab; there is no hand-written config file. This is not `~/.config/hypr/monitors.json`. That file is the persistent Hyprland layout edited by the `bos-settings` Display panel and applied on login/reload. breadmon never reads or writes it. + +## bread event integration + +breadmon works the same with or without `breadd`. After a successful live apply (`hyprctl eval 'hl.monitor({...})'` on BOS-patched Hyprland — not the `bos-settings` Display panel), it publishes `bread.mon.applied`. If breadd is down, the emit is a silent no-op; apply itself is unchanged. See [EVENTS.md](EVENTS.md) for the bus contract. `bread` is not a bakery dependency. diff --git a/src/bread_events.rs b/src/bread_events.rs new file mode 100644 index 0000000..7197765 --- /dev/null +++ b/src/bread_events.rs @@ -0,0 +1,43 @@ +//! `bread.mon.*` event integration — optional, non-blocking. See +//! `EVENTS.md` at the repo root for the full contract. breadmon works +//! identically with or without breadd running; every call here is +//! fire-and-forget (`BreadClient::emit` never blocks or errors this +//! process) so a missing or restarting breadd never affects apply itself. + +use bread_utils::bread_client::BreadClient; +use serde_json::{json, Value}; + +/// This app's id in bread's sibling-app namespace registry +/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.mon.*`. +pub const APP_ID: &str = "mon"; + +/// JSON payload for `bread.mon.applied`. `profile` is the named snapshot +/// that was just applied, or `null` for an ad-hoc layout. +pub fn applied_data(profile: Option<&str>) -> Value { + json!({ "profile": profile }) +} + +/// Publishes `bread.mon.applied` after a successful hyprctl apply. +/// Fire-and-forget and non-fatal by design — breadd being absent or not +/// installed must never affect breadmon's own apply path. +pub fn emit_applied(profile: Option<&str>) { + BreadClient::connect(APP_ID).emit("bread.mon.applied", applied_data(profile)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn applied_data_serializes_name_or_null() { + assert_eq!(applied_data(Some("dock")), json!({ "profile": "dock" })); + assert_eq!(applied_data(None), json!({ "profile": null })); + } + + #[test] + fn emit_applied_is_silent_when_breadd_is_down() { + // No daemon in the unit-test environment; must not panic or block. + emit_applied(Some("dock")); + emit_applied(None); + } +} diff --git a/src/main.rs b/src/main.rs index 5343ef1..fd3e35b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod bread_events; mod layout; mod mirror; mod monitor; @@ -135,6 +136,7 @@ async fn run( if let Ok(monitors) = monitor::load_monitors().await { state.monitors = monitors; state.layout.clamp_selected(state.monitors.len()); + state.active_profile = None; state.set_status("Monitor configuration changed.", StatusLevel::Info); } } @@ -166,6 +168,7 @@ async fn run( state.monitors = monitors; state.layout.clamp_selected(state.monitors.len()); state.dirty = false; + state.active_profile = None; state.set_status("Monitors refreshed.", StatusLevel::Success); } Err(e) => { @@ -189,6 +192,7 @@ async fn run( state.pending_apply = false; match monitor::apply_monitors(&state.monitors).await { Ok(()) => { + bread_events::emit_applied(state.active_profile.as_deref()); state.set_status("Applied.", StatusLevel::Success); } Err(e) => { diff --git a/src/ui/config_view.rs b/src/ui/config_view.rs index d3a3352..247cac9 100644 --- a/src/ui/config_view.rs +++ b/src/ui/config_view.rs @@ -239,7 +239,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { let m = &state.monitors[idx]; state.config.update_refreshes(m); sync_mode_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } } KeyCode::Char('l') | KeyCode::Right @@ -249,7 +249,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { let m = &state.monitors[idx]; state.config.update_refreshes(m); sync_mode_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } _ => {} }, @@ -258,7 +258,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { if state.config.refresh_idx > 0 { state.config.refresh_idx -= 1; sync_mode_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } } KeyCode::Char('l') | KeyCode::Right @@ -266,7 +266,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { { state.config.refresh_idx += 1; sync_mode_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } _ => {} }, @@ -276,14 +276,14 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { state.monitors[idx].scale = (s * 100.0).round() / 100.0; state.monitors[idx].scale = state.monitors[idx].scale.max(0.1); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('.') => { let s = state.monitors[idx].scale + 0.1; state.monitors[idx].scale = (s * 100.0).round() / 100.0; state.monitors[idx].scale = state.monitors[idx].scale.min(10.0); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => { state.config.scale_editing = true; @@ -303,27 +303,27 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { .checked_sub(1) .unwrap_or(all.len() - 1); state.monitors[idx].transform = all[state.config.transform_idx]; - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('l') | KeyCode::Right => { let all = Transform::all(); state.config.transform_idx = (state.config.transform_idx + 1) % all.len(); state.monitors[idx].transform = all[state.config.transform_idx]; - state.dirty = true; + state.mark_dirty(); } _ => {} }, ConfigField::Vrr => match event.code { KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { state.monitors[idx].vrr = !state.monitors[idx].vrr; - state.dirty = true; + state.mark_dirty(); } _ => {} }, ConfigField::Dpms => match event.code { KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { state.monitors[idx].dpms = !state.monitors[idx].dpms; - state.dirty = true; + state.mark_dirty(); } _ => {} }, @@ -332,7 +332,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { if state.config.mirror_idx > 0 { state.config.mirror_idx -= 1; sync_mirror_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } } KeyCode::Char('l') | KeyCode::Right @@ -340,7 +340,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { { state.config.mirror_idx += 1; sync_mirror_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } _ => {} }, @@ -370,7 +370,7 @@ fn commit_scale(state: &mut AppState) { if let Ok(v) = state.config.scale_str.parse::() { state.monitors[idx].scale = v.clamp(0.1, 10.0); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); - state.dirty = true; + state.mark_dirty(); } state.config.scale_editing = false; } diff --git a/src/ui/layout_view.rs b/src/ui/layout_view.rs index c64d1e3..a1aa3df 100644 --- a/src/ui/layout_view.rs +++ b/src/ui/layout_view.rs @@ -22,22 +22,22 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { KeyCode::Char('h') | KeyCode::Left => { state.push_undo(); move_selected(&state.layout, &mut state.monitors, -step, 0); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('l') | KeyCode::Right => { state.push_undo(); move_selected(&state.layout, &mut state.monitors, step, 0); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('k') | KeyCode::Up => { state.push_undo(); move_selected(&state.layout, &mut state.monitors, 0, -step); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('j') | KeyCode::Down => { state.push_undo(); move_selected(&state.layout, &mut state.monitors, 0, step); - state.dirty = true; + state.mark_dirty(); } KeyCode::Tab | KeyCode::Char('n') => state.layout.next(count), KeyCode::BackTab | KeyCode::Char('p') => state.layout.prev(count), @@ -46,7 +46,7 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { KeyCode::Char('0') => { state.push_undo(); auto_arrange(&mut state.monitors); - state.dirty = true; + state.mark_dirty(); } KeyCode::Enter => { state.config.sync_from_monitor(state.layout.selected, &state.monitors); @@ -93,7 +93,7 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { let (sx, sy) = snap_position(idx, new_x, new_y, &state.monitors, state.layout.snap_threshold); state.monitors[idx].x = sx; state.monitors[idx].y = sy; - state.dirty = true; + state.mark_dirty(); } } MouseEventKind::Up(MouseButton::Left) => { diff --git a/src/ui/mirror_view.rs b/src/ui/mirror_view.rs index 57f0d9d..c8b35f2 100644 --- a/src/ui/mirror_view.rs +++ b/src/ui/mirror_view.rs @@ -137,7 +137,7 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { state.monitors[tgt_idx].active_mode = result.mirror_mode.clone(); state.monitors[tgt_idx].mirror_of = Some(src_name.clone()); - state.dirty = true; + state.mark_dirty(); state.mirror.result = None; state.mirror.focused = 0; state.set_status( @@ -225,7 +225,7 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { let tgt_idx = state.mirror.target_idx; state.monitors[tgt_idx].active_mode = result.mirror_mode.clone(); state.monitors[tgt_idx].mirror_of = Some(src_name.clone()); - state.dirty = true; + state.mark_dirty(); state.mirror.result = None; state.mirror.focused = 0; state.set_status( diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e4891c4..ad44d1d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -111,6 +111,10 @@ pub struct AppState { pub terminal_size: (u16, u16), /// Set to true by any handler that wants `main.rs` to run `apply_monitors`. pub pending_apply: bool, + /// Named snapshot last loaded or saved this session. Cleared when the + /// in-memory layout is edited, so `bread.mon.applied` can report it + /// honestly (or `null` for an ad-hoc layout). + pub active_profile: Option, /// Snapshots for Ctrl+Z undo (up to 20 deep). pub undo_stack: Vec>, } @@ -131,6 +135,7 @@ impl AppState { drag_state: None, terminal_size, pending_apply: false, + active_profile: None, undo_stack: Vec::new(), } } @@ -139,6 +144,14 @@ impl AppState { self.status = Some(StatusMsg { text: text.into(), level, born: Instant::now() }); } + /// Mark the in-memory layout as edited. Also forgets `active_profile` + /// — a mutated layout is no longer the named snapshot that was loaded + /// or saved. + pub fn mark_dirty(&mut self) { + self.dirty = true; + self.active_profile = None; + } + pub fn tick_status(&mut self) { if let Some(s) = &self.status { if s.born.elapsed().as_secs() >= 3 { @@ -166,7 +179,7 @@ impl AppState { pub fn undo(&mut self) { if let Some(snapshot) = self.undo_stack.pop() { self.monitors = snapshot; - self.dirty = true; + self.mark_dirty(); self.layout.clamp_selected(self.monitors.len()); // Re-sync config view to the restored state let idx = self.layout.selected; diff --git a/src/ui/profiles_view.rs b/src/ui/profiles_view.rs index 7e23bfd..c50cea8 100644 --- a/src/ui/profiles_view.rs +++ b/src/ui/profiles_view.rs @@ -240,6 +240,7 @@ fn do_save(state: &mut AppState) { Ok(()) => { state.profiles.new_name.clear(); state.profiles.refresh(); + state.active_profile = Some(name.clone()); state.set_status(format!("Saved profile '{}'", name), StatusLevel::Success); } Err(e) => { @@ -254,6 +255,7 @@ fn do_load(state: &mut AppState) { Ok(p) => { profile::apply_to_monitors(&p, &mut state.monitors); state.dirty = true; + state.active_profile = Some(name.clone()); state.set_status( format!("Loaded profile '{}'. Press [a] to apply.", name), StatusLevel::Success, From 8d43a03d44e1481bcaa04d9465ce3e7016c483dc Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:32:36 +0800 Subject: [PATCH 15/20] gitignore: exclude graphify-out local cache --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 0a33787..272a817 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ logs/ *.pid # Local hygiene notes (not for commit) +CLAUDE.md + +# graphify knowledge-graph output (local tool cache, not for commit) +graphify-out/ From d98b5b4fad427b22cf306ae4f61c066e1308cc9d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:53:28 +0800 Subject: [PATCH 16/20] Share ~/.config/hypr/monitors.json with the BOS Display panel Read the file as the initial layout on start. After a successful hyprctl apply (including applying a named profile), write pretty JSON so Hyprland and bos-settings stay in sync. Profiles remain named snapshots under ~/.config/breadmon/profiles/. Pin bread-utils to bread-ecosystem v0.7.2. --- Cargo.lock | 4 +- Cargo.toml | 2 +- EVENTS.md | 7 +- README.md | 22 ++- src/layout.rs | 6 +- src/main.rs | 57 ++++--- src/mirror.rs | 53 ++++-- src/monitor.rs | 43 +++-- src/profile.rs | 9 +- src/store.rs | 369 ++++++++++++++++++++++++++++++++++++++++ src/ui/config_view.rs | 55 ++++-- src/ui/layout_view.rs | 105 +++++++----- src/ui/mirror_view.rs | 115 +++++++++---- src/ui/mod.rs | 34 ++-- src/ui/profiles_view.rs | 36 ++-- 15 files changed, 749 insertions(+), 168 deletions(-) create mode 100644 src/store.rs diff --git a/Cargo.lock b/Cargo.lock index d56164e..fd9b3a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,8 +33,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "bread-shared", "dirs", diff --git a/Cargo.toml b/Cargo.toml index b2da7f6..647a201 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ toml = "0.8" anyhow = "1" dirs = "5" futures = "0.3" -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["bread-client"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } [profile.release] lto = "thin" diff --git a/EVENTS.md b/EVENTS.md index 1b92496..b854967 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -14,9 +14,10 @@ connection (`BreadClient::emit` never blocks or errors the caller). This event is about breadmon's own live apply (`hyprctl eval 'hl.monitor({...})'` on [BOS](https://git.breadway.dev/breadway/bos)-patched -Hyprland). It is **not** fired by the `bos-settings` Display panel, which -writes `~/.config/hypr/monitors.json` and is a separate store breadmon -never reads or writes. Vanilla/upstream Hyprland has no `eval` request +Hyprland). After that apply succeeds, breadmon also writes +`~/.config/hypr/monitors.json` (the store shared with the `bos-settings` +Display panel). The event is **not** fired by Display itself — that GUI +only edits the JSON. Vanilla/upstream Hyprland has no `eval` request and no `hl.monitor()`, so apply fails there and this event is not published. diff --git a/README.md b/README.md index b15936c..ccddd7d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A terminal UI monitor manager for Hyprland. Lets you position, configure, and mirror displays interactively, then apply a live layout on [BOS](https://git.breadway.dev/breadway/bos)-patched Hyprland. -breadmon is **not** the Display panel in `bos-settings`. That panel edits `~/.config/hypr/monitors.json` (Hyprland's login/reload layout store). breadmon keeps its own named snapshots as TOML under `~/.config/breadmon/profiles/` and applies them live through BOS Hyprland. The two stores are independent — changing one does not update the other. +The Display panel in `bos-settings` (GUI) and breadmon (TUI) share `~/.config/hypr/monitors.json` — the layout Hyprland reads at login/reload. Applying in breadmon writes that file so the settings app and the next session stay in sync. Named profiles remain optional snapshots under `~/.config/breadmon/profiles/`. ## Requirements @@ -80,7 +80,7 @@ Finds the best common mode between two monitors and sets one to mirror the other ### Profiles -Named snapshots of the current monitor configuration, stored as TOML files. +Named snapshots of the current monitor configuration, stored as TOML files. Loading a profile updates the TUI; applying it (`a`) also writes `monitors.json`. | Key | Action | |-----|--------| @@ -95,8 +95,8 @@ Profiles are saved to `~/.config/breadmon/profiles/`. | Key | Action | |-----|--------| -| `a` | Apply current configuration via `hyprctl eval 'hl.monitor({...})'` (BOS-patched Hyprland only) | -| `s` | Save current configuration as a profile | +| `a` | Apply current configuration via `hyprctl eval 'hl.monitor({...})'` (BOS-patched Hyprland only) and write `~/.config/hypr/monitors.json` | +| `s` | Write `~/.config/hypr/monitors.json` without a live apply | | `r` | Refresh monitor list from Hyprland | | `Ctrl+Z` | Undo last change (up to 20 steps) | | `q` / `Ctrl+C` | Quit (prompts once if there are unsaved changes) | @@ -105,9 +105,19 @@ breadmon also listens on Hyprland's event socket and reloads the monitor list au ## Config -Profiles are plain TOML files under `~/.config/breadmon/profiles/`. Each file records the monitor name, mode, position, scale, transform, VRR, DPMS, and mirror source. They are created and managed through the Profiles tab; there is no hand-written config file. +**Shared store:** `~/.config/hypr/monitors.json` — the same file the bos-settings Display panel edits and Hyprland applies on login/reload. breadmon is the TUI; Display is the GUI. Schema: -This is not `~/.config/hypr/monitors.json`. That file is the persistent Hyprland layout edited by the `bos-settings` Display panel and applied on login/reload. breadmon never reads or writes it. +```json +{ + "monitors": [ + { "output": "", "mode": "preferred", "position": "auto", "scale": "auto", "mirror": "" } + ] +} +``` + +Empty `output` is the wildcard default (any connector). breadmon loads this file on start (overlaid onto the live `hyprctl` list) and writes it — pretty JSON — after a successful apply, and when you press `s`. + +**Named snapshots:** plain TOML under `~/.config/breadmon/profiles/`. Each file records the monitor name, mode, position, scale, transform, VRR, DPMS, and mirror source. They are created and managed through the Profiles tab. Applying a profile writes `monitors.json` so Hyprland and Display stay in sync. ## bread event integration diff --git a/src/layout.rs b/src/layout.rs index 0b6e835..9789c2a 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -196,7 +196,11 @@ mod tests { Monitor { name: name.into(), description: String::new(), - active_mode: Mode { width: w, height: h, refresh: 60.0 }, + active_mode: Mode { + width: w, + height: h, + refresh: 60.0, + }, x, y, scale: 1.0, diff --git a/src/main.rs b/src/main.rs index fd3e35b..a9c2086 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod layout; mod mirror; mod monitor; mod profile; +mod store; mod ui; use std::io; @@ -10,8 +11,7 @@ use std::io; use anyhow::Result; use crossterm::{ event::{ - DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, - MouseEventKind, + DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, MouseEventKind, }, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, @@ -36,10 +36,15 @@ enum AppEvent { #[tokio::main] async fn main() -> Result<()> { - let monitors = monitor::load_monitors().await.unwrap_or_else(|e| { + let mut monitors = monitor::load_monitors().await.unwrap_or_else(|e| { eprintln!("Warning: could not load monitors: {}", e); vec![] }); + match store::load() { + Ok(Some(file)) => store::apply_to_monitors(&file, &mut monitors), + Ok(None) => {} + Err(e) => eprintln!("Warning: could not load monitors.json: {}", e), + } // Terminal setup enable_raw_mode()?; @@ -52,7 +57,11 @@ async fn main() -> Result<()> { // Restore terminal disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; terminal.show_cursor()?; result @@ -162,23 +171,18 @@ async fn run( crossterm::event::KeyCode::Char('s') => { ui::layout_view::trigger_save(&mut state); } - crossterm::event::KeyCode::Char('r') => { - match monitor::load_monitors().await { - Ok(monitors) => { - state.monitors = monitors; - state.layout.clamp_selected(state.monitors.len()); - state.dirty = false; - state.active_profile = None; - state.set_status("Monitors refreshed.", StatusLevel::Success); - } - Err(e) => { - state.set_status( - format!("Refresh failed: {}", e), - StatusLevel::Error, - ); - } + crossterm::event::KeyCode::Char('r') => match monitor::load_monitors().await { + Ok(monitors) => { + state.monitors = monitors; + state.layout.clamp_selected(state.monitors.len()); + state.dirty = false; + state.active_profile = None; + state.set_status("Monitors refreshed.", StatusLevel::Success); } - } + Err(e) => { + state.set_status(format!("Refresh failed: {}", e), StatusLevel::Error); + } + }, _ => { if !ui::handle_key(key, &mut state) { break; @@ -193,7 +197,18 @@ async fn run( match monitor::apply_monitors(&state.monitors).await { Ok(()) => { bread_events::emit_applied(state.active_profile.as_deref()); - state.set_status("Applied.", StatusLevel::Success); + match store::save_from_monitors(&state.monitors) { + Ok(()) => { + state.dirty = false; + state.set_status("Applied.", StatusLevel::Success); + } + Err(e) => { + state.set_status( + format!("Applied, but monitors.json write failed: {}", e), + StatusLevel::Error, + ); + } + } } Err(e) => { state.set_status(format!("Apply failed: {}", e), StatusLevel::Error); diff --git a/src/mirror.rs b/src/mirror.rs index 2e46367..124e598 100644 --- a/src/mirror.rs +++ b/src/mirror.rs @@ -12,7 +12,11 @@ pub struct MirrorResult { } fn gcd(a: u32, b: u32) -> u32 { - if b == 0 { a } else { gcd(b, a % b) } + if b == 0 { + a + } else { + gcd(b, a % b) + } } fn reduced_ar(w: u32, h: u32) -> (u32, u32) { @@ -35,7 +39,10 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option> = HashMap::new(); for m in src_modes { - src_by_ar.entry(reduced_ar(m.width, m.height)).or_default().push(m); + src_by_ar + .entry(reduced_ar(m.width, m.height)) + .or_default() + .push(m); } #[derive(Debug)] @@ -53,8 +60,15 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option Option Option = src_refreshes @@ -164,7 +188,10 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option &'static str { #[cfg(test)] mod tests { use super::*; - use crate::monitor::{Transform}; + use crate::monitor::Transform; fn make_monitor_with_modes(name: &str, modes: Vec) -> Monitor { let active = modes[0].clone(); @@ -240,7 +267,11 @@ mod tests { } fn m(w: u32, h: u32, r: f64) -> Mode { - Mode { width: w, height: h, refresh: r } + Mode { + width: w, + height: h, + refresh: r, + } } #[test] diff --git a/src/monitor.rs b/src/monitor.rs index 37f3128..fab2d5b 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -145,11 +145,15 @@ impl Monitor { .collect(); // Sort descending by pixels then refresh for consistent ordering modes.sort_by(|a, b| { - b.pixels() - .cmp(&a.pixels()) - .then(b.refresh.partial_cmp(&a.refresh).unwrap_or(std::cmp::Ordering::Equal)) + b.pixels().cmp(&a.pixels()).then( + b.refresh + .partial_cmp(&a.refresh) + .unwrap_or(std::cmp::Ordering::Equal), + ) + }); + modes.dedup_by(|a, b| { + a.width == b.width && a.height == b.height && (a.refresh - b.refresh).abs() < 0.01 }); - modes.dedup_by(|a, b| a.width == b.width && a.height == b.height && (a.refresh - b.refresh).abs() < 0.01); let active_mode = Mode { width: raw.width, @@ -214,8 +218,10 @@ impl Monitor { if self.physical_width_mm == 0 || self.physical_height_mm == 0 { return None; } - let diag_px = ((self.active_mode.width.pow(2) + self.active_mode.height.pow(2)) as f64).sqrt(); - let diag_mm = ((self.physical_width_mm.pow(2) + self.physical_height_mm.pow(2)) as f64).sqrt(); + let diag_px = + ((self.active_mode.width.pow(2) + self.active_mode.height.pow(2)) as f64).sqrt(); + let diag_mm = + ((self.physical_width_mm.pow(2) + self.physical_height_mm.pow(2)) as f64).sqrt(); Some(diag_px / (diag_mm / 25.4)) } @@ -268,8 +274,10 @@ pub async fn load_monitors() -> Result> { // Hyprland reports mirrorOf as a numeric ID string when using `monitors all`. // Resolve to monitor name so format_hypr_line emits the correct `mirror,`. - let id_to_name: std::collections::HashMap = - raw.iter().map(|r| (r.id.to_string(), r.name.clone())).collect(); + let id_to_name: std::collections::HashMap = raw + .iter() + .map(|r| (r.id.to_string(), r.name.clone())) + .collect(); Ok(raw .into_iter() @@ -284,6 +292,7 @@ pub async fn load_monitors() -> Result> { .collect()) } +#[cfg(test)] pub fn format_hypr_line(m: &Monitor) -> String { if let Some(src) = &m.mirror_of { format!( @@ -444,7 +453,11 @@ mod tests { #[test] fn mode_compact_roundtrip() { - let m = Mode { width: 1920, height: 1080, refresh: 60.0 }; + let m = Mode { + width: 1920, + height: 1080, + refresh: 60.0, + }; let s = m.compact(); let m2 = Mode::parse(&format!("{}Hz", s)).unwrap(); assert_eq!(m.width, m2.width); @@ -456,7 +469,11 @@ mod tests { let m = Monitor { name: "eDP-1".into(), description: String::new(), - active_mode: Mode { width: 1920, height: 1200, refresh: 60.0 }, + active_mode: Mode { + width: 1920, + height: 1200, + refresh: 60.0, + }, x: 0, y: 0, scale: 1.0, @@ -480,7 +497,11 @@ mod tests { let m = Monitor { name: "HDMI-A-1".into(), description: String::new(), - active_mode: Mode { width: 1920, height: 1080, refresh: 60.0 }, + active_mode: Mode { + width: 1920, + height: 1080, + refresh: 60.0, + }, x: 1920, y: 0, scale: 1.0, diff --git a/src/profile.rs b/src/profile.rs index eed2978..38ab498 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -77,8 +77,7 @@ pub fn list() -> Result> { pub fn delete(name: &str) -> Result<()> { let path = profiles_dir().join(format!("{}.toml", name)); - std::fs::remove_file(&path) - .with_context(|| format!("failed to delete profile '{}'", name)) + std::fs::remove_file(&path).with_context(|| format!("failed to delete profile '{}'", name)) } pub fn from_monitors(name: &str, monitors: &[Monitor]) -> Profile { @@ -150,7 +149,11 @@ mod tests { Monitor { name: name.into(), description: String::new(), - active_mode: Mode { width: w, height: h, refresh: 60.0 }, + active_mode: Mode { + width: w, + height: h, + refresh: 60.0, + }, x, y, scale: 1.0, diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..ed1d4a7 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,369 @@ +//! Shared Hyprland layout store: `~/.config/hypr/monitors.json`. +//! +//! Same schema as bos-settings `MonitorRule` and +//! `iso/airootfs/etc/skel/.config/hypr/scripts/display/monitors.lua`. +//! Empty `output` is the wildcard default (matches any connector). + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::monitor::{Mode, Monitor}; + +fn default_mode() -> String { + "preferred".to_string() +} +fn default_position() -> String { + "auto".to_string() +} +fn default_scale() -> String { + "auto".to_string() +} + +/// One `hl.monitor()` rule. Field names and defaults must stay in sync with +/// bos-settings `MonitorRule` and the ISO `monitors.lua` loader. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MonitorRule { + pub output: String, + #[serde(default = "default_mode")] + pub mode: String, + #[serde(default = "default_position")] + pub position: String, + #[serde(default = "default_scale")] + pub scale: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mirror: Option, +} + +impl Default for MonitorRule { + fn default() -> Self { + Self { + output: String::new(), + mode: default_mode(), + position: default_position(), + scale: default_scale(), + mirror: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MonitorsFile { + #[serde(default)] + pub monitors: Vec, +} + +impl Default for MonitorsFile { + fn default() -> Self { + Self { + monitors: vec![MonitorRule::default()], + } + } +} + +/// `~/.config/hypr/monitors.json` — same path Hyprland and bos-settings use. +pub fn config_path() -> PathBuf { + bread_utils::xdg::config_dir("hypr").join("monitors.json") +} + +pub fn load() -> Result> { + load_from(&config_path()) +} + +pub fn load_from(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + let file: MonitorsFile = serde_json::from_str(&content) + .with_context(|| format!("failed to parse {}", path.display()))?; + // Empty file ≡ missing: Lua falls back to the wildcard default rather + // than applying zero rules (which can black-screen the session). + if file.monitors.is_empty() { + return Ok(None); + } + Ok(Some(file)) +} + +pub fn save(file: &MonitorsFile) -> Result<()> { + save_to(&config_path(), file) +} + +pub fn save_to(path: &Path, file: &MonitorsFile) -> Result<()> { + let json = serde_json::to_string_pretty(file).context("failed to serialize monitors.json")?; + bread_utils::atomic::write_atomic_backed_up(path, &json) + .with_context(|| format!("failed to write {}", path.display())) +} + +pub fn save_from_monitors(monitors: &[Monitor]) -> Result<()> { + save(&from_monitors(monitors)) +} + +/// Persist the TUI layout as named `hl.monitor()` rules. Mirror slaves are +/// omitted (mirror is recorded on the source, matching `hl.monitor()`). If +/// nothing is writable, emit the wildcard default so the file is never empty. +pub fn from_monitors(monitors: &[Monitor]) -> MonitorsFile { + let mut source_to_slave: HashMap<&str, &str> = HashMap::new(); + for m in monitors { + if let Some(src) = &m.mirror_of { + source_to_slave.insert(src.as_str(), m.name.as_str()); + } + } + + let mut rules = Vec::new(); + for m in monitors { + if m.disabled || m.mirror_of.is_some() { + continue; + } + let refresh = (m.active_mode.refresh + 0.5) as u32; + rules.push(MonitorRule { + output: m.name.clone(), + mode: format!( + "{}x{}@{}", + m.active_mode.width, m.active_mode.height, refresh + ), + position: format!("{}x{}", m.x, m.y), + scale: format!("{:.2}", m.scale), + mirror: source_to_slave + .get(m.name.as_str()) + .map(|s| (*s).to_owned()), + }); + } + + if rules.is_empty() { + MonitorsFile::default() + } else { + MonitorsFile { monitors: rules } + } +} + +/// Overlay persisted rules onto live `hyprctl` monitors (matched by name; +/// empty `output` is the wildcard fallback). `preferred` / `auto` leave the +/// live value. A file with at least one named output is treated as a full +/// layout and replaces live mirrors; a wildcard-only file does not. +pub fn apply_to_monitors(file: &MonitorsFile, monitors: &mut [Monitor]) { + let has_specific = file.monitors.iter().any(|r| !r.output.is_empty()); + if has_specific { + for m in monitors.iter_mut() { + m.mirror_of = None; + } + for rule in &file.monitors { + let Some(slave_name) = rule.mirror.as_deref().filter(|s| !s.is_empty()) else { + continue; + }; + if rule.output.is_empty() { + continue; + } + if let Some(slave) = monitors.iter_mut().find(|m| m.name == slave_name) { + slave.mirror_of = Some(rule.output.clone()); + } + } + } + + for m in monitors.iter_mut() { + if let Some(rule) = find_rule(&file.monitors, &m.name) { + apply_rule_fields(m, rule); + } + } +} + +fn find_rule<'a>(rules: &'a [MonitorRule], name: &str) -> Option<&'a MonitorRule> { + rules + .iter() + .find(|r| r.output == name) + .or_else(|| rules.iter().find(|r| r.output.is_empty())) +} + +fn apply_rule_fields(m: &mut Monitor, rule: &MonitorRule) { + if rule.mode != "preferred" { + if let Some(mode) = + Mode::parse(&format!("{}Hz", rule.mode)).or_else(|| Mode::parse(&rule.mode)) + { + m.active_mode = mode; + } + } + if rule.position != "auto" { + if let Some((x, y)) = parse_position(&rule.position) { + m.x = x; + m.y = y; + } + } + if rule.scale != "auto" { + if let Ok(scale) = rule.scale.parse::() { + if scale > 0.0 { + m.scale = scale; + } + } + } +} + +fn parse_position(s: &str) -> Option<(i32, i32)> { + let (x, y) = s.split_once('x')?; + Some((x.parse().ok()?, y.parse().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::monitor::Transform; + + fn make_monitor(name: &str, w: u32, h: u32, x: i32, y: i32) -> Monitor { + Monitor { + name: name.into(), + description: String::new(), + active_mode: Mode { + width: w, + height: h, + refresh: 60.0, + }, + x, + y, + scale: 1.0, + transform: Transform::Normal, + vrr: false, + dpms: true, + disabled: false, + mirror_of: None, + available_modes: vec![], + physical_width_mm: 0, + physical_height_mm: 0, + } + } + + #[test] + fn iso_default_parses() { + let json = r#"{ + "monitors": [ + { "output": "", "mode": "preferred", "position": "auto", "scale": "auto" } + ] +}"#; + let file: MonitorsFile = serde_json::from_str(json).unwrap(); + assert_eq!(file.monitors.len(), 1); + assert_eq!(file.monitors[0], MonitorRule::default()); + } + + #[test] + fn pretty_roundtrip_omits_absent_mirror() { + let file = MonitorsFile::default(); + let json = serde_json::to_string_pretty(&file).unwrap(); + assert!(json.contains("\"output\": \"\"")); + assert!(json.contains("\"mode\": \"preferred\"")); + assert!(!json.contains("mirror")); + let back: MonitorsFile = serde_json::from_str(&json).unwrap(); + assert_eq!(file, back); + } + + #[test] + fn from_monitors_writes_named_rules_and_source_mirror() { + let mut hdmi = make_monitor("HDMI-A-1", 1920, 1080, 1920, 0); + hdmi.mirror_of = Some("eDP-1".into()); + let file = from_monitors(&[make_monitor("eDP-1", 1920, 1200, 0, 0), hdmi]); + assert_eq!(file.monitors.len(), 1); + let rule = &file.monitors[0]; + assert_eq!(rule.output, "eDP-1"); + assert_eq!(rule.mode, "1920x1200@60"); + assert_eq!(rule.position, "0x0"); + assert_eq!(rule.scale, "1.00"); + assert_eq!(rule.mirror.as_deref(), Some("HDMI-A-1")); + } + + #[test] + fn from_monitors_empty_or_all_slaves_emits_wildcard() { + let mut only_slave = make_monitor("HDMI-A-1", 1920, 1080, 0, 0); + only_slave.mirror_of = Some("missing".into()); + assert_eq!(from_monitors(&[]), MonitorsFile::default()); + assert_eq!(from_monitors(&[only_slave]), MonitorsFile::default()); + } + + #[test] + fn wildcard_overlay_leaves_live_geometry_and_mirrors() { + let file = MonitorsFile::default(); + let mut monitors = vec![make_monitor("eDP-1", 1920, 1200, 10, 20)]; + monitors[0].scale = 1.5; + monitors[0].mirror_of = Some("HDMI-A-1".into()); + apply_to_monitors(&file, &mut monitors); + assert_eq!(monitors[0].x, 10); + assert_eq!(monitors[0].y, 20); + assert!((monitors[0].scale - 1.5).abs() < f64::EPSILON); + assert_eq!(monitors[0].mirror_of.as_deref(), Some("HDMI-A-1")); + } + + #[test] + fn specific_overlay_applies_fields_and_replaces_mirrors() { + let file = MonitorsFile { + monitors: vec![ + MonitorRule { + output: "eDP-1".into(), + mode: "1920x1200@60".into(), + position: "0x0".into(), + scale: "1.25".into(), + mirror: Some("HDMI-A-1".into()), + }, + MonitorRule { + output: "DP-1".into(), + mode: "2560x1440@144".into(), + position: "-2560x0".into(), + scale: "1".into(), + mirror: None, + }, + ], + }; + let mut monitors = vec![ + make_monitor("eDP-1", 1600, 900, 100, 100), + make_monitor("HDMI-A-1", 1920, 1080, 200, 0), + make_monitor("DP-1", 1920, 1080, 300, 0), + ]; + monitors[1].mirror_of = Some("DP-1".into()); + apply_to_monitors(&file, &mut monitors); + + assert_eq!(monitors[0].active_mode.width, 1920); + assert_eq!(monitors[0].active_mode.height, 1200); + assert!((monitors[0].active_mode.refresh - 60.0).abs() < 0.01); + assert_eq!(monitors[0].x, 0); + assert_eq!(monitors[0].y, 0); + assert!((monitors[0].scale - 1.25).abs() < f64::EPSILON); + + assert_eq!(monitors[1].mirror_of.as_deref(), Some("eDP-1")); + + assert_eq!(monitors[2].active_mode.width, 2560); + assert_eq!(monitors[2].x, -2560); + assert!(monitors[2].mirror_of.is_none()); + } + + #[test] + fn load_from_missing_or_empty_is_none() { + let dir = std::env::temp_dir().join(format!( + "breadmon-store-test-{}-{}", + std::process::id(), + "empty" + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let missing = dir.join("nope.json"); + assert!(load_from(&missing).unwrap().is_none()); + + let empty = dir.join("empty.json"); + std::fs::write(&empty, "{ \"monitors\": [] }\n").unwrap(); + assert!(load_from(&empty).unwrap().is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn save_to_roundtrips() { + let dir = std::env::temp_dir().join(format!( + "breadmon-store-test-{}-{}", + std::process::id(), + "save" + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("monitors.json"); + let file = from_monitors(&[make_monitor("eDP-1", 1920, 1200, 0, 0)]); + save_to(&path, &file).unwrap(); + let loaded = load_from(&path).unwrap().unwrap(); + assert_eq!(loaded, file); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/ui/config_view.rs b/src/ui/config_view.rs index 247cac9..0f9bd38 100644 --- a/src/ui/config_view.rs +++ b/src/ui/config_view.rs @@ -125,7 +125,10 @@ impl ConfigState { } fn prev_field(&mut self) { - self.focused = self.focused.checked_sub(1).unwrap_or(ConfigField::ALL.len() - 1); + self.focused = self + .focused + .checked_sub(1) + .unwrap_or(ConfigField::ALL.len() - 1); } } @@ -314,14 +317,22 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { _ => {} }, ConfigField::Vrr => match event.code { - KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { + KeyCode::Char('h') + | KeyCode::Left + | KeyCode::Char('l') + | KeyCode::Right + | KeyCode::Char(' ') => { state.monitors[idx].vrr = !state.monitors[idx].vrr; state.mark_dirty(); } _ => {} }, ConfigField::Dpms => match event.code { - KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { + KeyCode::Char('h') + | KeyCode::Left + | KeyCode::Char('l') + | KeyCode::Right + | KeyCode::Char(' ') => { state.monitors[idx].dpms = !state.monitors[idx].dpms; state.mark_dirty(); } @@ -358,7 +369,11 @@ fn sync_mode_to_monitor(state: &mut AppState, idx: usize) { } fn sync_mirror_to_monitor(state: &mut AppState, idx: usize) { - let chosen = state.config.mirror_options.get(state.config.mirror_idx).cloned(); + let chosen = state + .config + .mirror_options + .get(state.config.mirror_idx) + .cloned(); state.monitors[idx].mirror_of = match chosen.as_deref() { Some("(none)") | None => None, Some(s) => Some(s.to_owned()), @@ -366,7 +381,10 @@ fn sync_mirror_to_monitor(state: &mut AppState, idx: usize) { } fn commit_scale(state: &mut AppState) { - let idx = state.config.monitor_idx.min(state.monitors.len().saturating_sub(1)); + let idx = state + .config + .monitor_idx + .min(state.monitors.len().saturating_sub(1)); if let Ok(v) = state.config.scale_str.parse::() { state.monitors[idx].scale = v.clamp(0.1, 10.0); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); @@ -405,7 +423,11 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) { }; let header = format!(" {} — {}{}", m.name, m.description, ppi_hint); f.render_widget( - Paragraph::new(header).style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Paragraph::new(header).style( + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), chunks[0], ); @@ -463,20 +485,31 @@ fn field_value(field: ConfigField, state: &AppState, m: &Monitor) -> String { } ConfigField::Scale => { if state.config.scale_editing { - format!("{}| (Enter to commit, ,/. for ±0.1)", state.config.scale_str) + format!( + "{}| (Enter to commit, ,/. for ±0.1)", + state.config.scale_str + ) } else { format!("{} (,/. for ±0.1)", state.config.scale_str) } } ConfigField::Transform => Transform::all() [state.config.transform_idx.min(Transform::all().len() - 1)] - .label() - .to_owned(), + .label() + .to_owned(), ConfigField::Vrr => { - if m.vrr { "ON".to_owned() } else { "OFF".to_owned() } + if m.vrr { + "ON".to_owned() + } else { + "OFF".to_owned() + } } ConfigField::Dpms => { - if m.dpms { "ON".to_owned() } else { "OFF".to_owned() } + if m.dpms { + "ON".to_owned() + } else { + "OFF".to_owned() + } } ConfigField::MirrorOf => state .config diff --git a/src/ui/layout_view.rs b/src/ui/layout_view.rs index a1aa3df..3b21f7a 100644 --- a/src/ui/layout_view.rs +++ b/src/ui/layout_view.rs @@ -8,7 +8,10 @@ use ratatui::{ }; use crate::{ - layout::{auto_arrange, bounding_box, canvas_scale, canvas_to_world, move_selected, snap_position, world_to_canvas}, + layout::{ + auto_arrange, bounding_box, canvas_scale, canvas_to_world, move_selected, snap_position, + world_to_canvas, + }, monitor::Monitor, ui::{AppState, DragState, StatusLevel, Tab}, }; @@ -49,7 +52,9 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { state.mark_dirty(); } KeyCode::Enter => { - state.config.sync_from_monitor(state.layout.selected, &state.monitors); + state + .config + .sync_from_monitor(state.layout.selected, &state.monitors); state.tab = Tab::Config; } _ => {} @@ -66,7 +71,8 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { if let Some(idx) = monitor_at(col, row, canvas, state) { let (min_x, min_y, _, _) = bounding_box(&state.monitors); let scale = canvas_scale_for(canvas, state); - let (wx, wy) = canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); + let (wx, wy) = + canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); // Push undo at drag start, not on every move state.push_undo(); @@ -85,12 +91,19 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { let canvas = canvas_area(state.terminal_size); let (min_x, min_y, _, _) = bounding_box(&state.monitors); let scale = canvas_scale_for(canvas, state); - let (wx, wy) = canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); + let (wx, wy) = + canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); let idx = drag.monitor_idx; let new_x = drag.origin_x + (wx - drag.click_world_x); let new_y = drag.origin_y + (wy - drag.click_world_y); - let (sx, sy) = snap_position(idx, new_x, new_y, &state.monitors, state.layout.snap_threshold); + let (sx, sy) = snap_position( + idx, + new_x, + new_y, + &state.monitors, + state.layout.snap_threshold, + ); state.monitors[idx].x = sx; state.monitors[idx].y = sy; state.mark_dirty(); @@ -162,18 +175,31 @@ fn render_canvas(f: &mut Frame, area: Rect, state: &AppState) { continue; } - let rect = Rect { x: cx, y: cy, width: cw, height: ch }; + let rect = Rect { + x: cx, + y: cy, + width: cw, + height: ch, + }; let is_selected = i == selected; - let is_dragging = state.drag_state.as_ref().map(|d| d.monitor_idx == i).unwrap_or(false); + let is_dragging = state + .drag_state + .as_ref() + .map(|d| d.monitor_idx == i) + .unwrap_or(false); let is_overlapping = overlapping[i]; let border_style = if is_dragging { - Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD) } else if is_overlapping { Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) } else if is_selected { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Blue) }; @@ -190,7 +216,10 @@ fn render_canvas(f: &mut Frame, area: Rect, state: &AppState) { }) .border_style(border_style) .title(Span::styled(&label, border_style)) - .title_bottom(Span::styled(&mode_str, Style::default().fg(Color::DarkGray))); + .title_bottom(Span::styled( + &mode_str, + Style::default().fg(Color::DarkGray), + )); f.render_widget(block, rect); } @@ -203,7 +232,9 @@ fn render_readout(f: &mut Frame, area: Rect, state: &AppState) { let idx = state.layout.selected.min(state.monitors.len() - 1); let m = &state.monitors[idx]; - let mirror_info = m.mirror_of.as_ref() + let mirror_info = m + .mirror_of + .as_ref() .map(|src| format!(" mirror:{}", src)) .unwrap_or_default(); @@ -213,14 +244,24 @@ fn render_readout(f: &mut Frame, area: Rect, state: &AppState) { "" }; - let drag_hint = if state.drag_state.is_some() { " [dragging]" } else { "" }; + let drag_hint = if state.drag_state.is_some() { + " [dragging]" + } else { + "" + }; let text = format!( " {} x:{} y:{} {}x{}@{:.0}Hz scale:{:.2}{}{}{}", - m.name, m.x, m.y, - m.active_mode.width, m.active_mode.height, m.active_mode.refresh, + m.name, + m.x, + m.y, + m.active_mode.width, + m.active_mode.height, + m.active_mode.refresh, m.scale, - mirror_info, overlap_warn, drag_hint, + mirror_info, + overlap_warn, + drag_hint, ); f.render_widget( Paragraph::new(text).style(Style::default().fg(Color::Cyan)), @@ -235,8 +276,10 @@ fn overlapping_monitors(monitors: &[Monitor]) -> Vec { for j in (i + 1)..monitors.len() { let a = &monitors[i]; let b = &monitors[j]; - if a.x < b.right_edge() && a.right_edge() > b.x - && a.y < b.bottom_edge() && a.bottom_edge() > b.y + if a.x < b.right_edge() + && a.right_edge() > b.x + && a.y < b.bottom_edge() + && a.bottom_edge() > b.y { flags[i] = true; flags[j] = true; @@ -301,31 +344,13 @@ fn monitor_at(col: u16, row: u16, canvas: Rect, state: &AppState) -> Option { - if is_new { - state.set_status( - format!("Saved. Add: source = {} to hyprland.conf", path.display()), - StatusLevel::Success, - ); - } else { - state.set_status(format!("Saved to {}", path.display()), StatusLevel::Success); - } state.dirty = false; + state.set_status( + format!("Saved to {}", crate::store::config_path().display()), + StatusLevel::Success, + ); } Err(e) => state.set_status(format!("Save failed: {}", e), StatusLevel::Error), } diff --git a/src/ui/mirror_view.rs b/src/ui/mirror_view.rs index c8b35f2..249d33f 100644 --- a/src/ui/mirror_view.rs +++ b/src/ui/mirror_view.rs @@ -45,7 +45,9 @@ impl MirrorState { fn next_field(&mut self) { // Skip Apply/Cancel if no result yet let mut next = (self.focused + 1) % FIELDS.len(); - if self.result.is_none() && (FIELDS[next] == MirrorField::Apply || FIELDS[next] == MirrorField::Cancel) { + if self.result.is_none() + && (FIELDS[next] == MirrorField::Apply || FIELDS[next] == MirrorField::Cancel) + { next = 0; } self.focused = next; @@ -54,8 +56,13 @@ impl MirrorState { fn prev_field(&mut self) { let len = FIELDS.len(); let mut prev = self.focused.checked_sub(1).unwrap_or(len - 1); - if self.result.is_none() && (FIELDS[prev] == MirrorField::Apply || FIELDS[prev] == MirrorField::Cancel) { - prev = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2); + if self.result.is_none() + && (FIELDS[prev] == MirrorField::Apply || FIELDS[prev] == MirrorField::Cancel) + { + prev = FIELDS + .iter() + .position(|&f| f == MirrorField::Compute) + .unwrap_or(2); } self.focused = prev; } @@ -86,12 +93,14 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { } KeyCode::Char('h') | KeyCode::Left => match state.mirror.current_field() { MirrorField::Source => { - state.mirror.source_idx = state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.source_idx = + state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1); state.mirror.fix_indices(count); state.mirror.result = None; } MirrorField::Target => { - state.mirror.target_idx = state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.target_idx = + state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1); state.mirror.fix_indices(count); state.mirror.result = None; } @@ -118,7 +127,10 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { Some(result) => { state.mirror.result = Some(result); // Move focus to Apply - state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); + state.mirror.focused = FIELDS + .iter() + .position(|&f| f == MirrorField::Apply) + .unwrap_or(3); } None => { state.set_status( @@ -143,9 +155,7 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { state.set_status( format!( "Mirror set: {} → {} at {}", - src_name, - state.monitors[tgt_idx].name, - result.mirror_mode + src_name, state.monitors[tgt_idx].name, result.mirror_mode ), StatusLevel::Success, ); @@ -182,17 +192,26 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { match row { 2 | 3 => { // Source picker area - let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Source).unwrap_or(0); + let f_idx = FIELDS + .iter() + .position(|&f| f == MirrorField::Source) + .unwrap_or(0); state.mirror.focused = f_idx; } 4 | 5 => { // Target picker area - let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Target).unwrap_or(1); + let f_idx = FIELDS + .iter() + .position(|&f| f == MirrorField::Target) + .unwrap_or(1); state.mirror.focused = f_idx; } 6 => { // Compute button - let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2); + let f_idx = FIELDS + .iter() + .position(|&f| f == MirrorField::Compute) + .unwrap_or(2); state.mirror.focused = f_idx; // Also activate it let src = &state.monitors[state.mirror.source_idx]; @@ -200,7 +219,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { match crate::mirror::find_mirror_modes(src, tgt) { Some(result) => { state.mirror.result = Some(result); - state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); + state.mirror.focused = FIELDS + .iter() + .position(|&f| f == MirrorField::Apply) + .unwrap_or(3); } None => { state.set_status( @@ -218,7 +240,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { let col = event.column; if col < 20 { // Activate Apply - state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); + state.mirror.focused = FIELDS + .iter() + .position(|&f| f == MirrorField::Apply) + .unwrap_or(3); if let Some(result) = state.mirror.result.clone() { state.push_undo(); let src_name = state.monitors[state.mirror.source_idx].name.clone(); @@ -229,7 +254,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { state.mirror.result = None; state.mirror.focused = 0; state.set_status( - format!("Mirror set: {} → {} at {}", src_name, state.monitors[tgt_idx].name, result.mirror_mode), + format!( + "Mirror set: {} → {} at {}", + src_name, state.monitors[tgt_idx].name, result.mirror_mode + ), crate::ui::StatusLevel::Success, ); } @@ -246,33 +274,33 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { // Scroll in source/target pickers to cycle monitors match state.mirror.current_field() { MirrorField::Source => { - state.mirror.source_idx = state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.source_idx = + state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1); state.mirror.fix_indices(count); state.mirror.result = None; } MirrorField::Target => { - state.mirror.target_idx = state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.target_idx = + state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1); state.mirror.fix_indices(count); state.mirror.result = None; } _ => {} } } - MouseEventKind::ScrollDown => { - match state.mirror.current_field() { - MirrorField::Source => { - state.mirror.source_idx = (state.mirror.source_idx + 1) % count; - state.mirror.fix_indices(count); - state.mirror.result = None; - } - MirrorField::Target => { - state.mirror.target_idx = (state.mirror.target_idx + 1) % count; - state.mirror.fix_indices(count); - state.mirror.result = None; - } - _ => {} + MouseEventKind::ScrollDown => match state.mirror.current_field() { + MirrorField::Source => { + state.mirror.source_idx = (state.mirror.source_idx + 1) % count; + state.mirror.fix_indices(count); + state.mirror.result = None; } - } + MirrorField::Target => { + state.mirror.target_idx = (state.mirror.target_idx + 1) % count; + state.mirror.fix_indices(count); + state.mirror.result = None; + } + _ => {} + }, _ => {} } } @@ -324,12 +352,16 @@ fn render_pickers(f: &mut Frame, area: Rect, state: &AppState, count: usize) { let focused = state.mirror.current_field(); let src_style = if focused == MirrorField::Source { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let tgt_style = if focused == MirrorField::Target { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; @@ -354,7 +386,9 @@ fn render_pickers(f: &mut Frame, area: Rect, state: &AppState, count: usize) { fn render_compute_btn(f: &mut Frame, area: Rect, state: &AppState) { let focused = state.mirror.current_field() == MirrorField::Compute; let style = if focused { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; @@ -376,12 +410,16 @@ fn render_result(f: &mut Frame, area: Rect, state: &AppState, result: &MirrorRes let focused = state.mirror.current_field(); let apply_style = if focused == MirrorField::Apply { - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let cancel_style = if focused == MirrorField::Cancel { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; @@ -397,7 +435,10 @@ fn render_result(f: &mut Frame, area: Rect, state: &AppState, result: &MirrorRes Style::default().fg(Color::White), )), Line::from(Span::styled( - format!(" Refresh: {:.2} Hz ({})", result.refresh, refresh_label), + format!( + " Refresh: {:.2} Hz ({})", + result.refresh, refresh_label + ), Style::default().fg(Color::White), )), Line::raw(""), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ad44d1d..4987538 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -14,10 +14,7 @@ use ratatui::{ Frame, }; -use crate::{ - layout::LayoutState, - monitor::Monitor, -}; +use crate::{layout::LayoutState, monitor::Monitor}; use config_view::ConfigState; use mirror_view::MirrorState; @@ -141,7 +138,11 @@ impl AppState { } pub fn set_status(&mut self, text: impl Into, level: StatusLevel) { - self.status = Some(StatusMsg { text: text.into(), level, born: Instant::now() }); + self.status = Some(StatusMsg { + text: text.into(), + level, + born: Instant::now(), + }); } /// Mark the in-memory layout as edited. Also forgets `active_profile` @@ -163,7 +164,8 @@ impl AppState { pub fn switch_tab(&mut self, tab: Tab) { self.tab = tab; if tab == Tab::Config { - self.config.sync_from_monitor(self.layout.selected, &self.monitors); + self.config + .sync_from_monitor(self.layout.selected, &self.monitors); } } @@ -214,10 +216,22 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) -> bool { // Global tab switching match event.code { - KeyCode::Char('1') | KeyCode::F(1) => { state.switch_tab(Tab::Layout); return true; } - KeyCode::Char('2') | KeyCode::F(2) => { state.switch_tab(Tab::Config); return true; } - KeyCode::Char('3') | KeyCode::F(3) => { state.switch_tab(Tab::Mirror); return true; } - KeyCode::Char('4') | KeyCode::F(4) => { state.switch_tab(Tab::Profiles); return true; } + KeyCode::Char('1') | KeyCode::F(1) => { + state.switch_tab(Tab::Layout); + return true; + } + KeyCode::Char('2') | KeyCode::F(2) => { + state.switch_tab(Tab::Config); + return true; + } + KeyCode::Char('3') | KeyCode::F(3) => { + state.switch_tab(Tab::Mirror); + return true; + } + KeyCode::Char('4') | KeyCode::F(4) => { + state.switch_tab(Tab::Profiles); + return true; + } _ => {} } diff --git a/src/ui/profiles_view.rs b/src/ui/profiles_view.rs index c50cea8..b05f9be 100644 --- a/src/ui/profiles_view.rs +++ b/src/ui/profiles_view.rs @@ -126,7 +126,10 @@ fn handle_list_key(event: KeyEvent, state: &mut AppState) { match profile::delete(&name) { Ok(()) => { state.profiles.refresh(); - state.set_status(format!("Deleted profile '{}'", name), StatusLevel::Success); + state.set_status( + format!("Deleted profile '{}'", name), + StatusLevel::Success, + ); } Err(e) => { state.set_status(format!("Delete failed: {}", e), StatusLevel::Error); @@ -195,8 +198,11 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { MouseEventKind::ScrollUp => { let count = state.profiles.profiles.len(); if count > 0 { - state.profiles.selected_idx = - state.profiles.selected_idx.checked_sub(1).unwrap_or(count - 1); + state.profiles.selected_idx = state + .profiles + .selected_idx + .checked_sub(1) + .unwrap_or(count - 1); state.profiles.focused = ProfileField::List; } } @@ -308,7 +314,9 @@ fn render_list(f: &mut Frame, area: Rect, state: &AppState) { .add_modifier(Modifier::BOLD) .bg(Color::DarkGray) } else if is_selected { - Style::default().fg(Color::White).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; @@ -362,22 +370,28 @@ fn render_save_row(f: &mut Frame, area: Rect, state: &AppState) { Style::default().fg(Color::DarkGray) }; f.render_widget( - Paragraph::new(input_display) - .style(input_style) - .block(Block::default().borders(Borders::ALL).border_style(input_style)), + Paragraph::new(input_display).style(input_style).block( + Block::default() + .borders(Borders::ALL) + .border_style(input_style), + ), chunks[0], ); // Save button let save_style = if save_focused { - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; f.render_widget( - Paragraph::new(" [ Save ] ") - .style(save_style) - .block(Block::default().borders(Borders::ALL).border_style(save_style)), + Paragraph::new(" [ Save ] ").style(save_style).block( + Block::default() + .borders(Borders::ALL) + .border_style(save_style), + ), chunks[1], ); } From 4daef2431ebad29120447172fcacee1198b1a428 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:05:47 +0800 Subject: [PATCH 17/20] Bump version to v0.1.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd9b3a6..b5818f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "breadmon" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "bread-utils", diff --git a/Cargo.toml b/Cargo.toml index 647a201..bc93db7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadmon" -version = "0.1.2" +version = "0.1.3" edition = "2021" description = "TUI monitor manager for Hyprland" license = "MIT" From 40d28d3ec4a864ff805c9a1e8f7bd60b2c39407b Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:50:22 +0800 Subject: [PATCH 18/20] CI: refuse unsigned bakery index on stable tag releases --- .forgejo/workflows/release.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index b3b2164..7286868 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -17,7 +17,16 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && bash ci/build.sh cargo build --release --locked + run: | + set -euo pipefail + if [ ! -f src/ci/build.sh ]; then + echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper" + exit 1 + fi + cd src && bash ci/build.sh cargo build --release --locked || { + echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked." + exit 1 + } - name: prepare artifacts run: | @@ -33,8 +42,14 @@ jobs: ln -sfn "${VERSION}" "/srv/breadway-dl/breadmon/latest" - name: regenerate index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} run: | set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)" + exit 1 + fi rm -rf /tmp/bread-ecosystem-ci git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh From 8e0b95899c23448de3ce5fe6073bd6d905e25af3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 30 Aug 2026 18:34:41 +0800 Subject: [PATCH 19/20] Guard unsaved edits against refresh and failed apply; coalesce undo - Refresh (r) no longer clears the dirty flag or clobbers in-progress edits: it skips with a status message while changes are uncommitted, keeping the unsaved-changes quit guard intact. - Config-tab apply keeps dirty set until the hyprctl eval and monitors.json write both succeed, so a failed apply no longer drops the modified marker. - Undo snapshots coalesce runs of nudges/value cycles into one step via burst tracking in AppState, so 20 px of nudges undo as a single step. - Replace the `date` subprocess timestamp with an in-process ISO 8601 formatter (Hinnant days-from-civil); remove dead row_height code. --- src/main.rs | 18 ++++++++++---- src/profile.rs | 57 ++++++++++++++++++++++++++++++++++++------- src/ui/config_view.rs | 38 +++++++++++++++++------------ src/ui/layout_view.rs | 29 ++++++++++++++++------ src/ui/mod.rs | 32 +++++++++++++++++++++++- 5 files changed, 135 insertions(+), 39 deletions(-) diff --git a/src/main.rs b/src/main.rs index a9c2086..7cc5a23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -173,11 +173,19 @@ async fn run( } crossterm::event::KeyCode::Char('r') => match monitor::load_monitors().await { Ok(monitors) => { - state.monitors = monitors; - state.layout.clamp_selected(state.monitors.len()); - state.dirty = false; - state.active_profile = None; - state.set_status("Monitors refreshed.", StatusLevel::Success); + if state.dirty { + // Don't clobber unsaved edits (or silently drop the + // unsaved-changes quit guard) on a refresh. + state.set_status( + "Refresh skipped: unsaved changes present.", + StatusLevel::Info, + ); + } else { + state.monitors = monitors; + state.layout.clamp_selected(state.monitors.len()); + state.active_profile = None; + state.set_status("Monitors refreshed.", StatusLevel::Success); + } } Err(e) => { state.set_status(format!("Refresh failed: {}", e), StatusLevel::Error); diff --git a/src/profile.rs b/src/profile.rs index 38ab498..ad26ad1 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -129,15 +129,39 @@ pub fn apply_to_monitors(profile: &Profile, monitors: &mut [Monitor]) { } fn chrono_now() -> String { - // Simple ISO 8601 timestamp without pulling in chrono - // Uses date command; falls back to a placeholder if unavailable - std::process::Command::new("date") - .arg("+%Y-%m-%dT%H:%M:%SZ") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_owned()) - .unwrap_or_else(|| "unknown".to_owned()) + // In-process ISO 8601 (UTC) timestamp — no chrono crate, and no shelling + // out to `date`. `civil_from_days` is the Hinnant days-from-civil epoch + // algorithm. Falls back to the Unix epoch instant if the clock is broken. + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let (h, m, s) = secs_of_day(secs % 86_400); + let (y, mo, d) = civil_from_days((secs / 86_400) as i64); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") +} + +/// Convert days since 1970-01-01 to a (year, month, day) civil date. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// Seconds within the day -> (hours, minutes, seconds). +fn secs_of_day(secs: u64) -> (u32, u32, u32) { + ( + ((secs / 3600) % 24) as u32, + ((secs / 60) % 60) as u32, + (secs % 60) as u32, + ) } #[cfg(test)] @@ -184,4 +208,19 @@ mod tests { assert_eq!(deserialized.monitors[0].mode, "1920x1200@60.00"); assert_eq!(deserialized.monitors[1].x, 1920); } + + #[test] + fn chrono_now_helpers() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + // 2024-01-01 is epoch day 19723. + assert_eq!(civil_from_days(19_723), (2024, 1, 1)); + assert_eq!(secs_of_day(0), (0, 0, 0)); + assert_eq!(secs_of_day(86_399), (23, 59, 59)); + // Spot-check the formatted output shape. + let s = chrono_now(); + assert_eq!(s.len(), 20); + + assert!(s.ends_with('Z')); + assert!(s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-'); + } } diff --git a/src/ui/config_view.rs b/src/ui/config_view.rs index 0f9bd38..22cc80e 100644 --- a/src/ui/config_view.rs +++ b/src/ui/config_view.rs @@ -133,27 +133,30 @@ impl ConfigState { } pub fn handle_key(event: KeyEvent, state: &mut AppState) { - let cfg = &mut state.config; - match event.code { KeyCode::Char('j') | KeyCode::Down => { - cfg.scale_editing = false; - cfg.next_field(); + state.clear_burst(); + state.config.scale_editing = false; + state.config.next_field(); } KeyCode::Char('k') | KeyCode::Up => { - cfg.scale_editing = false; - cfg.prev_field(); + state.clear_burst(); + state.config.scale_editing = false; + state.config.prev_field(); } KeyCode::Tab => { - cfg.scale_editing = false; - cfg.next_field(); + state.clear_burst(); + state.config.scale_editing = false; + state.config.next_field(); } KeyCode::BackTab => { - cfg.scale_editing = false; - cfg.prev_field(); + state.clear_burst(); + state.config.scale_editing = false; + state.config.prev_field(); } // Navigate between monitors KeyCode::Char('[') => { + state.clear_burst(); let count = state.monitors.len(); if count > 0 { let new_idx = state.config.monitor_idx.checked_sub(1).unwrap_or(count - 1); @@ -162,6 +165,7 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { } } KeyCode::Char(']') => { + state.clear_burst(); let count = state.monitors.len(); if count > 0 { let new_idx = (state.config.monitor_idx + 1) % count; @@ -176,19 +180,20 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { crate::ui::layout_view::trigger_save(state); } KeyCode::Esc => { + state.clear_burst(); state.config.scale_editing = false; // Re-sync from live monitor to discard pending edits let idx = state.config.monitor_idx; state.config.sync_from_monitor(idx, &state.monitors); } KeyCode::Enter => { + state.clear_burst(); if state.config.current_field() == ConfigField::Scale { commit_scale(state); } apply_current(state); } _ => { - state.push_undo(); handle_field_key(event, state); } } @@ -214,12 +219,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { } } MouseEventKind::ScrollUp => { - state.push_undo(); let fake_right = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE); handle_field_key(fake_right, state); } MouseEventKind::ScrollDown => { - state.push_undo(); let fake_left = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE); handle_field_key(fake_left, state); } @@ -233,6 +236,8 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { return; } let idx = state.config.monitor_idx.min(monitors_len - 1); + // Coalesce consecutive value cycles (and scroll) into one undo step. + state.micro_edit(); match state.config.current_field() { ConfigField::Resolution => match event.code { @@ -394,9 +399,12 @@ fn commit_scale(state: &mut AppState) { } fn apply_current(state: &mut AppState) { + state.clear_burst(); state.pending_apply = true; state.set_status("Applying...", StatusLevel::Info); - state.dirty = false; + // Don't clear `dirty` here: it must survive until the apply actually + // succeeds (main.rs clears it on a successful apply + save). Otherwise a + // failed `hyprctl` apply would silently drop the unsaved-changes guard. } pub fn render(f: &mut Frame, area: Rect, state: &AppState) { @@ -432,7 +440,6 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) { ); let form_area = chunks[1]; - let row_height = 1u16; let fields = ConfigField::ALL; let items: Vec = fields @@ -454,7 +461,6 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) { }) .collect(); - let _ = row_height; // used implicitly via ListItem heights let list = List::new(items).block( Block::default() .borders(Borders::ALL) diff --git a/src/ui/layout_view.rs b/src/ui/layout_view.rs index 3b21f7a..00a54c0 100644 --- a/src/ui/layout_view.rs +++ b/src/ui/layout_view.rs @@ -23,35 +23,48 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { match event.code { KeyCode::Char('h') | KeyCode::Left => { - state.push_undo(); + state.micro_edit(); move_selected(&state.layout, &mut state.monitors, -step, 0); state.mark_dirty(); } KeyCode::Char('l') | KeyCode::Right => { - state.push_undo(); + state.micro_edit(); move_selected(&state.layout, &mut state.monitors, step, 0); state.mark_dirty(); } KeyCode::Char('k') | KeyCode::Up => { - state.push_undo(); + state.micro_edit(); move_selected(&state.layout, &mut state.monitors, 0, -step); state.mark_dirty(); } KeyCode::Char('j') | KeyCode::Down => { - state.push_undo(); + state.micro_edit(); move_selected(&state.layout, &mut state.monitors, 0, step); state.mark_dirty(); } - KeyCode::Tab | KeyCode::Char('n') => state.layout.next(count), - KeyCode::BackTab | KeyCode::Char('p') => state.layout.prev(count), - KeyCode::Char('[') => state.layout.zoom = (state.layout.zoom - 0.1).max(0.1), - KeyCode::Char(']') => state.layout.zoom = (state.layout.zoom + 0.1).min(5.0), + KeyCode::Tab | KeyCode::Char('n') => { + state.clear_burst(); + state.layout.next(count); + } + KeyCode::BackTab | KeyCode::Char('p') => { + state.clear_burst(); + state.layout.prev(count); + } + KeyCode::Char('[') => { + state.clear_burst(); + state.layout.zoom = (state.layout.zoom - 0.1).max(0.1); + } + KeyCode::Char(']') => { + state.clear_burst(); + state.layout.zoom = (state.layout.zoom + 0.1).min(5.0); + } KeyCode::Char('0') => { state.push_undo(); auto_arrange(&mut state.monitors); state.mark_dirty(); } KeyCode::Enter => { + state.clear_burst(); state .config .sync_from_monitor(state.layout.selected, &state.monitors); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4987538..cadb387 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -114,6 +114,9 @@ pub struct AppState { pub active_profile: Option, /// Snapshots for Ctrl+Z undo (up to 20 deep). pub undo_stack: Vec>, + /// True while a run of small incremental edits (nudges / value cycles) + /// is ongoing, so undo coalesces the whole burst into one snapshot. + undo_in_burst: bool, } impl AppState { @@ -134,6 +137,7 @@ impl AppState { pending_apply: false, active_profile: None, undo_stack: Vec::new(), + undo_in_burst: false, } } @@ -162,6 +166,7 @@ impl AppState { } pub fn switch_tab(&mut self, tab: Tab) { + self.undo_in_burst = false; self.tab = tab; if tab == Tab::Config { self.config @@ -169,8 +174,32 @@ impl AppState { } } - /// Save a monitor snapshot for undo (max 20 entries). + /// Save a monitor snapshot for undo (max 20 entries) and end any + /// in-progress edit burst. pub fn push_undo(&mut self) { + self.push_snapshot(); + self.undo_in_burst = false; + } + + /// Start (or continue) a run of small incremental edits. Only the first + /// edit in the run actually snapshots, so nudging a monitor 20 px (or + /// cycling a value repeatedly) collapses to a single undo step rather + /// than consuming 20 of the 20-step undo stack. + pub fn micro_edit(&mut self) { + if !self.undo_in_burst { + self.push_snapshot(); + self.undo_in_burst = true; + } + } + + /// End a coalesced-edit burst without snapping. Called on navigation + /// (tab switches, monitor/field changes, zoom) so bursts don't bleed + /// across distinct actions. + pub fn clear_burst(&mut self) { + self.undo_in_burst = false; + } + + fn push_snapshot(&mut self) { self.undo_stack.push(self.monitors.clone()); if self.undo_stack.len() > 20 { self.undo_stack.remove(0); @@ -181,6 +210,7 @@ impl AppState { pub fn undo(&mut self) { if let Some(snapshot) = self.undo_stack.pop() { self.monitors = snapshot; + self.undo_in_burst = false; self.mark_dirty(); self.layout.clamp_selected(self.monitors.len()); // Re-sync config view to the restored state From 6fda461133dde4f2d6b2124510a28147fc9065e4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 31 Aug 2026 14:45:15 +0800 Subject: [PATCH 20/20] gitignore untracked .freebuff/ local tool state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 272a817..c3b4cc4 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ CLAUDE.md # graphify knowledge-graph output (local tool cache, not for commit) graphify-out/ + +# .freebuff local tool state (not for commit) +.freebuff/