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

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 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) => {

View file

@ -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::<f64>() {
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;
}

View file

@ -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) => {

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].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(

View file

@ -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<String>,
/// Snapshots for Ctrl+Z undo (up to 20 deep).
pub undo_stack: Vec<Vec<Monitor>>,
}
@ -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;

View file

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