Compare commits

..

21 commits
v0.1.2 ... main

Author SHA1 Message Date
Breadway
6fda461133 gitignore untracked .freebuff/ local tool state
All checks were successful
dev release / build (push) Successful in 1m10s
Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-31 14:45:15 +08:00
Breadway
8e0b95899c 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.
2026-08-30 19:17:13 +08:00
Breadway
40d28d3ec4 CI: refuse unsigned bakery index on stable tag releases
Some checks failed
check / check (push) Failing after 3s
dev release / build (push) Successful in 59s
beta (rc) release / build (push) Has been skipped
release / build (push) Successful in 37s
2026-08-16 00:50:22 +08:00
Breadway
4daef2431e Bump version to v0.1.3
Some checks failed
dev release / build (push) Failing after 1s
beta (rc) release / build (push) Has been skipped
release / build (push) Failing after 1s
2026-08-15 23:05:47 +08:00
Breadway
d98b5b4fad Share ~/.config/hypr/monitors.json with the BOS Display panel
Some checks failed
dev release / build (push) Failing after 0s
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.
2026-08-15 22:53:28 +08:00
Breadway
8d43a03d44 gitignore: exclude graphify-out local cache
Some checks failed
dev release / build (push) Failing after 1s
2026-08-15 22:32:36 +08:00
Breadway
aec2fa5290 Wire breadmon into the bread event fabric (app id mon)
Some checks failed
check / check (push) Failing after 1s
dev release / build (push) Failing after 1s
After a successful hyprctl apply, publish bread.mon.applied
{ "profile": <string or null> }. BreadClient is fail-silent: if
breadd is down, apply behaves exactly as before.

Document the contract in EVENTS.md.
2026-08-15 22:16:36 +08:00
Breadway
7aa95e1ffa Track AGENTS.md
Some checks failed
check / check (push) Failing after 1s
2026-08-15 22:03:30 +08:00
Breadway
20d4825cf0 deps: pin bread-utils to bread-ecosystem v0.7.1
Some checks failed
check / check (push) Failing after 1s
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).
2026-08-15 21:35:35 +08:00
Breadway
598f85d930 Merge fix/shared-ecosystem-ci: shared Arch CI image + clippy fixes
All checks were successful
dev release / build (push) Successful in 36s
2026-08-05 14:06:14 +08:00
Breadway
d73eacf40f ci: build against bread-ecosystem's shared Arch CI image, add check.yml
All checks were successful
check / check (push) Successful in 26s
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.
2026-08-05 14:02:47 +08:00
Breadway
c692b597b3 CONTRIBUTING.md: document single-trunk + RC-tag release model
All checks were successful
dev release / build (push) Successful in 2m3s
2026-07-31 11:08:41 +08:00
Breadway
b131a47c7d CI: single-trunk model — dev triggers on main, beta becomes RC-tag-triggered
Replaces the dev/beta branch split with one trunk (main): dev-track
builds still publish on every push, but the beta track now publishes
from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained
beta branch. Removes the branch nobody reliably kept in sync.
2026-07-31 11:05:29 +08:00
Breadway
96de829201 docs: add CONTRIBUTING.md
All checks were successful
dev release / build (push) Successful in 1m3s
Documents the dev/beta/main branch and release-track workflow shared
across the bread ecosystem. See bread-ecosystem's docs/release-channels.md
for the full policy this implements.
2026-07-22 19:41:19 +08:00
Breadway
dec791b73f ci: make beta a branch-triggered freeze track, not a one-off tag
All checks were successful
dev release / build (push) Successful in 37s
beta release / build (push) Successful in 1m0s
Beta is now a real stabilization branch: publishes on every push to
`beta` (mirroring dev's model, auto-versioned X.Y.Z-beta.<ts>+<sha>,
base version from the latest published tag) instead of a manual
beta-v* tag. Fixes made during the freeze land via fix/<issue> branches
merged into `beta` directly. The gen-index.sh clone for beta pulls
bread-ecosystem's default branch (main) rather than pinning to dev,
since beta is the more stable track and main now carries the
TRACK-aware script.
2026-07-22 18:37:41 +08:00
Breadway
db37668a03 ci: base dev version on the latest published tag, not Cargo.toml
All checks were successful
dev release / build (push) Successful in 32s
Cargo.toml can drift stale relative to the actual last release (observed
on breadbox/breadpad/breadcrumbs/breadpaper), which made the auto-bumped
dev version sort as OLDER than what's already installed — bakery's semver
check correctly refused those "updates". Deriving the base version from
git ls-remote --tags instead is self-healing regardless of Cargo.toml
drift, with a Cargo.toml fallback only for a repo with no tags yet.
2026-07-22 13:52:37 +08:00
Breadway
c2e650730b random commit message, read it yourself 2026-07-22 11:55:43 +08:00
Breadway
79ef7b6927 ci: use a unique temp dir for the bread-ecosystem clone in dev/beta CI
All checks were successful
dev release / build (push) Successful in 46s
The fixed /tmp/bread-ecosystem-ci path races when multiple repos' dev/beta
workflows run close together on the same self-hosted runner — one job's
rm -rf/clone can stomp another's in-progress checkout, causing the
regenerate-index step to fail intermittently. Switch to mktemp -d.
2026-07-22 10:24:38 +08:00
Breadway
e088ab54ee ci: retrigger dev-track build now that BAKERY_MINISIGN_SEC_KEY_PATH is set
All checks were successful
dev release / build (push) Successful in 1m6s
2026-07-22 10:10:13 +08:00
Breadway
f2c1e55956 ci: add dev/beta build track workflows
Some checks failed
dev release / build (push) Failing after 47s
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.
2026-07-22 09:56:49 +08:00
Breadway
a86d3b5ee4 ci: remove GitHub push-mirror workflow 2026-07-21 19:17:57 +08:00
26 changed files with 1391 additions and 289 deletions

View file

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

View file

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

View file

@ -1,19 +0,0 @@
name: Mirror to GitHub
on:
push:
branches: ['**']
tags: ['**']
jobs:
mirror:
runs-on: [self-hosted, hestia]
steps:
- name: Mirror to GitHub
run: |
set -euo pipefail
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
cd repo.git
git push --prune \
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadmon.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'

View file

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

View file

@ -6,6 +6,7 @@ on:
jobs: jobs:
build: build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia] runs-on: [self-hosted, hestia]
steps: steps:
- name: checkout - name: checkout
@ -16,7 +17,16 @@ jobs:
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build - name: build
run: cd src && cargo build --release --locked run: |
set -euo pipefail
if [ ! -f src/ci/build.sh ]; then
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
exit 1
fi
cd src && bash ci/build.sh cargo build --release --locked || {
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
exit 1
}
- name: prepare artifacts - name: prepare artifacts
run: | run: |
@ -32,8 +42,14 @@ jobs:
ln -sfn "${VERSION}" "/srv/breadway-dl/breadmon/latest" ln -sfn "${VERSION}" "/srv/breadway-dl/breadmon/latest"
- name: regenerate index.json - name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: | run: |
set -euo pipefail 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 rm -rf /tmp/bread-ecosystem-ci
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /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 bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh

9
.gitignore vendored
View file

