diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index c7dcde0ff..c384f89c8 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -47,7 +47,11 @@ impl SheetApp { return r; } let before = self.session.active_window(); + let zoom_from = matches!(id, "view.zoom" | "view.zoom100" | "view.zoomToSelection").then(|| crate::grid::Trace::key(self)); let r = self.session.run(id, params); + if let Some(from) = zoom_from { + crate::grid::Trace::keep_through_zoom(self, from); + } if id.starts_with("file.") && self.session.active_window() != before { // File › New or Open from the Backstage view: the window it was in goes back to its sheet. self.backstage = None; diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index c5e1dd473..562ca8fc3 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -154,7 +154,7 @@ pub struct FillTag { } /// Trace Precedents / Trace Dependents arrows: they stay on the cell they were traced from, on its -/// sheet, while the selection moves. They go away when anything changes the workbook +/// sheet, while the selection moves. They go away when anything but a zoom changes the workbook /// (`revision`): Excel removes them on edits to the traced formula and on inserted, deleted or /// moved cells, this also on a new value in a precedent. pub struct Trace { @@ -171,6 +171,24 @@ impl Trace { pub fn new(app: &SheetApp, items: serde_json::Value) -> Option { app.session.active().map(|d| Trace { uid: d.uid, revision: d.revision, sheet: d.wb.active_sheet(), cell: d.selection.active, items }) } + + /// The active document's uid and revision, to hand [`Trace::keep_through_zoom`] after a zoom. + pub fn key(app: &SheetApp) -> Option<(u64, u64)> { + app.session.active().map(|d| (d.uid, d.revision)) + } + + /// Zooming changes the workbook (its revision) but moves no cell: arrows that were shown + /// before a zoom stay after it, as in Excel (#3097). `before` is [`Trace::key`] from then. + pub fn keep_through_zoom(app: &mut SheetApp, before: Option<(u64, u64)>) { + let now = Trace::key(app); + if let Some(t) = app.grid.trace.as_mut() + && before == Some((t.uid, t.revision)) + && let Some((uid, revision)) = now + && uid == t.uid + { + t.revision = revision; + } + } } #[derive(Default)] diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs index 2e79ee165..5906ee96f 100644 --- a/crates/ui-egui/src/grid/show.rs +++ b/crates/ui-egui/src/grid/show.rs @@ -1,4 +1,3 @@ -#[cfg(test)] use super::Trace; use super::cursor::update_cursor; use super::editor::{in_cell_editor, list_picker, validation_arrow}; @@ -358,7 +357,9 @@ fn update_zoom_glide(app: &mut SheetApp, ui: &egui::Ui, place: (u64, usize), zoo let glides = app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight(); let share = egui::emath::exponential_smooth_factor(0.9, 0.1, ui.input(|i| i.stable_dt).min(0.1)); let z = if glides { zoom_glide_step(shown, to, share) } else { to }; + let from = Trace::key(app); let _ = app.session.run("view.zoom", json!({"percent": z})); + Trace::keep_through_zoom(app, from); zooming.glide = (z != to).then_some((to, z)); ui.ctx().request_repaint(); } @@ -945,4 +946,25 @@ mod trace_arrow_tests { assert_eq!(revisions[0], revisions[1]); assert!(arrows(&mut app).1.is_empty(), "Book1's arrows are not drawn on Book2"); } + + #[test] + fn precedent_arrows_stay_through_a_zoom() { + let mut app = app(); + app.run("home.insertSheet", json!({"name": "Two"})).unwrap(); + app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); + app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); + select(&mut app, "F7"); + trace(&mut app, "formulas.tracePrecedents"); + app.run("view.zoom", json!({"percent": 75})).unwrap(); + let (geo, lines) = arrows(&mut app); + assert_eq!(lines, [(center(&app, &geo, "B2"), center(&app, &geo, "F7"))], "#3097"); + // Zooming another sheet leaves them too, and they are back on returning to their sheet. + app.run("sheet.activate", json!({"sheet": "Two"})).unwrap(); + app.run("view.zoom", json!({"percent": 200})).unwrap(); + assert!(arrows(&mut app).1.is_empty()); + app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + let (geo, lines) = arrows(&mut app); + assert_eq!(lines, [(center(&app, &geo, "B2"), center(&app, &geo, "F7"))]); + } } diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index a730e77d8..6702f7cb8 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -387,7 +387,9 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { let slider = ui.add(egui::Slider::new(&mut z, 10.0..=400.0).show_value(false).logarithmic(true)); ui.ctx().accesskit_node_builder(slider.id, |node| node.set_label(app.ui.language.tr("Zoom"))); if slider.changed() { + let from = crate::grid::Trace::key(app); let _ = app.session.run("view.zoom", json!({"percent": z.round()})); + crate::grid::Trace::keep_through_zoom(app, from); } if crate::widgets::icon_button(ui, Icon::Minus, t.text_dim, "Zoom Out", vec2(18.0, 18.0)).clicked() { let z = ((zoom as f32 / 10.0).ceil() * 10.0 - 10.0).max(10.0); diff --git a/crates/ui-egui/tests/it/wheel_zoom.rs b/crates/ui-egui/tests/it/wheel_zoom.rs index 634bc68ea..727e55020 100644 --- a/crates/ui-egui/tests/it/wheel_zoom.rs +++ b/crates/ui-egui/tests/it/wheel_zoom.rs @@ -276,3 +276,26 @@ fn page_and_point_units_scroll_the_grid_by_their_converted_distance() { let point = h.state().view().scroll.y; assert!(point > 0.0 && point < page, "40 points scroll less than a page: point {point}, page {page}"); } + +#[test] +/// Zooming moves no cell: trace arrows shown before a Ctrl+wheel zoom are still shown after its +/// glide (#3097), while an edit still removes them. +fn trace_arrows_stay_through_a_wheel_zoom() { + let mut h = animated(); + h.state_mut().run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); + h.state_mut().run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); + h.state_mut().run("selection.set", json!({"range": "F7"})).unwrap(); + let r = h.state_mut().run("formulas.tracePrecedents", json!({})).unwrap(); + let app = h.state_mut(); + app.grid.trace = gridcraft_ui_egui::grid::Trace::new(app, r); + let current = |h: &Harness| { + let d = h.state().session.active().unwrap(); + h.state().grid.trace.as_ref().is_some_and(|t| (t.uid, t.revision) == (d.uid, d.revision)) + }; + assert!(current(&h)); + let seen = turn_and_watch(&mut h, 1.0, 40); + assert!(seen.windows(2).filter(|w| w[0] != w[1]).count() > 1, "the zoom glides over several frames: {seen:?}"); + assert!(current(&h), "the arrows stay at {}%", zoom(&h)); + h.state_mut().run("cell.set", json!({"cell": "B2", "input": "2"})).unwrap(); + assert!(!current(&h), "an edit removes them"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a0236a79f..346d903ec 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-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (trace arrows stay through a zoom (#3097); earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **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 @@ -196,6 +196,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Trace arrows stay when the sheet is zoomed (View › Zoom, the status bar, Ctrl+wheel), as in Excel; every zoom used to remove them (#3097) | | 2026-10-31 | minor | Esc in a File-page text field leaves the field and cancels editing; the next Esc returns to the sheet (#3035) | | 2026-10-31 | minor | Recently Used lists the functions most recently entered in a formula from any source, kept with the preferences; the Insert Function dialog is titled as in Excel, its category list (Most Recently Used, All, each category) works, and its category column follows the widest name shown | | 2026-10-31 | minor | Formulas › Function Library: Insert Function, the AutoSum split button and three columns of labelled small buttons with category colours; category menus end with Insert Function…; More Functions cascades; Home › Editing shares the AutoSum menu; MR key tip for Recently Used |