This commit is contained in:
Brandon Thomas committed 2026-11-03 09:55:37 -05:00
commit d7b53fcc20
5 files changed
+216 -7

No files matched your search

+4
View File
@@ -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,
+1 -1
View File
@@ -48,7 +48,7 @@ fn drag_cursor(drag: &Drag, rtl: bool) -> Option<CursorIcon> {
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),
+63 -4
View File
@@ -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);
@@ -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");
}
+3 -2
View File
@@ -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) |