diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml deleted file mode 100644 index b547c34..0000000 --- a/.forgejo/workflows/check.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: check - -# Fast-fail lint/test on short-lived work branches, before it ever reaches -# main and triggers a dev-track release build. -on: - push: - branches: ['feature/**', 'fix/**'] - -jobs: - check: - runs-on: [self-hosted, hestia] - steps: - - name: checkout - run: | - set -euo pipefail - rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: clippy - run: cd src && bash ci/build.sh cargo clippy --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 deleted file mode 100644 index 3087715..0000000 --- a/.forgejo/workflows/dev-release.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: dev release - -# Publishes a dev-track build on every push to `main` (the trunk -# branch — there is no separate `dev` branch). See bread-ecosystem's -# docs/release-channels.md for the release-track policy this is part of. -on: - push: - branches: ['main'] - -jobs: - build: - runs-on: [self-hosted, hestia] - steps: - - name: checkout - run: | - set -euo pipefail - rm -rf src && mkdir src - git clone --branch main --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: build - run: cd src && bash ci/build.sh cargo build --release --locked - - - name: 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 new file mode 100644 index 0000000..7603cc8 --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -0,0 +1,19 @@ +name: Mirror to GitHub + +on: + push: + branches: ['**'] + tags: ['**'] + +jobs: + mirror: + runs-on: [self-hosted, hestia] + steps: + - name: Mirror to GitHub + run: | + set -euo pipefail + git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git + cd repo.git + git push --prune \ + "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadmon.git" \ + '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml deleted file mode 100644 index fd6ba9a..0000000 --- a/.forgejo/workflows/rc-release.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: beta (rc) release - -# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag -# pushed to `main` — there is no separate `beta` branch; "freezing" is -# just pausing pushes to main while an RC gets tested. See -# bread-ecosystem's docs/release-channels.md for the release-track policy. -on: - push: - tags: ['v*'] - -jobs: - build: - if: ${{ contains(github.ref_name, '-rc.') }} - runs-on: [self-hosted, hestia] - steps: - - name: checkout - run: | - set -euo pipefail - rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: build - run: cd src && bash ci/build.sh cargo build --release --locked - - - name: 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 7286868..7903d69 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,7 +6,6 @@ on: jobs: build: - if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout @@ -17,16 +16,7 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: | - set -euo pipefail - if [ ! -f src/ci/build.sh ]; then - echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper" - exit 1 - fi - cd src && bash ci/build.sh cargo build --release --locked || { - echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked." - exit 1 - } + run: cd src && cargo build --release --locked - name: prepare artifacts run: | @@ -42,14 +32,8 @@ 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 c3b4cc4..36f7f5b 100644 --- a/.gitignore +++ b/.gitignore @@ -29,12 +29,3 @@ 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 deleted file mode 100644 index e911ad9..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,33 +0,0 @@ -# AGENTS.md — Repo hygiene - -This repo follows the branch/release workflow documented in `CONTRIBUTING.md` -— read and follow it for any git, branch, or release work here (the -single-trunk `main` model, `feature/x`/`fix/x` branch naming, RC-tag-driven -beta releases, etc). Don't improvise a different workflow. 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 deleted file mode 100644 index 370bfa2..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,84 +0,0 @@ -# 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 b5818f4..67a5c2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,23 +20,11 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -[[package]] -name = "bread-shared" -version = "0.7.0" -source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" -dependencies = [ - "dirs", - "serde", - "serde_json", - "toml", -] - [[package]] name = "bread-utils" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.3.0" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" dependencies = [ - "bread-shared", "dirs", "serde", "serde_json", @@ -44,7 +32,7 @@ dependencies = [ [[package]] name = "breadmon" -version = "0.1.3" +version = "0.1.2" dependencies = [ "anyhow", "bread-utils", diff --git a/Cargo.toml b/Cargo.toml index bc93db7..6cc904c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadmon" -version = "0.1.3" +version = "0.1.2" edition = "2021" description = "TUI monitor manager for Hyprland" license = "MIT" @@ -19,7 +19,8 @@ toml = "0.8" anyhow = "1" dirs = "5" futures = "0.3" -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } +# 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" } [profile.release] lto = "thin" diff --git a/EVENTS.md b/EVENTS.md deleted file mode 100644 index b854967..0000000 --- a/EVENTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# 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 ccddd7d..a0d2396 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,14 @@ # breadmon -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/`. +A terminal UI monitor manager for Hyprland. Lets you position, configure, and mirror displays interactively, then apply changes live via `hyprctl`. ## Requirements -- **[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. +- **[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. - The `hyprctl` binary must be on `PATH` - Rust toolchain (to build from source) -## Install - -Via [bakery](https://git.breadway.dev/Breadway/bread-ecosystem), the bread ecosystem package manager: - -``` -bakery install breadmon -``` - -Or build from source: +## Build ``` cargo build --release @@ -26,6 +16,12 @@ 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 ``` @@ -80,7 +76,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. Loading a profile updates the TUI; applying it (`a`) also writes `monitors.json`. +Named snapshots of the current monitor configuration, stored as TOML files. | Key | Action | |-----|--------| @@ -95,8 +91,8 @@ Profiles are saved to `~/.config/breadmon/profiles/`. | Key | Action | |-----|--------| -| `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 | +| `a` | Apply current configuration via `hyprctl` | +| `s` | Save current configuration as a profile | | `r` | Refresh monitor list from Hyprland | | `Ctrl+Z` | Undo last change (up to 20 steps) | | `q` / `Ctrl+C` | Quit (prompts once if there are unsaved changes) | @@ -105,20 +101,4 @@ breadmon also listens on Hyprland's event socket and reloads the monitor list au ## Config -**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. +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. diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev deleted file mode 100644 index 474f1fd..0000000 --- a/ci/bread-ecosystem.rev +++ /dev/null @@ -1 +0,0 @@ -620c5a1317a6b57276eabca961facdb78bf510db diff --git a/ci/build.sh b/ci/build.sh deleted file mode 100755 index 7582664..0000000 --- a/ci/build.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Delegates to bread-ecosystem's shared CI build image/script, pinned to -# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI -# files now affect every product's release pipeline, so bumping the pin -# is a deliberate act instead of silent drift. -# -# 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 deleted file mode 100644 index 7197765..0000000 --- a/src/bread_events.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! `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 9789c2a..c19923a 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 [Monitor], dx: i32, dy: i32) { +pub fn move_selected(state: &LayoutState, monitors: &mut Vec, 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 [Monitor], dx: i32, dy: } /// Place monitors in a left-to-right row with no gaps. -pub fn auto_arrange(monitors: &mut [Monitor]) { +pub fn auto_arrange(monitors: &mut Vec) { let mut cursor = 0i32; for m in monitors.iter_mut() { m.x = cursor; @@ -196,11 +196,7 @@ 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 7cc5a23..5343ef1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,7 @@ -mod bread_events; mod layout; mod mirror; mod monitor; mod profile; -mod store; mod ui; use std::io; @@ -11,7 +9,8 @@ use std::io; use anyhow::Result; use crossterm::{ event::{ - DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, MouseEventKind, + DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, + MouseEventKind, }, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, @@ -36,15 +35,10 @@ enum AppEvent { #[tokio::main] async fn main() -> Result<()> { - let mut monitors = monitor::load_monitors().await.unwrap_or_else(|e| { + let 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()?; @@ -57,11 +51,7 @@ 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 @@ -145,7 +135,6 @@ 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); } } @@ -171,26 +160,22 @@ 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) => { - 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 { + crossterm::event::KeyCode::Char('r') => { + match monitor::load_monitors().await { + Ok(monitors) => { state.monitors = monitors; state.layout.clamp_selected(state.monitors.len()); - state.active_profile = None; + state.dirty = false; 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; @@ -204,19 +189,7 @@ async fn run( state.pending_apply = false; match monitor::apply_monitors(&state.monitors).await { Ok(()) => { - bread_events::emit_applied(state.active_profile.as_deref()); - 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, - ); - } - } + state.set_status("Applied.", StatusLevel::Success); } Err(e) => { state.set_status(format!("Apply failed: {}", e), StatusLevel::Error); diff --git a/src/mirror.rs b/src/mirror.rs index 124e598..6485a58 100644 --- a/src/mirror.rs +++ b/src/mirror.rs @@ -12,11 +12,7 @@ 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) { @@ -39,10 +35,7 @@ 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)] @@ -60,15 +53,8 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option Option Option = src_refreshes @@ -188,10 +165,7 @@ 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(); @@ -267,11 +241,7 @@ 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 fab2d5b..37f3128 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -145,15 +145,11 @@ 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), - ) - }); - modes.dedup_by(|a, b| { - a.width == b.width && a.height == b.height && (a.refresh - b.refresh).abs() < 0.01 + 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); let active_mode = Mode { width: raw.width, @@ -218,10 +214,8 @@ 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)) } @@ -274,10 +268,8 @@ 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() @@ -292,7 +284,6 @@ 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!( @@ -453,11 +444,7 @@ 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); @@ -469,11 +456,7 @@ 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, @@ -497,11 +480,7 @@ 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 ad26ad1..b108b2e 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -77,7 +77,8 @@ 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 { @@ -107,7 +108,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 [Monitor]) { +pub fn apply_to_monitors(profile: &Profile, monitors: &mut Vec) { 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)) { @@ -129,39 +130,15 @@ pub fn apply_to_monitors(profile: &Profile, monitors: &mut [Monitor]) { } fn chrono_now() -> String { - // 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, - ) + // 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()) } #[cfg(test)] @@ -173,11 +150,7 @@ 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, @@ -208,19 +181,4 @@ 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 deleted file mode 100644 index ed1d4a7..0000000 --- a/src/store.rs +++ /dev/null @@ -1,369 +0,0 @@ -//! 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 22cc80e..4ec4026 100644 --- a/src/ui/config_view.rs +++ b/src/ui/config_view.rs @@ -125,38 +125,32 @@ 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 => { - state.clear_burst(); - state.config.scale_editing = false; - state.config.next_field(); + cfg.scale_editing = false; + cfg.next_field(); } KeyCode::Char('k') | KeyCode::Up => { - state.clear_burst(); - state.config.scale_editing = false; - state.config.prev_field(); + cfg.scale_editing = false; + cfg.prev_field(); } KeyCode::Tab => { - state.clear_burst(); - state.config.scale_editing = false; - state.config.next_field(); + cfg.scale_editing = false; + cfg.next_field(); } KeyCode::BackTab => { - state.clear_burst(); - state.config.scale_editing = false; - state.config.prev_field(); + cfg.scale_editing = false; + cfg.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); @@ -165,7 +159,6 @@ 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; @@ -180,20 +173,19 @@ 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); } } @@ -219,10 +211,12 @@ 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); } @@ -236,8 +230,6 @@ 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 { @@ -247,17 +239,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.mark_dirty(); + 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(); + 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; + } } _ => {} }, @@ -266,15 +258,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.mark_dirty(); + 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(); + 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; + } } _ => {} }, @@ -284,14 +276,14 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { state.monitors[idx].scale = (s * 100.0).round() / 100.0; state.monitors[idx].scale = state.monitors[idx].scale.max(0.1); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); - state.mark_dirty(); + state.dirty = true; } 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.mark_dirty(); + state.dirty = true; } KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => { state.config.scale_editing = true; @@ -311,35 +303,27 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { .checked_sub(1) .unwrap_or(all.len() - 1); state.monitors[idx].transform = all[state.config.transform_idx]; - state.mark_dirty(); + state.dirty = true; } 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.mark_dirty(); + state.dirty = true; } _ => {} }, ConfigField::Vrr => match event.code { - KeyCode::Char('h') - | KeyCode::Left - | KeyCode::Char('l') - | KeyCode::Right - | KeyCode::Char(' ') => { + KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { state.monitors[idx].vrr = !state.monitors[idx].vrr; - state.mark_dirty(); + state.dirty = true; } _ => {} }, ConfigField::Dpms => match event.code { - KeyCode::Char('h') - | KeyCode::Left - | KeyCode::Char('l') - | KeyCode::Right - | KeyCode::Char(' ') => { + KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { state.monitors[idx].dpms = !state.monitors[idx].dpms; - state.mark_dirty(); + state.dirty = true; } _ => {} }, @@ -348,15 +332,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.mark_dirty(); + 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(); + 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; + } } _ => {} }, @@ -374,11 +358,7 @@ 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()), @@ -386,25 +366,19 @@ 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.mark_dirty(); + state.dirty = true; } state.config.scale_editing = false; } fn apply_current(state: &mut AppState) { - state.clear_burst(); state.pending_apply = true; state.set_status("Applying...", StatusLevel::Info); - // 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. + state.dirty = false; } pub fn render(f: &mut Frame, area: Rect, state: &AppState) { @@ -431,15 +405,12 @@ 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 @@ -461,6 +432,7 @@ 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) @@ -491,31 +463,20 @@ 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 00a54c0..ffa7131 100644 --- a/src/ui/layout_view.rs +++ b/src/ui/layout_view.rs @@ -8,10 +8,7 @@ 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}, }; @@ -23,51 +20,36 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { match event.code { KeyCode::Char('h') | KeyCode::Left => { - state.micro_edit(); + state.push_undo(); move_selected(&state.layout, &mut state.monitors, -step, 0); - state.mark_dirty(); + state.dirty = true; } KeyCode::Char('l') | KeyCode::Right => { - state.micro_edit(); + state.push_undo(); move_selected(&state.layout, &mut state.monitors, step, 0); - state.mark_dirty(); + state.dirty = true; } KeyCode::Char('k') | KeyCode::Up => { - state.micro_edit(); + state.push_undo(); move_selected(&state.layout, &mut state.monitors, 0, -step); - state.mark_dirty(); + state.dirty = true; } KeyCode::Char('j') | KeyCode::Down => { - state.micro_edit(); + state.push_undo(); move_selected(&state.layout, &mut state.monitors, 0, step); - 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); + state.dirty = true; } + 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.mark_dirty(); + state.dirty = true; } KeyCode::Enter => { - state.clear_burst(); - state - .config - .sync_from_monitor(state.layout.selected, &state.monitors); + state.config.sync_from_monitor(state.layout.selected, &state.monitors); state.tab = Tab::Config; } _ => {} @@ -84,8 +66,7 @@ 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(); @@ -104,22 +85,15 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { let canvas = canvas_area(state.terminal_size); let (min_x, min_y, _, _) = bounding_box(&state.monitors); let scale = canvas_scale_for(canvas, state); - let (wx, wy) = - canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); + let (wx, wy) = canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); let idx = drag.monitor_idx; let new_x = drag.origin_x + (wx - drag.click_world_x); let new_y = drag.origin_y + (wy - drag.click_world_y); - let (sx, sy) = snap_position( - idx, - new_x, - new_y, - &state.monitors, - state.layout.snap_threshold, - ); + let (sx, sy) = snap_position(idx, new_x, new_y, &state.monitors, state.layout.snap_threshold); state.monitors[idx].x = sx; state.monitors[idx].y = sy; - state.mark_dirty(); + state.dirty = true; } } MouseEventKind::Up(MouseButton::Left) => { @@ -188,31 +162,18 @@ 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) }; @@ -229,10 +190,7 @@ 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); } @@ -245,9 +203,7 @@ 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(); @@ -257,24 +213,14 @@ 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)), @@ -289,10 +235,8 @@ 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; @@ -315,9 +259,9 @@ pub fn canvas_area(terminal_size: (u16, u16)) -> Rect { } fn in_canvas(col: u16, row: u16, canvas: Rect) -> bool { - col > canvas.x + col >= canvas.x + 1 && col < canvas.x + canvas.width.saturating_sub(1) - && row > canvas.y + && row >= canvas.y + 1 && row < canvas.y + canvas.height.saturating_sub(1) } @@ -357,13 +301,31 @@ 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 249d33f..b593dd1 100644 --- a/src/ui/mirror_view.rs +++ b/src/ui/mirror_view.rs @@ -45,9 +45,7 @@ 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; @@ -56,13 +54,8 @@ 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; } @@ -93,14 +86,12 @@ 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; } @@ -127,10 +118,7 @@ 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( @@ -149,13 +137,15 @@ 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.mark_dirty(); + 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 + src_name, + state.monitors[tgt_idx].name, + result.mirror_mode ), StatusLevel::Success, ); @@ -192,26 +182,17 @@ 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]; @@ -219,10 +200,7 @@ 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( @@ -232,39 +210,33 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { } } } - r if r >= 7 + r if r >= 7 => { // Result panel: Apply is on the line with buttons. // Rough column check: col < 20 = Apply, col >= 20 = Cancel - && 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(); + 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 = 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; } } _ => {} @@ -274,33 +246,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; + 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; + } + _ => {} } - MirrorField::Target => { - state.mirror.target_idx = (state.mirror.target_idx + 1) % count; - state.mirror.fix_indices(count); - state.mirror.result = None; - } - _ => {} - }, + } _ => {} } } @@ -352,16 +324,12 @@ 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) }; @@ -386,9 +354,7 @@ 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) }; @@ -410,16 +376,12 @@ 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) }; @@ -435,10 +397,7 @@ 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 cadb387..e4891c4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -14,7 +14,10 @@ use ratatui::{ Frame, }; -use crate::{layout::LayoutState, monitor::Monitor}; +use crate::{ + layout::LayoutState, + monitor::Monitor, +}; use config_view::ConfigState; use mirror_view::MirrorState; @@ -108,15 +111,8 @@ 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 { @@ -135,26 +131,12 @@ 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(), - }); - } - - /// 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; + self.status = Some(StatusMsg { text: text.into(), level, born: Instant::now() }); } pub fn tick_status(&mut self) { @@ -166,40 +148,14 @@ 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) and end any - /// in-progress edit burst. + /// Save a monitor snapshot for undo (max 20 entries). 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); @@ -210,8 +166,7 @@ impl AppState { pub fn undo(&mut self) { if let Some(snapshot) = self.undo_stack.pop() { self.monitors = snapshot; - self.undo_in_burst = false; - self.mark_dirty(); + self.dirty = true; self.layout.clamp_selected(self.monitors.len()); // Re-sync config view to the restored state let idx = self.layout.selected; @@ -246,22 +201,10 @@ 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 b05f9be..7e23bfd 100644 --- a/src/ui/profiles_view.rs +++ b/src/ui/profiles_view.rs @@ -126,10 +126,7 @@ 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); @@ -198,11 +195,8 @@ 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; } } @@ -246,7 +240,6 @@ 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) => { @@ -261,7 +254,6 @@ 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, @@ -314,9 +306,7 @@ 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) }; @@ -370,28 +360,22 @@ 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], ); }