Wire breadmon into the bread event fabric (app id mon)
Some checks failed
check / check (push) Failing after 1s
dev release / build (push) Failing after 1s

After a successful hyprctl apply, publish bread.mon.applied
{ "profile": <string or null> }. BreadClient is fail-silent: if
breadd is down, apply behaves exactly as before.

Document the contract in EVENTS.md.
This commit is contained in:
Breadway 2026-08-15 22:16:36 +08:00
parent 7aa95e1ffa
commit aec2fa5290
11 changed files with 145 additions and 23 deletions

12
Cargo.lock generated
View file

@ -20,11 +20,23 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" 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]] [[package]]
name = "bread-utils" name = "bread-utils"
version = "0.3.1" version = "0.3.1"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886"
dependencies = [ dependencies = [
"bread-shared",
"dirs", "dirs",
"serde", "serde",
"serde_json", "serde_json",

View file

@ -19,7 +19,7 @@ toml = "0.8"
anyhow = "1" anyhow = "1"
dirs = "5" dirs = "5"
futures = "0.3" 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] [profile.release]
lto = "thin" lto = "thin"

44
EVENTS.md Normal file
View file

@ -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": <string or null> }` | After `hyprctl eval 'hl.monitor({...})'` succeeds. `profile` is the named snapshot that was just applied (the last loaded or saved profile this session, if the layout was not edited after that), or `null` for an ad-hoc layout. Not emitted when apply fails. |
## Commands honored (`bread.command.mon.*`)
None. breadmon is an interactive TUI, not a long-running daemon — a
command subscription would only be live while the TUI is open, which is
a poor control surface. Apply, load, and save stay keyboard-driven.
If/when breadmon grows a headless apply path, the corresponding
`bread.command.mon.apply` verb should be added at the same time, not
stubbed out ahead of it.
## Fail-safe behavior
- If breadd isn't installed or isn't running, `emit` is a silent no-op
(`BreadClient::emit` never blocks or errors the caller) — breadmon's
actual apply / profile / TUI functionality is entirely unaffected.
- There is no command subscription, so a breadd restart has nothing to
reconnect.

View file

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

43
src/bread_events.rs Normal file
View file

@ -0,0 +1,43 @@
//! `bread.mon.*` event integration — optional, non-blocking. See
//! `EVENTS.md` at the repo root for the full contract. breadmon works
//! identically with or without breadd running; every call here is
//! fire-and-forget (`BreadClient::emit` never blocks or errors this
//! process) so a missing or restarting breadd never affects apply itself.
use bread_utils::bread_client::BreadClient;
use serde_json::{json, Value};
/// This app's id in bread's sibling-app namespace registry
/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.mon.*`.
pub const APP_ID: &str = "mon";
/// JSON payload for `bread.mon.applied`. `profile` is the named snapshot
/// that was just applied, or `null` for an ad-hoc layout.
pub fn applied_data(profile: Option<&str>) -> Value {
json!({ "profile": profile })
}
/// Publishes `bread.mon.applied` after a successful hyprctl apply.
/// Fire-and-forget and non-fatal by design — breadd being absent or not
/// installed must never affect breadmon's own apply path.
pub fn emit_applied(profile: Option<&str>) {
BreadClient::connect(APP_ID).emit("bread.mon.applied", applied_data(profile));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn applied_data_serializes_name_or_null() {
assert_eq!(applied_data(Some("dock")), json!({ "profile": "dock" }));
assert_eq!(applied_data(None), json!({ "profile": null }));
}
#[test]
fn emit_applied_is_silent_when_breadd_is_down() {
// No daemon in the unit-test environment; must not panic or block.
emit_applied(Some("dock"));
emit_applied(None);
}
}

View file

@ -1,3 +1,4 @@
mod bread_events;
mod layout; mod layout;
mod mirror; mod mirror;
mod monitor; mod monitor;
@ -135,6 +136,7 @@ async fn run(
if let Ok(monitors) = monitor::load_monitors().await { if let Ok(monitors) = monitor::load_monitors().await {
state.monitors = monitors; state.monitors = monitors;
state.layout.clamp_selected(state.monitors.len()); state.layout.clamp_selected(state.monitors.len());
state.active_profile = None;
state.set_status("Monitor configuration changed.", StatusLevel::Info); state.set_status("Monitor configuration changed.", StatusLevel::Info);
} }
} }
@ -166,6 +168,7 @@ async fn run(
state.monitors = monitors; state.monitors = monitors;
state.layout.clamp_selected(state.monitors.len()); state.layout.clamp_selected(state.monitors.len());
state.dirty = false; state.dirty = false;
state.active_profile = None;
state.set_status("Monitors refreshed.", StatusLevel::Success); state.set_status("Monitors refreshed.", StatusLevel::Success);
} }
Err(e) => { Err(e) => {
@ -189,6 +192,7 @@ async fn run(
state.pending_apply = false; state.pending_apply = false;
match monitor::apply_monitors(&state.monitors).await { match monitor::apply_monitors(&state.monitors).await {
Ok(()) => { Ok(()) => {
bread_events::emit_applied(state.active_profile.as_deref());
state.set_status("Applied.", StatusLevel::Success); state.set_status("Applied.", StatusLevel::Success);
} }
Err(e) => { Err(e) => {

View file

@ -239,7 +239,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
let m = &state.monitors[idx]; let m = &state.monitors[idx];
state.config.update_refreshes(m); state.config.update_refreshes(m);
sync_mode_to_monitor(state, idx); sync_mode_to_monitor(state, idx);
state.dirty = true; state.mark_dirty();
} }
} }
KeyCode::Char('l') | KeyCode::Right KeyCode::Char('l') | KeyCode::Right
@ -249,7 +249,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
let m = &state.monitors[idx]; let m = &state.monitors[idx];
state.config.update_refreshes(m); state.config.update_refreshes(m);
sync_mode_to_monitor(state, idx); sync_mode_to_monitor(state, idx);
state.dirty = true; state.mark_dirty();
} }
_ => {} _ => {}
}, },
@ -258,7 +258,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
if state.config.refresh_idx > 0 { if state.config.refresh_idx > 0 {
state.config.refresh_idx -= 1; state.config.refresh_idx -= 1;
sync_mode_to_monitor(state, idx); sync_mode_to_monitor(state, idx);
state.dirty = true; state.mark_dirty();
} }
} }
KeyCode::Char('l') | KeyCode::Right KeyCode::Char('l') | KeyCode::Right
@ -266,7 +266,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
{ {
state.config.refresh_idx += 1; state.config.refresh_idx += 1;
sync_mode_to_monitor(state, idx); sync_mode_to_monitor(state, idx);
state.dirty = true; state.mark_dirty();
} }
_ => {} _ => {}
}, },
@ -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 = (s * 100.0).round() / 100.0;
state.monitors[idx].scale = state.monitors[idx].scale.max(0.1); state.monitors[idx].scale = state.monitors[idx].scale.max(0.1);
state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale);
state.dirty = true; state.mark_dirty();
} }
KeyCode::Char('.') => { KeyCode::Char('.') => {
let s = state.monitors[idx].scale + 0.1; let s = state.monitors[idx].scale + 0.1;
state.monitors[idx].scale = (s * 100.0).round() / 100.0; state.monitors[idx].scale = (s * 100.0).round() / 100.0;
state.monitors[idx].scale = state.monitors[idx].scale.min(10.0); state.monitors[idx].scale = state.monitors[idx].scale.min(10.0);
state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale);
state.dirty = true; state.mark_dirty();
} }
KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => { KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => {
state.config.scale_editing = true; state.config.scale_editing = true;
@ -303,27 +303,27 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
.checked_sub(1) .checked_sub(1)
.unwrap_or(all.len() - 1); .unwrap_or(all.len() - 1);
state.monitors[idx].transform = all[state.config.transform_idx]; state.monitors[idx].transform = all[state.config.transform_idx];
state.dirty = true; state.mark_dirty();
} }
KeyCode::Char('l') | KeyCode::Right => { KeyCode::Char('l') | KeyCode::Right => {
let all = Transform::all(); let all = Transform::all();
state.config.transform_idx = (state.config.transform_idx + 1) % all.len(); state.config.transform_idx = (state.config.transform_idx + 1) % all.len();
state.monitors[idx].transform = all[state.config.transform_idx]; state.monitors[idx].transform = all[state.config.transform_idx];
state.dirty = true; state.mark_dirty();
} }
_ => {} _ => {}
}, },
ConfigField::Vrr => match event.code { ConfigField::Vrr => match event.code {
KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => {
state.monitors[idx].vrr = !state.monitors[idx].vrr; state.monitors[idx].vrr = !state.monitors[idx].vrr;
state.dirty = true; state.mark_dirty();
} }
_ => {} _ => {}
}, },
ConfigField::Dpms => match event.code { ConfigField::Dpms => match event.code {
KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => {
state.monitors[idx].dpms = !state.monitors[idx].dpms; state.monitors[idx].dpms = !state.monitors[idx].dpms;
state.dirty = true; state.mark_dirty();
} }
_ => {} _ => {}
}, },
@ -332,7 +332,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
if state.config.mirror_idx > 0 { if state.config.mirror_idx > 0 {
state.config.mirror_idx -= 1; state.config.mirror_idx -= 1;
sync_mirror_to_monitor(state, idx); sync_mirror_to_monitor(state, idx);
state.dirty = true; state.mark_dirty();
} }
} }
KeyCode::Char('l') | KeyCode::Right KeyCode::Char('l') | KeyCode::Right
@ -340,7 +340,7 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
{ {
state.config.mirror_idx += 1; state.config.mirror_idx += 1;
sync_mirror_to_monitor(state, idx); sync_mirror_to_monitor(state, idx);
state.dirty = true; state.mark_dirty();
} }
_ => {} _ => {}
}, },
@ -370,7 +370,7 @@ fn commit_scale(state: &mut AppState) {
if let Ok(v) = state.config.scale_str.parse::<f64>() { if let Ok(v) = state.config.scale_str.parse::<f64>() {
state.monitors[idx].scale = v.clamp(0.1, 10.0); state.monitors[idx].scale = v.clamp(0.1, 10.0);
state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); state.config.scale_str = format!("{:.2}", state.monitors[idx].scale);
state.dirty = true; state.mark_dirty();
} }
state.config.scale_editing = false; state.config.scale_editing = false;
} }

View file

@ -22,22 +22,22 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
KeyCode::Char('h') | KeyCode::Left => { KeyCode::Char('h') | KeyCode::Left => {
state.push_undo(); state.push_undo();
move_selected(&state.layout, &mut state.monitors, -step, 0); move_selected(&state.layout, &mut state.monitors, -step, 0);
state.dirty = true; state.mark_dirty();
} }
KeyCode::Char('l') | KeyCode::Right => { KeyCode::Char('l') | KeyCode::Right => {
state.push_undo(); state.push_undo();
move_selected(&state.layout, &mut state.monitors, step, 0); move_selected(&state.layout, &mut state.monitors, step, 0);
state.dirty = true; state.mark_dirty();
} }
KeyCode::Char('k') | KeyCode::Up => { KeyCode::Char('k') | KeyCode::Up => {
state.push_undo(); state.push_undo();
move_selected(&state.layout, &mut state.monitors, 0, -step); move_selected(&state.layout, &mut state.monitors, 0, -step);
state.dirty = true; state.mark_dirty();
} }
KeyCode::Char('j') | KeyCode::Down => { KeyCode::Char('j') | KeyCode::Down => {
state.push_undo(); state.push_undo();
move_selected(&state.layout, &mut state.monitors, 0, step); move_selected(&state.layout, &mut state.monitors, 0, step);
state.dirty = true; state.mark_dirty();
} }
KeyCode::Tab | KeyCode::Char('n') => state.layout.next(count), KeyCode::Tab | KeyCode::Char('n') => state.layout.next(count),
KeyCode::BackTab | KeyCode::Char('p') => state.layout.prev(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') => { KeyCode::Char('0') => {
state.push_undo(); state.push_undo();
auto_arrange(&mut state.monitors); auto_arrange(&mut state.monitors);
state.dirty = true; state.mark_dirty();
} }
KeyCode::Enter => { KeyCode::Enter => {
state.config.sync_from_monitor(state.layout.selected, &state.monitors); state.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); let (sx, sy) = snap_position(idx, new_x, new_y, &state.monitors, state.layout.snap_threshold);
state.monitors[idx].x = sx; state.monitors[idx].x = sx;
state.monitors[idx].y = sy; state.monitors[idx].y = sy;
state.dirty = true; state.mark_dirty();
} }
} }
MouseEventKind::Up(MouseButton::Left) => { MouseEventKind::Up(MouseButton::Left) => {

View file

@ -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].active_mode = result.mirror_mode.clone();
state.monitors[tgt_idx].mirror_of = Some(src_name.clone()); state.monitors[tgt_idx].mirror_of = Some(src_name.clone());
state.dirty = true; state.mark_dirty();
state.mirror.result = None; state.mirror.result = None;
state.mirror.focused = 0; state.mirror.focused = 0;
state.set_status( state.set_status(
@ -225,7 +225,7 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
let tgt_idx = state.mirror.target_idx; let tgt_idx = state.mirror.target_idx;
state.monitors[tgt_idx].active_mode = result.mirror_mode.clone(); state.monitors[tgt_idx].active_mode = result.mirror_mode.clone();
state.monitors[tgt_idx].mirror_of = Some(src_name.clone()); state.monitors[tgt_idx].mirror_of = Some(src_name.clone());
state.dirty = true; state.mark_dirty();
state.mirror.result = None; state.mirror.result = None;
state.mirror.focused = 0; state.mirror.focused = 0;
state.set_status( state.set_status(

View file

@ -111,6 +111,10 @@ pub struct AppState {
pub terminal_size: (u16, u16), pub terminal_size: (u16, u16),
/// Set to true by any handler that wants `main.rs` to run `apply_monitors`. /// Set to true by any handler that wants `main.rs` to run `apply_monitors`.
pub pending_apply: bool, pub pending_apply: bool,
/// Named snapshot last loaded or saved this session. Cleared when the
/// in-memory layout is edited, so `bread.mon.applied` can report it
/// honestly (or `null` for an ad-hoc layout).
pub active_profile: Option<String>,
/// Snapshots for Ctrl+Z undo (up to 20 deep). /// Snapshots for Ctrl+Z undo (up to 20 deep).
pub undo_stack: Vec<Vec<Monitor>>, pub undo_stack: Vec<Vec<Monitor>>,
} }
@ -131,6 +135,7 @@ impl AppState {
drag_state: None, drag_state: None,
terminal_size, terminal_size,
pending_apply: false, pending_apply: false,
active_profile: None,
undo_stack: Vec::new(), undo_stack: Vec::new(),
} }
} }
@ -139,6 +144,14 @@ impl AppState {
self.status = Some(StatusMsg { text: text.into(), level, born: Instant::now() }); self.status = Some(StatusMsg { text: text.into(), level, born: Instant::now() });
} }
/// Mark the in-memory layout as edited. Also forgets `active_profile`
/// — a mutated layout is no longer the named snapshot that was loaded
/// or saved.
pub fn mark_dirty(&mut self) {
self.dirty = true;
self.active_profile = None;
}
pub fn tick_status(&mut self) { pub fn tick_status(&mut self) {
if let Some(s) = &self.status { if let Some(s) = &self.status {
if s.born.elapsed().as_secs() >= 3 { if s.born.elapsed().as_secs() >= 3 {
@ -166,7 +179,7 @@ impl AppState {
pub fn undo(&mut self) { pub fn undo(&mut self) {
if let Some(snapshot) = self.undo_stack.pop() { if let Some(snapshot) = self.undo_stack.pop() {
self.monitors = snapshot; self.monitors = snapshot;
self.dirty = true; self.mark_dirty();
self.layout.clamp_selected(self.monitors.len()); self.layout.clamp_selected(self.monitors.len());
// Re-sync config view to the restored state // Re-sync config view to the restored state
let idx = self.layout.selected; let idx = self.layout.selected;

View file

@ -240,6 +240,7 @@ fn do_save(state: &mut AppState) {
Ok(()) => { Ok(()) => {
state.profiles.new_name.clear(); state.profiles.new_name.clear();
state.profiles.refresh(); state.profiles.refresh();
state.active_profile = Some(name.clone());
state.set_status(format!("Saved profile '{}'", name), StatusLevel::Success); state.set_status(format!("Saved profile '{}'", name), StatusLevel::Success);
} }
Err(e) => { Err(e) => {
@ -254,6 +255,7 @@ fn do_load(state: &mut AppState) {
Ok(p) => { Ok(p) => {
profile::apply_to_monitors(&p, &mut state.monitors); profile::apply_to_monitors(&p, &mut state.monitors);
state.dirty = true; state.dirty = true;
state.active_profile = Some(name.clone());
state.set_status( state.set_status(
format!("Loaded profile '{}'. Press [a] to apply.", name), format!("Loaded profile '{}'. Press [a] to apply.", name),
StatusLevel::Success, StatusLevel::Success,