From 20d4825cf0197b77b08ea66e5e1aa77e616928b7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 21:35:35 +0800 Subject: [PATCH 1/3] deps: pin bread-utils to bread-ecosystem v0.7.1 Replace the old `bread modules install` docs with `bakery install breadmon`. Spell out that `hyprctl eval 'hl.monitor({...})'` is BOS-patched Hyprland only, and that breadmon is not the bos-settings Display panel (which edits monitors.json). --- Cargo.lock | 4 ++-- Cargo.toml | 3 +-- README.md | 26 ++++++++++++++++---------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 67a5c2c..1ac0ca2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,8 +22,8 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-utils" -version = "0.3.0" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ "dirs", "serde", diff --git a/Cargo.toml b/Cargo.toml index 6cc904c..0e635ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,8 +19,7 @@ toml = "0.8" anyhow = "1" dirs = "5" futures = "0.3" -# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } [profile.release] lto = "thin" diff --git a/README.md b/README.md index a0d2396..d4ff828 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,24 @@ # breadmon -A terminal UI monitor manager for Hyprland. Lets you position, configure, and mirror displays interactively, then apply changes live via `hyprctl`. +A terminal UI monitor manager for Hyprland. Lets you position, configure, and mirror displays interactively, then apply a live layout on [BOS](https://git.breadway.dev/breadway/bos)-patched Hyprland. + +breadmon is **not** the Display panel in `bos-settings`. That panel edits `~/.config/hypr/monitors.json` (Hyprland's login/reload layout store). breadmon keeps its own named snapshots as TOML under `~/.config/breadmon/profiles/` and applies them live through BOS Hyprland. The two stores are independent — changing one does not update the other. ## Requirements -- **[BOS (Bread OS)](https://git.breadway.dev/breadway/bos)'s patched Hyprland build.** Applying changes (the `a` key / Global keys "Apply") runs `hyprctl eval` with a `hl.monitor({...})` Lua call — a BOS-specific extension that does not exist on vanilla/upstream Hyprland. On a non-BOS Hyprland install, `hyprctl eval` itself is not a recognized request, and breadmon will fail to apply with an explicit error explaining this instead of the raw hyprctl response. Everything else in the TUI (viewing/arranging/saving profiles) works regardless; only the live-apply step needs BOS. +- **[BOS (Bread OS)](https://git.breadway.dev/breadway/bos)'s patched Hyprland build** for live apply. The `a` key runs `hyprctl eval 'hl.monitor({...})'` — a BOS-specific Lua extension. Vanilla/upstream Hyprland has no `eval` request and no `hl.monitor()`, so apply will fail there with an explicit error instead of the raw hyprctl response. Viewing, arranging, and saving profiles work on any Hyprland; only live apply needs BOS. - The `hyprctl` binary must be on `PATH` - Rust toolchain (to build from source) -## Build +## Install + +Via [bakery](https://git.breadway.dev/Breadway/bread-ecosystem), the bread ecosystem package manager: + +``` +bakery install breadmon +``` + +Or build from source: ``` cargo build --release @@ -16,12 +26,6 @@ cargo build --release The binary is written to `target/release/breadmon`. -If you use the bread ecosystem, `bakery` can install it instead: - -``` -bread modules install /path/to/breadmon -``` - ## Usage ``` @@ -91,7 +95,7 @@ Profiles are saved to `~/.config/breadmon/profiles/`. | Key | Action | |-----|--------| -| `a` | Apply current configuration via `hyprctl` | +| `a` | Apply current configuration via `hyprctl eval 'hl.monitor({...})'` (BOS-patched Hyprland only) | | `s` | Save current configuration as a profile | | `r` | Refresh monitor list from Hyprland | | `Ctrl+Z` | Undo last change (up to 20 steps) | @@ -102,3 +106,5 @@ breadmon also listens on Hyprland's event socket and reloads the monitor list au ## Config Profiles are plain TOML files under `~/.config/breadmon/profiles/`. Each file records the monitor name, mode, position, scale, transform, VRR, DPMS, and mirror source. They are created and managed through the Profiles tab; there is no hand-written config file. + +This is not `~/.config/hypr/monitors.json`. That file is the persistent Hyprland layout edited by the `bos-settings` Display panel and applied on login/reload. breadmon never reads or writes it. From 7aa95e1ffa13fa2df721d070af65b456eb95edb4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:03:30 +0800 Subject: [PATCH 2/3] Track AGENTS.md --- .gitignore | 1 - AGENTS.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index 8d2803a..0a33787 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,3 @@ logs/ *.pid # Local hygiene notes (not for commit) -CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e911ad9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# AGENTS.md — Repo hygiene + +This repo follows the branch/release workflow documented in `CONTRIBUTING.md` +— read and follow it for any git, branch, or release work here (the +single-trunk `main` model, `feature/x`/`fix/x` branch naming, RC-tag-driven +beta releases, etc). Don't improvise a different workflow. The short version: +there is one long-lived branch, `main` — no `dev` or `beta` branch exists. +`main` auto-publishes a dev-track build on every push. "Beta" and "stable" +are both just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a +beta-track build, push a plain `vX.Y.Z` tag to cut the signed stable +release. "Freezing" for stabilization means pausing pushes to `main`, not +moving a branch. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push both when publishing. + +## CI +- `check.yml` — clippy + test, triggers on push to `feature/**`/`fix/**`. +- `dev-release.yml` — triggers on push to `main`. +- `rc-release.yml` — triggers on `vX.Y.Z-rc.N` tag push. +- `release.yml` — triggers on any other `v*` tag push. + +All four run on a self-hosted runner (`hestia`) inside a pinned Arch +container — not the host's native environment. The Containerfile/build +script are shared across bread-ecosystem products and live in +`bread-ecosystem/ci/`; this repo's `ci/build.sh` clones that repo at the +sha in `ci/bread-ecosystem.rev` (deliberately pinned, not `main`) and +delegates to it. Nothing runs automatically on plain commits or PRs +beyond what's listed. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. From aec2fa52907c1e0de4cb40e9c0e17057ffaca6a5 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:16:36 +0800 Subject: [PATCH 3/3] Wire breadmon into the bread event fabric (app id mon) After a successful hyprctl apply, publish bread.mon.applied { "profile": }. BreadClient is fail-silent: if breadd is down, apply behaves exactly as before. Document the contract in EVENTS.md. --- Cargo.lock | 12 +++++++++++ Cargo.toml | 2 +- EVENTS.md | 44 +++++++++++++++++++++++++++++++++++++++++ README.md | 4 ++++ src/bread_events.rs | 43 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 4 ++++ src/ui/config_view.rs | 26 ++++++++++++------------ src/ui/layout_view.rs | 12 +++++------ src/ui/mirror_view.rs | 4 ++-- src/ui/mod.rs | 15 +++++++++++++- src/ui/profiles_view.rs | 2 ++ 11 files changed, 145 insertions(+), 23 deletions(-) create mode 100644 EVENTS.md create mode 100644 src/bread_events.rs diff --git a/Cargo.lock b/Cargo.lock index 1ac0ca2..d56164e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,11 +20,23 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bread-shared" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +dependencies = [ + "dirs", + "serde", + "serde_json", + "toml", +] + [[package]] name = "bread-utils" version = "0.3.1" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ + "bread-shared", "dirs", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 0e635ab..b2da7f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ toml = "0.8" anyhow = "1" dirs = "5" futures = "0.3" -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["bread-client"] } [profile.release] lto = "thin" diff --git a/EVENTS.md b/EVENTS.md new file mode 100644 index 0000000..1b92496 --- /dev/null +++ b/EVENTS.md @@ -0,0 +1,44 @@ +# breadmon — bread event integration + +breadmon is a standalone TUI monitor manager: it works exactly the same +with or without `breadd` running. When breadd *is* present, a successful +live apply publishes an event into the shared bread automation fabric. +See the parent `bread` repo's `Documentation.md` — specifically its +"Namespaces" and "Integrating a bread\* app" sections — for the general +convention this follows. + +App id: **`mon`**. Transport: `bread-utils`'s `bread_client` module +(feature `bread-client`) — the TUI links it directly and emits from the +same process that ran `hyprctl eval`. Each `emit` is its own short-lived +connection (`BreadClient::emit` never blocks or errors the caller). + +This event is about breadmon's own live apply (`hyprctl eval +'hl.monitor({...})'` on [BOS](https://git.breadway.dev/breadway/bos)-patched +Hyprland). It is **not** fired by the `bos-settings` Display panel, which +writes `~/.config/hypr/monitors.json` and is a separate store breadmon +never reads or writes. Vanilla/upstream Hyprland has no `eval` request +and no `hl.monitor()`, so apply fails there and this event is not +published. + +## Events published (`bread.mon.*`) + +| Event | Data | When | +|-------|------|------| +| `bread.mon.applied` | `{ "profile": }` | After `hyprctl eval 'hl.monitor({...})'` succeeds. `profile` is the named snapshot that was just applied (the last loaded or saved profile this session, if the layout was not edited after that), or `null` for an ad-hoc layout. Not emitted when apply fails. | + +## Commands honored (`bread.command.mon.*`) + +None. breadmon is an interactive TUI, not a long-running daemon — a +command subscription would only be live while the TUI is open, which is +a poor control surface. Apply, load, and save stay keyboard-driven. +If/when breadmon grows a headless apply path, the corresponding +`bread.command.mon.apply` verb should be added at the same time, not +stubbed out ahead of it. + +## Fail-safe behavior + +- If breadd isn't installed or isn't running, `emit` is a silent no-op + (`BreadClient::emit` never blocks or errors the caller) — breadmon's + actual apply / profile / TUI functionality is entirely unaffected. +- There is no command subscription, so a breadd restart has nothing to + reconnect. diff --git a/README.md b/README.md index d4ff828..b15936c 100644 --- a/README.md +++ b/README.md @@ -108,3 +108,7 @@ breadmon also listens on Hyprland's event socket and reloads the monitor list au Profiles are plain TOML files under `~/.config/breadmon/profiles/`. Each file records the monitor name, mode, position, scale, transform, VRR, DPMS, and mirror source. They are created and managed through the Profiles tab; there is no hand-written config file. This is not `~/.config/hypr/monitors.json`. That file is the persistent Hyprland layout edited by the `bos-settings` Display panel and applied on login/reload. breadmon never reads or writes it. + +## bread event integration + +breadmon works the same with or without `breadd`. After a successful live apply (`hyprctl eval 'hl.monitor({...})'` on BOS-patched Hyprland — not the `bos-settings` Display panel), it publishes `bread.mon.applied`. If breadd is down, the emit is a silent no-op; apply itself is unchanged. See [EVENTS.md](EVENTS.md) for the bus contract. `bread` is not a bakery dependency. diff --git a/src/bread_events.rs b/src/bread_events.rs new file mode 100644 index 0000000..7197765 --- /dev/null +++ b/src/bread_events.rs @@ -0,0 +1,43 @@ +//! `bread.mon.*` event integration — optional, non-blocking. See +//! `EVENTS.md` at the repo root for the full contract. breadmon works +//! identically with or without breadd running; every call here is +//! fire-and-forget (`BreadClient::emit` never blocks or errors this +//! process) so a missing or restarting breadd never affects apply itself. + +use bread_utils::bread_client::BreadClient; +use serde_json::{json, Value}; + +/// This app's id in bread's sibling-app namespace registry +/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.mon.*`. +pub const APP_ID: &str = "mon"; + +/// JSON payload for `bread.mon.applied`. `profile` is the named snapshot +/// that was just applied, or `null` for an ad-hoc layout. +pub fn applied_data(profile: Option<&str>) -> Value { + json!({ "profile": profile }) +} + +/// Publishes `bread.mon.applied` after a successful hyprctl apply. +/// Fire-and-forget and non-fatal by design — breadd being absent or not +/// installed must never affect breadmon's own apply path. +pub fn emit_applied(profile: Option<&str>) { + BreadClient::connect(APP_ID).emit("bread.mon.applied", applied_data(profile)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn applied_data_serializes_name_or_null() { + assert_eq!(applied_data(Some("dock")), json!({ "profile": "dock" })); + assert_eq!(applied_data(None), json!({ "profile": null })); + } + + #[test] + fn emit_applied_is_silent_when_breadd_is_down() { + // No daemon in the unit-test environment; must not panic or block. + emit_applied(Some("dock")); + emit_applied(None); + } +} diff --git a/src/main.rs b/src/main.rs index 5343ef1..fd3e35b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod bread_events; mod layout; mod mirror; mod monitor; @@ -135,6 +136,7 @@ async fn run( if let Ok(monitors) = monitor::load_monitors().await { state.monitors = monitors; state.layout.clamp_selected(state.monitors.len()); + state.active_profile = None; state.set_status("Monitor configuration changed.", StatusLevel::Info); } } @@ -166,6 +168,7 @@ async fn run( state.monitors = monitors; state.layout.clamp_selected(state.monitors.len()); state.dirty = false; + state.active_profile = None; state.set_status("Monitors refreshed.", StatusLevel::Success); } Err(e) => { @@ -189,6 +192,7 @@ async fn run( state.pending_apply = false; match monitor::apply_monitors(&state.monitors).await { Ok(()) => { + bread_events::emit_applied(state.active_profile.as_deref()); state.set_status("Applied.", StatusLevel::Success); } Err(e) => { diff --git a/src/ui/config_view.rs b/src/ui/config_view.rs index d3a3352..247cac9 100644 --- a/src/ui/config_view.rs +++ b/src/ui/config_view.rs @@ -239,7 +239,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { let m = &state.monitors[idx]; state.config.update_refreshes(m); sync_mode_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } } KeyCode::Char('l') | KeyCode::Right @@ -249,7 +249,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { let m = &state.monitors[idx]; state.config.update_refreshes(m); sync_mode_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } _ => {} }, @@ -258,7 +258,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { if state.config.refresh_idx > 0 { state.config.refresh_idx -= 1; sync_mode_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } } KeyCode::Char('l') | KeyCode::Right @@ -266,7 +266,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { { state.config.refresh_idx += 1; sync_mode_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } _ => {} }, @@ -276,14 +276,14 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { state.monitors[idx].scale = (s * 100.0).round() / 100.0; state.monitors[idx].scale = state.monitors[idx].scale.max(0.1); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('.') => { let s = state.monitors[idx].scale + 0.1; state.monitors[idx].scale = (s * 100.0).round() / 100.0; state.monitors[idx].scale = state.monitors[idx].scale.min(10.0); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => { state.config.scale_editing = true; @@ -303,27 +303,27 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { .checked_sub(1) .unwrap_or(all.len() - 1); state.monitors[idx].transform = all[state.config.transform_idx]; - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('l') | KeyCode::Right => { let all = Transform::all(); state.config.transform_idx = (state.config.transform_idx + 1) % all.len(); state.monitors[idx].transform = all[state.config.transform_idx]; - state.dirty = true; + state.mark_dirty(); } _ => {} }, ConfigField::Vrr => match event.code { KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { state.monitors[idx].vrr = !state.monitors[idx].vrr; - state.dirty = true; + state.mark_dirty(); } _ => {} }, ConfigField::Dpms => match event.code { KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { state.monitors[idx].dpms = !state.monitors[idx].dpms; - state.dirty = true; + state.mark_dirty(); } _ => {} }, @@ -332,7 +332,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { if state.config.mirror_idx > 0 { state.config.mirror_idx -= 1; sync_mirror_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } } KeyCode::Char('l') | KeyCode::Right @@ -340,7 +340,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { { state.config.mirror_idx += 1; sync_mirror_to_monitor(state, idx); - state.dirty = true; + state.mark_dirty(); } _ => {} }, @@ -370,7 +370,7 @@ fn commit_scale(state: &mut AppState) { if let Ok(v) = state.config.scale_str.parse::() { state.monitors[idx].scale = v.clamp(0.1, 10.0); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); - state.dirty = true; + state.mark_dirty(); } state.config.scale_editing = false; } diff --git a/src/ui/layout_view.rs b/src/ui/layout_view.rs index c64d1e3..a1aa3df 100644 --- a/src/ui/layout_view.rs +++ b/src/ui/layout_view.rs @@ -22,22 +22,22 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { KeyCode::Char('h') | KeyCode::Left => { state.push_undo(); move_selected(&state.layout, &mut state.monitors, -step, 0); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('l') | KeyCode::Right => { state.push_undo(); move_selected(&state.layout, &mut state.monitors, step, 0); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('k') | KeyCode::Up => { state.push_undo(); move_selected(&state.layout, &mut state.monitors, 0, -step); - state.dirty = true; + state.mark_dirty(); } KeyCode::Char('j') | KeyCode::Down => { state.push_undo(); move_selected(&state.layout, &mut state.monitors, 0, step); - state.dirty = true; + state.mark_dirty(); } KeyCode::Tab | KeyCode::Char('n') => state.layout.next(count), KeyCode::BackTab | KeyCode::Char('p') => state.layout.prev(count), @@ -46,7 +46,7 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { KeyCode::Char('0') => { state.push_undo(); auto_arrange(&mut state.monitors); - state.dirty = true; + state.mark_dirty(); } KeyCode::Enter => { state.config.sync_from_monitor(state.layout.selected, &state.monitors); @@ -93,7 +93,7 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { let (sx, sy) = snap_position(idx, new_x, new_y, &state.monitors, state.layout.snap_threshold); state.monitors[idx].x = sx; state.monitors[idx].y = sy; - state.dirty = true; + state.mark_dirty(); } } MouseEventKind::Up(MouseButton::Left) => { diff --git a/src/ui/mirror_view.rs b/src/ui/mirror_view.rs index 57f0d9d..c8b35f2 100644 --- a/src/ui/mirror_view.rs +++ b/src/ui/mirror_view.rs @@ -137,7 +137,7 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { state.monitors[tgt_idx].active_mode = result.mirror_mode.clone(); state.monitors[tgt_idx].mirror_of = Some(src_name.clone()); - state.dirty = true; + state.mark_dirty(); state.mirror.result = None; state.mirror.focused = 0; state.set_status( @@ -225,7 +225,7 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { let tgt_idx = state.mirror.target_idx; state.monitors[tgt_idx].active_mode = result.mirror_mode.clone(); state.monitors[tgt_idx].mirror_of = Some(src_name.clone()); - state.dirty = true; + state.mark_dirty(); state.mirror.result = None; state.mirror.focused = 0; state.set_status( diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e4891c4..ad44d1d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -111,6 +111,10 @@ pub struct AppState { pub terminal_size: (u16, u16), /// Set to true by any handler that wants `main.rs` to run `apply_monitors`. pub pending_apply: bool, + /// Named snapshot last loaded or saved this session. Cleared when the + /// in-memory layout is edited, so `bread.mon.applied` can report it + /// honestly (or `null` for an ad-hoc layout). + pub active_profile: Option, /// Snapshots for Ctrl+Z undo (up to 20 deep). pub undo_stack: Vec>, } @@ -131,6 +135,7 @@ impl AppState { drag_state: None, terminal_size, pending_apply: false, + active_profile: None, undo_stack: Vec::new(), } } @@ -139,6 +144,14 @@ impl AppState { self.status = Some(StatusMsg { text: text.into(), level, born: Instant::now() }); } + /// Mark the in-memory layout as edited. Also forgets `active_profile` + /// — a mutated layout is no longer the named snapshot that was loaded + /// or saved. + pub fn mark_dirty(&mut self) { + self.dirty = true; + self.active_profile = None; + } + pub fn tick_status(&mut self) { if let Some(s) = &self.status { if s.born.elapsed().as_secs() >= 3 { @@ -166,7 +179,7 @@ impl AppState { pub fn undo(&mut self) { if let Some(snapshot) = self.undo_stack.pop() { self.monitors = snapshot; - self.dirty = true; + self.mark_dirty(); self.layout.clamp_selected(self.monitors.len()); // Re-sync config view to the restored state let idx = self.layout.selected; diff --git a/src/ui/profiles_view.rs b/src/ui/profiles_view.rs index 7e23bfd..c50cea8 100644 --- a/src/ui/profiles_view.rs +++ b/src/ui/profiles_view.rs @@ -240,6 +240,7 @@ fn do_save(state: &mut AppState) { Ok(()) => { state.profiles.new_name.clear(); state.profiles.refresh(); + state.active_profile = Some(name.clone()); state.set_status(format!("Saved profile '{}'", name), StatusLevel::Success); } Err(e) => { @@ -254,6 +255,7 @@ fn do_load(state: &mut AppState) { Ok(p) => { profile::apply_to_monitors(&p, &mut state.monitors); state.dirty = true; + state.active_profile = Some(name.clone()); state.set_status( format!("Loaded profile '{}'. Press [a] to apply.", name), StatusLevel::Success,