From a54cf58f2e060223d81261f3edc9f7a79d0bd7a1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 07:48:23 +0200 Subject: [PATCH 1/2] Keyboard: Enter after a run of Tabs goes to the next row in the column the run started from (fixes #911) --- crates/ui-egui/src/lib.rs | 19 +++++++++++++++++++ crates/ui-egui/tests/interaction.rs | 29 ++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index be0319972..189fe106b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -258,6 +258,9 @@ pub struct SheetApp { /// text noted for an unpublished cut); `None` after a copy, or once the clipboard is seen /// holding other text. Once the cut has been pasted, Paste leaves this text alone. cut_text: Option, + /// A run of Tabs: (doc uid, sheet, column it started in, cell the last Tab landed on). + /// Enter on that cell goes to the next row in the starting column, as in Excel (#911). + tab_run: Option<(u64, usize, u32, CellRef)>, pub started: f64, } @@ -301,6 +304,7 @@ impl SheetApp { selected_chart: None, clipboard_text_at_copy: None, cut_text: None, + tab_run: None, started: now_ms(), } } @@ -951,11 +955,26 @@ impl SheetApp { /// Enter/Tab move inside a multi-cell selection, otherwise to the next cell. pub fn move_after_enter(&mut self, dr: i64, dc: i64) { let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell()); + // Any other move (arrows, clicks, another sheet) leaves the selection off the cell the + // last Tab landed on, which ends the run. + let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet, d.selection.active)); + let run = self.tab_run.take().filter(|&(uid, sheet, _, at)| here == Some((uid, sheet, at))); if multi { let fwd = dr > 0 || dc > 0; let _ = self.session.run("selection.next", json!({"forward": fwd, "byRow": dc != 0})); + } else if let (Some((_, _, col, at)), 1, 0) = (run, dr, dc) { + let back = i64::from(col) - i64::from(at.col); + let _ = self.session.run("selection.move", json!({"dr": 1, "dc": back})); } else { let _ = self.session.run("selection.move", json!({"dr": dr, "dc": dc})); + if dr == 0 && dc != 0 { + let start = run.map(|r| r.2); + self.tab_run = self.session.active().and_then(|d| { + let (uid, sheet) = (d.uid, d.wb.active_sheet); + let col = start.or(here.map(|h| h.2.col))?; + Some((uid, sheet, col, d.selection.active)) + }); + } } self.grid.ensure_visible = true; } diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 0958c8c98..04e08d8b3 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -177,7 +177,8 @@ fn keyboard_navigation_does_not_replay_the_last_pointer_click() { (Key::Enter, Modifiers::NONE, "B5"), (Key::Enter, Modifiers::SHIFT, "B4"), (Key::Tab, Modifiers::NONE, "C4"), - (Key::Enter, Modifiers::NONE, "C5"), + // #911: Enter after a Tab goes back to the column the Tab started from. + (Key::Enter, Modifiers::NONE, "B5"), ] { key(&mut h, key_code, modifiers); let selection = &h.state().session.active().unwrap().selection; @@ -241,6 +242,32 @@ fn typing_enters_values_and_moves_down() { assert_eq!(active.as_deref(), Some("B2")); } +#[test] +fn enter_after_tabs_returns_to_the_column_where_the_tabs_started() { + // #911: Excel moves to the next row in the column the run of Tabs started from. + let mut h = harness(blank()); + for t in ["a", "b"] { + text(&mut h, t); + key(&mut h, Key::Tab, Modifiers::NONE); + } + text(&mut h, "c"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(value(&h, "C1"), Value::from("c")); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2"); + // The next Enter is an ordinary one again. + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A3"); +} + +#[test] +fn moving_with_an_arrow_ends_the_run_of_tabs() { + let mut h = harness(blank()); + key(&mut h, Key::Tab, Modifiers::NONE); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C2"); +} + #[test] fn arrows_escape_and_undo() { let mut h = harness(blank()); From f8838b15e13a943ea6755b80842439db2634a64d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 14:32:23 +0200 Subject: [PATCH 2/2] Keyboard: an arrow that commits typing ends the run of Tabs, as in Excel Typing then Right or Down committed through the Enter/Tab path, so Right continued the run and Down went back to its starting column. Found by @richard1912 against Excel 365: from B2, type, Tab, type, Right, Enter lands on D3, and with Down on C4. --- crates/ui-egui/src/formula_bar.rs | 8 +++++++- crates/ui-egui/src/lib.rs | 9 +++++++++ crates/ui-egui/tests/interaction.rs | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 83278b190..4c83eb033 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -162,6 +162,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: let focused = ui.ctx().memory(|m| m.focused()); 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; let mut cancel = false; if has_focus && mine { let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| { @@ -243,6 +244,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: crate::grid::point_move(app, &mut ed, dr, dc, ashift); } else { action = Some((dr, dc, false, false)); + arrow = true; } } // F4 cycles $ anchors of the reference before the caret. @@ -407,7 +409,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: if cancel { app.cancel_edit(); } else if let Some((dr, dc, array, fill)) = action { - app.commit_edit(dr, dc, array, fill); + if arrow { + app.commit_edit_arrow(dr, dc); + } else { + app.commit_edit(dr, dc, array, fill); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 189fe106b..4da3dbf4e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -947,6 +947,15 @@ impl SheetApp { } } + /// Commits the edit with an arrow key. The arrow is an ordinary move, so it ends a run of Tabs + /// instead of continuing it (Right) or going back to its column like Enter (Down), as in Excel. + pub fn commit_edit_arrow(&mut self, dr: i64, dc: i64) -> bool { + let run = self.tab_run.take(); + let ok = self.commit_edit(dr, dc, false, false); + self.tab_run = if ok { None } else { run }; + ok + } + pub fn cancel_edit(&mut self) { self.editor = None; self.session.mode = gridcraft_engine::Mode::Ready; diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 04e08d8b3..7c30e8b38 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -268,6 +268,24 @@ fn moving_with_an_arrow_ends_the_run_of_tabs() { assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C2"); } +#[test] +fn an_arrow_that_commits_typing_ends_the_run_of_tabs() { + // Excel 365 from B2: the arrow is an ordinary move, neither one more Tab (Right) nor an Enter + // back to the starting column (Down). + for (arrow, after_enter) in [(Key::ArrowRight, "D3"), (Key::ArrowDown, "C4")] { + let mut s = blank(); + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut h = harness(s); + text(&mut h, "a"); + key(&mut h, Key::Tab, Modifiers::NONE); + text(&mut h, "b"); + key(&mut h, arrow, Modifiers::NONE); + assert_eq!(value(&h, "C2"), Value::from("b"), "{arrow:?}"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), after_enter, "{arrow:?}"); + } +} + #[test] fn arrows_escape_and_undo() { let mut h = harness(blank());