diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml new file mode 100644 index 0000000..b547c34 --- /dev/null +++ b/.forgejo/workflows/check.yml @@ -0,0 +1,24 @@ +name: check + +# Fast-fail lint/test on short-lived work branches, before it ever reaches +# main and triggers a dev-track release build. +on: + push: + branches: ['feature/**', 'fix/**'] + +jobs: + check: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: clippy + run: cd src && bash ci/build.sh cargo clippy --workspace --all-targets --locked -- -D warnings + + - name: test + run: cd src && bash ci/build.sh cargo test --workspace --locked diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml new file mode 100644 index 0000000..3087715 --- /dev/null +++ b/.forgejo/workflows/dev-release.yml @@ -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}" diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index 7603cc8..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Mirror to GitHub - -on: - push: - branches: ['**'] - tags: ['**'] - -jobs: - mirror: - runs-on: [self-hosted, hestia] - steps: - - name: Mirror to GitHub - run: | - set -euo pipefail - git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git - cd repo.git - git push --prune \ - "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadmon.git" \ - '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml new file mode 100644 index 0000000..fd6ba9a --- /dev/null +++ b/.forgejo/workflows/rc-release.yml @@ -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}" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 7903d69..7286868 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,6 +6,7 @@ on: jobs: build: + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout @@ -16,7 +17,16 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - 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 run: | @@ -32,8 +42,14 @@ jobs: ln -sfn "${VERSION}" "/srv/breadway-dl/breadmon/latest" - name: regenerate index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} run: | set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)" + exit 1 + fi rm -rf /tmp/bread-ecosystem-ci git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh diff --git a/.gitignore b/.gitignore index 36f7f5b..c3b4cc4 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,12 @@ logs/ # Runtime files *.sock *.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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e911ad9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# AGENTS.md — Repo hygiene + +This repo follows the branch/release workflow documented in `CONTRIBUTING.md` +— read and follow it for any git, branch, or release work here (the +single-trunk `main` model, `feature/x`/`fix/x` branch naming, RC-tag-driven +beta releases, etc). Don't improvise a different workflow. The short version: +there is one long-lived branch, `main` — no `dev` or `beta` branch exists. +`main` auto-publishes a dev-track build on every push. "Beta" and "stable" +are both just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a +beta-track build, push a plain `vX.Y.Z` tag to cut the signed stable +release. "Freezing" for stabilization means pausing pushes to `main`, not +moving a branch. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push both when publishing. + +## CI +- `check.yml` — clippy + test, triggers on push to `feature/**`/`fix/**`. +- `dev-release.yml` — triggers on push to `main`. +- `rc-release.yml` — triggers on `vX.Y.Z-rc.N` tag push. +- `release.yml` — triggers on any other `v*` tag push. + +All four run on a self-hosted runner (`hestia`) inside a pinned Arch +container — not the host's native environment. The Containerfile/build +script are shared across bread-ecosystem products and live in +`bread-ecosystem/ci/`; this repo's `ci/build.sh` clones that repo at the +sha in `ci/bread-ecosystem.rev` (deliberately pinned, not `main`) and +delegates to it. Nothing runs automatically on plain commits or PRs +beyond what's listed. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..370bfa2 --- /dev/null +++ b/CONTRIBUTING.md @@ -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/ +fix/ +``` + +Branch off `main`, open a PR/push back into `main` when ready. Short-lived +branches get deleted on merge — they never accumulate the kind of drift a +second long-lived branch does. + +## The release cycle + +There's no separate `beta` or release branch — "stable" and "beta" are both +just **tags** on `main`, not branches that need to be kept in sync: + +1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push + auto-publishes a dev build — install it with `bakery track set dev` and + `bakery update --all`, then fix anything broken with another push. +2. When you want to stabilize before a real release, tag a release + candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to + both remotes). That tag alone triggers a beta-track build — + "freezing" is just pausing pushes to `main` while you test it, not a + branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes. +3. Once an RC has gone without issues, tag the real release: + `git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the + signed stable release build. + +## Tracks, from a user's perspective + +``` +bakery track show # what you're currently on (defaults to stable) +bakery track set dev # or beta, or stable +bakery update --all # pull the latest build on your current track +``` + +| Track | What it is | Published from | +|--------|-----------|-----------------| +| `stable` | The last tagged release | a `vX.Y.Z` tag | +| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag | +| `dev` | Bleeding edge | `main`, on every push | + +Dev versions are auto-computed (`X.Y.Z-dev.+`) from the +latest published stable tag, so they always sort as newer than what you +have installed — no manual version bumping needed. Beta versions are just +the RC tag itself (already valid semver, already sorts below the real +release it's a candidate for). + +## Local development + +```sh +cargo build --release +cargo test --release +``` + +## CI + +- `dev-release.yml` — triggered on push to `main`. +- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push. +- `release.yml` — triggered on any other `v*` tag push, cuts the actual + stable release. + +All CI runs on a self-hosted runner; nothing runs automatically on plain +commits or PRs beyond the track builds above. See +[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md) +for the full policy, including how a new product gets wired onto these tracks. + +## Questions + +Open an issue on this repo's Forgejo tracker. diff --git a/Cargo.lock b/Cargo.lock index 67a5c2c..b5818f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,18 +21,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] -name = "bread-utils" -version = "0.3.0" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" +name = "bread-shared" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" dependencies = [ "dirs", "serde", "serde_json", + "toml", +] + +[[package]] +name = "bread-utils" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +dependencies = [ + "bread-shared", + "dirs", + "serde", + "serde_json", ] [[package]] name = "breadmon" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "bread-utils", diff --git a/Cargo.toml b/Cargo.toml index 6cc904c..bc93db7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadmon" -version = "0.1.2" +version = "0.1.3" edition = "2021" description = "TUI monitor manager for Hyprland" license = "MIT" @@ -19,8 +19,7 @@ toml = "0.8" anyhow = "1" dirs = "5" futures = "0.3" -# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } [profile.release] lto = "thin" diff --git a/EVENTS.md b/EVENTS.md new file mode 100644 index 0000000..b854967 --- /dev/null +++ b/EVENTS.md @@ -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": }` | After `hyprctl eval 'hl.monitor({...})'` succeeds. `profile` is the named snapshot that was just applied (the last loaded or saved profile this session, if the layout was not edited after that), or `null` for an ad-hoc layout. Not emitted when apply fails. | + +## Commands honored (`bread.command.mon.*`) + +None. breadmon is an interactive TUI, not a long-running daemon — a +command subscription would only be live while the TUI is open, which is +a poor control surface. Apply, load, and save stay keyboard-driven. +If/when breadmon grows a headless apply path, the corresponding +`bread.command.mon.apply` verb should be added at the same time, not +stubbed out ahead of it. + +## Fail-safe behavior + +- If breadd isn't installed or isn't running, `emit` is a silent no-op + (`BreadClient::emit` never blocks or errors the caller) — breadmon's + actual apply / profile / TUI functionality is entirely unaffected. +- There is no command subscription, so a breadd restart has nothing to + reconnect. diff --git a/README.md b/README.md index a0d2396..ccddd7d 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,24 @@ # breadmon -A terminal UI monitor manager for Hyprland. Lets you position, configure, and mirror displays interactively, then apply changes live via `hyprctl`. +A terminal UI monitor manager for Hyprland. Lets you position, configure, and mirror displays interactively, then apply a live layout on [BOS](https://git.breadway.dev/breadway/bos)-patched Hyprland. + +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 -- **[BOS (Bread OS)](https://git.breadway.dev/breadway/bos)'s patched Hyprland build.** Applying changes (the `a` key / Global keys "Apply") runs `hyprctl eval` with a `hl.monitor({...})` Lua call — a BOS-specific extension that does not exist on vanilla/upstream Hyprland. On a non-BOS Hyprland install, `hyprctl eval` itself is not a recognized request, and breadmon will fail to apply with an explicit error explaining this instead of the raw hyprctl response. Everything else in the TUI (viewing/arranging/saving profiles) works regardless; only the live-apply step needs BOS. +- **[BOS (Bread OS)](https://git.breadway.dev/breadway/bos)'s patched Hyprland build** for live apply. The `a` key runs `hyprctl eval 'hl.monitor({...})'` — a BOS-specific Lua extension. Vanilla/upstream Hyprland has no `eval` request and no `hl.monitor()`, so apply will fail there with an explicit error instead of the raw hyprctl response. Viewing, arranging, and saving profiles work on any Hyprland; only live apply needs BOS. - The `hyprctl` binary must be on `PATH` - Rust toolchain (to build from source) -## Build +## Install + +Via [bakery](https://git.breadway.dev/Breadway/bread-ecosystem), the bread ecosystem package manager: + +``` +bakery install breadmon +``` + +Or build from source: ``` cargo build --release @@ -16,12 +26,6 @@ cargo build --release The binary is written to `target/release/breadmon`. -If you use the bread ecosystem, `bakery` can install it instead: - -``` -bread modules install /path/to/breadmon -``` - ## Usage ``` @@ -76,7 +80,7 @@ Finds the best common mode between two monitors and sets one to mirror the other ### Profiles -Named snapshots of the current monitor configuration, stored as TOML files. +Named snapshots of the current monitor configuration, stored as TOML files. Loading a profile updates the TUI; applying it (`a`) also writes `monitors.json`. | Key | Action | |-----|--------| @@ -91,8 +95,8 @@ Profiles are saved to `~/.config/breadmon/profiles/`. | Key | Action | |-----|--------| -| `a` | Apply current configuration via `hyprctl` | -| `s` | Save current configuration as a profile | +| `a` | Apply current configuration via `hyprctl eval 'hl.monitor({...})'` (BOS-patched Hyprland only) and write `~/.config/hypr/monitors.json` | +| `s` | Write `~/.config/hypr/monitors.json` without a live apply | | `r` | Refresh monitor list from Hyprland | | `Ctrl+Z` | Undo last change (up to 20 steps) | | `q` / `Ctrl+C` | Quit (prompts once if there are unsaved changes) | @@ -101,4 +105,20 @@ breadmon also listens on Hyprland's event socket and reloads the monitor list au ## Config -Profiles are plain TOML files under `~/.config/breadmon/profiles/`. Each file records the monitor name, mode, position, scale, transform, VRR, DPMS, and mirror source. They are created and managed through the Profiles tab; there is no hand-written config file. +**Shared store:** `~/.config/hypr/monitors.json` — the same file the bos-settings Display panel edits and Hyprland applies on login/reload. breadmon is the TUI; Display is the GUI. Schema: + +```json +{ + "monitors": [ + { "output": "", "mode": "preferred", "position": "auto", "scale": "auto", "mirror": "" } + ] +} +``` + +Empty `output` is the wildcard default (any connector). breadmon loads this file on start (overlaid onto the live `hyprctl` list) and writes it — pretty JSON — after a successful apply, and when you press `s`. + +**Named snapshots:** plain TOML under `~/.config/breadmon/profiles/`. Each file records the monitor name, mode, position, scale, transform, VRR, DPMS, and mirror source. They are created and managed through the Profiles tab. Applying a profile writes `monitors.json` so Hyprland and Display stay in sync. + +## bread event integration + +breadmon works the same with or without `breadd`. After a successful live apply (`hyprctl eval 'hl.monitor({...})'` on BOS-patched Hyprland — not the `bos-settings` Display panel), it publishes `bread.mon.applied`. If breadd is down, the emit is a silent no-op; apply itself is unchanged. See [EVENTS.md](EVENTS.md) for the bus contract. `bread` is not a bakery dependency. diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev new file mode 100644 index 0000000..474f1fd --- /dev/null +++ b/ci/bread-ecosystem.rev @@ -0,0 +1 @@ +620c5a1317a6b57276eabca961facdb78bf510db diff --git a/ci/build.sh b/ci/build.sh new file mode 100755 index 0000000..7582664 --- /dev/null +++ b/ci/build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Delegates to bread-ecosystem's shared CI build image/script, pinned to +# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI +# files now affect every product's release pipeline, so bumping the pin +# is a deliberate act instead of silent drift. +# +# Usage: ci/build.sh cargo build --release --locked +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")" + +CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}" +if [ ! -d "$CACHE_DIR" ]; then + rm -rf /tmp/bread-ecosystem-ci-* + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR" + git -C "$CACHE_DIR" checkout --quiet "$REV" +fi + +bash "${CACHE_DIR}/ci/build.sh" breadmon "$ROOT" "$@" diff --git a/src/bread_events.rs b/src/bread_events.rs new file mode 100644 index 0000000..7197765 --- /dev/null +++ b/src/bread_events.rs @@ -0,0 +1,43 @@ +//! `bread.mon.*` event integration — optional, non-blocking. See +//! `EVENTS.md` at the repo root for the full contract. breadmon works +//! identically with or without breadd running; every call here is +//! fire-and-forget (`BreadClient::emit` never blocks or errors this +//! process) so a missing or restarting breadd never affects apply itself. + +use bread_utils::bread_client::BreadClient; +use serde_json::{json, Value}; + +/// This app's id in bread's sibling-app namespace registry +/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.mon.*`. +pub const APP_ID: &str = "mon"; + +/// JSON payload for `bread.mon.applied`. `profile` is the named snapshot +/// that was just applied, or `null` for an ad-hoc layout. +pub fn applied_data(profile: Option<&str>) -> Value { + json!({ "profile": profile }) +} + +/// Publishes `bread.mon.applied` after a successful hyprctl apply. +/// Fire-and-forget and non-fatal by design — breadd being absent or not +/// installed must never affect breadmon's own apply path. +pub fn emit_applied(profile: Option<&str>) { + BreadClient::connect(APP_ID).emit("bread.mon.applied", applied_data(profile)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn applied_data_serializes_name_or_null() { + assert_eq!(applied_data(Some("dock")), json!({ "profile": "dock" })); + assert_eq!(applied_data(None), json!({ "profile": null })); + } + + #[test] + fn emit_applied_is_silent_when_breadd_is_down() { + // No daemon in the unit-test environment; must not panic or block. + emit_applied(Some("dock")); + emit_applied(None); + } +} diff --git a/src/layout.rs b/src/layout.rs index c19923a..9789c2a 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -103,7 +103,7 @@ pub fn snap_position( } /// Move the selected monitor by (dx, dy) pixels, then snap. -pub fn move_selected(state: &LayoutState, monitors: &mut Vec, dx: i32, dy: i32) { +pub fn move_selected(state: &LayoutState, monitors: &mut [Monitor], dx: i32, dy: i32) { let idx = state.selected; if idx >= monitors.len() { return; @@ -116,7 +116,7 @@ pub fn move_selected(state: &LayoutState, monitors: &mut Vec, dx: i32, } /// Place monitors in a left-to-right row with no gaps. -pub fn auto_arrange(monitors: &mut Vec) { +pub fn auto_arrange(monitors: &mut [Monitor]) { let mut cursor = 0i32; for m in monitors.iter_mut() { m.x = cursor; @@ -196,7 +196,11 @@ mod tests { Monitor { name: name.into(), description: String::new(), - active_mode: Mode { width: w, height: h, refresh: 60.0 }, + active_mode: Mode { + width: w, + height: h, + refresh: 60.0, + }, x, y, scale: 1.0, diff --git a/src/main.rs b/src/main.rs index 5343ef1..7cc5a23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,9 @@ +mod bread_events; mod layout; mod mirror; mod monitor; mod profile; +mod store; mod ui; use std::io; @@ -9,8 +11,7 @@ use std::io; use anyhow::Result; use crossterm::{ event::{ - DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, - MouseEventKind, + DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, MouseEventKind, }, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, @@ -35,10 +36,15 @@ enum AppEvent { #[tokio::main] async fn main() -> Result<()> { - let monitors = monitor::load_monitors().await.unwrap_or_else(|e| { + let mut monitors = monitor::load_monitors().await.unwrap_or_else(|e| { eprintln!("Warning: could not load monitors: {}", e); vec![] }); + match store::load() { + Ok(Some(file)) => store::apply_to_monitors(&file, &mut monitors), + Ok(None) => {} + Err(e) => eprintln!("Warning: could not load monitors.json: {}", e), + } // Terminal setup enable_raw_mode()?; @@ -51,7 +57,11 @@ async fn main() -> Result<()> { // Restore terminal disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; terminal.show_cursor()?; result @@ -135,6 +145,7 @@ async fn run( if let Ok(monitors) = monitor::load_monitors().await { state.monitors = monitors; state.layout.clamp_selected(state.monitors.len()); + state.active_profile = None; state.set_status("Monitor configuration changed.", StatusLevel::Info); } } @@ -160,22 +171,26 @@ async fn run( crossterm::event::KeyCode::Char('s') => { ui::layout_view::trigger_save(&mut state); } - crossterm::event::KeyCode::Char('r') => { - match monitor::load_monitors().await { - Ok(monitors) => { + crossterm::event::KeyCode::Char('r') => match monitor::load_monitors().await { + 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.layout.clamp_selected(state.monitors.len()); - state.dirty = false; + state.active_profile = None; state.set_status("Monitors refreshed.", StatusLevel::Success); } - Err(e) => { - state.set_status( - format!("Refresh failed: {}", e), - StatusLevel::Error, - ); - } } - } + Err(e) => { + state.set_status(format!("Refresh failed: {}", e), StatusLevel::Error); + } + }, _ => { if !ui::handle_key(key, &mut state) { break; @@ -189,7 +204,19 @@ async fn run( state.pending_apply = false; match monitor::apply_monitors(&state.monitors).await { 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) => { state.set_status(format!("Apply failed: {}", e), StatusLevel::Error); diff --git a/src/mirror.rs b/src/mirror.rs index 6485a58..124e598 100644 --- a/src/mirror.rs +++ b/src/mirror.rs @@ -12,7 +12,11 @@ pub struct MirrorResult { } fn gcd(a: u32, b: u32) -> u32 { - if b == 0 { a } else { gcd(b, a % b) } + if b == 0 { + a + } else { + gcd(b, a % b) + } } fn reduced_ar(w: u32, h: u32) -> (u32, u32) { @@ -35,7 +39,10 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option> = HashMap::new(); for m in src_modes { - src_by_ar.entry(reduced_ar(m.width, m.height)).or_default().push(m); + src_by_ar + .entry(reduced_ar(m.width, m.height)) + .or_default() + .push(m); } #[derive(Debug)] @@ -53,8 +60,15 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option Option Option = src_refreshes @@ -165,7 +188,10 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option &'static str { #[cfg(test)] mod tests { use super::*; - use crate::monitor::{Transform}; + use crate::monitor::Transform; fn make_monitor_with_modes(name: &str, modes: Vec) -> Monitor { let active = modes[0].clone(); @@ -241,7 +267,11 @@ mod tests { } fn m(w: u32, h: u32, r: f64) -> Mode { - Mode { width: w, height: h, refresh: r } + Mode { + width: w, + height: h, + refresh: r, + } } #[test] diff --git a/src/monitor.rs b/src/monitor.rs index 37f3128..fab2d5b 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -145,11 +145,15 @@ impl Monitor { .collect(); // Sort descending by pixels then refresh for consistent ordering modes.sort_by(|a, b| { - b.pixels() - .cmp(&a.pixels()) - .then(b.refresh.partial_cmp(&a.refresh).unwrap_or(std::cmp::Ordering::Equal)) + b.pixels().cmp(&a.pixels()).then( + b.refresh + .partial_cmp(&a.refresh) + .unwrap_or(std::cmp::Ordering::Equal), + ) + }); + modes.dedup_by(|a, b| { + a.width == b.width && a.height == b.height && (a.refresh - b.refresh).abs() < 0.01 }); - modes.dedup_by(|a, b| a.width == b.width && a.height == b.height && (a.refresh - b.refresh).abs() < 0.01); let active_mode = Mode { width: raw.width, @@ -214,8 +218,10 @@ impl Monitor { if self.physical_width_mm == 0 || self.physical_height_mm == 0 { return None; } - let diag_px = ((self.active_mode.width.pow(2) + self.active_mode.height.pow(2)) as f64).sqrt(); - let diag_mm = ((self.physical_width_mm.pow(2) + self.physical_height_mm.pow(2)) as f64).sqrt(); + let diag_px = + ((self.active_mode.width.pow(2) + self.active_mode.height.pow(2)) as f64).sqrt(); + let diag_mm = + ((self.physical_width_mm.pow(2) + self.physical_height_mm.pow(2)) as f64).sqrt(); Some(diag_px / (diag_mm / 25.4)) } @@ -268,8 +274,10 @@ pub async fn load_monitors() -> Result> { // Hyprland reports mirrorOf as a numeric ID string when using `monitors all`. // Resolve to monitor name so format_hypr_line emits the correct `mirror,`. - let id_to_name: std::collections::HashMap = - raw.iter().map(|r| (r.id.to_string(), r.name.clone())).collect(); + let id_to_name: std::collections::HashMap = raw + .iter() + .map(|r| (r.id.to_string(), r.name.clone())) + .collect(); Ok(raw .into_iter() @@ -284,6 +292,7 @@ pub async fn load_monitors() -> Result> { .collect()) } +#[cfg(test)] pub fn format_hypr_line(m: &Monitor) -> String { if let Some(src) = &m.mirror_of { format!( @@ -444,7 +453,11 @@ mod tests { #[test] fn mode_compact_roundtrip() { - let m = Mode { width: 1920, height: 1080, refresh: 60.0 }; + let m = Mode { + width: 1920, + height: 1080, + refresh: 60.0, + }; let s = m.compact(); let m2 = Mode::parse(&format!("{}Hz", s)).unwrap(); assert_eq!(m.width, m2.width); @@ -456,7 +469,11 @@ mod tests { let m = Monitor { name: "eDP-1".into(), description: String::new(), - active_mode: Mode { width: 1920, height: 1200, refresh: 60.0 }, + active_mode: Mode { + width: 1920, + height: 1200, + refresh: 60.0, + }, x: 0, y: 0, scale: 1.0, @@ -480,7 +497,11 @@ mod tests { let m = Monitor { name: "HDMI-A-1".into(), description: String::new(), - active_mode: Mode { width: 1920, height: 1080, refresh: 60.0 }, + active_mode: Mode { + width: 1920, + height: 1080, + refresh: 60.0, + }, x: 1920, y: 0, scale: 1.0, diff --git a/src/profile.rs b/src/profile.rs index b108b2e..ad26ad1 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -77,8 +77,7 @@ pub fn list() -> Result> { pub fn delete(name: &str) -> Result<()> { let path = profiles_dir().join(format!("{}.toml", name)); - std::fs::remove_file(&path) - .with_context(|| format!("failed to delete profile '{}'", name)) + std::fs::remove_file(&path).with_context(|| format!("failed to delete profile '{}'", name)) } pub fn from_monitors(name: &str, monitors: &[Monitor]) -> Profile { @@ -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). /// Monitors not in the profile are left unchanged. -pub fn apply_to_monitors(profile: &Profile, monitors: &mut Vec) { +pub fn apply_to_monitors(profile: &Profile, monitors: &mut [Monitor]) { for pm in &profile.monitors { if let Some(m) = monitors.iter_mut().find(|m| m.name == pm.name) { if let Some(mode) = Mode::parse(&format!("{}Hz", pm.mode)) { @@ -130,15 +129,39 @@ pub fn apply_to_monitors(profile: &Profile, monitors: &mut Vec) { } fn chrono_now() -> String { - // Simple ISO 8601 timestamp without pulling in chrono - // Uses date command; falls back to a placeholder if unavailable - std::process::Command::new("date") - .arg("+%Y-%m-%dT%H:%M:%SZ") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_owned()) - .unwrap_or_else(|| "unknown".to_owned()) + // In-process ISO 8601 (UTC) timestamp — no chrono crate, and no shelling + // out to `date`. `civil_from_days` is the Hinnant days-from-civil epoch + // algorithm. Falls back to the Unix epoch instant if the clock is broken. + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let (h, m, s) = secs_of_day(secs % 86_400); + let (y, mo, d) = civil_from_days((secs / 86_400) as i64); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") +} + +/// Convert days since 1970-01-01 to a (year, month, day) civil date. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// Seconds within the day -> (hours, minutes, seconds). +fn secs_of_day(secs: u64) -> (u32, u32, u32) { + ( + ((secs / 3600) % 24) as u32, + ((secs / 60) % 60) as u32, + (secs % 60) as u32, + ) } #[cfg(test)] @@ -150,7 +173,11 @@ mod tests { Monitor { name: name.into(), description: String::new(), - active_mode: Mode { width: w, height: h, refresh: 60.0 }, + active_mode: Mode { + width: w, + height: h, + refresh: 60.0, + }, x, y, scale: 1.0, @@ -181,4 +208,19 @@ mod tests { assert_eq!(deserialized.monitors[0].mode, "1920x1200@60.00"); assert_eq!(deserialized.monitors[1].x, 1920); } + + #[test] + fn chrono_now_helpers() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + // 2024-01-01 is epoch day 19723. + assert_eq!(civil_from_days(19_723), (2024, 1, 1)); + assert_eq!(secs_of_day(0), (0, 0, 0)); + assert_eq!(secs_of_day(86_399), (23, 59, 59)); + // Spot-check the formatted output shape. + let s = chrono_now(); + assert_eq!(s.len(), 20); + + assert!(s.ends_with('Z')); + assert!(s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-'); + } } diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..ed1d4a7 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,369 @@ +//! Shared Hyprland layout store: `~/.config/hypr/monitors.json`. +//! +//! Same schema as bos-settings `MonitorRule` and +//! `iso/airootfs/etc/skel/.config/hypr/scripts/display/monitors.lua`. +//! Empty `output` is the wildcard default (matches any connector). + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::monitor::{Mode, Monitor}; + +fn default_mode() -> String { + "preferred".to_string() +} +fn default_position() -> String { + "auto".to_string() +} +fn default_scale() -> String { + "auto".to_string() +} + +/// One `hl.monitor()` rule. Field names and defaults must stay in sync with +/// bos-settings `MonitorRule` and the ISO `monitors.lua` loader. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MonitorRule { + pub output: String, + #[serde(default = "default_mode")] + pub mode: String, + #[serde(default = "default_position")] + pub position: String, + #[serde(default = "default_scale")] + pub scale: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mirror: Option, +} + +impl Default for MonitorRule { + fn default() -> Self { + Self { + output: String::new(), + mode: default_mode(), + position: default_position(), + scale: default_scale(), + mirror: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MonitorsFile { + #[serde(default)] + pub monitors: Vec, +} + +impl Default for MonitorsFile { + fn default() -> Self { + Self { + monitors: vec![MonitorRule::default()], + } + } +} + +/// `~/.config/hypr/monitors.json` — same path Hyprland and bos-settings use. +pub fn config_path() -> PathBuf { + bread_utils::xdg::config_dir("hypr").join("monitors.json") +} + +pub fn load() -> Result> { + load_from(&config_path()) +} + +pub fn load_from(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + let file: MonitorsFile = serde_json::from_str(&content) + .with_context(|| format!("failed to parse {}", path.display()))?; + // Empty file ≡ missing: Lua falls back to the wildcard default rather + // than applying zero rules (which can black-screen the session). + if file.monitors.is_empty() { + return Ok(None); + } + Ok(Some(file)) +} + +pub fn save(file: &MonitorsFile) -> Result<()> { + save_to(&config_path(), file) +} + +pub fn save_to(path: &Path, file: &MonitorsFile) -> Result<()> { + let json = serde_json::to_string_pretty(file).context("failed to serialize monitors.json")?; + bread_utils::atomic::write_atomic_backed_up(path, &json) + .with_context(|| format!("failed to write {}", path.display())) +} + +pub fn save_from_monitors(monitors: &[Monitor]) -> Result<()> { + save(&from_monitors(monitors)) +} + +/// Persist the TUI layout as named `hl.monitor()` rules. Mirror slaves are +/// omitted (mirror is recorded on the source, matching `hl.monitor()`). If +/// nothing is writable, emit the wildcard default so the file is never empty. +pub fn from_monitors(monitors: &[Monitor]) -> MonitorsFile { + let mut source_to_slave: HashMap<&str, &str> = HashMap::new(); + for m in monitors { + if let Some(src) = &m.mirror_of { + source_to_slave.insert(src.as_str(), m.name.as_str()); + } + } + + let mut rules = Vec::new(); + for m in monitors { + if m.disabled || m.mirror_of.is_some() { + continue; + } + let refresh = (m.active_mode.refresh + 0.5) as u32; + rules.push(MonitorRule { + output: m.name.clone(), + mode: format!( + "{}x{}@{}", + m.active_mode.width, m.active_mode.height, refresh + ), + position: format!("{}x{}", m.x, m.y), + scale: format!("{:.2}", m.scale), + mirror: source_to_slave + .get(m.name.as_str()) + .map(|s| (*s).to_owned()), + }); + } + + if rules.is_empty() { + MonitorsFile::default() + } else { + MonitorsFile { monitors: rules } + } +} + +/// Overlay persisted rules onto live `hyprctl` monitors (matched by name; +/// empty `output` is the wildcard fallback). `preferred` / `auto` leave the +/// live value. A file with at least one named output is treated as a full +/// layout and replaces live mirrors; a wildcard-only file does not. +pub fn apply_to_monitors(file: &MonitorsFile, monitors: &mut [Monitor]) { + let has_specific = file.monitors.iter().any(|r| !r.output.is_empty()); + if has_specific { + for m in monitors.iter_mut() { + m.mirror_of = None; + } + for rule in &file.monitors { + let Some(slave_name) = rule.mirror.as_deref().filter(|s| !s.is_empty()) else { + continue; + }; + if rule.output.is_empty() { + continue; + } + if let Some(slave) = monitors.iter_mut().find(|m| m.name == slave_name) { + slave.mirror_of = Some(rule.output.clone()); + } + } + } + + for m in monitors.iter_mut() { + if let Some(rule) = find_rule(&file.monitors, &m.name) { + apply_rule_fields(m, rule); + } + } +} + +fn find_rule<'a>(rules: &'a [MonitorRule], name: &str) -> Option<&'a MonitorRule> { + rules + .iter() + .find(|r| r.output == name) + .or_else(|| rules.iter().find(|r| r.output.is_empty())) +} + +fn apply_rule_fields(m: &mut Monitor, rule: &MonitorRule) { + if rule.mode != "preferred" { + if let Some(mode) = + Mode::parse(&format!("{}Hz", rule.mode)).or_else(|| Mode::parse(&rule.mode)) + { + m.active_mode = mode; + } + } + if rule.position != "auto" { + if let Some((x, y)) = parse_position(&rule.position) { + m.x = x; + m.y = y; + } + } + if rule.scale != "auto" { + if let Ok(scale) = rule.scale.parse::() { + if scale > 0.0 { + m.scale = scale; + } + } + } +} + +fn parse_position(s: &str) -> Option<(i32, i32)> { + let (x, y) = s.split_once('x')?; + Some((x.parse().ok()?, y.parse().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::monitor::Transform; + + fn make_monitor(name: &str, w: u32, h: u32, x: i32, y: i32) -> Monitor { + Monitor { + name: name.into(), + description: String::new(), + active_mode: Mode { + width: w, + height: h, + refresh: 60.0, + }, + x, + y, + scale: 1.0, + transform: Transform::Normal, + vrr: false, + dpms: true, + disabled: false, + mirror_of: None, + available_modes: vec![], + physical_width_mm: 0, + physical_height_mm: 0, + } + } + + #[test] + fn iso_default_parses() { + let json = r#"{ + "monitors": [ + { "output": "", "mode": "preferred", "position": "auto", "scale": "auto" } + ] +}"#; + let file: MonitorsFile = serde_json::from_str(json).unwrap(); + assert_eq!(file.monitors.len(), 1); + assert_eq!(file.monitors[0], MonitorRule::default()); + } + + #[test] + fn pretty_roundtrip_omits_absent_mirror() { + let file = MonitorsFile::default(); + let json = serde_json::to_string_pretty(&file).unwrap(); + assert!(json.contains("\"output\": \"\"")); + assert!(json.contains("\"mode\": \"preferred\"")); + assert!(!json.contains("mirror")); + let back: MonitorsFile = serde_json::from_str(&json).unwrap(); + assert_eq!(file, back); + } + + #[test] + fn from_monitors_writes_named_rules_and_source_mirror() { + let mut hdmi = make_monitor("HDMI-A-1", 1920, 1080, 1920, 0); + hdmi.mirror_of = Some("eDP-1".into()); + let file = from_monitors(&[make_monitor("eDP-1", 1920, 1200, 0, 0), hdmi]); + assert_eq!(file.monitors.len(), 1); + let rule = &file.monitors[0]; + assert_eq!(rule.output, "eDP-1"); + assert_eq!(rule.mode, "1920x1200@60"); + assert_eq!(rule.position, "0x0"); + assert_eq!(rule.scale, "1.00"); + assert_eq!(rule.mirror.as_deref(), Some("HDMI-A-1")); + } + + #[test] + fn from_monitors_empty_or_all_slaves_emits_wildcard() { + let mut only_slave = make_monitor("HDMI-A-1", 1920, 1080, 0, 0); + only_slave.mirror_of = Some("missing".into()); + assert_eq!(from_monitors(&[]), MonitorsFile::default()); + assert_eq!(from_monitors(&[only_slave]), MonitorsFile::default()); + } + + #[test] + fn wildcard_overlay_leaves_live_geometry_and_mirrors() { + let file = MonitorsFile::default(); + let mut monitors = vec![make_monitor("eDP-1", 1920, 1200, 10, 20)]; + monitors[0].scale = 1.5; + monitors[0].mirror_of = Some("HDMI-A-1".into()); + apply_to_monitors(&file, &mut monitors); + assert_eq!(monitors[0].x, 10); + assert_eq!(monitors[0].y, 20); + assert!((monitors[0].scale - 1.5).abs() < f64::EPSILON); + assert_eq!(monitors[0].mirror_of.as_deref(), Some("HDMI-A-1")); + } + + #[test] + fn specific_overlay_applies_fields_and_replaces_mirrors() { + let file = MonitorsFile { + monitors: vec![ + MonitorRule { + output: "eDP-1".into(), + mode: "1920x1200@60".into(), + position: "0x0".into(), + scale: "1.25".into(), + mirror: Some("HDMI-A-1".into()), + }, + MonitorRule { + output: "DP-1".into(), + mode: "2560x1440@144".into(), + position: "-2560x0".into(), + scale: "1".into(), + mirror: None, + }, + ], + }; + let mut monitors = vec![ + make_monitor("eDP-1", 1600, 900, 100, 100), + make_monitor("HDMI-A-1", 1920, 1080, 200, 0), + make_monitor("DP-1", 1920, 1080, 300, 0), + ]; + monitors[1].mirror_of = Some("DP-1".into()); + apply_to_monitors(&file, &mut monitors); + + assert_eq!(monitors[0].active_mode.width, 1920); + assert_eq!(monitors[0].active_mode.height, 1200); + assert!((monitors[0].active_mode.refresh - 60.0).abs() < 0.01); + assert_eq!(monitors[0].x, 0); + assert_eq!(monitors[0].y, 0); + assert!((monitors[0].scale - 1.25).abs() < f64::EPSILON); + + assert_eq!(monitors[1].mirror_of.as_deref(), Some("eDP-1")); + + assert_eq!(monitors[2].active_mode.width, 2560); + assert_eq!(monitors[2].x, -2560); + assert!(monitors[2].mirror_of.is_none()); + } + + #[test] + fn load_from_missing_or_empty_is_none() { + let dir = std::env::temp_dir().join(format!( + "breadmon-store-test-{}-{}", + std::process::id(), + "empty" + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let missing = dir.join("nope.json"); + assert!(load_from(&missing).unwrap().is_none()); + + let empty = dir.join("empty.json"); + std::fs::write(&empty, "{ \"monitors\": [] }\n").unwrap(); + assert!(load_from(&empty).unwrap().is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn save_to_roundtrips() { + let dir = std::env::temp_dir().join(format!( + "breadmon-store-test-{}-{}", + std::process::id(), + "save" + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("monitors.json"); + let file = from_monitors(&[make_monitor("eDP-1", 1920, 1200, 0, 0)]); + save_to(&path, &file).unwrap(); + let loaded = load_from(&path).unwrap().unwrap(); + assert_eq!(loaded, file); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/ui/config_view.rs b/src/ui/config_view.rs index 4ec4026..22cc80e 100644 --- a/src/ui/config_view.rs +++ b/src/ui/config_view.rs @@ -125,32 +125,38 @@ impl ConfigState { } fn prev_field(&mut self) { - self.focused = self.focused.checked_sub(1).unwrap_or(ConfigField::ALL.len() - 1); + self.focused = self + .focused + .checked_sub(1) + .unwrap_or(ConfigField::ALL.len() - 1); } } pub fn handle_key(event: KeyEvent, state: &mut AppState) { - let cfg = &mut state.config; - match event.code { KeyCode::Char('j') | KeyCode::Down => { - cfg.scale_editing = false; - cfg.next_field(); + state.clear_burst(); + state.config.scale_editing = false; + state.config.next_field(); } KeyCode::Char('k') | KeyCode::Up => { - cfg.scale_editing = false; - cfg.prev_field(); + state.clear_burst(); + state.config.scale_editing = false; + state.config.prev_field(); } KeyCode::Tab => { - cfg.scale_editing = false; - cfg.next_field(); + state.clear_burst(); + state.config.scale_editing = false; + state.config.next_field(); } KeyCode::BackTab => { - cfg.scale_editing = false; - cfg.prev_field(); + state.clear_burst(); + state.config.scale_editing = false; + state.config.prev_field(); } // Navigate between monitors KeyCode::Char('[') => { + state.clear_burst(); let count = state.monitors.len(); if count > 0 { let new_idx = state.config.monitor_idx.checked_sub(1).unwrap_or(count - 1); @@ -159,6 +165,7 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { } } KeyCode::Char(']') => { + state.clear_burst(); let count = state.monitors.len(); if count > 0 { let new_idx = (state.config.monitor_idx + 1) % count; @@ -173,19 +180,20 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { crate::ui::layout_view::trigger_save(state); } KeyCode::Esc => { + state.clear_burst(); state.config.scale_editing = false; // Re-sync from live monitor to discard pending edits let idx = state.config.monitor_idx; state.config.sync_from_monitor(idx, &state.monitors); } KeyCode::Enter => { + state.clear_burst(); if state.config.current_field() == ConfigField::Scale { commit_scale(state); } apply_current(state); } _ => { - state.push_undo(); handle_field_key(event, state); } } @@ -211,12 +219,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { } } MouseEventKind::ScrollUp => { - state.push_undo(); let fake_right = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE); handle_field_key(fake_right, state); } MouseEventKind::ScrollDown => { - state.push_undo(); let fake_left = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE); handle_field_key(fake_left, state); } @@ -230,6 +236,8 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { return; } let idx = state.config.monitor_idx.min(monitors_len - 1); + // Coalesce consecutive value cycles (and scroll) into one undo step. + state.micro_edit(); match state.config.current_field() { ConfigField::Resolution => match event.code { @@ -239,17 +247,17 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { let m = &state.monitors[idx]; state.config.update_refreshes(m); sync_mode_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } } - KeyCode::Char('l') | KeyCode::Right => { - if state.config.res_idx + 1 < state.config.resolutions.len() { - state.config.res_idx += 1; - let m = &state.monitors[idx]; - state.config.update_refreshes(m); - sync_mode_to_monitor(state, idx); - state.dirty = true; - } + KeyCode::Char('l') | KeyCode::Right + if state.config.res_idx + 1 < state.config.resolutions.len() => + { + state.config.res_idx += 1; + let m = &state.monitors[idx]; + state.config.update_refreshes(m); + sync_mode_to_monitor(state, idx); + state.mark_dirty(); } _ => {} }, @@ -258,15 +266,15 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { if state.config.refresh_idx > 0 { state.config.refresh_idx -= 1; sync_mode_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } } - KeyCode::Char('l') | KeyCode::Right => { - if state.config.refresh_idx + 1 < state.config.refreshes.len() { - state.config.refresh_idx += 1; - sync_mode_to_monitor(state, idx); - state.dirty = true; - } + KeyCode::Char('l') | KeyCode::Right + if state.config.refresh_idx + 1 < state.config.refreshes.len() => + { + state.config.refresh_idx += 1; + sync_mode_to_monitor(state, idx); + state.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 = state.monitors[idx].scale.max(0.1); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('.') => { let s = state.monitors[idx].scale + 0.1; state.monitors[idx].scale = (s * 100.0).round() / 100.0; state.monitors[idx].scale = state.monitors[idx].scale.min(10.0); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => { state.config.scale_editing = true; @@ -303,27 +311,35 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { .checked_sub(1) .unwrap_or(all.len() - 1); state.monitors[idx].transform = all[state.config.transform_idx]; - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('l') | KeyCode::Right => { let all = Transform::all(); state.config.transform_idx = (state.config.transform_idx + 1) % all.len(); state.monitors[idx].transform = all[state.config.transform_idx]; - state.dirty = true; + state.mark_dirty(); } _ => {} }, ConfigField::Vrr => match event.code { - KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { + KeyCode::Char('h') + | KeyCode::Left + | KeyCode::Char('l') + | KeyCode::Right + | KeyCode::Char(' ') => { state.monitors[idx].vrr = !state.monitors[idx].vrr; - state.dirty = true; + state.mark_dirty(); } _ => {} }, ConfigField::Dpms => match event.code { - KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { + KeyCode::Char('h') + | KeyCode::Left + | KeyCode::Char('l') + | KeyCode::Right + | KeyCode::Char(' ') => { state.monitors[idx].dpms = !state.monitors[idx].dpms; - state.dirty = true; + state.mark_dirty(); } _ => {} }, @@ -332,15 +348,15 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { if state.config.mirror_idx > 0 { state.config.mirror_idx -= 1; sync_mirror_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } } - KeyCode::Char('l') | KeyCode::Right => { - if state.config.mirror_idx + 1 < state.config.mirror_options.len() { - state.config.mirror_idx += 1; - sync_mirror_to_monitor(state, idx); - state.dirty = true; - } + KeyCode::Char('l') | KeyCode::Right + if state.config.mirror_idx + 1 < state.config.mirror_options.len() => + { + state.config.mirror_idx += 1; + sync_mirror_to_monitor(state, idx); + state.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) { - let chosen = state.config.mirror_options.get(state.config.mirror_idx).cloned(); + let chosen = state + .config + .mirror_options + .get(state.config.mirror_idx) + .cloned(); state.monitors[idx].mirror_of = match chosen.as_deref() { Some("(none)") | None => None, Some(s) => Some(s.to_owned()), @@ -366,19 +386,25 @@ fn sync_mirror_to_monitor(state: &mut AppState, idx: usize) { } fn commit_scale(state: &mut AppState) { - let idx = state.config.monitor_idx.min(state.monitors.len().saturating_sub(1)); + let idx = state + .config + .monitor_idx + .min(state.monitors.len().saturating_sub(1)); if let Ok(v) = state.config.scale_str.parse::() { state.monitors[idx].scale = v.clamp(0.1, 10.0); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); - state.dirty = true; + state.mark_dirty(); } state.config.scale_editing = false; } fn apply_current(state: &mut AppState) { + state.clear_burst(); state.pending_apply = true; state.set_status("Applying...", StatusLevel::Info); - state.dirty = false; + // Don't clear `dirty` here: it must survive until the apply actually + // succeeds (main.rs clears it on a successful apply + save). Otherwise a + // failed `hyprctl` apply would silently drop the unsaved-changes guard. } pub fn render(f: &mut Frame, area: Rect, state: &AppState) { @@ -405,12 +431,15 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) { }; let header = format!(" {} — {}{}", m.name, m.description, ppi_hint); f.render_widget( - Paragraph::new(header).style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Paragraph::new(header).style( + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), chunks[0], ); let form_area = chunks[1]; - let row_height = 1u16; let fields = ConfigField::ALL; let items: Vec = fields @@ -432,7 +461,6 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) { }) .collect(); - let _ = row_height; // used implicitly via ListItem heights let list = List::new(items).block( Block::default() .borders(Borders::ALL) @@ -463,20 +491,31 @@ fn field_value(field: ConfigField, state: &AppState, m: &Monitor) -> String { } ConfigField::Scale => { if state.config.scale_editing { - format!("{}| (Enter to commit, ,/. for ±0.1)", state.config.scale_str) + format!( + "{}| (Enter to commit, ,/. for ±0.1)", + state.config.scale_str + ) } else { format!("{} (,/. for ±0.1)", state.config.scale_str) } } ConfigField::Transform => Transform::all() [state.config.transform_idx.min(Transform::all().len() - 1)] - .label() - .to_owned(), + .label() + .to_owned(), ConfigField::Vrr => { - if m.vrr { "ON".to_owned() } else { "OFF".to_owned() } + if m.vrr { + "ON".to_owned() + } else { + "OFF".to_owned() + } } ConfigField::Dpms => { - if m.dpms { "ON".to_owned() } else { "OFF".to_owned() } + if m.dpms { + "ON".to_owned() + } else { + "OFF".to_owned() + } } ConfigField::MirrorOf => state .config diff --git a/src/ui/layout_view.rs b/src/ui/layout_view.rs index ffa7131..00a54c0 100644 --- a/src/ui/layout_view.rs +++ b/src/ui/layout_view.rs @@ -8,7 +8,10 @@ use ratatui::{ }; use crate::{ - layout::{auto_arrange, bounding_box, canvas_scale, canvas_to_world, move_selected, snap_position, world_to_canvas}, + layout::{ + auto_arrange, bounding_box, canvas_scale, canvas_to_world, move_selected, snap_position, + world_to_canvas, + }, monitor::Monitor, ui::{AppState, DragState, StatusLevel, Tab}, }; @@ -20,36 +23,51 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { match event.code { KeyCode::Char('h') | KeyCode::Left => { - state.push_undo(); + state.micro_edit(); move_selected(&state.layout, &mut state.monitors, -step, 0); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('l') | KeyCode::Right => { - state.push_undo(); + state.micro_edit(); move_selected(&state.layout, &mut state.monitors, step, 0); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('k') | KeyCode::Up => { - state.push_undo(); + state.micro_edit(); move_selected(&state.layout, &mut state.monitors, 0, -step); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('j') | KeyCode::Down => { - state.push_undo(); + state.micro_edit(); 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') => { state.push_undo(); auto_arrange(&mut state.monitors); - state.dirty = true; + state.mark_dirty(); } KeyCode::Enter => { - state.config.sync_from_monitor(state.layout.selected, &state.monitors); + state.clear_burst(); + state + .config + .sync_from_monitor(state.layout.selected, &state.monitors); 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) { let (min_x, min_y, _, _) = bounding_box(&state.monitors); let scale = canvas_scale_for(canvas, state); - let (wx, wy) = canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); + let (wx, wy) = + canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); // Push undo at drag start, not on every move state.push_undo(); @@ -85,15 +104,22 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { let canvas = canvas_area(state.terminal_size); let (min_x, min_y, _, _) = bounding_box(&state.monitors); let scale = canvas_scale_for(canvas, state); - let (wx, wy) = canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); + let (wx, wy) = + canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); let idx = drag.monitor_idx; let new_x = drag.origin_x + (wx - drag.click_world_x); let new_y = drag.origin_y + (wy - drag.click_world_y); - let (sx, sy) = snap_position(idx, new_x, new_y, &state.monitors, state.layout.snap_threshold); + let (sx, sy) = snap_position( + idx, + new_x, + new_y, + &state.monitors, + state.layout.snap_threshold, + ); state.monitors[idx].x = sx; state.monitors[idx].y = sy; - state.dirty = true; + state.mark_dirty(); } } MouseEventKind::Up(MouseButton::Left) => { @@ -162,18 +188,31 @@ fn render_canvas(f: &mut Frame, area: Rect, state: &AppState) { continue; } - let rect = Rect { x: cx, y: cy, width: cw, height: ch }; + let rect = Rect { + x: cx, + y: cy, + width: cw, + height: ch, + }; let is_selected = i == selected; - let is_dragging = state.drag_state.as_ref().map(|d| d.monitor_idx == i).unwrap_or(false); + let is_dragging = state + .drag_state + .as_ref() + .map(|d| d.monitor_idx == i) + .unwrap_or(false); let is_overlapping = overlapping[i]; let border_style = if is_dragging { - Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD) } else if is_overlapping { Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) } else if is_selected { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Blue) }; @@ -190,7 +229,10 @@ fn render_canvas(f: &mut Frame, area: Rect, state: &AppState) { }) .border_style(border_style) .title(Span::styled(&label, border_style)) - .title_bottom(Span::styled(&mode_str, Style::default().fg(Color::DarkGray))); + .title_bottom(Span::styled( + &mode_str, + Style::default().fg(Color::DarkGray), + )); f.render_widget(block, rect); } @@ -203,7 +245,9 @@ fn render_readout(f: &mut Frame, area: Rect, state: &AppState) { let idx = state.layout.selected.min(state.monitors.len() - 1); let m = &state.monitors[idx]; - let mirror_info = m.mirror_of.as_ref() + let mirror_info = m + .mirror_of + .as_ref() .map(|src| format!(" mirror:{}", src)) .unwrap_or_default(); @@ -213,14 +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!( " {} x:{} y:{} {}x{}@{:.0}Hz scale:{:.2}{}{}{}", - m.name, m.x, m.y, - m.active_mode.width, m.active_mode.height, m.active_mode.refresh, + m.name, + m.x, + m.y, + m.active_mode.width, + m.active_mode.height, + m.active_mode.refresh, m.scale, - mirror_info, overlap_warn, drag_hint, + mirror_info, + overlap_warn, + drag_hint, ); f.render_widget( Paragraph::new(text).style(Style::default().fg(Color::Cyan)), @@ -235,8 +289,10 @@ fn overlapping_monitors(monitors: &[Monitor]) -> Vec { for j in (i + 1)..monitors.len() { let a = &monitors[i]; let b = &monitors[j]; - if a.x < b.right_edge() && a.right_edge() > b.x - && a.y < b.bottom_edge() && a.bottom_edge() > b.y + if a.x < b.right_edge() + && a.right_edge() > b.x + && a.y < b.bottom_edge() + && a.bottom_edge() > b.y { flags[i] = true; flags[j] = true; @@ -259,9 +315,9 @@ pub fn canvas_area(terminal_size: (u16, u16)) -> Rect { } fn in_canvas(col: u16, row: u16, canvas: Rect) -> bool { - col >= canvas.x + 1 + col > canvas.x && col < canvas.x + canvas.width.saturating_sub(1) - && row >= canvas.y + 1 + && row > canvas.y && row < canvas.y + canvas.height.saturating_sub(1) } @@ -301,31 +357,13 @@ fn monitor_at(col: u16, row: u16, canvas: Rect, state: &AppState) -> Option { - if is_new { - state.set_status( - format!("Saved. Add: source = {} to hyprland.conf", path.display()), - StatusLevel::Success, - ); - } else { - state.set_status(format!("Saved to {}", path.display()), StatusLevel::Success); - } state.dirty = false; + state.set_status( + format!("Saved to {}", crate::store::config_path().display()), + StatusLevel::Success, + ); } Err(e) => state.set_status(format!("Save failed: {}", e), StatusLevel::Error), } diff --git a/src/ui/mirror_view.rs b/src/ui/mirror_view.rs index b593dd1..249d33f 100644 --- a/src/ui/mirror_view.rs +++ b/src/ui/mirror_view.rs @@ -45,7 +45,9 @@ impl MirrorState { fn next_field(&mut self) { // Skip Apply/Cancel if no result yet let mut next = (self.focused + 1) % FIELDS.len(); - if self.result.is_none() && (FIELDS[next] == MirrorField::Apply || FIELDS[next] == MirrorField::Cancel) { + if self.result.is_none() + && (FIELDS[next] == MirrorField::Apply || FIELDS[next] == MirrorField::Cancel) + { next = 0; } self.focused = next; @@ -54,8 +56,13 @@ impl MirrorState { fn prev_field(&mut self) { let len = FIELDS.len(); let mut prev = self.focused.checked_sub(1).unwrap_or(len - 1); - if self.result.is_none() && (FIELDS[prev] == MirrorField::Apply || FIELDS[prev] == MirrorField::Cancel) { - prev = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2); + if self.result.is_none() + && (FIELDS[prev] == MirrorField::Apply || FIELDS[prev] == MirrorField::Cancel) + { + prev = FIELDS + .iter() + .position(|&f| f == MirrorField::Compute) + .unwrap_or(2); } self.focused = prev; } @@ -86,12 +93,14 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { } KeyCode::Char('h') | KeyCode::Left => match state.mirror.current_field() { MirrorField::Source => { - state.mirror.source_idx = state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.source_idx = + state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1); state.mirror.fix_indices(count); state.mirror.result = None; } MirrorField::Target => { - state.mirror.target_idx = state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.target_idx = + state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1); state.mirror.fix_indices(count); state.mirror.result = None; } @@ -118,7 +127,10 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { Some(result) => { state.mirror.result = Some(result); // Move focus to Apply - state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); + state.mirror.focused = FIELDS + .iter() + .position(|&f| f == MirrorField::Apply) + .unwrap_or(3); } None => { state.set_status( @@ -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].mirror_of = Some(src_name.clone()); - state.dirty = true; + state.mark_dirty(); state.mirror.result = None; state.mirror.focused = 0; state.set_status( format!( "Mirror set: {} → {} at {}", - src_name, - state.monitors[tgt_idx].name, - result.mirror_mode + src_name, state.monitors[tgt_idx].name, result.mirror_mode ), StatusLevel::Success, ); @@ -182,17 +192,26 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { match row { 2 | 3 => { // Source picker area - let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Source).unwrap_or(0); + let f_idx = FIELDS + .iter() + .position(|&f| f == MirrorField::Source) + .unwrap_or(0); state.mirror.focused = f_idx; } 4 | 5 => { // Target picker area - let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Target).unwrap_or(1); + let f_idx = FIELDS + .iter() + .position(|&f| f == MirrorField::Target) + .unwrap_or(1); state.mirror.focused = f_idx; } 6 => { // Compute button - let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2); + let f_idx = FIELDS + .iter() + .position(|&f| f == MirrorField::Compute) + .unwrap_or(2); state.mirror.focused = f_idx; // Also activate it let src = &state.monitors[state.mirror.source_idx]; @@ -200,7 +219,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { match crate::mirror::find_mirror_modes(src, tgt) { Some(result) => { state.mirror.result = Some(result); - state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); + state.mirror.focused = FIELDS + .iter() + .position(|&f| f == MirrorField::Apply) + .unwrap_or(3); } None => { state.set_status( @@ -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. // Rough column check: col < 20 = Apply, col >= 20 = Cancel - if state.mirror.result.is_some() { - let col = event.column; - if col < 20 { - // Activate Apply - state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); - if let Some(result) = state.mirror.result.clone() { - state.push_undo(); - let src_name = state.monitors[state.mirror.source_idx].name.clone(); - let tgt_idx = state.mirror.target_idx; - state.monitors[tgt_idx].active_mode = result.mirror_mode.clone(); - state.monitors[tgt_idx].mirror_of = Some(src_name.clone()); - state.dirty = true; - state.mirror.result = None; - state.mirror.focused = 0; - state.set_status( - format!("Mirror set: {} → {} at {}", src_name, state.monitors[tgt_idx].name, result.mirror_mode), - crate::ui::StatusLevel::Success, - ); - } - } else { - // Cancel + && state.mirror.result.is_some() => + { + let col = event.column; + if col < 20 { + // Activate Apply + state.mirror.focused = FIELDS + .iter() + .position(|&f| f == MirrorField::Apply) + .unwrap_or(3); + if let Some(result) = state.mirror.result.clone() { + state.push_undo(); + let src_name = state.monitors[state.mirror.source_idx].name.clone(); + let tgt_idx = state.mirror.target_idx; + state.monitors[tgt_idx].active_mode = result.mirror_mode.clone(); + state.monitors[tgt_idx].mirror_of = Some(src_name.clone()); + state.mark_dirty(); state.mirror.result = None; state.mirror.focused = 0; + state.set_status( + format!( + "Mirror set: {} → {} at {}", + src_name, state.monitors[tgt_idx].name, result.mirror_mode + ), + crate::ui::StatusLevel::Success, + ); } + } else { + // Cancel + state.mirror.result = None; + state.mirror.focused = 0; } } _ => {} @@ -246,33 +274,33 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { // Scroll in source/target pickers to cycle monitors match state.mirror.current_field() { MirrorField::Source => { - state.mirror.source_idx = state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.source_idx = + state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1); state.mirror.fix_indices(count); state.mirror.result = None; } MirrorField::Target => { - state.mirror.target_idx = state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.target_idx = + state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1); state.mirror.fix_indices(count); state.mirror.result = None; } _ => {} } } - MouseEventKind::ScrollDown => { - match state.mirror.current_field() { - MirrorField::Source => { - state.mirror.source_idx = (state.mirror.source_idx + 1) % count; - state.mirror.fix_indices(count); - state.mirror.result = None; - } - MirrorField::Target => { - state.mirror.target_idx = (state.mirror.target_idx + 1) % count; - state.mirror.fix_indices(count); - state.mirror.result = None; - } - _ => {} + MouseEventKind::ScrollDown => match state.mirror.current_field() { + MirrorField::Source => { + state.mirror.source_idx = (state.mirror.source_idx + 1) % count; + state.mirror.fix_indices(count); + state.mirror.result = None; } - } + MirrorField::Target => { + state.mirror.target_idx = (state.mirror.target_idx + 1) % count; + state.mirror.fix_indices(count); + state.mirror.result = None; + } + _ => {} + }, _ => {} } } @@ -324,12 +352,16 @@ fn render_pickers(f: &mut Frame, area: Rect, state: &AppState, count: usize) { let focused = state.mirror.current_field(); let src_style = if focused == MirrorField::Source { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let tgt_style = if focused == MirrorField::Target { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; @@ -354,7 +386,9 @@ fn render_pickers(f: &mut Frame, area: Rect, state: &AppState, count: usize) { fn render_compute_btn(f: &mut Frame, area: Rect, state: &AppState) { let focused = state.mirror.current_field() == MirrorField::Compute; let style = if focused { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; @@ -376,12 +410,16 @@ fn render_result(f: &mut Frame, area: Rect, state: &AppState, result: &MirrorRes let focused = state.mirror.current_field(); let apply_style = if focused == MirrorField::Apply { - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let cancel_style = if focused == MirrorField::Cancel { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; @@ -397,7 +435,10 @@ fn render_result(f: &mut Frame, area: Rect, state: &AppState, result: &MirrorRes Style::default().fg(Color::White), )), Line::from(Span::styled( - format!(" Refresh: {:.2} Hz ({})", result.refresh, refresh_label), + format!( + " Refresh: {:.2} Hz ({})", + result.refresh, refresh_label + ), Style::default().fg(Color::White), )), Line::raw(""), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e4891c4..cadb387 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -14,10 +14,7 @@ use ratatui::{ Frame, }; -use crate::{ - layout::LayoutState, - monitor::Monitor, -}; +use crate::{layout::LayoutState, monitor::Monitor}; use config_view::ConfigState; use mirror_view::MirrorState; @@ -111,8 +108,15 @@ pub struct AppState { pub terminal_size: (u16, u16), /// Set to true by any handler that wants `main.rs` to run `apply_monitors`. pub pending_apply: bool, + /// Named snapshot last loaded or saved this session. Cleared when the + /// in-memory layout is edited, so `bread.mon.applied` can report it + /// honestly (or `null` for an ad-hoc layout). + pub active_profile: Option, /// Snapshots for Ctrl+Z undo (up to 20 deep). pub undo_stack: Vec>, + /// 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 { @@ -131,12 +135,26 @@ impl AppState { drag_state: None, terminal_size, pending_apply: false, + active_profile: None, undo_stack: Vec::new(), + undo_in_burst: false, } } pub fn set_status(&mut self, text: impl Into, level: StatusLevel) { - self.status = Some(StatusMsg { text: text.into(), level, born: Instant::now() }); + self.status = Some(StatusMsg { + text: text.into(), + level, + born: Instant::now(), + }); + } + + /// Mark the in-memory layout as edited. Also forgets `active_profile` + /// — 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) { @@ -148,14 +166,40 @@ impl AppState { } pub fn switch_tab(&mut self, tab: Tab) { + self.undo_in_burst = false; self.tab = tab; if tab == Tab::Config { - self.config.sync_from_monitor(self.layout.selected, &self.monitors); + self.config + .sync_from_monitor(self.layout.selected, &self.monitors); } } - /// Save a monitor snapshot for undo (max 20 entries). + /// Save a monitor snapshot for undo (max 20 entries) and end any + /// in-progress edit burst. pub fn push_undo(&mut self) { + self.push_snapshot(); + self.undo_in_burst = false; + } + + /// Start (or continue) a run of small incremental edits. Only the first + /// edit in the run actually snapshots, so nudging a monitor 20 px (or + /// cycling a value repeatedly) collapses to a single undo step rather + /// than consuming 20 of the 20-step undo stack. + pub fn micro_edit(&mut self) { + if !self.undo_in_burst { + self.push_snapshot(); + self.undo_in_burst = true; + } + } + + /// End a coalesced-edit burst without snapping. Called on navigation + /// (tab switches, monitor/field changes, zoom) so bursts don't bleed + /// across distinct actions. + pub fn clear_burst(&mut self) { + self.undo_in_burst = false; + } + + fn push_snapshot(&mut self) { self.undo_stack.push(self.monitors.clone()); if self.undo_stack.len() > 20 { self.undo_stack.remove(0); @@ -166,7 +210,8 @@ impl AppState { pub fn undo(&mut self) { if let Some(snapshot) = self.undo_stack.pop() { self.monitors = snapshot; - self.dirty = true; + self.undo_in_burst = false; + self.mark_dirty(); self.layout.clamp_selected(self.monitors.len()); // Re-sync config view to the restored state let idx = self.layout.selected; @@ -201,10 +246,22 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) -> bool { // Global tab switching match event.code { - KeyCode::Char('1') | KeyCode::F(1) => { state.switch_tab(Tab::Layout); return true; } - KeyCode::Char('2') | KeyCode::F(2) => { state.switch_tab(Tab::Config); return true; } - KeyCode::Char('3') | KeyCode::F(3) => { state.switch_tab(Tab::Mirror); return true; } - KeyCode::Char('4') | KeyCode::F(4) => { state.switch_tab(Tab::Profiles); return true; } + KeyCode::Char('1') | KeyCode::F(1) => { + state.switch_tab(Tab::Layout); + return true; + } + KeyCode::Char('2') | KeyCode::F(2) => { + state.switch_tab(Tab::Config); + return true; + } + KeyCode::Char('3') | KeyCode::F(3) => { + state.switch_tab(Tab::Mirror); + return true; + } + KeyCode::Char('4') | KeyCode::F(4) => { + state.switch_tab(Tab::Profiles); + return true; + } _ => {} } diff --git a/src/ui/profiles_view.rs b/src/ui/profiles_view.rs index 7e23bfd..b05f9be 100644 --- a/src/ui/profiles_view.rs +++ b/src/ui/profiles_view.rs @@ -126,7 +126,10 @@ fn handle_list_key(event: KeyEvent, state: &mut AppState) { match profile::delete(&name) { Ok(()) => { state.profiles.refresh(); - state.set_status(format!("Deleted profile '{}'", name), StatusLevel::Success); + state.set_status( + format!("Deleted profile '{}'", name), + StatusLevel::Success, + ); } Err(e) => { state.set_status(format!("Delete failed: {}", e), StatusLevel::Error); @@ -195,8 +198,11 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { MouseEventKind::ScrollUp => { let count = state.profiles.profiles.len(); if count > 0 { - state.profiles.selected_idx = - state.profiles.selected_idx.checked_sub(1).unwrap_or(count - 1); + state.profiles.selected_idx = state + .profiles + .selected_idx + .checked_sub(1) + .unwrap_or(count - 1); state.profiles.focused = ProfileField::List; } } @@ -240,6 +246,7 @@ fn do_save(state: &mut AppState) { Ok(()) => { state.profiles.new_name.clear(); state.profiles.refresh(); + state.active_profile = Some(name.clone()); state.set_status(format!("Saved profile '{}'", name), StatusLevel::Success); } Err(e) => { @@ -254,6 +261,7 @@ fn do_load(state: &mut AppState) { Ok(p) => { profile::apply_to_monitors(&p, &mut state.monitors); state.dirty = true; + state.active_profile = Some(name.clone()); state.set_status( format!("Loaded profile '{}'. Press [a] to apply.", name), StatusLevel::Success, @@ -306,7 +314,9 @@ fn render_list(f: &mut Frame, area: Rect, state: &AppState) { .add_modifier(Modifier::BOLD) .bg(Color::DarkGray) } else if is_selected { - Style::default().fg(Color::White).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; @@ -360,22 +370,28 @@ fn render_save_row(f: &mut Frame, area: Rect, state: &AppState) { Style::default().fg(Color::DarkGray) }; f.render_widget( - Paragraph::new(input_display) - .style(input_style) - .block(Block::default().borders(Borders::ALL).border_style(input_style)), + Paragraph::new(input_display).style(input_style).block( + Block::default() + .borders(Borders::ALL) + .border_style(input_style), + ), chunks[0], ); // Save button let save_style = if save_focused { - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; f.render_widget( - Paragraph::new(" [ Save ] ") - .style(save_style) - .block(Block::default().borders(Borders::ALL).border_style(save_style)), + Paragraph::new(" [ Save ] ").style(save_style).block( + Block::default() + .borders(Borders::ALL) + .border_style(save_style), + ), chunks[1], ); }