@ -29,3 +29,12 @@ logs/
# Runtime files # Runtime files
*.sock *.sock
*.pid *.pid
# Local hygiene notes (not for commit)
CLAUDE.md
# graphify knowledge-graph output (local tool cache, not for commit)
graphify-out/
# .freebuff local tool state (not for commit)
.freebuff/

33
AGENTS.md Normal file
View file

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

84
CONTRIBUTING.md Normal file
View file

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

20
Cargo.lock generated
View file

@ -21,18 +21,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]] [[package]]
name = "bread-utils" name = "bread-shared"
version = "0.3.0" version = "0.7.0"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b"
dependencies = [ dependencies = [
"dirs", "dirs",
"serde", "serde",
"serde_json", "serde_json",
"toml",
]
[[package]]
name = "bread-utils"
version = "0.7.2"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
dependencies = [
"bread-shared",
"dirs",
"serde",
"serde_json",
] ]
[[package]] [[package]]
name = "breadmon" name = "breadmon"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-utils", "bread-utils",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "breadmon" name = "breadmon"
version = "0.1.2" version = "0.1.3"
edition = "2021" edition = "2021"
description = "TUI monitor manager for Hyprland" description = "TUI monitor manager for Hyprland"
license = "MIT" license = "MIT"
@ -19,8 +19,7 @@ toml = "0.8"
anyhow = "1" anyhow = "1"
dirs = "5" dirs = "5"
futures = "0.3" 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.7.2", features = ["bread-client"] }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" }
[profile.release] [profile.release]
lto = "thin" lto = "thin"

45
EVENTS.md Normal file
View file

@ -0,0 +1,45 @@
# 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). 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.
## Events published (`bread.mon.*`)
| Event | Data | When |
|-------|------|------|
| `bread.mon.applied` | `{ "profile": <string or null> }` | 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.

View file

@ -1,14 +1,24 @@
# breadmon # 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.
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 ## 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` - The `hyprctl` binary must be on `PATH`
- Rust toolchain (to build from source) - 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 cargo build --release
@ -16,12 +26,6 @@ cargo build --release
The binary is written to `target/release/breadmon`. 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 ## Usage
``` ```
@ -76,7 +80,7 @@ Finds the best common mode between two monitors and sets one to mirror the other
### Profiles ### 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 | | Key | Action |
|-----|--------| |-----|--------|
@ -91,8 +95,8 @@ Profiles are saved to `~/.config/breadmon/profiles/`.
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| `a` | Apply current configuration via `hyprctl` | | `a` | Apply current configuration via `hyprctl eval 'hl.monitor({...})'` (BOS-patched Hyprland only) and write `~/.config/hypr/monitors.json` |
| `s` | Save current configuration as a profile | | `s` | Write `~/.config/hypr/monitors.json` without a live apply |
| `r` | Refresh monitor list from Hyprland | | `r` | Refresh monitor list from Hyprland |
| `Ctrl+Z` | Undo last change (up to 20 steps) | | `Ctrl+Z` | Undo last change (up to 20 steps) |
| `q` / `Ctrl+C` | Quit (prompts once if there are unsaved changes) | | `q` / `Ctrl+C` | Quit (prompts once if there are unsaved changes) |
@ -101,4 +105,20 @@ breadmon also listens on Hyprland's event socket and reloads the monitor list au
## Config ## 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:
```json
{
"monitors": [
{ "output": "", "mode": "preferred", "position": "auto", "scale": "auto", "mirror": "<optional string>" }
]
}
```
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
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.

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

@ -0,0 +1 @@
620c5a1317a6b57276eabca961facdb78bf510db

20
ci/build.sh Executable file
View file

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

43
src/bread_events.rs Normal file
View file

@ -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);
}
}

View file

