diff --git a/Cargo.lock b/Cargo.lock index d56164e..fd9b3a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,8 +33,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "bread-shared", "dirs", diff --git a/Cargo.toml b/Cargo.toml index b2da7f6..647a201 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", features = ["bread-client"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } [profile.release] lto = "thin" diff --git a/EVENTS.md b/EVENTS.md index 1b92496..b854967 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -14,9 +14,10 @@ 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 +Hyprland). After that apply succeeds, breadmon also writes +`~/.config/hypr/monitors.json` (the store shared with the `bos-settings` +Display panel). The event is **not** fired by Display itself — that GUI +only edits the JSON. Vanilla/upstream Hyprland has no `eval` request and no `hl.monitor()`, so apply fails there and this event is not published. diff --git a/README.md b/README.md index b15936c..ccddd7d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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. +The Display panel in `bos-settings` (GUI) and breadmon (TUI) share `~/.config/hypr/monitors.json` — the layout Hyprland reads at login/reload. Applying in breadmon writes that file so the settings app and the next session stay in sync. Named profiles remain optional snapshots under `~/.config/breadmon/profiles/`. ## Requirements @@ -80,7 +80,7 @@ Finds the best common mode between two monitors and sets one to mirror the other ### Profiles -Named snapshots of the current monitor configuration, stored as TOML files. +Named snapshots of the current monitor configuration, stored as TOML files. Loading a profile updates the TUI; applying it (`a`) also writes `monitors.json`. | Key | Action | |-----|--------| @@ -95,8 +95,8 @@ Profiles are saved to `~/.config/breadmon/profiles/`. | Key | Action | |-----|--------| -| `a` | Apply current configuration via `hyprctl eval 'hl.monitor({...})'` (BOS-patched Hyprland only) | -| `s` | Save current configuration as a profile | +| `a` | Apply current configuration via `hyprctl eval 'hl.monitor({...})'` (BOS-patched Hyprland only) and write `~/.config/hypr/monitors.json` | +| `s` | Write `~/.config/hypr/monitors.json` without a live apply | | `r` | Refresh monitor list from Hyprland | | `Ctrl+Z` | Undo last change (up to 20 steps) | | `q` / `Ctrl+C` | Quit (prompts once if there are unsaved changes) | @@ -105,9 +105,19 @@ 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. +**Shared store:** `~/.config/hypr/monitors.json` — the same file the bos-settings Display panel edits and Hyprland applies on login/reload. breadmon is the TUI; Display is the GUI. Schema: -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. +```json +{ + "monitors": [ + { "output": "", "mode": "preferred", "position": "auto", "scale": "auto", "mirror": "" } + ] +} +``` + +Empty `output` is the wildcard default (any connector). breadmon loads this file on start (overlaid onto the live `hyprctl` list) and writes it — pretty JSON — after a successful apply, and when you press `s`. + +**Named snapshots:** plain TOML 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. Applying a profile writes `monitors.json` so Hyprland and Display stay in sync. ## bread event integration diff --git a/src/layout.rs b/src/layout.rs index 0b6e835..9789c2a 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -196,7 +196,11 @@ mod tests { Monitor { name: name.into(), description: String::new(), - active_mode: Mode { width: w, height: h, refresh: 60.0 }, + active_mode: Mode { + width: w, + height: h, + refresh: 60.0, + }, x, y, scale: 1.0, diff --git a/src/main.rs b/src/main.rs index fd3e35b..a9c2086 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod layout; mod mirror; mod monitor; mod profile; +mod store; mod ui; use std::io; @@ -10,8 +11,7 @@ use std::io; use anyhow::Result; use crossterm::{ event::{ - DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, - MouseEventKind, + DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, MouseEventKind, }, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, @@ -36,10 +36,15 @@ enum AppEvent { #[tokio::main] async fn main() -> Result<()> { - let monitors = monitor::load_monitors().await.unwrap_or_else(|e| { + let mut monitors = monitor::load_monitors().await.unwrap_or_else(|e| { eprintln!("Warning: could not load monitors: {}", e); vec![] }); + match store::load() { + Ok(Some(file)) => store::apply_to_monitors(&file, &mut monitors), + Ok(None) => {} + Err(e) => eprintln!("Warning: could not load monitors.json: {}", e), + } // Terminal setup enable_raw_mode()?; @@ -52,7 +57,11 @@ async fn main() -> Result<()> { // Restore terminal disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; terminal.show_cursor()?; result @@ -162,23 +171,18 @@ async fn run( crossterm::event::KeyCode::Char('s') => { ui::layout_view::trigger_save(&mut state); } - crossterm::event::KeyCode::Char('r') => { - match monitor::load_monitors().await { - Ok(monitors) => { - 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) => { - state.set_status( - format!("Refresh failed: {}", e), - StatusLevel::Error, - ); - } + crossterm::event::KeyCode::Char('r') => match monitor::load_monitors().await { + Ok(monitors) => { + 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) => { + state.set_status(format!("Refresh failed: {}", e), StatusLevel::Error); + } + }, _ => { if !ui::handle_key(key, &mut state) { break; @@ -193,7 +197,18 @@ async fn run( match monitor::apply_monitors(&state.monitors).await { Ok(()) => { bread_events::emit_applied(state.active_profile.as_deref()); - state.set_status("Applied.", StatusLevel::Success); + match store::save_from_monitors(&state.monitors) { + Ok(()) => { + state.dirty = false; + state.set_status("Applied.", StatusLevel::Success); + } + Err(e) => { + state.set_status( + format!("Applied, but monitors.json write failed: {}", e), + StatusLevel::Error, + ); + } + } } Err(e) => { state.set_status(format!("Apply failed: {}", e), StatusLevel::Error); diff --git a/src/mirror.rs b/src/mirror.rs index 2e46367..124e598 100644 --- a/src/mirror.rs +++ b/src/mirror.rs @@ -12,7 +12,11 @@ pub struct MirrorResult { } fn gcd(a: u32, b: u32) -> u32 { - if b == 0 { a } else { gcd(b, a % b) } + if b == 0 { + a + } else { + gcd(b, a % b) + } } fn reduced_ar(w: u32, h: u32) -> (u32, u32) { @@ -35,7 +39,10 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option> = HashMap::new(); for m in src_modes { - src_by_ar.entry(reduced_ar(m.width, m.height)).or_default().push(m); + src_by_ar + .entry(reduced_ar(m.width, m.height)) + .or_default() + .push(m); } #[derive(Debug)] @@ -53,8 +60,15 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option Option Option = src_refreshes @@ -164,7 +188,10 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option &'static str { #[cfg(test)] mod tests { use super::*; - use crate::monitor::{Transform}; + use crate::monitor::Transform; fn make_monitor_with_modes(name: &str, modes: Vec) -> Monitor { let active = modes[0].clone(); @@ -240,7 +267,11 @@ mod tests { } fn m(w: u32, h: u32, r: f64) -> Mode { - Mode { width: w, height: h, refresh: r } + Mode { + width: w, + height: h, + refresh: r, + } } #[test] diff --git a/src/monitor.rs b/src/monitor.rs index 37f3128..fab2d5b 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -145,11 +145,15 @@ impl Monitor { .collect(); // Sort descending by pixels then refresh for consistent ordering modes.sort_by(|a, b| { - b.pixels() - .cmp(&a.pixels()) - .then(b.refresh.partial_cmp(&a.refresh).unwrap_or(std::cmp::Ordering::Equal)) + b.pixels().cmp(&a.pixels()).then( + b.refresh + .partial_cmp(&a.refresh) + .unwrap_or(std::cmp::Ordering::Equal), + ) + }); + modes.dedup_by(|a, b| { + a.width == b.width && a.height == b.height && (a.refresh - b.refresh).abs() < 0.01 }); - modes.dedup_by(|a, b| a.width == b.width && a.height == b.height && (a.refresh - b.refresh).abs() < 0.01); let active_mode = Mode { width: raw.width, @@ -214,8 +218,10 @@ impl Monitor { if self.physical_width_mm == 0 || self.physical_height_mm == 0 { return None; } - let diag_px = ((self.active_mode.width.pow(2) + self.active_mode.height.pow(2)) as f64).sqrt(); - let diag_mm = ((self.physical_width_mm.pow(2) + self.physical_height_mm.pow(2)) as f64).sqrt(); + let diag_px = + ((self.active_mode.width.pow(2) + self.active_mode.height.pow(2)) as f64).sqrt(); + let diag_mm = + ((self.physical_width_mm.pow(2) + self.physical_height_mm.pow(2)) as f64).sqrt(); Some(diag_px / (diag_mm / 25.4)) } @@ -268,8 +274,10 @@ pub async fn load_monitors() -> Result> { // Hyprland reports mirrorOf as a numeric ID string when using `monitors all`. // Resolve to monitor name so format_hypr_line emits the correct `mirror,`. - let id_to_name: std::collections::HashMap = - raw.iter().map(|r| (r.id.to_string(), r.name.clone())).collect(); + let id_to_name: std::collections::HashMap = raw + .iter() + .map(|r| (r.id.to_string(), r.name.clone())) + .collect(); Ok(raw .into_iter() @@ -284,6 +292,7 @@ pub async fn load_monitors() -> Result> { .collect()) } +#[cfg(test)] pub fn format_hypr_line(m: &Monitor) -> String { if let Some(src) = &m.mirror_of { format!( @@ -444,7 +453,11 @@ mod tests { #[test] fn mode_compact_roundtrip() { - let m = Mode { width: 1920, height: 1080, refresh: 60.0 }; + let m = Mode { + width: 1920, + height: 1080, + refresh: 60.0, + }; let s = m.compact(); let m2 = Mode::parse(&format!("{}Hz", s)).unwrap(); assert_eq!(m.width, m2.width); @@ -456,7 +469,11 @@ mod tests { let m = Monitor { name: "eDP-1".into(), description: String::new(), - active_mode: Mode { width: 1920, height: 1200, refresh: 60.0 }, + active_mode: Mode { + width: 1920, + height: 1200, + refresh: 60.0, + }, x: 0, y: 0, scale: 1.0, @@ -480,7 +497,11 @@ mod tests { let m = Monitor { name: "HDMI-A-1".into(), description: String::new(), - active_mode: Mode { width: 1920, height: 1080, refresh: 60.0 }, + active_mode: Mode { + width: 1920, + height: 1080, + refresh: 60.0, + }, x: 1920, y: 0, scale: 1.0, diff --git a/src/profile.rs b/src/profile.rs index eed2978..38ab498 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -77,8 +77,7 @@ pub fn list() -> Result> { pub fn delete(name: &str) -> Result<()> { let path = profiles_dir().join(format!("{}.toml", name)); - std::fs::remove_file(&path) - .with_context(|| format!("failed to delete profile '{}'", name)) + std::fs::remove_file(&path).with_context(|| format!("failed to delete profile '{}'", name)) } pub fn from_monitors(name: &str, monitors: &[Monitor]) -> Profile { @@ -150,7 +149,11 @@ mod tests { Monitor { name: name.into(), description: String::new(), - active_mode: Mode { width: w, height: h, refresh: 60.0 }, + active_mode: Mode { + width: w, + height: h, + refresh: 60.0, + }, x, y, scale: 1.0, diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..ed1d4a7 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,369 @@ +//! Shared Hyprland layout store: `~/.config/hypr/monitors.json`. +//! +//! Same schema as bos-settings `MonitorRule` and +//! `iso/airootfs/etc/skel/.config/hypr/scripts/display/monitors.lua`. +//! Empty `output` is the wildcard default (matches any connector). + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::monitor::{Mode, Monitor}; + +fn default_mode() -> String { + "preferred".to_string() +} +fn default_position() -> String { + "auto".to_string() +} +fn default_scale() -> String { + "auto".to_string() +} + +/// One `hl.monitor()` rule. Field names and defaults must stay in sync with +/// bos-settings `MonitorRule` and the ISO `monitors.lua` loader. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MonitorRule { + pub output: String, + #[serde(default = "default_mode")] + pub mode: String, + #[serde(default = "default_position")] + pub position: String, + #[serde(default = "default_scale")] + pub scale: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mirror: Option, +} + +impl Default for MonitorRule { + fn default() -> Self { + Self { + output: String::new(), + mode: default_mode(), + position: default_position(), + scale: default_scale(), + mirror: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MonitorsFile { + #[serde(default)] + pub monitors: Vec, +} + +impl Default for MonitorsFile { + fn default() -> Self { + Self { + monitors: vec![MonitorRule::default()], + } + } +} + +/// `~/.config/hypr/monitors.json` — same path Hyprland and bos-settings use. +pub fn config_path() -> PathBuf { + bread_utils::xdg::config_dir("hypr").join("monitors.json") +} + +pub fn load() -> Result> { + load_from(&config_path()) +} + +pub fn load_from(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + let file: MonitorsFile = serde_json::from_str(&content) + .with_context(|| format!("failed to parse {}", path.display()))?; + // Empty file ≡ missing: Lua falls back to the wildcard default rather + // than applying zero rules (which can black-screen the session). + if file.monitors.is_empty() { + return Ok(None); + } + Ok(Some(file)) +} + +pub fn save(file: &MonitorsFile) -> Result<()> { + save_to(&config_path(), file) +} + +pub fn save_to(path: &Path, file: &MonitorsFile) -> Result<()> { + let json = serde_json::to_string_pretty(file).context("failed to serialize monitors.json")?; + bread_utils::atomic::write_atomic_backed_up(path, &json) + .with_context(|| format!("failed to write {}", path.display())) +} + +pub fn save_from_monitors(monitors: &[Monitor]) -> Result<()> { + save(&from_monitors(monitors)) +} + +/// Persist the TUI layout as named `hl.monitor()` rules. Mirror slaves are +/// omitted (mirror is recorded on the source, matching `hl.monitor()`). If +/// nothing is writable, emit the wildcard default so the file is never empty. +pub fn from_monitors(monitors: &[Monitor]) -> MonitorsFile { + let mut source_to_slave: HashMap<&str, &str> = HashMap::new(); + for m in monitors { + if let Some(src) = &m.mirror_of { + source_to_slave.insert(src.as_str(), m.name.as_str()); + } + } + + let mut rules = Vec::new(); + for m in monitors { + if m.disabled || m.mirror_of.is_some() { + continue; + } + let refresh = (m.active_mode.refresh + 0.5) as u32; + rules.push(MonitorRule { + output: m.name.clone(), + mode: format!( + "{}x{}@{}", + m.active_mode.width, m.active_mode.height, refresh + ), + position: format!("{}x{}", m.x, m.y), + scale: format!("{:.2}", m.scale), + mirror: source_to_slave + .get(m.name.as_str()) + .map(|s| (*s).to_owned()), + }); + } + + if rules.is_empty() { + MonitorsFile::default() + } else { + MonitorsFile { monitors: rules } + } +} + +/// Overlay persisted rules onto live `hyprctl` monitors (matched by name; +/// empty `output` is the wildcard fallback). `preferred` / `auto` leave the +/// live value. A file with at least one named output is treated as a full +/// layout and replaces live mirrors; a wildcard-only file does not. +pub fn apply_to_monitors(file: &MonitorsFile, monitors: &mut [Monitor]) { + let has_specific = file.monitors.iter().any(|r| !r.output.is_empty()); + if has_specific { + for m in monitors.iter_mut() { + m.mirror_of = None; + } + for rule in &file.monitors { + let Some(slave_name) = rule.mirror.as_deref().filter(|s| !s.is_empty()) else { + continue; + }; + if rule.output.is_empty() { + continue; + } + if let Some(slave) = monitors.iter_mut().find(|m| m.name == slave_name) { + slave.mirror_of = Some(rule.output.clone()); + } + } + } + + for m in monitors.iter_mut() { + if let Some(rule) = find_rule(&file.monitors, &m.name) { + apply_rule_fields(m, rule); + } + } +} + +fn find_rule<'a>(rules: &'a [MonitorRule], name: &str) -> Option<&'a MonitorRule> { + rules + .iter() + .find(|r| r.output == name) + .or_else(|| rules.iter().find(|r| r.output.is_empty())) +} + +fn apply_rule_fields(m: &mut Monitor, rule: &MonitorRule) { + if rule.mode != "preferred" { + if let Some(mode) = + Mode::parse(&format!("{}Hz", rule.mode)).or_else(|| Mode::parse(&rule.mode)) + { + m.active_mode = mode; + } + } + if rule.position != "auto" { + if let Some((x, y)) = parse_position(&rule.position) { + m.x = x; + m.y = y; + } + } + if rule.scale != "auto" { + if let Ok(scale) = rule.scale.parse::() { + if scale > 0.0 { + m.scale = scale; + } + } + } +} + +fn parse_position(s: &str) -> Option<(i32, i32)> { + let (x, y) = s.split_once('x')?; + Some((x.parse().ok()?, y.parse().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::monitor::Transform; + + fn make_monitor(name: &str, w: u32, h: u32, x: i32, y: i32) -> Monitor { + Monitor { + name: name.into(), + description: String::new(), + active_mode: Mode { + width: w, + height: h, + refresh: 60.0, + }, + x, + y, + scale: 1.0, + transform: Transform::Normal, + vrr: false, + dpms: true, + disabled: false, + mirror_of: None, + available_modes: vec![], + physical_width_mm: 0, + physical_height_mm: 0, + } + } + + #[test] + fn iso_default_parses() { + let json = r#"{ + "monitors": [ + { "output": "", "mode": "preferred", "position": "auto", "scale": "auto" } + ] +}"#; + let file: MonitorsFile = serde_json::from_str(json).unwrap(); + assert_eq!(file.monitors.len(), 1); + assert_eq!(file.monitors[0], MonitorRule::default()); + } + + #[test] + fn pretty_roundtrip_omits_absent_mirror() { + let file = MonitorsFile::default(); + let json = serde_json::to_string_pretty(&file).unwrap(); + assert!(json.contains("\"output\": \"\"")); + assert!(json.contains("\"mode\": \"preferred\"")); + assert!(!json.contains("mirror")); + let back: MonitorsFile = serde_json::from_str(&json).unwrap(); + assert_eq!(file, back); + } + + #[test] + fn from_monitors_writes_named_rules_and_source_mirror() { + let mut hdmi = make_monitor("HDMI-A-1", 1920, 1080, 1920, 0); + hdmi.mirror_of = Some("eDP-1".into()); + let file = from_monitors(&[make_monitor("eDP-1", 1920, 1200, 0, 0), hdmi]); + assert_eq!(file.monitors.len(), 1); + let rule = &file.monitors[0]; + assert_eq!(rule.output, "eDP-1"); + assert_eq!(rule.mode, "1920x1200@60"); + assert_eq!(rule.position, "0x0"); + assert_eq!(rule.scale, "1.00"); + assert_eq!(rule.mirror.as_deref(), Some("HDMI-A-1")); + } + + #[test] + fn from_monitors_empty_or_all_slaves_emits_wildcard() { + let mut only_slave = make_monitor("HDMI-A-1", 1920, 1080, 0, 0); + only_slave.mirror_of = Some("missing".into()); + assert_eq!(from_monitors(&[]), MonitorsFile::default()); + assert_eq!(from_monitors(&[only_slave]), MonitorsFile::default()); + } + + #[test] + fn wildcard_overlay_leaves_live_geometry_and_mirrors() { + let file = MonitorsFile::default(); + let mut monitors = vec![make_monitor("eDP-1", 1920, 1200, 10, 20)]; + monitors[0].scale = 1.5; + monitors[0].mirror_of = Some("HDMI-A-1".into()); + apply_to_monitors(&file, &mut monitors); + assert_eq!(monitors[0].x, 10); + assert_eq!(monitors[0].y, 20); + assert!((monitors[0].scale - 1.5).abs() < f64::EPSILON); + assert_eq!(monitors[0].mirror_of.as_deref(), Some("HDMI-A-1")); + } + + #[test] + fn specific_overlay_applies_fields_and_replaces_mirrors() { + let file = MonitorsFile { + monitors: vec![ + MonitorRule { + output: "eDP-1".into(), + mode: "1920x1200@60".into(), + position: "0x0".into(), + scale: "1.25".into(), + mirror: Some("HDMI-A-1".into()), + }, + MonitorRule { + output: "DP-1".into(), + mode: "2560x1440@144".into(), + position: "-2560x0".into(), + scale: "1".into(), + mirror: None, + }, + ], + }; + let mut monitors = vec![ + make_monitor("eDP-1", 1600, 900, 100, 100), + make_monitor("HDMI-A-1", 1920, 1080, 200, 0), + make_monitor("DP-1", 1920, 1080, 300, 0), + ]; + monitors[1].mirror_of = Some("DP-1".into()); + apply_to_monitors(&file, &mut monitors); + + assert_eq!(monitors[0].active_mode.width, 1920); + assert_eq!(monitors[0].active_mode.height, 1200); + assert!((monitors[0].active_mode.refresh - 60.0).abs() < 0.01); + assert_eq!(monitors[0].x, 0); + assert_eq!(monitors[0].y, 0); + assert!((monitors[0].scale - 1.25).abs() < f64::EPSILON); + + assert_eq!(monitors[1].mirror_of.as_deref(), Some("eDP-1")); + + assert_eq!(monitors[2].active_mode.width, 2560); + assert_eq!(monitors[2].x, -2560); + assert!(monitors[2].mirror_of.is_none()); + } + + #[test] + fn load_from_missing_or_empty_is_none() { + let dir = std::env::temp_dir().join(format!( + "breadmon-store-test-{}-{}", + std::process::id(), + "empty" + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let missing = dir.join("nope.json"); + assert!(load_from(&missing).unwrap().is_none()); + + let empty = dir.join("empty.json"); + std::fs::write(&empty, "{ \"monitors\": [] }\n").unwrap(); + assert!(load_from(&empty).unwrap().is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn save_to_roundtrips() { + let dir = std::env::temp_dir().join(format!( + "breadmon-store-test-{}-{}", + std::process::id(), + "save" + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("monitors.json"); + let file = from_monitors(&[make_monitor("eDP-1", 1920, 1200, 0, 0)]); + save_to(&path, &file).unwrap(); + let loaded = load_from(&path).unwrap().unwrap(); + assert_eq!(loaded, file); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/ui/config_view.rs b/src/ui/config_view.rs index 247cac9..0f9bd38 100644 --- a/src/ui/config_view.rs +++ b/src/ui/config_view.rs @@ -125,7 +125,10 @@ impl ConfigState { } fn prev_field(&mut self) { - self.focused = self.focused.checked_sub(1).unwrap_or(ConfigField::ALL.len() - 1); + self.focused = self + .focused + .checked_sub(1) + .unwrap_or(ConfigField::ALL.len() - 1); } } @@ -314,14 +317,22 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { _ => {} }, 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.mark_dirty(); } _ => {} }, 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.mark_dirty(); } @@ -358,7 +369,11 @@ fn sync_mode_to_monitor(state: &mut AppState, idx: usize) { } fn sync_mirror_to_monitor(state: &mut AppState, idx: usize) { - let chosen = state.config.mirror_options.get(state.config.mirror_idx).cloned(); + let chosen = state + .config + .mirror_options + .get(state.config.mirror_idx) + .cloned(); state.monitors[idx].mirror_of = match chosen.as_deref() { Some("(none)") | None => None, Some(s) => Some(s.to_owned()), @@ -366,7 +381,10 @@ fn sync_mirror_to_monitor(state: &mut AppState, idx: usize) { } fn commit_scale(state: &mut AppState) { - let idx = state.config.monitor_idx.min(state.monitors.len().saturating_sub(1)); + let idx = state + .config + .monitor_idx + .min(state.monitors.len().saturating_sub(1)); 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); @@ -405,7 +423,11 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) { }; let header = format!(" {} — {}{}", m.name, m.description, ppi_hint); f.render_widget( - Paragraph::new(header).style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Paragraph::new(header).style( + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), chunks[0], ); @@ -463,20 +485,31 @@ fn field_value(field: ConfigField, state: &AppState, m: &Monitor) -> String { } ConfigField::Scale => { if state.config.scale_editing { - format!("{}| (Enter to commit, ,/. for ±0.1)", state.config.scale_str) + format!( + "{}| (Enter to commit, ,/. for ±0.1)", + state.config.scale_str + ) } else { format!("{} (,/. for ±0.1)", state.config.scale_str) } } ConfigField::Transform => Transform::all() [state.config.transform_idx.min(Transform::all().len() - 1)] - .label() - .to_owned(), + .label() + .to_owned(), ConfigField::Vrr => { - if m.vrr { "ON".to_owned() } else { "OFF".to_owned() } + if m.vrr { + "ON".to_owned() + } else { + "OFF".to_owned() + } } ConfigField::Dpms => { - if m.dpms { "ON".to_owned() } else { "OFF".to_owned() } + if m.dpms { + "ON".to_owned() + } else { + "OFF".to_owned() + } } ConfigField::MirrorOf => state .config diff --git a/src/ui/layout_view.rs b/src/ui/layout_view.rs index a1aa3df..3b21f7a 100644 --- a/src/ui/layout_view.rs +++ b/src/ui/layout_view.rs @@ -8,7 +8,10 @@ use ratatui::{ }; use crate::{ - layout::{auto_arrange, bounding_box, canvas_scale, canvas_to_world, move_selected, snap_position, world_to_canvas}, + layout::{ + auto_arrange, bounding_box, canvas_scale, canvas_to_world, move_selected, snap_position, + world_to_canvas, + }, monitor::Monitor, ui::{AppState, DragState, StatusLevel, Tab}, }; @@ -49,7 +52,9 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { state.mark_dirty(); } KeyCode::Enter => { - state.config.sync_from_monitor(state.layout.selected, &state.monitors); + state + .config + .sync_from_monitor(state.layout.selected, &state.monitors); state.tab = Tab::Config; } _ => {} @@ -66,7 +71,8 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { if let Some(idx) = monitor_at(col, row, canvas, state) { let (min_x, min_y, _, _) = bounding_box(&state.monitors); let scale = canvas_scale_for(canvas, state); - let (wx, wy) = canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); + let (wx, wy) = + canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); // Push undo at drag start, not on every move state.push_undo(); @@ -85,12 +91,19 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { let canvas = canvas_area(state.terminal_size); let (min_x, min_y, _, _) = bounding_box(&state.monitors); let scale = canvas_scale_for(canvas, state); - let (wx, wy) = canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); + let (wx, wy) = + canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); let idx = drag.monitor_idx; let new_x = drag.origin_x + (wx - drag.click_world_x); let new_y = drag.origin_y + (wy - drag.click_world_y); - 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].y = sy; state.mark_dirty(); @@ -162,18 +175,31 @@ fn render_canvas(f: &mut Frame, area: Rect, state: &AppState) { continue; } - let rect = Rect { x: cx, y: cy, width: cw, height: ch }; + let rect = Rect { + x: cx, + y: cy, + width: cw, + height: ch, + }; let is_selected = i == selected; - let is_dragging = state.drag_state.as_ref().map(|d| d.monitor_idx == i).unwrap_or(false); + let is_dragging = state + .drag_state + .as_ref() + .map(|d| d.monitor_idx == i) + .unwrap_or(false); let is_overlapping = overlapping[i]; let border_style = if is_dragging { - Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD) } else if is_overlapping { Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) } else if is_selected { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Blue) }; @@ -190,7 +216,10 @@ fn render_canvas(f: &mut Frame, area: Rect, state: &AppState) { }) .border_style(border_style) .title(Span::styled(&label, border_style)) - .title_bottom(Span::styled(&mode_str, Style::default().fg(Color::DarkGray))); + .title_bottom(Span::styled( + &mode_str, + Style::default().fg(Color::DarkGray), + )); f.render_widget(block, rect); } @@ -203,7 +232,9 @@ fn render_readout(f: &mut Frame, area: Rect, state: &AppState) { let idx = state.layout.selected.min(state.monitors.len() - 1); let m = &state.monitors[idx]; - let mirror_info = m.mirror_of.as_ref() + let mirror_info = m + .mirror_of + .as_ref() .map(|src| format!(" mirror:{}", src)) .unwrap_or_default(); @@ -213,14 +244,24 @@ fn render_readout(f: &mut Frame, area: Rect, state: &AppState) { "" }; - let drag_hint = if state.drag_state.is_some() { " [dragging]" } else { "" }; + let drag_hint = if state.drag_state.is_some() { + " [dragging]" + } else { + "" + }; let text = format!( " {} x:{} y:{} {}x{}@{:.0}Hz scale:{:.2}{}{}{}", - m.name, m.x, m.y, - m.active_mode.width, m.active_mode.height, m.active_mode.refresh, + m.name, + m.x, + m.y, + m.active_mode.width, + m.active_mode.height, + m.active_mode.refresh, m.scale, - mirror_info, overlap_warn, drag_hint, + mirror_info, + overlap_warn, + drag_hint, ); f.render_widget( Paragraph::new(text).style(Style::default().fg(Color::Cyan)), @@ -235,8 +276,10 @@ fn overlapping_monitors(monitors: &[Monitor]) -> Vec { for j in (i + 1)..monitors.len() { let a = &monitors[i]; let b = &monitors[j]; - if a.x < b.right_edge() && a.right_edge() > b.x - && a.y < b.bottom_edge() && a.bottom_edge() > b.y + if a.x < b.right_edge() + && a.right_edge() > b.x + && a.y < b.bottom_edge() + && a.bottom_edge() > b.y { flags[i] = true; flags[j] = true; @@ -301,31 +344,13 @@ fn monitor_at(col: u16, row: u16, canvas: Rect, state: &AppState) -> Option { - if is_new { - state.set_status( - format!("Saved. Add: source = {} to hyprland.conf", path.display()), - StatusLevel::Success, - ); - } else { - state.set_status(format!("Saved to {}", path.display()), StatusLevel::Success); - } state.dirty = false; + state.set_status( + format!("Saved to {}", crate::store::config_path().display()), + StatusLevel::Success, + ); } Err(e) => state.set_status(format!("Save failed: {}", e), StatusLevel::Error), } diff --git a/src/ui/mirror_view.rs b/src/ui/mirror_view.rs index c8b35f2..249d33f 100644 --- a/src/ui/mirror_view.rs +++ b/src/ui/mirror_view.rs @@ -45,7 +45,9 @@ impl MirrorState { fn next_field(&mut self) { // Skip Apply/Cancel if no result yet let mut next = (self.focused + 1) % FIELDS.len(); - if self.result.is_none() && (FIELDS[next] == MirrorField::Apply || FIELDS[next] == MirrorField::Cancel) { + if self.result.is_none() + && (FIELDS[next] == MirrorField::Apply || FIELDS[next] == MirrorField::Cancel) + { next = 0; } self.focused = next; @@ -54,8 +56,13 @@ impl MirrorState { fn prev_field(&mut self) { let len = FIELDS.len(); let mut prev = self.focused.checked_sub(1).unwrap_or(len - 1); - if self.result.is_none() && (FIELDS[prev] == MirrorField::Apply || FIELDS[prev] == MirrorField::Cancel) { - prev = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2); + if self.result.is_none() + && (FIELDS[prev] == MirrorField::Apply || FIELDS[prev] == MirrorField::Cancel) + { + prev = FIELDS + .iter() + .position(|&f| f == MirrorField::Compute) + .unwrap_or(2); } self.focused = prev; } @@ -86,12 +93,14 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { } KeyCode::Char('h') | KeyCode::Left => match state.mirror.current_field() { MirrorField::Source => { - state.mirror.source_idx = state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.source_idx = + state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1); state.mirror.fix_indices(count); state.mirror.result = None; } MirrorField::Target => { - state.mirror.target_idx = state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.target_idx = + state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1); state.mirror.fix_indices(count); state.mirror.result = None; } @@ -118,7 +127,10 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { Some(result) => { state.mirror.result = Some(result); // Move focus to Apply - state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); + state.mirror.focused = FIELDS + .iter() + .position(|&f| f == MirrorField::Apply) + .unwrap_or(3); } None => { state.set_status( @@ -143,9 +155,7 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { state.set_status( format!( "Mirror set: {} → {} at {}", - src_name, - state.monitors[tgt_idx].name, - result.mirror_mode + src_name, state.monitors[tgt_idx].name, result.mirror_mode ), StatusLevel::Success, ); @@ -182,17 +192,26 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { match row { 2 | 3 => { // Source picker area - let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Source).unwrap_or(0); + let f_idx = FIELDS + .iter() + .position(|&f| f == MirrorField::Source) + .unwrap_or(0); state.mirror.focused = f_idx; } 4 | 5 => { // Target picker area - let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Target).unwrap_or(1); + let f_idx = FIELDS + .iter() + .position(|&f| f == MirrorField::Target) + .unwrap_or(1); state.mirror.focused = f_idx; } 6 => { // Compute button - let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2); + let f_idx = FIELDS + .iter() + .position(|&f| f == MirrorField::Compute) + .unwrap_or(2); state.mirror.focused = f_idx; // Also activate it let src = &state.monitors[state.mirror.source_idx]; @@ -200,7 +219,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { match crate::mirror::find_mirror_modes(src, tgt) { Some(result) => { state.mirror.result = Some(result); - state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); + state.mirror.focused = FIELDS + .iter() + .position(|&f| f == MirrorField::Apply) + .unwrap_or(3); } None => { state.set_status( @@ -218,7 +240,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { let col = event.column; if col < 20 { // Activate Apply - state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); + state.mirror.focused = FIELDS + .iter() + .position(|&f| f == MirrorField::Apply) + .unwrap_or(3); if let Some(result) = state.mirror.result.clone() { state.push_undo(); let src_name = state.monitors[state.mirror.source_idx].name.clone(); @@ -229,7 +254,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { state.mirror.result = None; state.mirror.focused = 0; state.set_status( - format!("Mirror set: {} → {} at {}", src_name, state.monitors[tgt_idx].name, result.mirror_mode), + format!( + "Mirror set: {} → {} at {}", + src_name, state.monitors[tgt_idx].name, result.mirror_mode + ), crate::ui::StatusLevel::Success, ); } @@ -246,33 +274,33 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { // Scroll in source/target pickers to cycle monitors match state.mirror.current_field() { MirrorField::Source => { - state.mirror.source_idx = state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.source_idx = + state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1); state.mirror.fix_indices(count); state.mirror.result = None; } MirrorField::Target => { - state.mirror.target_idx = state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.target_idx = + state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1); state.mirror.fix_indices(count); state.mirror.result = None; } _ => {} } } - MouseEventKind::ScrollDown => { - match state.mirror.current_field() { - MirrorField::Source => { - state.mirror.source_idx = (state.mirror.source_idx + 1) % count; - state.mirror.fix_indices(count); - state.mirror.result = None; - } - MirrorField::Target => { - state.mirror.target_idx = (state.mirror.target_idx + 1) % count; - state.mirror.fix_indices(count); - state.mirror.result = None; - } - _ => {} + MouseEventKind::ScrollDown => match state.mirror.current_field() { + MirrorField::Source => { + state.mirror.source_idx = (state.mirror.source_idx + 1) % count; + state.mirror.fix_indices(count); + state.mirror.result = None; } - } + MirrorField::Target => { + state.mirror.target_idx = (state.mirror.target_idx + 1) % count; + state.mirror.fix_indices(count); + state.mirror.result = None; + } + _ => {} + }, _ => {} } } @@ -324,12 +352,16 @@ fn render_pickers(f: &mut Frame, area: Rect, state: &AppState, count: usize) { let focused = state.mirror.current_field(); let src_style = if focused == MirrorField::Source { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let tgt_style = if focused == MirrorField::Target { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; @@ -354,7 +386,9 @@ fn render_pickers(f: &mut Frame, area: Rect, state: &AppState, count: usize) { fn render_compute_btn(f: &mut Frame, area: Rect, state: &AppState) { let focused = state.mirror.current_field() == MirrorField::Compute; let style = if focused { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; @@ -376,12 +410,16 @@ fn render_result(f: &mut Frame, area: Rect, state: &AppState, result: &MirrorRes let focused = state.mirror.current_field(); let apply_style = if focused == MirrorField::Apply { - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let cancel_style = if focused == MirrorField::Cancel { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; @@ -397,7 +435,10 @@ fn render_result(f: &mut Frame, area: Rect, state: &AppState, result: &MirrorRes Style::default().fg(Color::White), )), Line::from(Span::styled( - format!(" Refresh: {:.2} Hz ({})", result.refresh, refresh_label), + format!( + " Refresh: {:.2} Hz ({})", + result.refresh, refresh_label + ), Style::default().fg(Color::White), )), Line::raw(""), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ad44d1d..4987538 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -14,10 +14,7 @@ use ratatui::{ Frame, }; -use crate::{ - layout::LayoutState, - monitor::Monitor, -}; +use crate::{layout::LayoutState, monitor::Monitor}; use config_view::ConfigState; use mirror_view::MirrorState; @@ -141,7 +138,11 @@ impl AppState { } pub fn set_status(&mut self, text: impl Into, level: StatusLevel) { - 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` @@ -163,7 +164,8 @@ impl AppState { pub fn switch_tab(&mut self, tab: Tab) { self.tab = tab; if tab == Tab::Config { - self.config.sync_from_monitor(self.layout.selected, &self.monitors); + self.config + .sync_from_monitor(self.layout.selected, &self.monitors); } } @@ -214,10 +216,22 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) -> bool { // Global tab switching match event.code { - KeyCode::Char('1') | KeyCode::F(1) => { state.switch_tab(Tab::Layout); return true; } - KeyCode::Char('2') | KeyCode::F(2) => { state.switch_tab(Tab::Config); return true; } - KeyCode::Char('3') | KeyCode::F(3) => { state.switch_tab(Tab::Mirror); return true; } - KeyCode::Char('4') | KeyCode::F(4) => { state.switch_tab(Tab::Profiles); return true; } + KeyCode::Char('1') | KeyCode::F(1) => { + state.switch_tab(Tab::Layout); + return true; + } + KeyCode::Char('2') | KeyCode::F(2) => { + state.switch_tab(Tab::Config); + return true; + } + KeyCode::Char('3') | KeyCode::F(3) => { + state.switch_tab(Tab::Mirror); + return true; + } + KeyCode::Char('4') | KeyCode::F(4) => { + state.switch_tab(Tab::Profiles); + return true; + } _ => {} } diff --git a/src/ui/profiles_view.rs b/src/ui/profiles_view.rs index c50cea8..b05f9be 100644 --- a/src/ui/profiles_view.rs +++ b/src/ui/profiles_view.rs @@ -126,7 +126,10 @@ fn handle_list_key(event: KeyEvent, state: &mut AppState) { match profile::delete(&name) { Ok(()) => { state.profiles.refresh(); - state.set_status(format!("Deleted profile '{}'", name), StatusLevel::Success); + state.set_status( + format!("Deleted profile '{}'", name), + StatusLevel::Success, + ); } Err(e) => { state.set_status(format!("Delete failed: {}", e), StatusLevel::Error); @@ -195,8 +198,11 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { MouseEventKind::ScrollUp => { let count = state.profiles.profiles.len(); if count > 0 { - state.profiles.selected_idx = - state.profiles.selected_idx.checked_sub(1).unwrap_or(count - 1); + state.profiles.selected_idx = state + .profiles + .selected_idx + .checked_sub(1) + .unwrap_or(count - 1); state.profiles.focused = ProfileField::List; } } @@ -308,7 +314,9 @@ fn render_list(f: &mut Frame, area: Rect, state: &AppState) { .add_modifier(Modifier::BOLD) .bg(Color::DarkGray) } else if is_selected { - Style::default().fg(Color::White).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; @@ -362,22 +370,28 @@ fn render_save_row(f: &mut Frame, area: Rect, state: &AppState) { Style::default().fg(Color::DarkGray) }; f.render_widget( - Paragraph::new(input_display) - .style(input_style) - .block(Block::default().borders(Borders::ALL).border_style(input_style)), + Paragraph::new(input_display).style(input_style).block( + Block::default() + .borders(Borders::ALL) + .border_style(input_style), + ), chunks[0], ); // Save button let save_style = if save_focused { - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; f.render_widget( - Paragraph::new(" [ Save ] ") - .style(save_style) - .block(Block::default().borders(Borders::ALL).border_style(save_style)), + Paragraph::new(" [ Save ] ").style(save_style).block( + Block::default() + .borders(Borders::ALL) + .border_style(save_style), + ), chunks[1], ); }