Wire breadmon into the bread event fabric (app id mon)
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:
parent
7aa95e1ffa
commit
aec2fa5290
11 changed files with 145 additions and 23 deletions
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
44
EVENTS.md
Normal 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.
|
||||||
|
|
@ -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
43
src/bread_events.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue