# Conflicts:
#	docs/ui-parity.md
This commit is contained in:
Brandon Thomas
2026-10-23 10:11:33 -04:00
4 changed files with 166 additions and 1 deletions
+14
View File
@@ -122,6 +122,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<CellRef>,
pub last_title: Option<String>,
pub drag_select: bool,
pub rect: Option<Rect>,
@@ -698,6 +702,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);
@@ -711,6 +716,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() {
@@ -718,6 +728,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);
@@ -2997,9 +3009,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),
+2
View File
@@ -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()),
+148
View File
@@ -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"));
}
+2 -1
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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-22 · **Change:** minor (Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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
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
@@ -175,6 +175,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-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) |
| 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room |
| 2026-10-22 | minor | A number in a repeat-fill format (`*`, as in the Accounting format) that is too wide for its column shows #### like any other number, instead of the fill rebuilding the value so its leading digits were cut off (2,509.00 CHF drawn as "509.00 CHF") |