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 c50e352f9..12369a9ae 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -318,6 +318,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. @@ -348,6 +349,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) }; @@ -623,6 +625,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; @@ -699,6 +705,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 } => { @@ -762,11 +776,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)) @@ -787,9 +803,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 { @@ -798,9 +812,53 @@ 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) { + // The pointer's end moves with Shift+arrow next; the opposite corner keeps it a whole column or row. + let (range, pointer, opposite) = match *drag { + Drag::PointCols(anchor) => { + let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); + let range = RangeRef::cols(anchor.min(col), anchor.max(col)); + (range, CellRef::new(range.start.row, col), CellRef::new(range.end.row, anchor)) + } + Drag::PointRows(anchor) => { + let row = geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)); + let range = RangeRef::rows(anchor.min(row), anchor.max(row)); + (range, CellRef::new(row, range.start.col), CellRef::new(anchor, range.end.col)) + } + _ => 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(pointer); + ed.point_anchor = Some((wb.active_sheet(), opposite)); +} + 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)), @@ -1049,6 +1107,7 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte to }; let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() }; + let text = if ed.sheet != d.wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sheet.name)) } else { text }; let sheet = d.wb.active_sheet(); ed.insert_ref(&text); ed.point_cell = Some(to); diff --git a/crates/ui-egui/tests/it/formula_point_headers.rs b/crates/ui-egui/tests/it/formula_point_headers.rs index 61c4192fa..aeef72f7f 100644 --- a/crates/ui-egui/tests/it/formula_point_headers.rs +++ b/crates/ui-egui/tests/it/formula_point_headers.rs @@ -173,3 +173,148 @@ 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"); +} + +fn shift_arrow(h: &mut egui_kittest::Harness<'_, SheetApp>, key: egui::Key) { + let modifiers = Modifiers { shift: true, ..Modifiers::NONE }; + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +#[test] +fn shift_arrow_after_a_header_drag_moves_the_dragged_end() { + let mut h = harness(); + let (c, e) = (header_center(&h, false, 2), header_center(&h, false, 4)); + press_and_drag(&mut h, c, e, Modifiers::NONE); + release(&mut h, e); + assert_eq!(text(&h), "=SUM(C:E"); + shift_arrow(&mut h, egui::Key::ArrowRight); + assert_eq!(text(&h), "=SUM(C:F"); + + // Dragged leftwards, the dragged end is the first column. + let mut h = harness(); + let (e, c) = (header_center(&h, false, 4), header_center(&h, false, 2)); + press_and_drag(&mut h, e, c, Modifiers::NONE); + release(&mut h, c); + assert_eq!(text(&h), "=SUM(C:E"); + shift_arrow(&mut h, egui::Key::ArrowLeft); + assert_eq!(text(&h), "=SUM(B:E"); + + let mut h = harness(); + let (r1, r3) = (header_center(&h, true, 0), header_center(&h, true, 2)); + press_and_drag(&mut h, r1, r3, Modifiers::NONE); + release(&mut h, r3); + assert_eq!(text(&h), "=SUM(1:3"); + shift_arrow(&mut h, egui::Key::ArrowDown); + assert_eq!(text(&h), "=SUM(1:4"); +} + +#[test] +fn shift_arrow_pointing_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); + release(&mut h, d); + assert_eq!(text(&h), "=SUM('Other sheet'!B:D"); + let app = h.state_mut(); + let mut ed = app.editor.take().unwrap(); + gridcraft_ui_egui::grid::point_move(app, &mut ed, 0, 1, true, false); + app.editor = Some(ed); + assert_eq!(text(&h), "=SUM('Other sheet'!B:E"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c17d132a8..e4bf03ea1 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-03 · **Change:** minor (a picture on the system clipboard pastes as a floating picture at the active cell with Cmd/Ctrl+V or Paste (#699); earlier: frozen columns keep their text, gridlines and merged borders whole while the sheet scrolls (#2544); earlier: every Paste Special option has its access key; earlier: the Draw tab's live stroke preview uses the pen's colour and thickness; earlier: copy quotes only cells with a tab or line break, as Excel does; earlier: Ctrl+. Next Corner of Selection; earlier: dragging the selection's border onto data asks before replacing it; 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 +> **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: a picture on the system clipboard pastes as a floating picture at the active cell with Cmd/Ctrl+V or Paste (#699); earlier: frozen columns keep their text, gridlines and merged borders whole while the sheet scrolls (#2544); earlier: every Paste Special option has its access key; earlier: the Draw tab's live stroke preview uses the pen's colour and thickness; earlier: copy quotes only cells with a tab or line break, as Excel does; earlier: Ctrl+. Next Corner of Selection; earlier: dragging the selection's border onto data asks before replacing it; 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) | border drag moves (M3) and asks "There's already data here. Do you want to replace it?" before replacing cells that hold data (`range.move`, #3136); Ctrl/Option to copy and Shift to insert are not wired to the border drag yet (reorder whole columns by their headers instead) | | 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) | @@ -238,6 +238,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-03 | minor | Frozen panes: text in frozen columns overflows only into its own pane and keeps its whole width while the sheet scrolls; frozen gridlines and merged-cell borders reach the freeze line, and the freeze handle measures the last frozen column itself (#2544). Page Layout view gridlines stop at each page's last column | | 2026-11-03 | minor | Excel 2003 menu access keys: 43 legacy sequences (Insert, Edit, Format, Data, View, Tools and File ▸ Page Setup, e.g. Alt+I, R inserts rows, Alt+E, A, A clears all, Alt+D, S sorts, Alt+T, O opens Options) run the existing commands and dialogs; Alt+I, D, V and T paint no badges, as in Excel (#941, #2311) | | 2026-11-03 | minor | Paste Special: every option has Excel's access key (A, F, V, T, C, N, X, W, R, U; O, D, S, M, I; B, E; L pastes the link at once), not only F, V, T, C and E (#1482) |