@ -103,7 +103,7 @@ pub fn snap_position(
} }
/// Move the selected monitor by (dx, dy) pixels, then snap. /// Move the selected monitor by (dx, dy) pixels, then snap.
pub fn move_selected(state: &LayoutState, monitors: &mut Vec<Monitor>, dx: i32, dy: i32) { pub fn move_selected(state: &LayoutState, monitors: &mut [Monitor], dx: i32, dy: i32) {
let idx = state.selected; let idx = state.selected;
if idx >= monitors.len() { if idx >= monitors.len() {
return; return;
@ -116,7 +116,7 @@ pub fn move_selected(state: &LayoutState, monitors: &mut Vec<Monitor>, dx: i32,
} }
/// Place monitors in a left-to-right row with no gaps. /// Place monitors in a left-to-right row with no gaps.
pub fn auto_arrange(monitors: &mut Vec<Monitor>) { pub fn auto_arrange(monitors: &mut [Monitor]) {
let mut cursor = 0i32; let mut cursor = 0i32;
for m in monitors.iter_mut() { for m in monitors.iter_mut() {
m.x = cursor; m.x = cursor;
@ -196,7 +196,11 @@ mod tests {
Monitor { Monitor {
name: name.into(), name: name.into(),
description: String::new(), description: String::new(),
active_mode: Mode { width: w, height: h, refresh: 60.0 }, active_mode: Mode {
width: w,
height: h,
refresh: 60.0,
},
x, x,
y, y,
scale: 1.0, scale: 1.0,

View file

@ -1,7 +1,9 @@
mod bread_events;
mod layout; mod layout;
mod mirror; mod mirror;
mod monitor; mod monitor;
mod profile; mod profile;
mod store;
mod ui; mod ui;
use std::io; use std::io;
@ -9,8 +11,7 @@ use std::io;
use anyhow::Result; use anyhow::Result;
use crossterm::{ use crossterm::{
event::{ event::{
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, MouseEventKind,
MouseEventKind,
}, },
execute, execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
@ -35,10 +36,15 @@ enum AppEvent {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { 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); eprintln!("Warning: could not load monitors: {}", e);
vec![] 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 // Terminal setup
enable_raw_mode()?; enable_raw_mode()?;
@ -51,7 +57,11 @@ async fn main() -> Result<()> {
// Restore terminal // Restore terminal
disable_raw_mode()?; disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?; terminal.show_cursor()?;
result result
@ -135,6 +145,7 @@ async fn run(
if let Ok(monitors) = monitor::load_monitors().await { if let Ok(monitors) = monitor::load_monitors().await {
state.monitors = monitors; state.monitors = monitors;
state.layout.clamp_selected(state.monitors.len()); state.layout.clamp_selected(state.monitors.len());
state.active_profile = None;
state.set_status("Monitor configuration changed.", StatusLevel::Info); state.set_status("Monitor configuration changed.", StatusLevel::Info);
} }
} }
@ -160,22 +171,26 @@ async fn run(
crossterm::event::KeyCode::Char('s') => { crossterm::event::KeyCode::Char('s') => {
ui::layout_view::trigger_save(&mut state); ui::layout_view::trigger_save(&mut state);
} }
crossterm::event::KeyCode::Char('r') => { crossterm::event::KeyCode::Char('r') => match monitor::load_monitors().await {
match monitor::load_monitors().await { Ok(monitors) => {
Ok(monitors) => { 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.monitors = monitors;
state.layout.clamp_selected(state.monitors.len()); state.layout.clamp_selected(state.monitors.len());
state.dirty = false; state.active_profile = None;
state.set_status("Monitors refreshed.", StatusLevel::Success); state.set_status("Monitors refreshed.", StatusLevel::Success);
} }
Err(e) => {
state.set_status(
format!("Refresh failed: {}", e),
StatusLevel::Error,
);
}
} }
} Err(e) => {
state.set_status(format!("Refresh failed: {}", e), StatusLevel::Error);
}
},
_ => { _ => {
if !ui::handle_key(key, &mut state) { if !ui::handle_key(key, &mut state) {
break; break;
@ -189,7 +204,19 @@ async fn run(
state.pending_apply = false; state.pending_apply = false;
match monitor::apply_monitors(&state.monitors).await { match monitor::apply_monitors(&state.monitors).await {
Ok(()) => { Ok(()) => {
state.set_status("Applied.", StatusLevel::Success); bread_events::emit_applied(state.active_profile.as_deref());
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) => { Err(e) => {
state.set_status(format!("Apply failed: {}", e), StatusLevel::Error); state.set_status(format!("Apply failed: {}", e), StatusLevel::Error);

View file

@ -12,7 +12,11 @@ pub struct MirrorResult {
} }
fn gcd(a: u32, b: u32) -> u32 { 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) { fn reduced_ar(w: u32, h: u32) -> (u32, u32) {
@ -35,7 +39,10 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
// Group source modes by reduced AR // Group source modes by reduced AR
let mut src_by_ar: HashMap<(u32, u32), Vec<&Mode>> = HashMap::new(); let mut src_by_ar: HashMap<(u32, u32), Vec<&Mode>> = HashMap::new();
for m in src_modes { 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)] #[derive(Debug)]
@ -53,8 +60,15 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
if src_by_ar.contains_key(&tgt_ar) { if src_by_ar.contains_key(&tgt_ar) {
// Check if we already have this exact pair // Check if we already have this exact pair
if !candidates.iter().any(|c| c.src_ar == tgt_ar && c.tgt_ar == tgt_ar && c.is_exact) { if !candidates
candidates.push(Candidate { src_ar: tgt_ar, tgt_ar, is_exact: true }); .iter()
.any(|c| c.src_ar == tgt_ar && c.tgt_ar == tgt_ar && c.is_exact)
{
candidates.push(Candidate {
src_ar: tgt_ar,
tgt_ar,
is_exact: true,
});
} }
continue; continue;
} }
@ -62,10 +76,16 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
// Approximate: check all source ARs within 5% // Approximate: check all source ARs within 5%
for &s_ar in src_by_ar.keys() { for &s_ar in src_by_ar.keys() {
let s_ratio = ratio_f64(s_ar); let s_ratio = ratio_f64(s_ar);
if (s_ratio - tgt_ratio).abs() / s_ratio < 0.05 { if (s_ratio - tgt_ratio).abs() / s_ratio < 0.05
if !candidates.iter().any(|c| c.src_ar == s_ar && c.tgt_ar == tgt_ar) { && !candidates
candidates.push(Candidate { src_ar: s_ar, tgt_ar, is_exact: false }); .iter()
} .any(|c| c.src_ar == s_ar && c.tgt_ar == tgt_ar)
{
candidates.push(Candidate {
src_ar: s_ar,
tgt_ar,
is_exact: false,
});
} }
} }
} }
@ -148,7 +168,10 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
.collect(); .collect();
let chosen_refresh = if !exact_common.is_empty() { let chosen_refresh = if !exact_common.is_empty() {
exact_common.iter().copied().fold(f64::NEG_INFINITY, f64::max) exact_common
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max)
} else { } else {
// Near-match within 1 Hz // Near-match within 1 Hz
let near: Vec<f64> = src_refreshes let near: Vec<f64> = src_refreshes
@ -165,7 +188,10 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
near.iter().copied().fold(f64::NEG_INFINITY, f64::max) near.iter().copied().fold(f64::NEG_INFINITY, f64::max)
} else { } else {
// Fallback: max source refresh // Fallback: max source refresh
src_refreshes.iter().copied().fold(f64::NEG_INFINITY, f64::max) src_refreshes
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max)
} }
}; };
@ -218,7 +244,7 @@ pub fn refresh_match_label(result: &MirrorResult) -> &'static str {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::monitor::{Transform}; use crate::monitor::Transform;
fn make_monitor_with_modes(name: &str, modes: Vec<Mode>) -> Monitor { fn make_monitor_with_modes(name: &str, modes: Vec<Mode>) -> Monitor {
let active = modes[0].clone(); let active = modes[0].clone();
@ -241,7 +267,11 @@ mod tests {
} }
fn m(w: u32, h: u32, r: f64) -> Mode { fn m(w: u32, h: u32, r: f64) -> Mode {
Mode { width: w, height: h, refresh: r } Mode {
width: w,
height: h,
refresh: r,
}
} }
#[test] #[test]

View file

@ -145,11 +145,15 @@ impl Monitor {
.collect(); .collect();
// Sort descending by pixels then refresh for consistent ordering // Sort descending by pixels then refresh for consistent ordering
modes.sort_by(|a, b| { modes.sort_by(|a, b| {
b.pixels() b.pixels().cmp(&a.pixels()).then(
.cmp(&a.pixels()) b.refresh
.then(b.refresh.partial_cmp(&a.refresh).unwrap_or(std::cmp::Ordering::Equal)) .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 { let active_mode = Mode {
width: raw.width, width: raw.width,
@ -214,8 +218,10 @@ impl Monitor {
if self.physical_width_mm == 0 || self.physical_height_mm == 0 { if self.physical_width_mm == 0 || self.physical_height_mm == 0 {
return None; return None;
} }
let diag_px = ((self.active_mode.width.pow(2) + self.active_mode.height.pow(2)) as f64).sqrt(); let diag_px =
let diag_mm = ((self.physical_width_mm.pow(2) + self.physical_height_mm.pow(2)) as f64).sqrt(); ((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)) Some(diag_px / (diag_mm / 25.4))
} }
@ -268,8 +274,10 @@ pub async fn load_monitors() -> Result<Vec<Monitor>> {
// Hyprland reports mirrorOf as a numeric ID string when using `monitors all`. // Hyprland reports mirrorOf as a numeric ID string when using `monitors all`.
// Resolve to monitor name so format_hypr_line emits the correct `mirror,<name>`. // Resolve to monitor name so format_hypr_line emits the correct `mirror,<name>`.
let id_to_name: std::collections::HashMap<String, String> = let id_to_name: std::collections::HashMap<String, String> = raw
raw.iter().map(|r| (r.id.to_string(), r.name.clone())).collect(); .iter()
.map(|r| (r.id.to_string(), r.name.clone()))
.collect();
Ok(raw Ok(raw
.into_iter() .into_iter()
@ -284,6 +292,7 @@ pub async fn load_monitors() -> Result<Vec<Monitor>> {
.collect()) .collect())
} }
#[cfg(test)]
pub fn format_hypr_line(m: &Monitor) -> String { pub fn format_hypr_line(m: &Monitor) -> String {
if let Some(src) = &m.mirror_of { if let Some(src) = &m.mirror_of {
format!( format!(
@ -444,7 +453,11 @@ mod tests {
#[test] #[test]
fn mode_compact_roundtrip() { 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 s = m.compact();
let m2 = Mode::parse(&format!("{}Hz", s)).unwrap(); let m2 = Mode::parse(&format!("{}Hz", s)).unwrap();
assert_eq!(m.width, m2.width); assert_eq!(m.width, m2.width);
@ -456,7 +469,11 @@ mod tests {
let m = Monitor { let m = Monitor {
name: "eDP-1".into(), name: "eDP-1".into(),
description: String::new(), 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, x: 0,
y: 0, y: 0,
scale: 1.0, scale: 1.0,
@ -480,7 +497,11 @@ mod tests {
let m = Monitor { let m = Monitor {
name: "HDMI-A-1".into(), name: "HDMI-A-1".into(),
description: String::new(), 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, x: 1920,
y: 0, y: 0,
scale: 1.0, scale: 1.0,

View file

@ -77,8 +77,7 @@ pub fn list() -> Result<Vec<String>> {
pub fn delete(name: &str) -> Result<()> { pub fn delete(name: &str) -> Result<()> {
let path = profiles_dir().join(format!("{}.toml", name)); let path = profiles_dir().join(format!("{}.toml", name));
std::fs::remove_file(&path) std::fs::remove_file(&path).with_context(|| format!("failed to delete profile '{}'", name))
.with_context(|| format!("failed to delete profile '{}'", name))
} }
pub fn from_monitors(name: &str, monitors: &[Monitor]) -> Profile { pub fn from_monitors(name: &str, monitors: &[Monitor]) -> Profile {
@ -108,7 +107,7 @@ pub fn from_monitors(name: &str, monitors: &[Monitor]) -> Profile {
/// Apply a profile's settings onto a list of live monitors (matched by name). /// Apply a profile's settings onto a list of live monitors (matched by name).
/// Monitors not in the profile are left unchanged. /// Monitors not in the profile are left unchanged.
pub fn apply_to_monitors(profile: &Profile, monitors: &mut Vec<Monitor>) { pub fn apply_to_monitors(profile: &Profile, monitors: &mut [Monitor]) {
for pm in &profile.monitors { for pm in &profile.monitors {
if let Some(m) = monitors.iter_mut().find(|m| m.name == pm.name) { if let Some(m) = monitors.iter_mut().find(|m| m.name == pm.name) {
if let Some(mode) = Mode::parse(&format!("{}Hz", pm.mode)) { if let Some(mode) = Mode::parse(&format!("{}Hz", pm.mode)) {
@ -130,15 +129,39 @@ pub fn apply_to_monitors(profile: &Profile, monitors: &mut Vec<Monitor>) {
} }
fn chrono_now() -> String { fn chrono_now() -> String {
// Simple ISO 8601 timestamp without pulling in chrono // In-process ISO 8601 (UTC) timestamp — no chrono crate, and no shelling
// Uses date command; falls back to a placeholder if unavailable // out to `date`. `civil_from_days` is the Hinnant days-from-civil epoch
std::process::Command::new("date") // algorithm. Falls back to the Unix epoch instant if the clock is broken.
.arg("+%Y-%m-%dT%H:%M:%SZ") let secs = std::time::SystemTime::now()
.output() .duration_since(std::time::UNIX_EPOCH)
.ok() .map(|d| d.as_secs())
.and_then(|o| String::from_utf8(o.stdout).ok()) .unwrap_or(0);
.map(|s| s.trim().to_owned()) let (h, m, s) = secs_of_day(secs % 86_400);
.unwrap_or_else(|| "unknown".to_owned()) 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)] #[cfg(test)]
@ -150,7 +173,11 @@ mod tests {
Monitor { Monitor {
name: name.into(), name: name.into(),
description: String::new(), description: String::new(),
active_mode: Mode { width: w, height: h, refresh: 60.0 }, active_mode: Mode {
width: w,
height: h,
refresh: 60.0,
},
x, x,
y, y,
scale: 1.0, scale: 1.0,
@ -181,4 +208,19 @@ mod tests {
assert_eq!(deserialized.monitors[0].mode, "1920x1200@60.00"); assert_eq!(deserialized.monitors[0].mode, "1920x1200@60.00");
assert_eq!(deserialized.monitors[1].x, 1920); 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'-');
}
} }

369
src/store.rs Normal file
View file

@ -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<String>,
}
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<MonitorRule>,
}
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<Option<MonitorsFile>> {
load_from(&config_path())
}
pub fn load_from(path: &Path) -> Result<Option<MonitorsFile>> {
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::<f64>() {
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);
}
}

View file

@ -125,32 +125,38 @@ impl ConfigState {
} }
fn prev_field(&mut self) { 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);
} }
} }
pub fn handle_key(event: KeyEvent, state: &mut AppState) { pub fn handle_key(event: KeyEvent, state: &mut AppState) {
let cfg = &mut state.config;
match event.code { match event.code {
KeyCode::Char('j') | KeyCode::Down => { KeyCode::Char('j') | KeyCode::Down => {
cfg.scale_editing = false; state.clear_burst();
cfg.next_field(); state.config.scale_editing = false;
state.config.next_field();
} }
KeyCode::Char('k') | KeyCode::Up => { KeyCode::Char('k') | KeyCode::Up => {
cfg.scale_editing = false; state.clear_burst();
cfg.prev_field(); state.config.scale_editing = false;
state.config.prev_field();
} }
KeyCode::Tab => { KeyCode::Tab => {
cfg.scale_editing = false; state.clear_burst();
cfg.next_field(); state.config.scale_editing = false;
state.config.next_field();
} }
KeyCode::BackTab => { KeyCode::BackTab => {
cfg.scale_editing = false; state.clear_burst();
cfg.prev_field(); state.config.scale_editing = false;
state.config.prev_field();
} }
// Navigate between monitors // Navigate between monitors
KeyCode::Char('[') => { KeyCode::Char('[') => {
state.clear_burst();
let count = state.monitors.len(); let count = state.monitors.len();
if count > 0 { if count > 0 {
let new_idx = state.config.monitor_idx.checked_sub(1).unwrap_or(count - 1); let new_idx = state.config.monitor_idx.checked_sub(1).unwrap_or(count - 1);
@ -159,6 +165,7 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
} }
} }
KeyCode::Char(']') => { KeyCode::Char(']') => {
state.clear_burst();
let count = state.monitors.len(); let count = state.monitors.len();
if count > 0 { if count > 0 {
let new_idx = (state.config.monitor_idx + 1) % count; let new_idx = (state.config.monitor_idx + 1) % count;
@ -173,19 +180,20 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
crate::ui::layout_view::trigger_save(state); crate::ui::layout_view::trigger_save(state);
} }
KeyCode::Esc => { KeyCode::Esc => {
state.clear_burst();
state.config.scale_editing = false; state.config.scale_editing = false;
// Re-sync from live monitor to discard pending edits // Re-sync from live monitor to discard pending edits
let idx = state.config.monitor_idx; let idx = state.config.monitor_idx;
state.config.sync_from_monitor(idx, &state.monitors); state.config.sync_from_monitor(idx, &state.monitors);
} }
KeyCode::Enter => { KeyCode::Enter => {
state.clear_burst();
if state.config.current_field() == ConfigField::Scale { if state.config.current_field() == ConfigField::Scale {
commit_scale(state); commit_scale(state);
} }
apply_current(state); apply_current(state);
} }
_ => { _ => {
state.push_undo();
handle_field_key(event, state); handle_field_key(event, state);
} }
} }
@ -211,12 +219,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
} }
} }
MouseEventKind::ScrollUp => { MouseEventKind::ScrollUp => {
state.push_undo();
let fake_right = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE); let fake_right = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE);
handle_field_key(fake_right, state); handle_field_key(fake_right, state);
} }
MouseEventKind::ScrollDown => { MouseEventKind::ScrollDown => {
state.push_undo();
let fake_left = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE); let fake_left = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE);
handle_field_key(fake_left, state); handle_field_key(fake_left, state);
} }
@ -230,6 +236,8 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
return; return;
} }
let idx = state.config.monitor_idx.min(monitors_len - 1); 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() { match state.config.current_field() {
ConfigField::Resolution => match event.code { ConfigField::Resolution => match event.code {
@ -239,17 +247,17 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
let m = &state.monitors[idx]; let m = &state.monitors[idx];
state.config.update_refreshes(m); state.config.update_refreshes(m);
sync_mode_to_monitor(state, idx); sync_mode_to_monitor(state, idx);
state.dirty = true; state.mark_dirty();
} }
} }
KeyCode::Char('l') | KeyCode::Right => { KeyCode::Char('l') | KeyCode::Right
if state.config.res_idx + 1 < state.config.resolutions.len() { if state.config.res_idx + 1 < state.config.resolutions.len() =>
state.config.res_idx += 1; {
let m = &state.monitors[idx]; state.config.res_idx += 1;
state.config.update_refreshes(m); let m = &state.monitors[idx];
sync_mode_to_monitor(state, idx); state.config.update_refreshes(m);
state.dirty = true; sync_mode_to_monitor(state, idx);
} state.mark_dirty();
} }
_ => {} _ => {}
}, },
@ -258,15 +266,15 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
if state.config.refresh_idx > 0 { if state.config.refresh_idx > 0 {
state.config.refresh_idx -= 1; state.config.refresh_idx -= 1;
sync_mode_to_monitor(state, idx); sync_mode_to_monitor(state, idx);
state.dirty = true; state.mark_dirty();
} }
} }
KeyCode::Char('l') | KeyCode::Right => { KeyCode::Char('l') | KeyCode::Right
if state.config.refresh_idx + 1 < state.config.refreshes.len() { if state.config.refresh_idx + 1 < state.config.refreshes.len() =>
state.config.refresh_idx += 1; {
sync_mode_to_monitor(state, idx); state.config.refresh_idx += 1;
state.dirty = true; sync_mode_to_monitor(state, idx);
} state.mark_dirty();
} }
_ => {} _ => {}
}, },
@ -276,14 +284,14 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
state.monitors[idx].scale = (s * 100.0).round() / 100.0; state.monitors[idx].scale = (s * 100.0).round() / 100.0;
state.monitors[idx].scale = state.monitors[idx].scale.max(0.1); state.monitors[idx].scale = state.monitors[idx].scale.max(0.1);
state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale);
state.dirty = true; state.mark_dirty();
} }
KeyCode::Char('.') => { KeyCode::Char('.') => {
let s = state.monitors[idx].scale + 0.1; let s = state.monitors[idx].scale + 0.1;
state.monitors[idx].scale = (s * 100.0).round() / 100.0; state.monitors[idx].scale = (s * 100.0).round() / 100.0;
state.monitors[idx].scale = state.monitors[idx].scale.min(10.0); state.monitors[idx].scale = state.monitors[idx].scale.min(10.0);
state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); 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 == '.' => { KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => {
state.config.scale_editing = true; state.config.scale_editing = true;
@ -303,27 +311,35 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
.checked_sub(1) .checked_sub(1)
.unwrap_or(all.len() - 1); .unwrap_or(all.len() - 1);
state.monitors[idx].transform = all[state.config.transform_idx]; state.monitors[idx].transform = all[state.config.transform_idx];
state.dirty = true; state.mark_dirty();
} }
KeyCode::Char('l') | KeyCode::Right => { KeyCode::Char('l') | KeyCode::Right => {
let all = Transform::all(); let all = Transform::all();
state.config.transform_idx = (state.config.transform_idx + 1) % all.len(); state.config.transform_idx = (state.config.transform_idx + 1) % all.len();
state.monitors[idx].transform = all[state.config.transform_idx]; state.monitors[idx].transform = all[state.config.transform_idx];
state.dirty = true; state.mark_dirty();
} }
_ => {} _ => {}
}, },
ConfigField::Vrr => match event.code { 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.monitors[idx].vrr = !state.monitors[idx].vrr;
state.dirty = true; state.mark_dirty();
} }
_ => {} _ => {}
}, },
ConfigField::Dpms => match event.code { 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.monitors[idx].dpms = !state.monitors[idx].dpms;
state.dirty = true; state.mark_dirty();
} }
_ => {} _ => {}
}, },
@ -332,15 +348,15 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
if state.config.mirror_idx > 0 { if state.config.mirror_idx > 0 {
state.config.mirror_idx -= 1; state.config.mirror_idx -= 1;
sync_mirror_to_monitor(state, idx); sync_mirror_to_monitor(state, idx);
state.dirty = true; state.mark_dirty();
} }
} }
KeyCode::Char('l') | KeyCode::Right => { KeyCode::Char('l') | KeyCode::Right
if state.config.mirror_idx + 1 < state.config.mirror_options.len() { if state.config.mirror_idx + 1 < state.config.mirror_options.len() =>
state.config.mirror_idx += 1; {
sync_mirror_to_monitor(state, idx); state.config.mirror_idx += 1;
state.dirty = true; sync_mirror_to_monitor(state, idx);
} state.mark_dirty();
} }
_ => {} _ => {}
}, },
@ -358,7 +374,11 @@ fn sync_mode_to_monitor(state: &mut AppState, idx: usize) {
} }
fn sync_mirror_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() { state.monitors[idx].mirror_of = match chosen.as_deref() {
Some("(none)") | None => None, Some("(none)") | None => None,
Some(s) => Some(s.to_owned()), Some(s) => Some(s.to_owned()),
@ -366,19 +386,25 @@ fn sync_mirror_to_monitor(state: &mut AppState, idx: usize) {
} }
fn commit_scale(state: &mut AppState) { 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::<f64>() { if let Ok(v) = state.config.scale_str.parse::<f64>() {
state.monitors[idx].scale = v.clamp(0.1, 10.0); state.monitors[idx].scale = v.clamp(0.1, 10.0);
state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale);
state.dirty = true; state.mark_dirty();
} }
state.config.scale_editing = false; state.config.scale_editing = false;
} }
fn apply_current(state: &mut AppState) { fn apply_current(state: &mut AppState) {
state.clear_burst();
state.pending_apply = true; state.pending_apply = true;
state.set_status("Applying...", StatusLevel::Info); 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) { pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
@ -405,12 +431,15 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
}; };
let header = format!(" {}{}{}", m.name, m.description, ppi_hint); let header = format!(" {}{}{}", m.name, m.description, ppi_hint);
f.render_widget( 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], chunks[0],
); );
let form_area = chunks[1]; let form_area = chunks[1];
let row_height = 1u16;
let fields = ConfigField::ALL; let fields = ConfigField::ALL;
let items: Vec<ListItem> = fields let items: Vec<ListItem> = fields
@ -432,7 +461,6 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
}) })
.collect(); .collect();
let _ = row_height; // used implicitly via ListItem heights
let list = List::new(items).block( let list = List::new(items).block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
@ -463,20 +491,31 @@ fn field_value(field: ConfigField, state: &AppState, m: &Monitor) -> String {
} }
ConfigField::Scale => { ConfigField::Scale => {
if state.config.scale_editing { 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 { } else {
format!("{} (,/. for ±0.1)", state.config.scale_str) format!("{} (,/. for ±0.1)", state.config.scale_str)
} }
} }
ConfigField::Transform => Transform::all() ConfigField::Transform => Transform::all()
[state.config.transform_idx.min(Transform::all().len() - 1)] [state.config.transform_idx.min(Transform::all().len() - 1)]
.label() .label()
.to_owned(), .to_owned(),
ConfigField::Vrr => { ConfigField::Vrr => {
if m.vrr { "ON".to_owned() } else { "OFF".to_owned() } if m.vrr {
"ON".to_owned()
} else {
"OFF".to_owned()
}
} }
ConfigField::Dpms => { 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 ConfigField::MirrorOf => state
.config .config

View file

@ -8,7 +8,10 @@ use ratatui::{
}; };
use crate::{ 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, monitor::Monitor,
ui::{AppState, DragState, StatusLevel, Tab}, ui::{AppState, DragState, StatusLevel, Tab},
}; };
@ -20,36 +23,51 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
match event.code { match event.code {
KeyCode::Char('h') | KeyCode::Left => { KeyCode::Char('h') | KeyCode::Left => {
state.push_undo(); state.micro_edit();
move_selected(&state.layout, &mut state.monitors, -step, 0); move_selected(&state.layout, &mut state.monitors, -step, 0);
state.dirty = true; state.mark_dirty();
} }
KeyCode::Char('l') | KeyCode::Right => { KeyCode::Char('l') | KeyCode::Right => {
state.push_undo(); state.micro_edit();
move_selected(&state.layout, &mut state.monitors, step, 0); move_selected(&state.layout, &mut state.monitors, step, 0);
state.dirty = true; state.mark_dirty();
} }
KeyCode::Char('k') | KeyCode::Up => { KeyCode::Char('k') | KeyCode::Up => {
state.push_undo(); state.micro_edit();
move_selected(&state.layout, &mut state.monitors, 0, -step); move_selected(&state.layout, &mut state.monitors, 0, -step);
state.dirty = true; state.mark_dirty();
} }
KeyCode::Char('j') | KeyCode::Down => { KeyCode::Char('j') | KeyCode::Down => {
state.push_undo(); state.micro_edit();
move_selected(&state.layout, &mut state.monitors, 0, step); move_selected(&state.layout, &mut state.monitors, 0, step);
state.dirty = true; state.mark_dirty();
}
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::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::Char('0') => { KeyCode::Char('0') => {
state.push_undo(); state.push_undo();
auto_arrange(&mut state.monitors); auto_arrange(&mut state.monitors);
state.dirty = true; state.mark_dirty();
} }
KeyCode::Enter => { KeyCode::Enter => {
state.config.sync_from_monitor(state.layout.selected, &state.monitors); state.clear_burst();
state
.config
.sync_from_monitor(state.layout.selected, &state.monitors);
state.tab = Tab::Config; state.tab = Tab::Config;
} }
_ => {} _ => {}
@ -66,7 +84,8 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
if let Some(idx) = monitor_at(col, row, canvas, state) { if let Some(idx) = monitor_at(col, row, canvas, state) {
let (min_x, min_y, _, _) = bounding_box(&state.monitors); let (min_x, min_y, _, _) = bounding_box(&state.monitors);
let scale = canvas_scale_for(canvas, state); 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 // Push undo at drag start, not on every move
state.push_undo(); state.push_undo();
@ -85,15 +104,22 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
let canvas = canvas_area(state.terminal_size); let canvas = canvas_area(state.terminal_size);
let (min_x, min_y, _, _) = bounding_box(&state.monitors); let (min_x, min_y, _, _) = bounding_box(&state.monitors);
let scale = canvas_scale_for(canvas, state); 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 idx = drag.monitor_idx;
let new_x = drag.origin_x + (wx - drag.click_world_x); let new_x = drag.origin_x + (wx - drag.click_world_x);
let new_y = drag.origin_y + (wy - drag.click_world_y); 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].x = sx;
state.monitors[idx].y = sy; state.monitors[idx].y = sy;
state.dirty = true; state.mark_dirty();
} }
} }
MouseEventKind::Up(MouseButton::Left) => { MouseEventKind::Up(MouseButton::Left) => {
@ -162,18 +188,31 @@ fn render_canvas(f: &mut Frame, area: Rect, state: &AppState) {
continue; 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_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 is_overlapping = overlapping[i];
let border_style = if is_dragging { 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 { } else if is_overlapping {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else if is_selected { } else if is_selected {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else { } else {
Style::default().fg(Color::Blue) Style::default().fg(Color::Blue)
}; };
@ -190,7 +229,10 @@ fn render_canvas(f: &mut Frame, area: Rect, state: &AppState) {
}) })
.border_style(border_style) .border_style(border_style)
.title(Span::styled(&label, 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); f.render_widget(block, rect);
} }
@ -203,7 +245,9 @@ fn render_readout(f: &mut Frame, area: Rect, state: &AppState) {
let idx = state.layout.selected.min(state.monitors.len() - 1); let idx = state.layout.selected.min(state.monitors.len() - 1);
let m = &state.monitors[idx]; 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)) .map(|src| format!(" mirror:{}", src))
.unwrap_or_default(); .unwrap_or_default();
@ -213,14 +257,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!( let text = format!(
" {} x:{} y:{} {}x{}@{:.0}Hz scale:{:.2}{}{}{}", " {} x:{} y:{} {}x{}@{:.0}Hz scale:{:.2}{}{}{}",
m.name, m.x, m.y, m.name,
m.active_mode.width, m.active_mode.height, m.active_mode.refresh, m.x,
m.y,
m.active_mode.width,
m.active_mode.height,
m.active_mode.refresh,
m.scale, m.scale,
mirror_info, overlap_warn, drag_hint, mirror_info,
overlap_warn,
drag_hint,
); );
f.render_widget( f.render_widget(
Paragraph::new(text).style(Style::default().fg(Color::Cyan)), Paragraph::new(text).style(Style::default().fg(Color::Cyan)),
@ -235,8 +289,10 @@ fn overlapping_monitors(monitors: &[Monitor]) -> Vec<bool> {
for j in (i + 1)..monitors.len() { for j in (i + 1)..monitors.len() {
let a = &monitors[i]; let a = &monitors[i];
let b = &monitors[j]; let b = &monitors[j];
if a.x < b.right_edge() && a.right_edge() > b.x if a.x < b.right_edge()
&& a.y < b.bottom_edge() && a.bottom_edge() > b.y && a.right_edge() > b.x
&& a.y < b.bottom_edge()
&& a.bottom_edge() > b.y
{ {
flags[i] = true; flags[i] = true;
flags[j] = true; flags[j] = true;
@ -259,9 +315,9 @@ pub fn canvas_area(terminal_size: (u16, u16)) -> Rect {
} }
fn in_canvas(col: u16, row: u16, canvas: Rect) -> bool { 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) && col < canvas.x + canvas.width.saturating_sub(1)
&& row >= canvas.y + 1 && row > canvas.y
&& row < canvas.y + canvas.height.saturating_sub(1) && row < canvas.y + canvas.height.saturating_sub(1)
} }
@ -301,31 +357,13 @@ fn monitor_at(col: u16, row: u16, canvas: Rect, state: &AppState) -> Option<usiz
} }
pub fn trigger_save(state: &mut AppState) { pub fn trigger_save(state: &mut AppState) {
use crate::monitor::format_hypr_line; match crate::store::save_from_monitors(&state.monitors) {
use std::path::PathBuf;
let path: PathBuf = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()))
.join("hypr/monitors.conf");
let is_new = !path.exists();
let mut lines = vec!["# Generated by breadmon — do not edit by hand".to_owned()];
for m in &state.monitors {
lines.push(format_hypr_line(m));
}
let content = lines.join("\n") + "\n";
match std::fs::write(&path, &content) {
Ok(()) => { Ok(()) => {
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.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), Err(e) => state.set_status(format!("Save failed: {}", e), StatusLevel::Error),
} }

View file

@ -45,7 +45,9 @@ impl MirrorState {
fn next_field(&mut self) { fn next_field(&mut self) {
// Skip Apply/Cancel if no result yet // Skip Apply/Cancel if no result yet
let mut next = (self.focused + 1) % FIELDS.len(); 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; next = 0;
} }
self.focused = next; self.focused = next;
@ -54,8 +56,13 @@ impl MirrorState {
fn prev_field(&mut self) { fn prev_field(&mut self) {
let len = FIELDS.len(); let len = FIELDS.len();
let mut prev = self.focused.checked_sub(1).unwrap_or(len - 1); 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) { if self.result.is_none()
prev = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2); && (FIELDS[prev] == MirrorField::Apply || FIELDS[prev] == MirrorField::Cancel)
{
prev = FIELDS
.iter()
.position(|&f| f == MirrorField::Compute)
.unwrap_or(2);
} }
self.focused = prev; 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() { KeyCode::Char('h') | KeyCode::Left => match state.mirror.current_field() {
MirrorField::Source => { 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.fix_indices(count);
state.mirror.result = None; state.mirror.result = None;
} }
MirrorField::Target => { 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.fix_indices(count);
state.mirror.result = None; state.mirror.result = None;
} }
@ -118,7 +127,10 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
Some(result) => { Some(result) => {
state.mirror.result = Some(result); state.mirror.result = Some(result);
// Move focus to Apply // 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 => { None => {
state.set_status( state.set_status(
@ -137,15 +149,13 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
state.monitors[tgt_idx].active_mode = result.mirror_mode.clone(); state.monitors[tgt_idx].active_mode = result.mirror_mode.clone();
state.monitors[tgt_idx].mirror_of = Some(src_name.clone()); state.monitors[tgt_idx].mirror_of = Some(src_name.clone());
state.dirty = true; state.mark_dirty();
state.mirror.result = None; state.mirror.result = None;
state.mirror.focused = 0; state.mirror.focused = 0;
state.set_status( state.set_status(
format!( format!(
"Mirror set: {} → {} at {}", "Mirror set: {} → {} at {}",
src_name, src_name, state.monitors[tgt_idx].name, result.mirror_mode
state.monitors[tgt_idx].name,
result.mirror_mode
), ),
StatusLevel::Success, StatusLevel::Success,
); );
@ -182,17 +192,26 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
match row { match row {
2 | 3 => { 2 | 3 => {
// Source picker area // 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; state.mirror.focused = f_idx;
} }
4 | 5 => { 4 | 5 => {
// Target picker area // 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; state.mirror.focused = f_idx;
} }
6 => { 6 => {
// Compute button // 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; state.mirror.focused = f_idx;
// Also activate it // Also activate it
let src = &state.monitors[state.mirror.source_idx]; 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) { match crate::mirror::find_mirror_modes(src, tgt) {
Some(result) => { Some(result) => {
state.mirror.result = 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 => { None => {
state.set_status( state.set_status(
@ -210,33 +232,39 @@ 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. // Result panel: Apply is on the line with buttons.
// Rough column check: col < 20 = Apply, col >= 20 = Cancel // Rough column check: col < 20 = Apply, col >= 20 = Cancel
if state.mirror.result.is_some() { && state.mirror.result.is_some() =>
let col = event.column; {
if col < 20 { let col = event.column;
// Activate Apply if col < 20 {
state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); // Activate Apply
if let Some(result) = state.mirror.result.clone() { state.mirror.focused = FIELDS
state.push_undo(); .iter()
let src_name = state.monitors[state.mirror.source_idx].name.clone(); .position(|&f| f == MirrorField::Apply)
let tgt_idx = state.mirror.target_idx; .unwrap_or(3);
state.monitors[tgt_idx].active_mode = result.mirror_mode.clone(); if let Some(result) = state.mirror.result.clone() {
state.monitors[tgt_idx].mirror_of = Some(src_name.clone()); state.push_undo();
state.dirty = true; let src_name = state.monitors[state.mirror.source_idx].name.clone();
state.mirror.result = None; let tgt_idx = state.mirror.target_idx;
state.mirror.focused = 0; state.monitors[tgt_idx].active_mode = result.mirror_mode.clone();
state.set_status( state.monitors[tgt_idx].mirror_of = Some(src_name.clone());
format!("Mirror set: {}{} at {}", src_name, state.monitors[tgt_idx].name, result.mirror_mode), state.mark_dirty();
crate::ui::StatusLevel::Success,
);
}
} else {
// Cancel
state.mirror.result = None; state.mirror.result = None;
state.mirror.focused = 0; 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;
} }
} }
_ => {} _ => {}
@ -246,33 +274,33 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
// Scroll in source/target pickers to cycle monitors // Scroll in source/target pickers to cycle monitors
match state.mirror.current_field() { match state.mirror.current_field() {
MirrorField::Source => { 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.fix_indices(count);
state.mirror.result = None; state.mirror.result = None;
} }
MirrorField::Target => { 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.fix_indices(count);
state.mirror.result = None; state.mirror.result = None;
} }
_ => {} _ => {}
} }
} }
MouseEventKind::ScrollDown => { MouseEventKind::ScrollDown => match state.mirror.current_field() {
match state.mirror.current_field() { MirrorField::Source => {
MirrorField::Source => { state.mirror.source_idx = (state.mirror.source_idx + 1) % count;
state.mirror.source_idx = (state.mirror.source_idx + 1) % count; state.mirror.fix_indices(count);
state.mirror.fix_indices(count); state.mirror.result = None;
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;
}
_ => {}
} }
} 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 focused = state.mirror.current_field();
let src_style = if focused == MirrorField::Source { 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 { } else {
Style::default().fg(Color::White) Style::default().fg(Color::White)
}; };
let tgt_style = if focused == MirrorField::Target { 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 { } else {
Style::default().fg(Color::White) 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) { fn render_compute_btn(f: &mut Frame, area: Rect, state: &AppState) {
let focused = state.mirror.current_field() == MirrorField::Compute; let focused = state.mirror.current_field() == MirrorField::Compute;
let style = if focused { let style = if focused {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else { } else {
Style::default().fg(Color::DarkGray) 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 focused = state.mirror.current_field();
let apply_style = if focused == MirrorField::Apply { 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 { } else {
Style::default().fg(Color::White) Style::default().fg(Color::White)
}; };
let cancel_style = if focused == MirrorField::Cancel { 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 { } else {
Style::default().fg(Color::DarkGray) 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), Style::default().fg(Color::White),
)), )),
Line::from(Span::styled( 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), Style::default().fg(Color::White),
)), )),
Line::raw(""), Line::raw(""),

View file

@ -14,10 +14,7 @@ use ratatui::{
Frame, Frame,
}; };
use crate::{ use crate::{layout::LayoutState, monitor::Monitor};
layout::LayoutState,
monitor::Monitor,
};
use config_view::ConfigState; use config_view::ConfigState;
use mirror_view::MirrorState; use mirror_view::MirrorState;
@ -111,8 +108,15 @@ pub struct AppState {
pub terminal_size: (u16, u16), pub terminal_size: (u16, u16),
/// Set to true by any handler that wants `main.rs` to run `apply_monitors`. /// Set to true by any handler that wants `main.rs` to run `apply_monitors`.
pub pending_apply: bool, 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<String>,
/// Snapshots for Ctrl+Z undo (up to 20 deep). /// Snapshots for Ctrl+Z undo (up to 20 deep).
pub undo_stack: Vec<Vec<Monitor>>, pub undo_stack: Vec<Vec<Monitor>>,
/// 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 { impl AppState {
@ -131,12 +135,26 @@ impl AppState {
drag_state: None, drag_state: None,
terminal_size, terminal_size,
pending_apply: false, pending_apply: false,
active_profile: None,
undo_stack: Vec::new(), undo_stack: Vec::new(),
undo_in_burst: false,
} }
} }
pub fn set_status(&mut self, text: impl Into<String>, level: StatusLevel) { pub fn set_status(&mut self, text: impl Into<String>, 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`
/// — 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) { pub fn tick_status(&mut self) {
@ -148,14 +166,40 @@ impl AppState {
} }
pub fn switch_tab(&mut self, tab: Tab) { pub fn switch_tab(&mut self, tab: Tab) {
self.undo_in_burst = false;
self.tab = tab; self.tab = tab;
if tab == Tab::Config { if tab == Tab::Config {
self.config.sync_from_monitor(self.layout.selected, &self.monitors); self.config
.sync_from_monitor(self.layout.selected, &self.monitors);
} }
} }
/// 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) { 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()); self.undo_stack.push(self.monitors.clone());
if self.undo_stack.len() > 20 { if self.undo_stack.len() > 20 {
self.undo_stack.remove(0); self.undo_stack.remove(0);
@ -166,7 +210,8 @@ impl AppState {
pub fn undo(&mut self) { pub fn undo(&mut self) {
if let Some(snapshot) = self.undo_stack.pop() { if let Some(snapshot) = self.undo_stack.pop() {
self.monitors = snapshot; self.monitors = snapshot;
self.dirty = true; self.undo_in_burst = false;
self.mark_dirty();
self.layout.clamp_selected(self.monitors.len()); self.layout.clamp_selected(self.monitors.len());
// Re-sync config view to the restored state // Re-sync config view to the restored state
let idx = self.layout.selected; let idx = self.layout.selected;
@ -201,10 +246,22 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) -> bool {
// Global tab switching // Global tab switching
match event.code { match event.code {
KeyCode::Char('1') | KeyCode::F(1) => { state.switch_tab(Tab::Layout); return true; } KeyCode::Char('1') | KeyCode::F(1) => {
KeyCode::Char('2') | KeyCode::F(2) => { state.switch_tab(Tab::Config); return true; } state.switch_tab(Tab::Layout);
KeyCode::Char('3') | KeyCode::F(3) => { state.switch_tab(Tab::Mirror); return true; } return true;
KeyCode::Char('4') | KeyCode::F(4) => { state.switch_tab(Tab::Profiles); 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;
}
_ => {} _ => {}
} }

View file

@ -126,7 +126,10 @@ fn handle_list_key(event: KeyEvent, state: &mut AppState) {
match profile::delete(&name) { match profile::delete(&name) {
Ok(()) => { Ok(()) => {
state.profiles.refresh(); state.profiles.refresh();
state.set_status(format!("Deleted profile '{}'", name), StatusLevel::Success); state.set_status(
format!("Deleted profile '{}'", name),
StatusLevel::Success,
);
} }
Err(e) => { Err(e) => {
state.set_status(format!("Delete failed: {}", e), StatusLevel::Error); state.set_status(format!("Delete failed: {}", e), StatusLevel::Error);
@ -195,8 +198,11 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
MouseEventKind::ScrollUp => { MouseEventKind::ScrollUp => {
let count = state.profiles.profiles.len(); let count = state.profiles.profiles.len();
if count > 0 { if count > 0 {
state.profiles.selected_idx = state.profiles.selected_idx = state
state.profiles.selected_idx.checked_sub(1).unwrap_or(count - 1); .profiles
.selected_idx
.checked_sub(1)
.unwrap_or(count - 1);
state.profiles.focused = ProfileField::List; state.profiles.focused = ProfileField::List;
} }
} }
@ -240,6 +246,7 @@ fn do_save(state: &mut AppState) {
Ok(()) => { Ok(()) => {
state.profiles.new_name.clear(); state.profiles.new_name.clear();
state.profiles.refresh(); state.profiles.refresh();
state.active_profile = Some(name.clone());
state.set_status(format!("Saved profile '{}'", name), StatusLevel::Success); state.set_status(format!("Saved profile '{}'", name), StatusLevel::Success);
} }
Err(e) => { Err(e) => {
@ -254,6 +261,7 @@ fn do_load(state: &mut AppState) {
Ok(p) => { Ok(p) => {
profile::apply_to_monitors(&p, &mut state.monitors); profile::apply_to_monitors(&p, &mut state.monitors);
state.dirty = true; state.dirty = true;
state.active_profile = Some(name.clone());
state.set_status( state.set_status(
format!("Loaded profile '{}'. Press [a] to apply.", name), format!("Loaded profile '{}'. Press [a] to apply.", name),
StatusLevel::Success, StatusLevel::Success,
@ -306,7 +314,9 @@ fn render_list(f: &mut Frame, area: Rect, state: &AppState) {
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
.bg(Color::DarkGray) .bg(Color::DarkGray)
} else if is_selected { } else if is_selected {
Style::default().fg(Color::White).add_modifier(Modifier::BOLD) Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else { } else {
Style::default().fg(Color::White) Style::default().fg(Color::White)
}; };
@ -360,22 +370,28 @@ fn render_save_row(f: &mut Frame, area: Rect, state: &AppState) {
Style::default().fg(Color::DarkGray) Style::default().fg(Color::DarkGray)
}; };
f.render_widget( f.render_widget(
Paragraph::new(input_display) Paragraph::new(input_display).style(input_style).block(
.style(input_style) Block::default()
.block(Block::default().borders(Borders::ALL).border_style(input_style)), .borders(Borders::ALL)
.border_style(input_style),
),
chunks[0], chunks[0],
); );
// Save button // Save button
let save_style = if save_focused { 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 { } else {
Style::default().fg(Color::DarkGray) Style::default().fg(Color::DarkGray)
}; };
f.render_widget( f.render_widget(
Paragraph::new(" [ Save ] ") Paragraph::new(" [ Save ] ").style(save_style).block(
.style(save_style) Block::default()
.block(Block::default().borders(Borders::ALL).border_style(save_style)), .borders(Borders::ALL)
.border_style(save_style),
),
chunks[1], chunks[1],
); );
} }