diff --git a/.gitignore b/.gitignore index 272a817..c3b4cc4 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ CLAUDE.md # graphify knowledge-graph output (local tool cache, not for commit) graphify-out/ + +# .freebuff local tool state (not for commit) +.freebuff/ diff --git a/src/main.rs b/src/main.rs index a9c2086..7cc5a23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -173,11 +173,19 @@ async fn run( } 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); + if state.dirty { + // Don't clobber unsaved edits (or silently drop the + // unsaved-changes quit guard) on a refresh. + state.set_status( + "Refresh skipped: unsaved changes present.", + StatusLevel::Info, + ); + } else { + state.monitors = monitors; + state.layout.clamp_selected(state.monitors.len()); + state.active_profile = None; + state.set_status("Monitors refreshed.", StatusLevel::Success); + } } Err(e) => { state.set_status(format!("Refresh failed: {}", e), StatusLevel::Error); diff --git a/src/profile.rs b/src/profile.rs index 38ab498..ad26ad1 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -129,15 +129,39 @@ pub fn apply_to_monitors(profile: &Profile, monitors: &mut [Monitor]) { } fn chrono_now() -> String { - // Simple ISO 8601 timestamp without pulling in chrono - // Uses date command; falls back to a placeholder if unavailable - std::process::Command::new("date") - .arg("+%Y-%m-%dT%H:%M:%SZ") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_owned()) - .unwrap_or_else(|| "unknown".to_owned()) + // In-process ISO 8601 (UTC) timestamp — no chrono crate, and no shelling + // out to `date`. `civil_from_days` is the Hinnant days-from-civil epoch + // algorithm. Falls back to the Unix epoch instant if the clock is broken. + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let (h, m, s) = secs_of_day(secs % 86_400); + let (y, mo, d) = civil_from_days((secs / 86_400) as i64); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") +} + +/// Convert days since 1970-01-01 to a (year, month, day) civil date. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// Seconds within the day -> (hours, minutes, seconds). +fn secs_of_day(secs: u64) -> (u32, u32, u32) { + ( + ((secs / 3600) % 24) as u32, + ((secs / 60) % 60) as u32, + (secs % 60) as u32, + ) } #[cfg(test)] @@ -184,4 +208,19 @@ mod tests { assert_eq!(deserialized.monitors[0].mode, "1920x1200@60.00"); assert_eq!(deserialized.monitors[1].x, 1920); } + + #[test] + fn chrono_now_helpers() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + // 2024-01-01 is epoch day 19723. + assert_eq!(civil_from_days(19_723), (2024, 1, 1)); + assert_eq!(secs_of_day(0), (0, 0, 0)); + assert_eq!(secs_of_day(86_399), (23, 59, 59)); + // Spot-check the formatted output shape. + let s = chrono_now(); + assert_eq!(s.len(), 20); + + assert!(s.ends_with('Z')); + assert!(s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-'); + } } diff --git a/src/ui/config_view.rs b/src/ui/config_view.rs index 0f9bd38..22cc80e 100644 --- a/src/ui/config_view.rs +++ b/src/ui/config_view.rs @@ -133,27 +133,30 @@ impl ConfigState { } pub fn handle_key(event: KeyEvent, state: &mut AppState) { - let cfg = &mut state.config; - match event.code { KeyCode::Char('j') | KeyCode::Down => { - cfg.scale_editing = false; - cfg.next_field(); + state.clear_burst(); + state.config.scale_editing = false; + state.config.next_field(); } KeyCode::Char('k') | KeyCode::Up => { - cfg.scale_editing = false; - cfg.prev_field(); + state.clear_burst(); + state.config.scale_editing = false; + state.config.prev_field(); } KeyCode::Tab => { - cfg.scale_editing = false; - cfg.next_field(); + state.clear_burst(); + state.config.scale_editing = false; + state.config.next_field(); } KeyCode::BackTab => { - cfg.scale_editing = false; - cfg.prev_field(); + state.clear_burst(); + state.config.scale_editing = false; + state.config.prev_field(); } // Navigate between monitors KeyCode::Char('[') => { + state.clear_burst(); let count = state.monitors.len(); if count > 0 { let new_idx = state.config.monitor_idx.checked_sub(1).unwrap_or(count - 1); @@ -162,6 +165,7 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { } } KeyCode::Char(']') => { + state.clear_burst(); let count = state.monitors.len(); if count > 0 { let new_idx = (state.config.monitor_idx + 1) % count; @@ -176,19 +180,20 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { crate::ui::layout_view::trigger_save(state); } KeyCode::Esc => { + state.clear_burst(); state.config.scale_editing = false; // Re-sync from live monitor to discard pending edits let idx = state.config.monitor_idx; state.config.sync_from_monitor(idx, &state.monitors); } KeyCode::Enter => { + state.clear_burst(); if state.config.current_field() == ConfigField::Scale { commit_scale(state); } apply_current(state); } _ => { - state.push_undo(); handle_field_key(event, state); } } @@ -214,12 +219,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { } } MouseEventKind::ScrollUp => { - state.push_undo(); let fake_right = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE); handle_field_key(fake_right, state); } MouseEventKind::ScrollDown => { - state.push_undo(); let fake_left = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE); handle_field_key(fake_left, state); } @@ -233,6 +236,8 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) { return; } let idx = state.config.monitor_idx.min(monitors_len - 1); + // Coalesce consecutive value cycles (and scroll) into one undo step. + state.micro_edit(); match state.config.current_field() { ConfigField::Resolution => match event.code { @@ -394,9 +399,12 @@ fn commit_scale(state: &mut AppState) { } fn apply_current(state: &mut AppState) { + state.clear_burst(); state.pending_apply = true; state.set_status("Applying...", StatusLevel::Info); - state.dirty = false; + // Don't clear `dirty` here: it must survive until the apply actually + // succeeds (main.rs clears it on a successful apply + save). Otherwise a + // failed `hyprctl` apply would silently drop the unsaved-changes guard. } pub fn render(f: &mut Frame, area: Rect, state: &AppState) { @@ -432,7 +440,6 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) { ); let form_area = chunks[1]; - let row_height = 1u16; let fields = ConfigField::ALL; let items: Vec = fields @@ -454,7 +461,6 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) { }) .collect(); - let _ = row_height; // used implicitly via ListItem heights let list = List::new(items).block( Block::default() .borders(Borders::ALL) diff --git a/src/ui/layout_view.rs b/src/ui/layout_view.rs index 3b21f7a..00a54c0 100644 --- a/src/ui/layout_view.rs +++ b/src/ui/layout_view.rs @@ -23,35 +23,48 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) { match event.code { KeyCode::Char('h') | KeyCode::Left => { - state.push_undo(); + state.micro_edit(); move_selected(&state.layout, &mut state.monitors, -step, 0); state.mark_dirty(); } KeyCode::Char('l') | KeyCode::Right => { - state.push_undo(); + state.micro_edit(); move_selected(&state.layout, &mut state.monitors, step, 0); state.mark_dirty(); } KeyCode::Char('k') | KeyCode::Up => { - state.push_undo(); + state.micro_edit(); move_selected(&state.layout, &mut state.monitors, 0, -step); state.mark_dirty(); } KeyCode::Char('j') | KeyCode::Down => { - state.push_undo(); + state.micro_edit(); move_selected(&state.layout, &mut state.monitors, 0, step); state.mark_dirty(); } - KeyCode::Tab | KeyCode::Char('n') => state.layout.next(count), - KeyCode::BackTab | KeyCode::Char('p') => state.layout.prev(count), - KeyCode::Char('[') => state.layout.zoom = (state.layout.zoom - 0.1).max(0.1), - KeyCode::Char(']') => state.layout.zoom = (state.layout.zoom + 0.1).min(5.0), + KeyCode::Tab | KeyCode::Char('n') => { + state.clear_burst(); + state.layout.next(count); + } + KeyCode::BackTab | KeyCode::Char('p') => { + state.clear_burst(); + state.layout.prev(count); + } + KeyCode::Char('[') => { + state.clear_burst(); + state.layout.zoom = (state.layout.zoom - 0.1).max(0.1); + } + KeyCode::Char(']') => { + state.clear_burst(); + state.layout.zoom = (state.layout.zoom + 0.1).min(5.0); + } KeyCode::Char('0') => { state.push_undo(); auto_arrange(&mut state.monitors); state.mark_dirty(); } KeyCode::Enter => { + state.clear_burst(); state .config .sync_from_monitor(state.layout.selected, &state.monitors); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4987538..cadb387 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -114,6 +114,9 @@ pub struct AppState { pub active_profile: Option, /// Snapshots for Ctrl+Z undo (up to 20 deep). pub undo_stack: Vec>, + /// True while a run of small incremental edits (nudges / value cycles) + /// is ongoing, so undo coalesces the whole burst into one snapshot. + undo_in_burst: bool, } impl AppState { @@ -134,6 +137,7 @@ impl AppState { pending_apply: false, active_profile: None, undo_stack: Vec::new(), + undo_in_burst: false, } } @@ -162,6 +166,7 @@ impl AppState { } pub fn switch_tab(&mut self, tab: Tab) { + self.undo_in_burst = false; self.tab = tab; if tab == Tab::Config { self.config @@ -169,8 +174,32 @@ impl AppState { } } - /// Save a monitor snapshot for undo (max 20 entries). + /// Save a monitor snapshot for undo (max 20 entries) and end any + /// in-progress edit burst. pub fn push_undo(&mut self) { + self.push_snapshot(); + self.undo_in_burst = false; + } + + /// Start (or continue) a run of small incremental edits. Only the first + /// edit in the run actually snapshots, so nudging a monitor 20 px (or + /// cycling a value repeatedly) collapses to a single undo step rather + /// than consuming 20 of the 20-step undo stack. + pub fn micro_edit(&mut self) { + if !self.undo_in_burst { + self.push_snapshot(); + self.undo_in_burst = true; + } + } + + /// End a coalesced-edit burst without snapping. Called on navigation + /// (tab switches, monitor/field changes, zoom) so bursts don't bleed + /// across distinct actions. + pub fn clear_burst(&mut self) { + self.undo_in_burst = false; + } + + fn push_snapshot(&mut self) { self.undo_stack.push(self.monitors.clone()); if self.undo_stack.len() > 20 { self.undo_stack.remove(0); @@ -181,6 +210,7 @@ impl AppState { pub fn undo(&mut self) { if let Some(snapshot) = self.undo_stack.pop() { self.monitors = snapshot; + self.undo_in_burst = false; self.mark_dirty(); self.layout.clamp_selected(self.monitors.len()); // Re-sync config view to the restored state