From f4f69beec237c6cf7c7ba3a6675e17ed6a2f2065 Mon Sep 17 00:00:00 2001 From: Daniele Mori Date: Tue, 3 Nov 2026 13:45:53 +0100 Subject: [PATCH] Point mode: dragging over row or column headers extends the reference A press on a header while a formula waits for a reference inserted C:C, but the drag that followed did nothing, so dragging from column C to E left C:C. The drag now follows the pointer, as it does over cells, in either direction and for rows too, keeping the sheet qualifier, the earlier arguments of a Ctrl press and the far end of a Shift press. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 4 + crates/ui-egui/src/grid/cursor.rs | 2 +- crates/ui-egui/src/grid/interact.rs | 62 ++++++++++++- .../ui-egui/tests/it/formula_point_headers.rs | 89 +++++++++++++++++++ docs/ui-parity.md | 5 +- 5 files changed, 155 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 5a6dd3978..5dd7246b9 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -101,6 +101,10 @@ pub enum Drag { Select, Rows(u32), Cols(u32), + /// Point mode over the row headers: the formula's reference runs from this row to the pointer. + PointRows(u32), + /// Point mode over the column headers: the formula's reference runs from this column to the pointer. + PointCols(u32), /// Reordering one contiguous whole-column selection by its header. MoveCols { source: RangeRef, diff --git a/crates/ui-egui/src/grid/cursor.rs b/crates/ui-egui/src/grid/cursor.rs index f93e59cb6..abcd7ed78 100644 --- a/crates/ui-egui/src/grid/cursor.rs +++ b/crates/ui-egui/src/grid/cursor.rs @@ -48,7 +48,7 @@ fn drag_cursor(drag: &Drag, rtl: bool) -> Option { match drag { Drag::None => None, Drag::Select => Some(CursorIcon::Cell), - Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), + Drag::Rows(_) | Drag::Cols(_) | Drag::PointRows(_) | Drag::PointCols(_) => Some(CursorIcon::Default), Drag::Fill { .. } => Some(CursorIcon::Crosshair), Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { handle: None, .. } => Some(CursorIcon::Move), Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 5de25fd53..350aefd7a 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -307,6 +307,7 @@ fn handle_column_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, return; } if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) { + point_header_first_move(app, input); return; } // An armed Format Painter paints the columns dragged over instead of moving them. @@ -337,6 +338,7 @@ fn handle_row_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p: } let r = geo.row_at(sh, p.y); if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) { + point_header_first_move(app, input); return; } let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; @@ -612,6 +614,10 @@ fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) { app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()})); scroll_by(app, vec2(auto.x, 0.0)); } + drag @ (Drag::PointRows(_) | Drag::PointCols(_)) => { + point_header_to(app, geo, sh, wb, &drag, p); + scroll_by(app, if matches!(drag, Drag::PointRows(_)) { vec2(0.0, auto.y) } else { vec2(auto.x, 0.0) }); + } Drag::MoveCols { source, .. } => { let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5; @@ -688,6 +694,14 @@ fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) { } // Whole rows or columns clicked or dragged over by their headers. Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app), + // The release may be the first frame to report the pointer's last position. + drag @ (Drag::PointRows(_) | Drag::PointCols(_)) => { + if resp.drag_stopped() + && let Some(p) = pos + { + point_header_to(app, geo, sh, wb, &drag, p); + } + } // Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is // spent on the drag so the grid's Esc doesn't also drop the copy. Drag::Fill { target: Some(t), menu: true } => { @@ -751,11 +765,13 @@ fn format_painter_apply(app: &mut SheetApp) { } } -/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting. +/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting, +/// and dragging on from there extends it. fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool { let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; let edit_sheet = ed.sheet; let active_sheet = wb.active_sheet(); + let pressed = range; let range = if extend { ed.point .and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point)) @@ -776,9 +792,7 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, } else { range }; - let reference = range.a1(); - let reference = - if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }; + let reference = header_reference(ed, wb, sh, range); if add { ed.append_ref(&reference); } else { @@ -787,9 +801,49 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, ed.point_anchor = None; ed.point_cell = Some(range.start); ed.request_focus = true; + // The drag keeps the end of the reference the press didn't land on. + app.grid.drag = if range.is_full_cols() { + Drag::PointCols(if range.start.col == pressed.start.col { range.end.col } else { range.start.col }) + } else { + Drag::PointRows(if range.start.row == pressed.start.row { range.end.row } else { range.start.row }) + }; true } +fn header_reference(ed: &EditState, wb: &Workbook, sh: &Sheet, range: RangeRef) -> String { + let reference = range.a1(); + if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference } +} + +/// The frame that starts a drag reports no `dragged()`, so its movement is applied here. +fn point_header_first_move(app: &mut SheetApp, input: &InteractionInput<'_>) { + if input.response.drag_started() + && let Some(p) = input.position + { + let drag = app.grid.drag.clone(); + point_header_to(app, input.geo, input.sheet, input.workbook, &drag, p); + } +} + +/// Point mode dragged over the headers: the pointed reference runs from the anchor to the pointer. +fn point_header_to(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, drag: &Drag, p: Pos2) { + let range = match *drag { + Drag::PointCols(anchor) => { + let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); + RangeRef::cols(anchor.min(col), anchor.max(col)) + } + Drag::PointRows(anchor) => { + let row = geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)); + RangeRef::rows(anchor.min(row), anchor.max(row)) + } + _ => return, + }; + let Some(ed) = app.editor.as_mut().filter(|ed| ed.point_at_caret()) else { return }; + let reference = header_reference(ed, wb, sh, range); + ed.insert_ref(&reference); + ed.point_cell = Some(range.start); +} + fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { let cell = CellRef::new( geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), diff --git a/crates/ui-egui/tests/it/formula_point_headers.rs b/crates/ui-egui/tests/it/formula_point_headers.rs index 61c4192fa..06d5b9308 100644 --- a/crates/ui-egui/tests/it/formula_point_headers.rs +++ b/crates/ui-egui/tests/it/formula_point_headers.rs @@ -173,3 +173,92 @@ fn shift_does_not_extend_a_row_reference_across_sheets() { [("6:6".into(), Some("Other sheet".into())),] ); } + +fn press_and_drag(h: &mut egui_kittest::Harness<'_, SheetApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut() + .events + .extend([Event::PointerMoved(from), Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }]); + h.step(); + drag_to(h, to); +} + +fn drag_to(h: &mut egui_kittest::Harness<'_, SheetApp>, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(to)); + h.step(); +} + +fn release(h: &mut egui_kittest::Harness<'_, SheetApp>, at: Pos2) { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); +} + +fn text<'a>(h: &'a egui_kittest::Harness<'_, SheetApp>) -> &'a str { + h.state().editor.as_ref().map_or("", |ed| ed.text.as_str()) +} + +#[test] +fn dragging_over_column_headers_extends_the_reference() { + let mut h = harness(); + let (c, e, a, d) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 0), header_center(&h, false, 3)); + press_and_drag(&mut h, c, e, Modifiers::NONE); + assert_eq!(text(&h), "=SUM(C:E"); + // Back past the press: the reference follows the pointer instead of keeping E. + drag_to(&mut h, a); + assert_eq!(text(&h), "=SUM(A:C"); + // The release position counts even without a movement frame before it. + release(&mut h, d); + assert_eq!(text(&h), "=SUM(C:D"); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "F8"); +} + +#[test] +fn dragging_over_row_headers_extends_the_reference() { + let mut h = harness(); + let (r3, r6, r2) = (header_center(&h, true, 2), header_center(&h, true, 5), header_center(&h, true, 1)); + press_and_drag(&mut h, r3, r6, Modifiers::NONE); + assert_eq!(text(&h), "=SUM(3:6"); + release(&mut h, r2); + assert_eq!(text(&h), "=SUM(2:3"); + assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "F8"); +} + +#[test] +fn header_drag_on_another_sheet_keeps_the_qualifier() { + let mut h = harness(); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap(); + h.run_steps(2); + let (b, d) = (header_center(&h, false, 1), header_center(&h, false, 3)); + press_and_drag(&mut h, b, d, Modifiers::NONE); + assert_eq!(text(&h), "=SUM('Other sheet'!B:D"); +} + +#[test] +fn header_drag_with_command_keeps_the_earlier_argument() { + let mut h = harness(); + let (a, e, f) = (header_center(&h, false, 0), header_center(&h, false, 4), header_center(&h, false, 5)); + click(&mut h, a); + press_and_drag(&mut h, e, f, Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }); + assert_eq!(text(&h), "=SUM(A:A,E:F"); +} + +#[test] +fn header_drag_with_shift_keeps_the_far_end_of_the_reference() { + let mut h = harness(); + let (c, e, d) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 3)); + click(&mut h, c); + press_and_drag(&mut h, e, d, Modifiers { shift: true, ..Modifiers::NONE }); + assert_eq!(text(&h), "=SUM(C:D"); +} + +#[test] +fn clicks_on_headers_replace_the_reference_after_a_drag_too() { + let mut h = harness(); + let (c, e, g) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 6)); + press_and_drag(&mut h, c, e, Modifiers::NONE); + release(&mut h, e); + assert_eq!(text(&h), "=SUM(C:E"); + click(&mut h, g); + assert_eq!(text(&h), "=SUM(G:G"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..b15cd3fbf 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (dragging over row or column headers in point mode extends the reference; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -57,7 +57,7 @@ this logic, with one undo step. | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, and from a date Fill Days, Fill Weekdays, Fill Months and Fill Years, then Series…; a left-drag leaves an Auto Fill Options button with the same fill choices (gone once the workbook changes or the selection moves); Linear/Growth Trend and Flash Fill missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | -| Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | +| Point mode (click/drag/arrow references), reference colouring, F4 | done; dragging over row or column headers points at whole rows or columns (`A:C`, `2:5`); Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | | Number-format colors (`[Green]`, `[Color1]`–`[Color56]`) | Rendered in grid and PDF from shared RGB mappings (#1452) | | Point mode (click/drag/arrow references), reference colouring, F4 | done | | AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Point mode: dragging over row or column headers extends the whole-row or whole-column reference, as it does over cells | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it |