collective

Browse / github.com/storytold/gridcraft / patch

Formula pointing from the keyboard: Ctrl+Page Down / Ctrl+Page Up switch sheets (#2571)

Formula editing with cross-sheet reference cannot be done with mouse-free Description When editing the formula in formula bar, ctrl+page up/down cannot be used to switch to other tabs. User have to select the sheet and range by at least clicking the sheet, and then select the range. By then the Enter key cannot be used to finish the formula (because the editor is in a range selection mode). Environment Gridcraft Version: 0.12.1 Operating System: Cachyos (Linux) Steps to Reproduce Create a new sheet. From the new sheet, press = to start formula editing. Ctrl + page up/down have no effect at this stage. Then use mouse to select another sheet, and pick the range. Th

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #2699, open)
Base
11bb67386c9bb19b0717e21e98adc608162ae2e5
Size
+188 −7 in 7 files
Review
pending
Shared
2026-10-28
Id
75d2774e33faede49d800e4f

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/formula_bar.rs crates/ui-egui/src/grid.rs crates/ui-egui/src/grid/interact.rs crates/ui-egui/src/tabs.rs crates/ui-egui/tests/it/formula_point_sheets.rs crates/ui-egui/tests/it/main.rs docs/ui-parity.md

diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs--- a/crates/ui-egui/src/formula_bar.rs+++ b/crates/ui-egui/src/formula_bar.rs@@ -292,9 +292,17 @@ fn show_edit<'t>( /// `max_height` of text and scrolls beyond that, keeping the caret in view. pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32, max_height: f32) -> EditorInfo {     let Some(mut ed) = app.editor.take() else { return EditorInfo::default() };-    let mine = ed.from_formula_bar == (id == egui::Id::unique("gridcraft.formula_bar"));+    let is_bar = id == egui::Id::unique("gridcraft.formula_bar");+    // The in-cell editor is only drawn on the sheet the edit started on: while a formula points at+    // another sheet, the formula bar's field takes the keys (Enter, arrows, Ctrl+Page Down).+    let cell_editor_hidden = !ed.from_formula_bar && app.view_key() != Some((ed.doc, ed.sheet));+    let mine = ed.from_formula_bar == is_bar || (is_bar && cell_editor_hidden);     // Keys the editor handles itself (before the TextEdit sees them), only where it has focus.     let focused = ui.ctx().memory(|m| m.focused());+    // Back on the sheet the edit started on, the bar's field that took the keys there hands them back.+    if mine && !is_bar && !ed.from_formula_bar && focused == Some(egui::Id::unique("gridcraft.formula_bar")) {+        ed.request_focus = true;+    }     let has_focus = focused == Some(id) || (mine && (ed.request_focus || focused.is_none()));     let mut action: Option<(i64, i64, bool, bool)> = None;     let mut arrow = false;@@ -388,6 +396,22 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:                 arrow = true;             }         }+        // Ctrl+PageDown / Ctrl+PageUp while a formula waits for a reference: on to the next / previous sheet.+        // Only where the arrows point too, so every switch leaves the arrows working on the new sheet.+        if ed.enter_mode && !ed.from_formula_bar && ed.can_point() {+            let step = ui.input_mut(|i| {+                if i.consume_key(egui::Modifiers::CTRL, Key::PageDown) {+                    Some("sheet.next")+                } else if i.consume_key(egui::Modifiers::CTRL, Key::PageUp) {+                    Some("sheet.previous")+                } else {+                    None+                }+            });+            if let Some(command) = step {+                crate::grid::point_sheet(app, &mut ed, command, json!({}));+            }+        }         // F4 cycles $ anchors of the reference before the caret.         if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F4)) {             cycle_anchor(&mut ed);diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs--- a/crates/ui-egui/src/grid.rs+++ b/crates/ui-egui/src/grid.rs@@ -36,7 +36,7 @@ pub use editor::validation_arrow; #[cfg(test)] use editor::{editor_slack, editor_width, list_picker_height}; pub use geo::Geo;-pub use interact::{follow_link, freeze_handles, point_move};+pub use interact::{follow_link, freeze_handles, point_move, point_sheet}; pub(crate) use quadrant::paint_quadrant; pub(crate) use selection::paint_selection; pub use selection::selection_fill;diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs--- a/crates/ui-egui/src/grid/interact.rs+++ b/crates/ui-egui/src/grid/interact.rs@@ -877,12 +877,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) {     let Some(d) = app.session.active() else { return };-    let from = ed.point_cell.unwrap_or(ed.cell);+    // Before any pointing on another sheet, the arrows start from the cell selected there.+    let from = ed.point_cell.unwrap_or_else(|| if ed.sheet == d.wb.active_sheet() { ed.cell } else { d.selection.active });     let to = 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.-        let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(RangeRef::parse);+        let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(|t| RangeRef::parse(t.rsplit('!').next().unwrap_or(t)));         let inferred_anchor = pointed             .map(|r| {                 CellRef::new(@@ -897,8 +898,31 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte     };     let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() };     let sheet = d.wb.active_sheet();+    // Pointing on another sheet than the one the edit started on names it, as a click there does.+    let text = match d.wb.active() {+        Some(on) if ed.sheet != sheet => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&on.name)),+        _ => text,+    };     ed.insert_ref(&text);     ed.point_cell = Some(to);     ed.point_anchor = Some((sheet, anchor));     app.grid.ensure_visible = true; }++/// Switches to another sheet (`command` with `params`: `sheet.next`, `sheet.previous` or+/// `sheet.activate`) while a formula waits for a reference. The formula carries on there: the+/// arrows point from the cell selected on that sheet, and a range starts at it. The edit stays on+/// the sheet it started on, which Enter returns to. Nothing changes when the sheet does not (the+/// first or last sheet, a failed command).+pub fn point_sheet(app: &mut SheetApp, ed: &mut EditState, command: &str, params: serde_json::Value) {+    let before = app.session.active().map(|d| d.wb.active_sheet());+    if app.session.run(command, params).is_err() {+        return;+    }+    let Some(d) = app.session.active().filter(|d| Some(d.wb.active_sheet()) != before) else { return };+    ed.point_cell = Some(d.selection.active);+    ed.point_anchor = Some((d.wb.active_sheet(), d.selection.active));+    // The new sheet's grid took the focus: the editor needs it back for the next key.+    ed.request_focus = true;+    app.grid.ensure_visible = true;+}diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs--- a/crates/ui-egui/src/tabs.rs+++ b/crates/ui-egui/src/tabs.rs@@ -25,8 +25,11 @@ fn navigator_items(sheets: &[Arc<Sheet>]) -> Vec<(String, String)> { /// Switch to sheet `i` from the tab strip or the navigator. In point mode the formula being typed keeps going on the /// other sheet; otherwise the edit is committed first. fn activate_sheet(app: &mut SheetApp, i: usize) {-    if app.editor.as_ref().is_some_and(|e| e.can_point()) {-        let _ = app.session.run("sheet.activate", json!({"sheet": i}));+    if app.editor.as_ref().is_some_and(|e| e.can_point())+        && let Some(mut ed) = app.editor.take()+    {+        crate::grid::point_sheet(app, &mut ed, "sheet.activate", json!({"sheet": i}));+        app.editor = Some(ed);     } else {         if app.editor.is_some() && !app.commit_edit(0, 0, false, false) {             return;diff --git a/crates/ui-egui/tests/it/formula_point_sheets.rs b/crates/ui-egui/tests/it/formula_point_sheets.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/formula_point_sheets.rs@@ -0,0 +1,128 @@+//! Keyboard-only references to another sheet: Ctrl+PageDown / Ctrl+PageUp while a formula waits+//! for a reference switch sheets without ending the edit, and the arrows point on the sheet shown.++use egui::{Event, Key, Modifiers};+use gridcraft_engine::Session;+use gridcraft_engine::core::CellRef;+use gridcraft_ui_egui::SheetApp;+use serde_json::json;++type Harness = egui_kittest::Harness<'static, SheetApp>;++/// Ctrl on Windows and Linux sets both `ctrl` and `command`.+const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true };++/// Two sheets with B2 selected on the first, `=` typed there.+fn pointing(text: &str) -> Harness {+    let mut session = Session::new();+    session.new_workbook();+    session.execute("home.insertSheet", json!({})).unwrap();+    session.execute("sheet.activate", json!({"sheet": 0})).unwrap();+    session.execute("selection.set", json!({"range": "B2"})).unwrap();+    let mut h = crate::support::harness(session, 4);+    h.state_mut().begin_edit(Some(text.into()), false);+    h.run_steps(4);+    h+}++fn press(h: &mut Harness, k: Key, m: Modifiers) {+    h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m });+    h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m });+    h.run_steps(2);+}++/// A key pressed with Shift held, as the OS reports it: the modifier changes first.+fn press_shift(h: &mut Harness, k: Key) {+    h.input_mut().events.push(Event::ModifiersChanged(Modifiers::SHIFT));+    press(h, k, Modifiers::SHIFT);+    h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));+    h.run_steps(1);+}++fn text(h: &Harness) -> String {+    h.state().editor.as_ref().map(|e| e.text.clone()).unwrap_or_default()+}++fn shown(h: &Harness) -> usize {+    h.state().session.active().unwrap().wb.active_sheet()+}++fn second_name(h: &Harness) -> String {+    h.state().session.active().unwrap().wb.sheet(1).unwrap().name.clone()+}++#[test]+fn ctrl_page_down_goes_on_pointing_on_the_next_sheet() {+    let mut h = pointing("=");+    press(&mut h, Key::PageDown, CTRL);+    assert_eq!(shown(&h), 1, "the next sheet is shown");+    assert!(h.state().editor.is_some(), "the edit goes on");+    press(&mut h, Key::ArrowDown, Modifiers::NONE);+    assert_eq!(text(&h), format!("={}!A2", second_name(&h)), "the arrow points from the cell selected on that sheet and names the sheet");+}++#[test]+fn ctrl_page_up_comes_back_and_the_reference_loses_its_sheet() {+    let mut h = pointing("=");+    press(&mut h, Key::PageDown, CTRL);+    press(&mut h, Key::ArrowDown, Modifiers::NONE);+    press(&mut h, Key::PageUp, CTRL);+    assert_eq!(shown(&h), 0);+    press(&mut h, Key::ArrowRight, Modifiers::NONE);+    assert_eq!(text(&h), "=C2", "the pointed reference is replaced by one on the sheet being edited, from its selected cell");+}++#[test]+fn enter_commits_on_the_sheet_the_edit_started_on() {+    let mut h = pointing("=");+    press(&mut h, Key::PageDown, CTRL);+    press(&mut h, Key::ArrowDown, Modifiers::NONE);+    press(&mut h, Key::Enter, Modifiers::NONE);+    assert!(h.state().editor.is_none(), "Enter ends the edit");+    assert_eq!(shown(&h), 0, "the sheet the formula was typed on is shown again");+    let wb = &h.state().session.active().unwrap().wb;+    assert!(wb.sheet(0).unwrap().cell(CellRef::parse("B2").unwrap()).is_some_and(|c| c.formula.is_some()));+}++#[test]+fn text_that_is_not_a_formula_leaves_ctrl_page_down_alone() {+    let mut h = pointing("abc");+    press(&mut h, Key::PageDown, CTRL);+    assert_eq!(shown(&h), 0, "only a formula waiting for a reference switches sheets");+}++#[test]+fn a_formula_edited_in_the_formula_bar_leaves_ctrl_page_down_alone() {+    let mut h = pointing("=");+    h.state_mut().cancel_edit();+    h.state_mut().begin_edit(Some("=".into()), true);+    h.run_steps(4);+    press(&mut h, Key::PageDown, CTRL);+    assert_eq!(shown(&h), 0, "the arrows do not point there, so the sheet does not switch");+}++#[test]+fn on_the_last_sheet_ctrl_page_down_keeps_the_pointing_where_it_was() {+    let mut h = pointing("=");+    press(&mut h, Key::ArrowDown, Modifiers::NONE);+    assert_eq!(text(&h), "=B3");+    press(&mut h, Key::PageDown, CTRL);+    press(&mut h, Key::PageDown, CTRL);+    assert_eq!(shown(&h), 1, "the last sheet is as far as it goes");+    press(&mut h, Key::ArrowDown, Modifiers::NONE);+    assert_eq!(text(&h), format!("={}!A2", second_name(&h)));+    press(&mut h, Key::PageDown, CTRL);+    press(&mut h, Key::ArrowDown, Modifiers::NONE);+    assert_eq!(text(&h), format!("={}!A3", second_name(&h)), "no sheet change, so the pointing goes on from A2");+}++#[test]+fn shift_arrow_after_a_sheet_switch_starts_the_range_at_the_selected_cell() {+    let mut h = pointing("=");+    press(&mut h, Key::ArrowRight, Modifiers::NONE);+    press_shift(&mut h, Key::ArrowRight);+    assert_eq!(text(&h), "=C2:D2");+    press(&mut h, Key::PageDown, CTRL);+    press_shift(&mut h, Key::ArrowDown);+    assert_eq!(text(&h), format!("={}!A1:A2", second_name(&h)));+}diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -44,6 +44,7 @@ mod formula_bar; mod formula_bar_expand; mod formula_locale; mod formula_point_headers;+mod formula_point_sheets; mod formula_popup; mod freeze_handles; mod go_to_dialog;diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- 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-25 · **Change:** minor (AutoSum over a selected row puts its total to the right) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Ctrl+Page Down / Ctrl+Page Up while pointing a formula switch sheets) · **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@@ -187,6 +187,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Formula pointing from the keyboard: while a formula waits for a reference, Ctrl+Page Down / Ctrl+Page Up go to the next / previous sheet without ending the edit, and the arrows then point from the cell selected there and write the sheet name (`=Sheet2!A2`); Enter returns to the sheet the formula was typed on. Excel's Page Up / Page Down pointing by a screen is not done. Not observed in Excel: the keys cannot be driven through COM. | | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | | 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 |