From 60903665625df2e5d0c6bf0ca5b6bbdc591edd3a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 20:09:53 +0200 Subject: [PATCH] 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] {