Grid: resizing several selected columns applies the width on release (#1082)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-13 20:09:53 +02:00
co-authored by Claude Sonnet 5.5
parent 84acf6293b
commit 6090366562
3 changed files with 134 additions and 25 deletions
+1
View File
@@ -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) |
+85 -25
View File
@@ -68,15 +68,19 @@ pub enum Drag {
source: RangeRef,
before: Option<u32>,
},
/// `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<f32>,
},
ResizeRow {
row: u32,
start: f32,
orig: f32,
to: Option<f32>,
},
Fill {
target: Option<RangeRef>,
@@ -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::<Vec<_>>().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::<Vec<_>>()
.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::<Vec<_>>().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);
}
}
+48
View File
@@ -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] {