From aec2fa52907c1e0de4cb40e9c0e17057ffaca6a5 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:16:36 +0800 Subject: [PATCH] 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,