From 38c11f6fb9c78a9e2da66cb526c9439da490dc40 Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:23:09 -0400 Subject: [PATCH] Fix header double-click AutoFit for selected ranges (#42) --- crates/ui-egui/src/grid.rs | 59 ++++++++----- crates/ui-egui/tests/header_autofit.rs | 116 +++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/tests/header_autofit.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc36..834146cd3 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1080,6 +1080,32 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } } + // A double-click is also a click: handle header AutoFit before resize setup returns. + if resp.double_clicked() + && let Some(p) = pos + { + let autofit = if in_col_header { + col_edge(geo, sh, p.x).map(|(col, _)| { + let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col); + ("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) }) + }) + } else if in_row_header { + row_edge(geo, sh, p.y).map(|(row, _)| { + let selected = sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row); + ("home.autofitRowHeight", if selected { json!({}) } else { json!({"rows": RangeRef::rows(row, row).a1()}) }) + }) + } else { + None + }; + if let Some((command, params)) = autofit { + app.grid.drag = Drag::None; + app.grid.drag_select = false; + app.run_or_alert(command, params); + resp.request_focus(); + return; + } + } + // Press: decide the drag mode. if resp.drag_started() || (resp.clicked() && app.grid.drag == Drag::None) { // Where the press began (a drag is only recognised after the pointer has moved). @@ -1334,29 +1360,22 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.grid.drag_select = false; } - // Double-click on header edges autofits. + // Double-click the fill handle. if resp.double_clicked() && let Some(p) = pos + && handle.contains(p) { - if in_col_header && let Some((c, _)) = col_edge(geo, sh, p.x) { - let _ = app.run("home.autofitColumnWidth", json!({"cols": RangeRef::cols(c, c).a1()})); - } else if in_row_header && let Some((r, _)) = row_edge(geo, sh, p.y) { - let _ = app.run("home.autofitRowHeight", json!({"rows": format!("{}:{}", r + 1, r + 1)})); - } else if handle.contains(p) { - // Double-click the fill handle: fill down as far as the neighbouring column goes. - let src = sel.current(); - let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; - let mut last = src.end.row; - while last + 1 < MAX_ROWS - && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) - && last - src.end.row < 1_000_000 - { - last += 1; - } - if last > src.end.row { - let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); - let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); - } + // Fill down as far as the neighbouring column goes. + let src = sel.current(); + let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; + let mut last = src.end.row; + while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000 + { + last += 1; + } + if last > src.end.row { + let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); + let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); } } diff --git a/crates/ui-egui/tests/header_autofit.rs b/crates/ui-egui/tests/header_autofit.rs new file mode 100644 index 000000000..8a6673855 --- /dev/null +++ b/crates/ui-egui/tests/header_autofit.rs @@ -0,0 +1,116 @@ +//! Header AutoFit and resize gestures, driven through real egui pointer events. + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness(selection: &str) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session + .execute( + "range.setValues", + json!({"range": "A1", "values": [ + ["Short", "This column has considerably longer content", "Third", "Unchanged"], + ["Two\nlines", "B", "C", "D"], + ["Three\ntext\nlines", "B", "C", "D"] + ]}), + ) + .unwrap(); + session.execute("home.columnWidth", json!({"cols": "A:D", "width": 100})).unwrap(); + session.execute("home.rowHeight", json!({"rows": "1:4", "height": 60})).unwrap(); + session.execute("selection.set", json!({"range": selection})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h.state_mut().session.active_mut().unwrap().undo.clear(); + h.state_mut().session.journal.clear(); + h +} + +fn edge(h: &egui_kittest::Harness<'_, SheetApp>, rows: bool, index: u32) -> (Pos2, f32) { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let pos = if rows { + pos2(geo.rect.left() + geo.header_w / 2.0, geo.y(sheet, index + 1)) + } else { + pos2(geo.x(sheet, index + 1), geo.rect.top() + geo.header_h / 2.0) + }; + (pos, geo.z) +} + +fn button(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2, pressed: bool) { + h.input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.step(); +} + +fn sizes(h: &egui_kittest::Harness<'_, SheetApp>, rows: bool) -> Vec { + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + (0..4).map(|index| if rows { sheet.row_height(index) } else { sheet.col_width(index) }).collect() +} + +#[test] +fn header_double_click_autofits_selected_areas_or_only_the_clicked_line() { + for (rows, selection, clicked, changed) in [ + (false, "B:B", 1, [false, true, false, false]), + (false, "A:C", 1, [true, true, true, false]), + (false, "A:A,C:C", 0, [true, false, true, false]), + (false, "A:B", 2, [false, false, true, false]), + (true, "1:3", 1, [true, true, true, false]), + (true, "1:1,3:3", 0, [true, false, true, false]), + (true, "1:2", 2, [false, false, true, false]), + ] { + let mut h = harness(selection); + let before = sizes(&h, rows); + let selection_before = h.state().session.active().unwrap().selection.clone(); + let (pos, _) = edge(&h, rows, clicked); + button(&mut h, pos, true); + button(&mut h, pos, false); + h.step(); // Let the first click's resize state settle before the second click. + button(&mut h, pos, true); + button(&mut h, pos, false); + h.run_steps(2); + + for (index, ((old, new), changed)) in before.iter().zip(sizes(&h, rows)).zip(changed).enumerate() { + assert_eq!(*old != new, changed, "selection {selection}, clicked {clicked}, line {index}: {old} → {new}"); + } + assert_eq!(h.state().session.active().unwrap().selection, selection_before); + let command = if rows { "home.autofitRowHeight" } else { "home.autofitColumnWidth" }; + assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == command).count(), 1); + 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, "AutoFit should undo as a single operation"); + } +} + +#[test] +fn dragging_a_header_boundary_still_resizes_normally() { + for rows in [false, true] { + let mut h = harness(if rows { "2:2" } else { "B:B" }); + 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(); + button(&mut h, start + delta, false); + h.run_steps(2); + let after = sizes(&h, rows); + assert_eq!(after[1], (before[1] + 30.0 / zoom).round()); + for index in [0, 2, 3] { + assert_eq!(before[index], after[index]); + } + assert!(!h.state().session.journal.iter().any(|(id, _)| id.starts_with("home.autofit"))); + } +}