From 60903665625df2e5d0c6bf0ca5b6bbdc591edd3a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 20:09:53 +0200 Subject: [PATCH 1/4] Grid: resizing several selected columns applies the width on release (#1082) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/grid.rs | 110 +++++++++++++++++++------ crates/ui-egui/tests/header_autofit.rs | 48 +++++++++++ 3 files changed, 134 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..72a9ebf5b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-13 | Grid: resizing several selected columns or rows previews on the dragged one and applies the size to all on release; Esc cancels (#1082) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..b80b19ab9 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -68,15 +68,19 @@ pub enum Drag { source: RangeRef, before: Option, }, + /// `to`: the previewed size once the pointer moved. Only the dragged line changes while the + /// button is held; the release applies the size to every selected line (Excel). ResizeCol { col: u32, start: f32, orig: f32, + to: Option, }, ResizeRow { row: u32, start: f32, orig: f32, + to: Option, }, Fill { target: Option, @@ -2012,7 +2016,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } if in_col_header { if let Some((c, x)) = col_edge(geo, sh, p.x) { - app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z }; + app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z, to: None }; return; } let c = geo.col_at(sh, p.x); @@ -2035,7 +2039,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } if in_row_header { if let Some((r, y)) = row_edge(geo, sh, p.y) { - app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z }; + app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z, to: None }; return; } let r = geo.row_at(sh, p.y); @@ -2142,6 +2146,16 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } + // Esc during a header-boundary drag cancels it: undo the preview, ignore the release. + if resp.dragged() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if let Drag::ResizeCol { to: Some(_), .. } | Drag::ResizeRow { to: Some(_), .. } = app.grid.drag { + let _ = app.session.run("edit.undo", json!({})); + } + if matches!(app.grid.drag, Drag::ResizeCol { .. } | Drag::ResizeRow { .. }) { + app.grid.drag = Drag::None; + } + } + // Dragging. if resp.dragged() && let Some(p) = pos @@ -2190,35 +2204,21 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.grid.drag = Drag::MoveCols { source, before }; scroll_by(app, vec2(auto.x, 0.0)); } - Drag::ResizeCol { col, start, orig } => { - let w = (orig + (p.x - start) / geo.z).max(0.0); + Drag::ResizeCol { col, start, orig, .. } => { + let w = nan_safe_size(orig + (p.x - start) / geo.z); app.toast = Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms())); - // Inside a selection of whole columns, every selected column takes the new width - // (Ctrl-selected areas included); otherwise only the dragged one. - let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) { - sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::>().join(",") - } else { - RangeRef::cols(col, col).a1() - }; - let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()})); + // Preview on the dragged column only; the release applies it to the selection. + let _ = app.session.run("home.columnWidth", json!({"cols": RangeRef::cols(col, col).a1(), "width": w.round()})); coalesce_last_undo(app); + app.grid.drag = Drag::ResizeCol { col, start, orig, to: Some(w) }; } - Drag::ResizeRow { row, start, orig } => { - let h = (orig + (p.y - start) / geo.z).max(0.0); + Drag::ResizeRow { row, start, orig, .. } => { + let h = nan_safe_size(orig + (p.y - start) / geo.z); app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms())); - let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) { - sel.ranges - .iter() - .filter(|r| r.is_full_rows()) - .map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1)) - .collect::>() - .join(",") - } else { - format!("{}:{}", row + 1, row + 1) - }; - let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()})); + let _ = app.session.run("home.rowHeight", json!({"rows": format!("{}:{}", row + 1, row + 1), "height": h.round()})); coalesce_last_undo(app); + app.grid.drag = Drag::ResizeRow { row, start, orig, to: Some(h) }; } Drag::Freeze { cols, .. } => { app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) }; @@ -2288,6 +2288,21 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let _ = app.run("edit.paste", json!({"at": to.a1()})); } } + Drag::ResizeCol { col, to: Some(w), .. } => { + // The preview already sized the dragged column; the other selected ones follow now. + let cols = resize_targets(&sel.ranges, col, true); + if cols != RangeRef::cols(col, col).a1() { + let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()})); + coalesce_last_undo(app); + } + } + Drag::ResizeRow { row, to: Some(h), .. } => { + let rows = resize_targets(&sel.ranges, row, false); + if rows != format!("{}:{}", row + 1, row + 1) { + let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()})); + coalesce_last_undo(app); + } + } Drag::MoveCols { source, before: Some(before) } => { if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) { app.message = Some(("GridCraft".into(), crate::clean_error(&e))); @@ -2369,6 +2384,29 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p } } +/// A drag size in points: NaN or negative becomes 0 (a hidden line). +fn nan_safe_size(v: f32) -> f32 { + if v.is_nan() { 0.0 } else { v.max(0.0) } +} + +/// The lines a header-boundary drag resizes on release: every selected whole line when the +/// dragged one is part of the selection (Ctrl-selected areas included), otherwise only it. +fn resize_targets(ranges: &[RangeRef], index: u32, cols: bool) -> String { + let whole = |r: &RangeRef| if cols { r.is_full_cols() } else { r.is_full_rows() }; + let span = |r: &RangeRef| if cols { (r.start.col, r.end.col) } else { (r.start.row, r.end.row) }; + let name = |r: &RangeRef| { + let (a, b) = span(r); + if cols { RangeRef::cols(a, b).a1() } else { format!("{}:{}", a + 1, b + 1) } + }; + if ranges.iter().any(|r| whole(r) && (span(r).0..=span(r).1).contains(&index)) { + ranges.iter().filter(|r| whole(r)).map(name).collect::>().join(",") + } else if cols { + RangeRef::cols(index, index).a1() + } else { + format!("{}:{}", index + 1, index + 1) + } +} + fn coalesce_last_undo(app: &mut SheetApp) { // Merge consecutive drag steps into one undo entry. if let Some(d) = app.session.active_mut() @@ -3628,3 +3666,25 @@ mod outline_band_tests { assert!(outline_bands(Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(1.0, 1.0)), 2.0, clip).is_empty()); } } + +#[cfg(test)] +mod resize_tests { + use super::*; + + #[test] + fn resize_targets_follow_the_selection_only_when_the_dragged_line_is_in_it() { + let sel = [RangeRef::cols(0, 0), RangeRef::cols(2, 3)]; + assert_eq!(resize_targets(&sel, 3, true), "A:A,C:D"); + assert_eq!(resize_targets(&sel, 1, true), "B:B"); + assert_eq!(resize_targets(&[RangeRef::rows(1, 2)], 2, false), "2:3"); + assert_eq!(resize_targets(&[RangeRef::rows(1, 2)], 5, false), "6:6"); + assert_eq!(resize_targets(&[], 0, true), "A:A"); + } + + #[test] + fn drag_sizes_are_nan_safe() { + assert_eq!(nan_safe_size(f32::NAN), 0.0); + assert_eq!(nan_safe_size(-4.0), 0.0); + assert_eq!(nan_safe_size(12.5), 12.5); + } +} diff --git a/crates/ui-egui/tests/header_autofit.rs b/crates/ui-egui/tests/header_autofit.rs index 5e123809f..d94af1f18 100644 --- a/crates/ui-egui/tests/header_autofit.rs +++ b/crates/ui-egui/tests/header_autofit.rs @@ -153,6 +153,54 @@ fn dragging_a_boundary_inside_a_multi_area_selection_resizes_every_selected_line } } +#[test] +fn a_multi_selection_resize_previews_on_the_dragged_line_and_applies_on_release() { + // Issue #1082: only the dragged line moves while the button is held; Esc cancels. + for rows in [false, true] { + let mut h = harness(if rows { "1:3" } else { "A:C" }); + let before = sizes(&h, rows); + let (start, zoom) = edge(&h, rows, 1); + let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) }; + button(&mut h, start, true); + h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0)); + h.step(); + h.input_mut().events.push(Event::PointerMoved(start + delta)); + h.step(); + let held = sizes(&h, rows); + assert_eq!(held[1], (before[1] + 30.0 / zoom).round(), "the dragged line previews"); + for index in [0, 2, 3] { + assert_eq!(held[index], before[index], "line {index} waits for the release"); + } + button(&mut h, start + delta, false); + h.run_steps(2); + let after = sizes(&h, rows); + for (index, size) in after.iter().take(3).enumerate() { + assert_eq!(*size, held[1], "line {index} takes the width on release"); + } + assert_eq!(after[3], before[3]); + assert_eq!(h.state().session.active().unwrap().undo.len(), 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(sizes(&h, rows), before); + + // Esc during the drag cancels everything. + let mut h = harness(if rows { "1:3" } else { "A:C" }); + button(&mut h, start, true); + h.input_mut().events.push(Event::PointerMoved(start + delta)); + h.step(); + h.input_mut().events.push(Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Modifiers::NONE, + }); + h.step(); + button(&mut h, start + delta, false); + h.run_steps(2); + assert_eq!(sizes(&h, rows), before, "Esc restores the sizes"); + } +} + #[test] fn table_filter_autofit_works_through_command_and_header_double_click() { for zoom in [50, 100, 200] { From 96aece4e576ef21e1d4de1b56d55b1fb85bb48cf Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 30 Oct 2026 00:58:28 +0100 Subject: [PATCH 2/4] Keep Esc on a resize drag from undoing an earlier size edit A resize drag shares its undo label with an earlier width/height edit, so coalescing removed the drag's own entry and Esc undid the earlier edit too. Track the workbook the drag started from and merge/cancel only entries from there. Esc is also handled when egui has already ended the drag. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/grid.rs | 4 ++ crates/ui-egui/src/grid/interact.rs | 43 ++++++++++++----- crates/ui-egui/tests/it/header_autofit.rs | 56 +++++++++++++++++++++++ 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index aa43e47f0..36a521704 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -180,6 +180,10 @@ impl Trace { #[derive(Default)] pub struct GridState { pub drag: Drag, + /// A header-boundary resize drag: the workbook as it was when the drag began. The drag's own + /// undo entries are the ones from the first entry that snapshots it; earlier edits with the + /// same label are not the drag's to merge or cancel. + pub resize_base: Option>, pub ensure_visible: bool, /// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell /// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves). diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 28ca72fc7..4d2a72722 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -300,6 +300,7 @@ fn handle_column_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, let in_col_header = input.in_column_header; if in_col_header { if let Some((c, x)) = col_edge(geo, sh, p.x) { + app.grid.resize_base = app.session.active().map(|d| d.wb.clone()); app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z, to: None }; return; } @@ -335,6 +336,7 @@ fn handle_row_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p: let in_row_header = input.in_row_header; if in_row_header { if let Some((r, y)) = row_edge(geo, sh, p.y) { + app.grid.resize_base = app.session.active().map(|d| d.wb.clone()); app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z, to: None }; return; } @@ -576,14 +578,16 @@ fn handle_context_menu_and_keyboard(app: &mut SheetApp, input: &InteractionInput fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) { let (geo, sh, wb, sel, pos) = (input.geo, input.sheet, input.workbook, &input.selection, input.position); let resp = input.response; - // Esc during a header-boundary drag cancels it: undo the preview, ignore the release. - if resp.dragged() && input.context.input(|i| i.key_pressed(egui::Key::Escape)) { - if let Drag::ResizeCol { to: Some(_), .. } | Drag::ResizeRow { to: Some(_), .. } = app.grid.drag { - let _ = app.session.run("edit.undo", json!({})); - } - if matches!(app.grid.drag, Drag::ResizeCol { .. } | Drag::ResizeRow { .. }) { - app.grid.drag = Drag::None; + // Esc during a header-boundary drag cancels it: undo the preview, ignore the release. egui + // aborts the drag on Esc, so `resp.dragged()` is already false in this frame. + if matches!(app.grid.drag, Drag::ResizeCol { .. } | Drag::ResizeRow { .. }) && input.context.input(|i| i.key_pressed(egui::Key::Escape)) { + // Undo back to where the drag began, but never past it into an earlier edit. + let steps = resize_steps(app); + if steps > 0 { + let _ = app.session.run("edit.undo", json!({"steps": steps})); } + app.grid.drag = Drag::None; + app.grid.resize_base = None; } if resp.dragged() && let Some(p) = pos @@ -638,14 +642,14 @@ fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) { Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms())); // Preview on the dragged column only; the release applies it to the selection. let _ = app.session.run("home.columnWidth", json!({"cols": RangeRef::cols(col, col).a1(), "width": w.round()})); - coalesce_last_undo(app); + coalesce_resize_undo(app); app.grid.drag = Drag::ResizeCol { col, start, orig, to: Some(w) }; } Drag::ResizeRow { row, start, orig, .. } => { let h = nan_safe_size(orig + (p.y - start) / geo.z); app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms())); let _ = app.session.run("home.rowHeight", json!({"rows": format!("{}:{}", row + 1, row + 1), "height": h.round()})); - coalesce_last_undo(app); + coalesce_resize_undo(app); app.grid.drag = Drag::ResizeRow { row, start, orig, to: Some(h) }; } Drag::Freeze { cols, .. } => { @@ -741,14 +745,14 @@ fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) { let cols = resize_targets(&sel.ranges, col, true); if cols != RangeRef::cols(col, col).a1() { let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()})); - coalesce_last_undo(app); + coalesce_resize_undo(app); } } Drag::ResizeRow { row, to: Some(h), .. } => { let rows = resize_targets(&sel.ranges, row, false); if rows != format!("{}:{}", row + 1, row + 1) { let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()})); - coalesce_last_undo(app); + coalesce_resize_undo(app); } } Drag::MoveCols { source, before: Some(before) } => { @@ -884,6 +888,23 @@ fn coalesce_last_undo(app: &mut SheetApp) { } } +/// Undo entries a header resize drag has pushed so far: those from the first one that snapshots +/// the workbook the drag started from (`resize_base`). A failed command pushes none. +fn resize_steps(app: &SheetApp) -> usize { + let (Some(base), Some(d)) = (&app.grid.resize_base, app.session.active()) else { return 0 }; + d.undo.iter().position(|e| std::sync::Arc::ptr_eq(&e.wb, base)).map_or(0, |first| d.undo.len() - first) +} + +/// Like [`coalesce_last_undo`] for a resize drag: merges only the drag's own entries, so an +/// earlier edit with the same label ("Column width") stays a separate undo step. +fn coalesce_resize_undo(app: &mut SheetApp) { + if resize_steps(app) >= 2 + && let Some(d) = app.session.active_mut() + { + d.undo.pop(); + } +} + fn scroll_by(app: &mut SheetApp, d: egui::Vec2) { if d != egui::Vec2::ZERO && let Some(v) = app.view_mut() diff --git a/crates/ui-egui/tests/it/header_autofit.rs b/crates/ui-egui/tests/it/header_autofit.rs index 6fc46a854..bf53b624d 100644 --- a/crates/ui-egui/tests/it/header_autofit.rs +++ b/crates/ui-egui/tests/it/header_autofit.rs @@ -201,6 +201,62 @@ fn a_multi_selection_resize_previews_on_the_dragged_line_and_applies_on_release( } } +#[test] +fn escaping_a_resize_drag_keeps_an_earlier_size_edit() { + // A resize drag right after another resize shares its undo label; Esc must cancel only the drag. + for (rows, selection) in [(false, "B:B"), (true, "2:2"), (false, "A:C"), (true, "1:3")] { + let mut h = harness(selection); + let key = if rows { ("home.rowHeight", "rows", "2:2", "height") } else { ("home.columnWidth", "cols", "B:B", "width") }; + h.state_mut().session.execute(key.0, json!({key.1: key.2, key.3: 80})).unwrap(); + h.run_steps(2); + let earlier = sizes(&h, rows); + let undo_len = h.state().session.active().unwrap().undo.len(); + let (start, _) = edge(&h, rows, 1); + let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) }; + button(&mut h, start, true); + h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0)); + h.step(); + h.input_mut().events.push(Event::PointerMoved(start + delta)); + h.step(); + assert_ne!(sizes(&h, rows), earlier, "the drag previews"); + h.input_mut().events.push(Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Modifiers::NONE, + }); + h.step(); + button(&mut h, start + delta, false); + h.run_steps(2); + assert_eq!(sizes(&h, rows), earlier, "Esc cancels only the drag (rows: {rows}, selection {selection})"); + assert_eq!(h.state().session.active().unwrap().undo.len(), undo_len, "the earlier edit stays on the undo stack"); + } +} + +#[test] +fn a_resize_drag_after_another_resize_is_its_own_undo_step() { + for (rows, selection) in [(false, "B:B"), (true, "2:2"), (false, "A:C"), (true, "1:3")] { + let mut h = harness(selection); + let key = if rows { ("home.rowHeight", "rows", "2:2", "height") } else { ("home.columnWidth", "cols", "B:B", "width") }; + h.state_mut().session.execute(key.0, json!({key.1: key.2, key.3: 80})).unwrap(); + h.run_steps(2); + let earlier = sizes(&h, rows); + let (start, _) = edge(&h, rows, 1); + let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) }; + button(&mut h, start, true); + h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0)); + h.step(); + h.input_mut().events.push(Event::PointerMoved(start + delta)); + h.step(); + button(&mut h, start + delta, false); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().undo.len(), 2, "selection {selection}: two undo steps"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(sizes(&h, rows), earlier, "selection {selection}: one undo reverts only the drag"); + } +} + #[test] fn table_filter_autofit_works_through_command_and_header_double_click() { for zoom in [50, 100, 200] { From 3d50413aff16589caaf3fed42e3a286af4b2251d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 17:34:38 +0100 Subject: [PATCH 3/4] Resize drag: keep the last accepted preview and the redo history on Esc A refused size no longer replaces the size the release applies to the selection, and Esc puts back the redo stack the drag began with. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/grid.rs | 2 + crates/ui-egui/src/grid/interact.rs | 29 ++++++--- crates/ui-egui/tests/it/header_autofit.rs | 79 +++++++++++++++++++++++ 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a5e8eded7..3af153675 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -244,6 +244,8 @@ pub struct GridState { /// undo entries are the ones from the first entry that snapshots it; earlier edits with the /// same label are not the drag's to merge or cancel. pub resize_base: Option>, + /// The redo stack when that drag began; Esc puts it back (the preview commands clear it). + pub resize_redo: Vec, pub ensure_visible: bool, /// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell /// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves). diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 91630108c..fb0be26db 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -308,7 +308,7 @@ fn handle_column_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, let in_col_header = input.in_column_header; if in_col_header { if let Some((c, x)) = col_edge(geo, sh, p.x) { - app.grid.resize_base = app.session.active().map(|d| d.wb.clone()); + begin_resize(app); app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z, to: None }; return; } @@ -345,7 +345,7 @@ fn handle_row_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p: let in_row_header = input.in_row_header; if in_row_header { if let Some((r, y)) = row_edge(geo, sh, p.y) { - app.grid.resize_base = app.session.active().map(|d| d.wb.clone()); + begin_resize(app); app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z, to: None }; return; } @@ -596,6 +596,10 @@ fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) { if steps > 0 { let _ = app.session.run("edit.undo", json!({"steps": steps})); } + // Undo pushes the cancelled preview onto the redo stack; put back what the drag began with. + if let Some(d) = app.session.active_mut() { + d.redo = std::mem::take(&mut app.grid.resize_redo); + } app.grid.drag = Drag::None; app.grid.resize_base = None; } @@ -650,21 +654,22 @@ fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) { app.grid.drag = Drag::MoveCols { source, before }; scroll_by(app, vec2(auto.x, 0.0)); } - Drag::ResizeCol { col, start, orig, .. } => { + Drag::ResizeCol { col, start, orig, to } => { let w = nan_safe_size(orig + (p.x - start) / geo.z); app.toast = Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms())); - // Preview on the dragged column only; the release applies it to the selection. - let _ = app.session.run("home.columnWidth", json!({"cols": RangeRef::cols(col, col).a1(), "width": w.round()})); + // Preview on the dragged column only; the release applies it to the selection. A + // rejected size leaves the last shown preview (and `to`) as it was. + let ok = app.session.run("home.columnWidth", json!({"cols": RangeRef::cols(col, col).a1(), "width": w.round()})).is_ok(); coalesce_resize_undo(app); - app.grid.drag = Drag::ResizeCol { col, start, orig, to: Some(w) }; + app.grid.drag = Drag::ResizeCol { col, start, orig, to: if ok { Some(w) } else { to } }; } - Drag::ResizeRow { row, start, orig, .. } => { + Drag::ResizeRow { row, start, orig, to } => { let h = nan_safe_size(orig + (p.y - start) / geo.z); app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms())); - let _ = app.session.run("home.rowHeight", json!({"rows": format!("{}:{}", row + 1, row + 1), "height": h.round()})); + let ok = app.session.run("home.rowHeight", json!({"rows": format!("{}:{}", row + 1, row + 1), "height": h.round()})).is_ok(); coalesce_resize_undo(app); - app.grid.drag = Drag::ResizeRow { row, start, orig, to: Some(h) }; + app.grid.drag = Drag::ResizeRow { row, start, orig, to: if ok { Some(h) } else { to } }; } Drag::Freeze { cols, .. } => { app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) }; @@ -940,6 +945,12 @@ fn coalesce_last_undo(app: &mut SheetApp) { } } +/// Starts a header resize drag: remembers the workbook and the redo stack it began with. +fn begin_resize(app: &mut SheetApp) { + app.grid.resize_base = app.session.active().map(|d| d.wb.clone()); + app.grid.resize_redo = app.session.active().map(|d| d.redo.clone()).unwrap_or_default(); +} + /// Undo entries a header resize drag has pushed so far: those from the first one that snapshots /// the workbook the drag started from (`resize_base`). A failed command pushes none. fn resize_steps(app: &SheetApp) -> usize { diff --git a/crates/ui-egui/tests/it/header_autofit.rs b/crates/ui-egui/tests/it/header_autofit.rs index bf53b624d..08f0b1099 100644 --- a/crates/ui-egui/tests/it/header_autofit.rs +++ b/crates/ui-egui/tests/it/header_autofit.rs @@ -295,3 +295,82 @@ fn table_filter_autofit_works_through_command_and_header_double_click() { } } } + +fn press_escape(h: &mut egui_kittest::Harness<'_, SheetApp>) { + h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +#[test] +fn a_rejected_final_preview_releases_with_the_size_last_shown() { + // The engine refuses rows over 546 px and columns over 1800 px; the release must apply the last + // accepted preview to the whole selection, not retry the refused size. + for rows in [false, true] { + let mut h = harness(if rows { "1:3" } else { "A:C" }); + let before = sizes(&h, rows); + let (start, zoom) = edge(&h, rows, 1); + let unit = |d: f32| if rows { vec2(0.0, d) } else { vec2(d, 0.0) }; + button(&mut h, start, true); + h.input_mut().events.push(Event::PointerMoved(start + unit(15.0))); + h.step(); + h.input_mut().events.push(Event::PointerMoved(start + unit(30.0))); + h.step(); + let shown = sizes(&h, rows)[1]; + assert_eq!(shown, (before[1] + 30.0 / zoom).round()); + h.input_mut().events.push(Event::PointerMoved(start + unit(4000.0 * zoom))); + h.step(); + assert_eq!(sizes(&h, rows)[1], shown, "the refused size is not shown"); + button(&mut h, start + unit(4000.0 * zoom), false); + h.run_steps(2); + let after = sizes(&h, rows); + for (index, size) in after.iter().take(3).enumerate() { + assert_eq!(*size, shown, "line {index} takes the size that was shown (rows: {rows})"); + } + assert_eq!(after[3], before[3]); + } +} + +#[test] +fn escaping_a_resize_drag_leaves_redo_history_as_it_was() { + for rows in [false, true] { + for with_redo in [false, true] { + let mut h = harness(if rows { "1:3" } else { "A:C" }); + let cmd = if rows { ("home.rowHeight", "rows", "4:4", "height") } else { ("home.columnWidth", "cols", "D:D", "width") }; + let mut redo_len = 0; + let mut d_size = 0.0; + if with_redo { + h.state_mut().session.execute(cmd.0, json!({cmd.1: cmd.2, cmd.3: 80})).unwrap(); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + redo_len = h.state().session.active().unwrap().redo.len(); + assert_eq!(redo_len, 1); + d_size = sizes(&h, rows)[3]; + } + let before = sizes(&h, rows); + let (start, _) = edge(&h, rows, 1); + let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) }; + button(&mut h, start, true); + h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0)); + h.step(); + h.input_mut().events.push(Event::PointerMoved(start + delta)); + h.step(); + assert_ne!(sizes(&h, rows), before, "the drag previews"); + press_escape(&mut h); + button(&mut h, start + delta, false); + h.run_steps(2); + assert_eq!(sizes(&h, rows), before); + assert_eq!(h.state().session.active().unwrap().redo.len(), redo_len, "Esc leaves the redo stack alone (rows: {rows}, redo: {with_redo})"); + // Ctrl+Y brings back only what was undone before the drag. + let redone = h.state_mut().session.execute("edit.redo", json!({})); + if with_redo { + redone.unwrap(); + let redone = sizes(&h, rows); + assert_eq!(redone[3], 80.0); + assert_ne!(redone[3], d_size); + assert_eq!(redone[..3], before[..3], "the cancelled drag does not come back"); + } else { + assert!(redone.is_err(), "nothing to redo after a cancelled drag"); + } + } + } +} From 7cd0a6cc72f0bc8979e6dd947d25933c82888596 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 12:57:25 +0100 Subject: [PATCH 4/4] Resize drag: spend Esc on the cancelled drag Esc during a header-boundary resize was only peeked at, so the grid's own Esc handling saw it as well and dropped a marked copy and Format Painter. The cancel now removes the Esc presses from the frame's input, with any modifiers, as before, cancelling the drag. The regression test covers a pending copy and sticky Format Painter for rows and columns, with the release in a later frame or the same frame, and Shift+Esc. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid/interact.rs | 14 ++++++-- crates/ui-egui/tests/it/header_autofit.rs | 41 +++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index cee691b43..7aaed8ff3 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -634,8 +634,18 @@ fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) { let (geo, sh, wb, sel, pos) = (input.geo, input.sheet, input.workbook, &input.selection, input.position); let resp = input.response; // Esc during a header-boundary drag cancels it: undo the preview, ignore the release. egui - // aborts the drag on Esc, so `resp.dragged()` is already false in this frame. - if matches!(app.grid.drag, Drag::ResizeCol { .. } | Drag::ResizeRow { .. }) && input.context.input(|i| i.key_pressed(egui::Key::Escape)) { + // aborts the drag on Esc, so `resp.dragged()` is already false in this frame. The key is + // spent on the drag, so the grid's Esc doesn't also drop a pending copy or Format Painter. + if matches!(app.grid.drag, Drag::ResizeCol { .. } | Drag::ResizeRow { .. }) + && input.context.input_mut(|i| { + let pressed = i.key_pressed(egui::Key::Escape); + if pressed { + // With any modifiers, as before: the drag is cancelled and every Esc press spent. + i.events.retain(|e| !matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, .. })); + } + pressed + }) + { // Undo back to where the drag began, but never past it into an earlier edit. let steps = resize_steps(app); if steps > 0 { diff --git a/crates/ui-egui/tests/it/header_autofit.rs b/crates/ui-egui/tests/it/header_autofit.rs index 08f0b1099..e8fb39305 100644 --- a/crates/ui-egui/tests/it/header_autofit.rs +++ b/crates/ui-egui/tests/it/header_autofit.rs @@ -374,3 +374,44 @@ fn escaping_a_resize_drag_leaves_redo_history_as_it_was() { } } } + +#[test] +fn escaping_a_resize_drag_keeps_a_pending_copy_and_format_painter() { + // Esc cancels the drag only: the marked copy and Format Painter stay, whether the release comes + // in a later frame or in the same frame as Esc. + for rows in [false, true] { + for (same_frame, modifiers) in [(false, Modifiers::NONE), (true, Modifiers::NONE), (false, Modifiers::SHIFT)] { + let mut h = harness(if rows { "1:3" } else { "A:C" }); + h.state_mut().session.execute("edit.copy", json!({"range": "A1:B2"})).unwrap(); + h.state_mut().session.execute("edit.formatPainter", json!({"sticky": true})).unwrap(); + h.run_steps(2); + assert!(h.state().session.clipboard.is_some() && h.state().session.format_painter.is_some()); + let before = sizes(&h, rows); + let (start, _) = edge(&h, rows, 1); + let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) }; + button(&mut h, start, true); + h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0)); + h.step(); + h.input_mut().events.push(Event::PointerMoved(start + delta)); + h.step(); + assert_ne!(sizes(&h, rows), before, "the drag previews"); + let pos = start + delta; + let esc = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers }; + if same_frame { + h.input_mut().events.extend([esc, Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers }]); + } else { + h.input_mut().events.push(esc); + h.step(); + h.input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers }]); + } + h.step(); + h.run_steps(2); + let what = format!("rows: {rows}, same frame: {same_frame}, modifiers: {modifiers:?}"); + assert_eq!(sizes(&h, rows), before, "the drag is cancelled ({what})"); + assert!(h.state().session.clipboard.is_some(), "the copy is still marked ({what})"); + assert!(h.state().session.format_painter.is_some(), "Format Painter is still on ({what})"); + } + } +}