Compare commits
3 commits
598f85d930
...
aec2fa5290
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aec2fa5290 | ||
|
|
7aa95e1ffa | ||
|
|
20d4825cf0 |
13 changed files with 197 additions and 38 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -31,4 +31,3 @@ logs/
|
|||
*.pid
|
||||
|
||||
# Local hygiene notes (not for commit)
|
||||
CLAUDE.md
|
||||
|
|
|
|||
33
AGENTS.md
Normal file
33
AGENTS.md
Normal file
|
|
@ -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.
|
||||
18
Cargo.lock
generated
18
Cargo.lock
generated
|
|
@ -21,13 +21,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bread-utils"
|
||||
version = "0.3.0"
|
||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939"
|
||||
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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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", features = ["bread-client"] }
|
||||
|
||||
[profile.release]
|
||||
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.
|
||||
30
README.md
30
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,9 @@ 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.
|
||||
|
||||
## 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 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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue