Merge PR #913: Keyboard: Enter after a run of Tabs goes to the next row in the column the run started from

This commit is contained in:
aiongg
2026-10-22 02:35:22 +00:00
3 changed files with 81 additions and 2 deletions
+7 -1
View File
@@ -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| {
@@ -249,6 +250,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.
@@ -413,7 +415,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);
}
}
}
+28
View File
@@ -284,6 +284,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<String>,
/// 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,
/// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next
/// window close goes through.
@@ -331,6 +334,7 @@ impl SheetApp {
selected_chart: None,
clipboard_text_at_copy: None,
cut_text: None,
tab_run: None,
started: now_ms(),
allow_close: false,
}
@@ -995,6 +999,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;
@@ -1003,11 +1016,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;
}
+46 -1
View File
@@ -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,50 @@ 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 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());