Guard unsaved edits against refresh and failed apply; coalesce undo

- Refresh (r) no longer clears the dirty flag or clobbers in-progress
  edits: it skips with a status message while changes are uncommitted,
  keeping the unsaved-changes quit guard intact.
- Config-tab apply keeps dirty set until the hyprctl eval and
  monitors.json write both succeed, so a failed apply no longer drops
  the modified marker.
- Undo snapshots coalesce runs of nudges/value cycles into one step via
  burst tracking in AppState, so 20 px of nudges undo as a single step.
- Replace the `date` subprocess timestamp with an in-process ISO 8601
  formatter (Hinnant days-from-civil); remove dead row_height code.
This commit is contained in:
Breadway 2026-08-30 18:34:41 +08:00
parent 40d28d3ec4
commit 8e0b95899c
5 changed files with 135 additions and 39 deletions

View file

@ -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);

View file

@ -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'-');
}
}

View file

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

View file

@ -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);

View file

@ -114,6 +114,9 @@ pub struct AppState {
pub active_profile: Option<String>,
/// Snapshots for Ctrl+Z undo (up to 20 deep).
pub undo_stack: Vec<Vec<Monitor>>,
/// 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