diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..d42ef180b 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -109,6 +109,10 @@ pub struct GridState { /// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell /// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves). pub reveal_moving_end: bool, + /// With `ensure_visible`: the cell an arrow or paging key moved from (the moving end when + /// extending), set only by those keys. A move from the scrolled rows or columns into the + /// frozen ones scrolls them back to their top or left. + pub reveal_from: Option, pub last_title: Option, pub drag_select: bool, pub rect: Option, @@ -681,6 +685,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if app.grid.ensure_visible { app.grid.ensure_visible = false; let cell = if std::mem::take(&mut app.grid.reveal_moving_end) { sel.moving_end() } else { sel.active }; + let from = app.grid.reveal_from.take(); let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell); if paper { crate::paper_view::ensure_covers(app, &wb, si, target); @@ -694,6 +699,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } else if cr.bottom() > area.bottom() - 2.0 { view.scroll.y += (cr.bottom() - area.bottom() + 2.0) / geo.z; } + } else if from.is_some_and(|f| f.row >= geo.fr) { + // A move from the scrolled rows into the frozen ones (Page Up or Ctrl+Up onto a frozen + // row 1) brings the scrolled rows back to their top; the rows it passed over would + // otherwise stay out of sight. + view.scroll.y = 0.0; } if target.col >= geo.fc { if cr.left() < area.left() { @@ -701,6 +711,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } else if cr.right() > area.right() - 2.0 { view.scroll.x += ((cr.right() - area.right() + 2.0) / geo.z).min(cr.left() - area.left()); } + } else if from.is_some_and(|f| f.col >= geo.fc) { + view.scroll.x = 0.0; } view.scroll = view.scroll.max(egui::Vec2::ZERO); geo = make_geo(app, view.scroll); @@ -2967,9 +2979,11 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: let cmd = m.command; let shift = m.shift; let mv = |app: &mut SheetApp, dr: i64, dc: i64| { + let from = app.session.active().map(|d| if shift { d.selection.moving_end() } else { d.selection.active }); let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd})); app.grid.ensure_visible = true; app.grid.reveal_moving_end = shift; + app.grid.reveal_from = from; }; match key { Key::ArrowUp => mv(app, -1, 0), diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index fea791b69..5840036a1 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -223,9 +223,11 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic let width = ((geo.cells.width() - geo.frozen_w) / geo.z).max(1.0); let cols = (width / sh.default_col_width.max(1.0)).floor().max(1.0) as i64; let dc = if shortcut == Shortcut::ScreenRight { cols } else { -cols }; + let from = app.session.active().map(|d| if m.shift { d.selection.moving_end() } else { d.selection.active }); let _ = app.session.run("selection.move", json!({"dr": 0, "dc": dc, "extend": m.shift})); app.grid.ensure_visible = true; app.grid.reveal_moving_end = m.shift; + app.grid.reveal_from = from; } Shortcut::Edit => app.begin_edit(None, false), Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()), diff --git a/crates/ui-egui/tests/selection_scroll.rs b/crates/ui-egui/tests/selection_scroll.rs index 719f1a0f2..9a78f9b9f 100644 --- a/crates/ui-egui/tests/selection_scroll.rs +++ b/crates/ui-egui/tests/selection_scroll.rs @@ -142,3 +142,151 @@ fn select_over_the_control_channel_scrolls_the_active_cell_into_view() { assert_eq!(active(&h), CellRef::parse("BZ150").unwrap()); assert!(visible(&h, "BZ150") && !visible(&h, "A1"), "selecting a far range scrolls its active cell into view"); } + +fn scroll(h: &Harness) -> egui::Vec2 { + h.state().view().scroll +} + +#[test] +fn page_down_and_back_up_from_row_10_shows_rows_2_to_9_again() { + // The reported steps: row 1 frozen, row 10 selected, Page Down, Page Up, Page Up. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + h.state_mut().session.execute("selection.set", json!({"cell": "A10"})).unwrap(); + h.run_steps(2); + key(&mut h, Key::PageDown, Modifiers::NONE); + key(&mut h, Key::PageUp, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("A10").unwrap()); + key(&mut h, Key::PageUp, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).y, 0.0); + assert!(visible(&h, "A2") && visible(&h, "A9")); +} + +#[test] +fn page_up_into_a_frozen_top_row_scrolls_the_rows_below_it_back_to_the_top() { + // Row 1 frozen: Page Up from far down ends on A1, and rows 2 onwards show under it again + // instead of the view staying a page down. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + assert!(!visible(&h, "A2")); + for _ in 0..20 { + key(&mut h, Key::PageUp, Modifiers::NONE); + } + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).y, 0.0, "the scrolled rows are back at the top"); + assert!(visible(&h, "A2")); + // Ctrl+Up from the bottom lands on A1 the same way. + key(&mut h, Key::ArrowDown, CTRL); + assert!(!visible(&h, "A2")); + key(&mut h, Key::ArrowUp, CTRL); + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert!(visible(&h, "A2")); +} + +#[test] +fn ctrl_left_into_a_frozen_first_column_scrolls_the_columns_beside_it_back() { + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "B1"})).unwrap(); + key(&mut h, Key::ArrowRight, CTRL); + assert!(!visible(&h, "B1")); + key(&mut h, Key::ArrowLeft, CTRL); + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).x, 0.0); + assert!(visible(&h, "B1")); +} + +#[test] +fn moving_within_a_frozen_row_leaves_the_scrolled_rows_where_they_are() { + // Selecting in row 1 while the rows below are scrolled down (without a reveal, as a click + // does), then moving along the frozen row, must not jump the view back to the top. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + let down = scroll(&h).y; + assert!(down > 0.0); + h.state_mut().session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + h.run_steps(2); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("B1").unwrap()); + assert_eq!(scroll(&h).y, down, "the view stays scrolled down"); +} + +#[test] +fn alt_page_up_into_a_frozen_first_column_scrolls_the_columns_beside_it_back() { + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "B1"})).unwrap(); + key(&mut h, Key::ArrowRight, CTRL); + assert!(!visible(&h, "B1")); + for _ in 0..20 { + key(&mut h, Key::PageUp, Modifiers::ALT); + } + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).x, 0.0); + assert!(visible(&h, "B1")); +} + +#[test] +fn extending_up_into_a_frozen_top_row_scrolls_the_rows_below_it_back_to_the_top() { + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + assert!(!visible(&h, "A2")); + key(&mut h, Key::ArrowUp, CTRL_SHIFT); + assert_extended(&h, "A200", "A1"); + assert!(visible(&h, "A2")); +} + +#[test] +fn other_reveals_onto_a_frozen_cell_leave_the_scrolled_rows_where_they_are() { + // Only arrow and paging keys bring the scrolled rows back; selecting a header cell from the + // control channel (as Find, Go To or Undo would) keeps the place the user scrolled to. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + let down = scroll(&h).y; + control(&mut h, "engine.execute", json!({"command": "selection.set", "params": {"cell": "B1"}})); + assert_eq!(active(&h), CellRef::parse("B1").unwrap()); + assert_eq!(scroll(&h).y, down); +} + +#[test] +fn pointing_along_a_frozen_row_keeps_the_edited_cell_in_view() { + // Editing a formula far down and pointing at header cells with the arrows must not scroll + // the cell being edited away. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + let down = scroll(&h).y; + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + let ed = h.state_mut().editor.as_mut().expect("editing"); + ed.caret = ed.text.chars().count(); + ed.point_cell = Some(CellRef::parse("B1").unwrap()); + h.run_steps(2); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(h.state().editor.as_ref().and_then(|e| e.point_cell), Some(CellRef::parse("C1").unwrap()), "the arrow moved the point"); + assert_eq!(scroll(&h).y, down); + assert!(visible(&h, "A200"), "the edited cell stays in view"); +} + +#[test] +fn down_from_a_frozen_header_row_goes_to_row_2_at_the_top_from_far_down() { + // Excel's quick way back to the top: select a cell in the frozen header row while far down, + // press Down, and the view jumps to row 2 under the header. Selecting the header cell alone + // doesn't scroll. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + h.state_mut().session.execute("selection.set", json!({"cell": "A100000"})).unwrap(); + h.state_mut().grid.ensure_visible = true; + h.run_steps(2); + assert!(!visible(&h, "A2") && visible(&h, "A100000")); + h.state_mut().session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + h.run_steps(2); + assert!(visible(&h, "A100000")); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("A2").unwrap()); + assert_eq!(scroll(&h).y, 0.0); + assert!(visible(&h, "A2")); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07797d70d..0b4ee83f2 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-21 · **Change:** minor (Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back) · **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 @@ -176,6 +176,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel | | 2026-10-21 | minor | Paste Special laid out as Excel's: Paste and Operation options each in two columns inside a frame, Skip blanks and Transpose side by side, Paste Link as a button that pastes references at once; labels translated (the generic form didn't translate them) and the window no longer runs past the screen | | 2026-10-21 | minor | Form dialogs (conditional-format quick rules, New Name, Remove Duplicates, Text to Columns and every other `Dialog::form`) fit their content: OK and Cancel sit in a row under the last field instead of a right-to-left layout that took the screen's whole height and left the buttons floating halfway down |