mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-13 01:14:54 -06:00
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:
co-authored by
Claude Sonnet 5.5
parent
84acf6293b
commit
6090366562
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
Reference in New Issue
Block a user