diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 574f766de..3db0c3da4 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -518,9 +518,23 @@ fn editor_arrow_keys( ed: &mut crate::editor::EditState, mut action: Option, ) -> (Option, bool) { - let (up, down, left, right, shift) = ui.input(|i| { - (i.key_pressed(Key::ArrowUp), i.key_pressed(Key::ArrowDown), i.key_pressed(Key::ArrowLeft), i.key_pressed(Key::ArrowRight), i.modifiers.shift) + // The modifiers come with the key event: an arrow pressed and released within one frame (a + // control client's or an automation tool's keystroke) has already lost them from `i.modifiers`. + let arrow = ui.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key: key @ (Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight), pressed: true, modifiers, .. } => { + Some((*key, *modifiers)) + } + _ => None, + }) }); + let (up, down, left, right) = ( + arrow.is_some_and(|(k, _)| k == Key::ArrowUp), + arrow.is_some_and(|(k, _)| k == Key::ArrowDown), + arrow.is_some_and(|(k, _)| k == Key::ArrowLeft), + arrow.is_some_and(|(k, _)| k == Key::ArrowRight), + ); + let (shift, jump) = arrow.map_or((false, false), |(_, m)| (m.shift, m.command)); if (up || down) && !ed.autocomplete.is_empty() { ui.input_mut(|i| { i.consume_key(egui::Modifiers::NONE, Key::ArrowUp); @@ -545,13 +559,18 @@ fn editor_arrow_keys( let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); let dc = if rtl { -dc } else { dc }; if ed.can_point() { + // Ctrl+arrow (⌘arrow) jumps the pointed cell to the edge of its data, Ctrl+Shift+arrow + // extends the pointed range there, as the same keys do on the selection. ui.input_mut(|i| { for key in [Key::ArrowUp, Key::ArrowDown, Key::ArrowLeft, Key::ArrowRight] { - i.consume_key(egui::Modifiers::NONE, key); - i.consume_key(egui::Modifiers::SHIFT, key); + for modifiers in + [egui::Modifiers::NONE, egui::Modifiers::SHIFT, egui::Modifiers::COMMAND, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT] + { + i.consume_key(modifiers, key); + } } }); - crate::grid::point_move(app, ed, dr, dc, shift); + crate::grid::point_move(app, ed, dr, dc, shift, jump); (action, false) } else { action = Some((dr, dc, false, false)); diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index fdbf632a5..4ffeaeb83 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -1014,11 +1014,13 @@ pub fn follow_link(app: &mut SheetApp, target: &str) { } } -/// Used by `EditState` users to keep the active cell visible while pointing. -pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) { +/// Moves the pointed cell one step, or with `jump` (Ctrl+arrow) to the edge of its data region; +/// `extend` (Shift) moves the far end of the pointed range instead. Keeps the pointed cell visible. +pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool, jump: bool) { let Some(d) = app.session.active() else { return }; + let Some(sheet) = d.wb.active() else { return }; let from = ed.point_cell.unwrap_or(ed.cell); - let to = from.offset_clamped(dr, dc); + let to = if jump { gridcraft_engine::selection::jump(sheet, from, dr.signum(), dc.signum()) } else { from.offset_clamped(dr, dc) }; let anchor = if extend { // The anchor is the corner opposite the moving end: the written range is normalized, so // after extending up or left its first cell is the moving end, not the anchor. diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index e48a5cd32..e876d736f 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -331,6 +331,43 @@ fn shift_arrows_in_point_mode_extend_from_the_pointed_cell() { assert_eq!(edited(&h).as_deref(), Some("=SUM(A8:A10"), "Shift+Down shrinks back towards the anchor"); } +#[test] +fn ctrl_arrows_in_point_mode_jump_to_the_edge_of_the_data() { + // Typing `=COUNTIF(`, clicking the top of a column and pressing Ctrl+Shift+Down (⌘⇧↓) points + // at the whole column of data, as the same keys select it outside an edit. + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1:B5", "values": [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]]})).unwrap(); + s.execute("selection.set", json!({"cell": "D1"})).unwrap(); + let mut h = harness(s); + text(&mut h, "=COUNTIF("); + click_cell(&mut h, "A1"); + let edited = |h: &egui_kittest::Harness<'static, SheetApp>| h.state().editor.as_ref().map(|e| e.text.clone()); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1")); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT)); + key(&mut h, Key::ArrowDown, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1:A5"), "Ctrl+Shift+Down extends to the last filled cell"); + key(&mut h, Key::ArrowRight, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1:B5"), "Ctrl+Shift+Right extends the moving end across the data"); + key(&mut h, Key::ArrowDown, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A:B"), "past the data, Ctrl+Shift+Down reaches the sheet edge (written as whole columns)"); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + key(&mut h, Key::ArrowUp, Modifiers::COMMAND); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B5"), "Ctrl+Up without Shift points at the next filled cell"); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B4"), "a plain arrow still moves one cell"); + // An automation tool (osascript `key code`) presses and releases within one frame, so by the + // time the frame runs `i.modifiers` is already empty: the modifiers that count are the ones on + // the press event. + h.input_mut().events.extend([ + Event::Key { key: Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }, + Event::ModifiersChanged(Modifiers::NONE), + Event::Key { key: Key::ArrowUp, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B1"), "Command released in the same frame as the press still jumps"); +} + #[test] fn shortcuts_format_and_extend() { let mut s = blank(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d7cbb7fea..0e4ddedb9 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-10-29 · **Change:** minor (editing a hyperlink loads its existing fields; earlier: Ctrl with the numpad's + inserts, as Ctrl+Shift+= does) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: 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 @@ -201,6 +201,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-24 | minor | Macro step editor: step list with breakpoints, drag to reorder and a command picker, an inspector with reference boxes, a debug bar and grid overlays | | 2026-10-24 | minor | Automate › Macros group (Record Macro, Use Relative References, Run Macro), recording status in the status bar, Macros dialog with macros beside scripts, Record Macro dialog, Ctrl+Shift+letter macro shortcuts; View › Macros keytips open the dialogs | | 2026-10-24 | minor | Ribbon catalog count 275 / 291 → 276 / 292 (View › Macros › Record Macro) | +| 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl | | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list | | 2026-10-24 | minor | Right-clicking the status bar opens a menu of its statistics, as in Excel ("If you want to customize the status bar, right-click it, and then click the options that you want"): Average, Count and Sum are on by default, and Numerical Count, Minimum and Maximum are new and off by default. The choice is a preference (`view.statusBar`), kept in `prefs.json` | | 2026-10-24 | minor | Ctrl+Shift+U (Control+Shift+U on a Mac) expands or collapses the formula bar, as in Excel ("Expand or collapse the formula bar. Ctrl+Shift+U"), on the grid and in the cell editor or formula bar while editing. On Windows and Linux it used to underline the selection, as Ctrl+U does. The command is `view.expandFormulaBar` (`{on?}`, toggles without it) |