From 3830cee850cb9f795a53961b04b278c4822c7823 Mon Sep 17 00:00:00 2001 From: Vedran Dobos <19259909+vedran-dobos@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:05:49 +0200 Subject: [PATCH] View: Ctrl+wheel zoom steps along a ladder that never skips 100% (#689) Each frame multiplied the whole-number zoom by egui's eased wheel factor and rounded, so a notch out never undid a notch in: 100 -> 122 -> 101 -> 123 -> 102. A notch now moves to the next step on WHEEL_ZOOM_LADDER (10 ... 85, 100, 120 ... 400; four per doubling, about 20% apart). A step less than 5% away is skipped, but 100% and the limits never are, so 121% out goes to 100% and 99% in goes to 100%. The wheel's turning is counted in notches: parts of a notch add up, and a notch steps once 60% of it has turned. With animations on the zoom glides to the step, and a notch turned during a glide steps on from where it's going. The status bar's Zoom In / Zoom Out keep their 10% steps; that, and whether the glide needs a setting of its own, is noted in docs/decisions.md for the maintainers. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 173 ++++++++++++++++++++++++++- crates/ui-egui/tests/wheel_zoom.rs | 186 +++++++++++++++++++++++++++++ docs/decisions.md | 24 ++++ docs/ui-parity.md | 3 +- 4 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/wheel_zoom.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..84f5b6f 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -20,6 +20,42 @@ pub const COL_HEADER_H: f32 = 21.0; /// which matches desktop spreadsheet density (a default 20 px row is 16 pt tall). pub const DISPLAY_SCALE: f32 = 0.8; +/// The zoom percentages a Ctrl+wheel notch steps between: four per doubling (about 20% apart), +/// so 25, 50, 100, 200 and 400 are on it. Excel steps 15 points a notch instead; the status bar's +/// Zoom In / Zoom Out buttons keep their 10% steps. +pub const WHEEL_ZOOM_LADDER: [u16; 22] = [10, 12, 15, 18, 21, 25, 30, 35, 42, 50, 60, 70, 85, 100, 120, 140, 170, 200, 240, 280, 340, 400]; + +/// Seconds without Ctrl+wheel input after which part of a notch turned so far is forgotten. +const WHEEL_ZOOM_IDLE: f64 = 0.75; + +/// The zoom one Ctrl+wheel notch goes to from `zoom`: the next step on [`WHEEL_ZOOM_LADDER`] in +/// that direction, skipping a step less than 5% away (121% out goes to 100%, not 120%), but never +/// 100% or the 10% / 400% limits (99% or 101% towards 100% goes to 100%). +pub fn wheel_zoom_step(zoom: u16, zoom_in: bool) -> u16 { + let zoom = zoom.clamp(10, 400); + let z = u32::from(zoom); + let far = |l: u32| if zoom_in { l * 100 >= z * 105 } else { l * 105 <= z * 100 }; + let mut ahead = WHEEL_ZOOM_LADDER.iter().copied().filter(|&l| if zoom_in { l > zoom } else { l < zoom }).collect::>(); + if !zoom_in { + ahead.reverse(); + } + let limit = ahead.last().copied().unwrap_or(zoom); + ahead.into_iter().find(|&l| l == 100 || far(u32::from(l))).unwrap_or(limit) +} + +/// One frame of the zoom gliding from `from` to `to` percent: the share `t` (0–1) of the way, +/// measured as a ratio so zooming in and out feel alike, and at least one percent, so it arrives. +pub fn zoom_glide_step(from: u16, to: u16, t: f32) -> u16 { + let (f, g) = (f32::from(from.max(1)), f32::from(to.max(1))); + let next = (f * (g / f).powf(t.clamp(0.0, 1.0))).round(); + let next = if next.is_finite() { next.clamp(f32::from(from.min(to)), f32::from(from.max(to))) as u16 } else { to }; + match (next == from, from.cmp(&to)) { + (true, std::cmp::Ordering::Less) => from + 1, + (true, std::cmp::Ordering::Greater) => from - 1, + _ => next, + } +} + #[derive(Clone, Debug, Default, PartialEq)] pub enum Drag { #[default] @@ -104,6 +140,34 @@ pub struct GridState { pub hf_start: bool, /// The grid geometry of the last frame (hit-testing for agents and tests). pub geo: Option, + /// Ctrl+wheel zoom in progress (see [`WheelZoom`]). + pub wheel_zoom: Option, +} + +/// Ctrl+wheel zoom for one (doc uid, sheet). egui eases a notch in over several frames and +/// fine-grained wheels send parts of one, so the turning is counted in notches, and each whole +/// notch steps [`WHEEL_ZOOM_LADDER`]. With animations on the zoom then glides to that step. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WheelZoom { + pub place: (u64, usize), + /// Turned so far towards the next step, in notches (+ in, − out). + pub notches: f32, + /// When `notches` last changed. + pub time: f64, + /// The step the zoom is gliding to, and the percentage set last on the way. + pub glide: Option<(u16, u16)>, +} + +impl WheelZoom { + /// The state for `place` showing `zoom` percent: fresh for another document or sheet, and + /// without its glide if the zoom was changed some other way since the glide set it. + fn at(app: &SheetApp, place: (u64, usize), zoom: u16, now: f64) -> WheelZoom { + let mut w = app.grid.wheel_zoom.filter(|w| w.place == place).unwrap_or(WheelZoom { place, notches: 0.0, time: now, glide: None }); + if w.glide.is_some_and(|(_, shown)| shown != zoom) { + w.glide = None; + } + w + } } impl GridState { @@ -502,8 +566,34 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time)); if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) { if zoom_delta != 1.0 { - let z = (sh.zoom as f32 * zoom_delta).clamp(10.0, 400.0).round(); - let _ = app.session.run("view.zoom", json!({"percent": z})); + // egui's zoom factor is e^(speed × points); one notch is `line_scroll_speed` points. + let notch = ui.ctx().options(|o| o.input_options.scroll_zoom_speed * o.input_options.line_scroll_speed); + let turned = zoom_delta.ln() / notch; + let mut zooming = WheelZoom::at(app, place, sh.zoom, now); + if now - zooming.time > WHEEL_ZOOM_IDLE { + zooming.notches = 0.0; + } + if turned.is_finite() { + zooming.notches = (zooming.notches + turned).clamp(-32.0, 32.0); + } + zooming.time = now; + // A notch steps once 60% of it has turned, and the rest of it brings `notches` back to + // about 0. (Past half, so what's left after a step can't step back.) Steps count from + // where a glide is going, not from where it has got to. + let from = zooming.glide.map_or(sh.zoom, |(to, _)| to); + let mut z = from; + while zooming.notches >= 0.6 { + z = wheel_zoom_step(z, true); + zooming.notches -= 1.0; + } + while zooming.notches <= -0.6 { + z = wheel_zoom_step(z, false); + zooming.notches += 1.0; + } + if z != from { + zooming.glide = Some((z, sh.zoom)); + } + app.grid.wheel_zoom = Some(zooming); } else if scroll_delta != egui::Vec2::ZERO { let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; let mut d = scroll_delta; @@ -518,6 +608,22 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { view.scroll -= d / z; } } + // The zoom glides to its step like egui eases a wheel notch (90% of the way in 0.1 s), or goes + // there at once without animations (as for the selection outline below: View › Animations + // off, egui's off in the headless harness, or a screenshot on its way). + let mut zooming = WheelZoom::at(app, place, sh.zoom, now); + if let Some((to, shown)) = zooming.glide { + 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 _ = app.session.run("view.zoom", json!({"percent": z})); + zooming.glide = (z != to).then_some((to, z)); + ui.ctx().request_repaint(); + } + // Store a glide's progress, or that it stopped. + if app.grid.wheel_zoom.is_some_and(|w| w.glide.is_some()) { + app.grid.wheel_zoom = Some(zooming); + } view.scroll = view.scroll.max(egui::Vec2::ZERO); let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32); if paper { @@ -3053,6 +3159,69 @@ mod selection_fill_tests { mod tests { use super::*; + #[test] + fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() { + for (from, zoom_in, to) in [ + (100, true, 120), + (120, false, 100), + (100, false, 85), + // Between steps, the next one each way (Excel: 112% goes to 115% or 100%). + (112, true, 120), + (112, false, 100), + // A step less than 5% away is skipped… + (121, false, 100), + (86, false, 70), + (139, true, 170), + // …but never 100% or a limit. + (99, true, 100), + (101, false, 100), + (96, true, 100), + (385, true, 400), + (11, false, 10), + (400, true, 400), + (10, false, 10), + // Out-of-range zooms (from a file) come into range first. + (5, true, 12), + (1000, false, 340), + (0, false, 10), + (u16::MAX, true, 400), + ] { + assert_eq!(wheel_zoom_step(from, zoom_in), to, "{from}% {}", if zoom_in { "in" } else { "out" }); + } + // Every step on the ladder is 15–25% from the next, and a notch in then out returns. + // (The glide between steps is `zoom_glide_moves_by_ratio_and_always_arrives`.) + for w in WHEEL_ZOOM_LADDER.windows(2) { + let ratio = f32::from(w[1]) / f32::from(w[0]); + assert!((1.15..=1.25).contains(&ratio), "{w:?}"); + assert_eq!(wheel_zoom_step(w[0], true), w[1]); + assert_eq!(wheel_zoom_step(w[1], false), w[0]); + } + } + + #[test] + fn zoom_glide_moves_by_ratio_and_always_arrives() { + // A third of the way per frame, as at 60 frames a second. + for (from, to) in [(100, 120), (120, 100), (10, 12), (340, 400), (400, 10), (85, 100)] { + let (mut z, mut frames) = (from, 0); + while z != to { + let next = zoom_glide_step(z, to, 0.32); + assert!(next != z && next.abs_diff(to) < z.abs_diff(to), "{from} → {to}: {z} then {next}"); + z = next; + frames += 1; + assert!(frames < 40, "{from} → {to} arrives"); + } + assert!(frames >= 2, "{from} → {to} glides over more than one frame ({frames})"); + } + assert_eq!(zoom_glide_step(100, 120, 0.32), 106, "a third of the way by ratio"); + assert_eq!(zoom_glide_step(120, 100, 0.32), 113); + // No progress asked or nonsense: still a percent closer, or straight there. + assert_eq!(zoom_glide_step(100, 120, 0.0), 101); + assert_eq!(zoom_glide_step(120, 100, f32::NAN), 100); + assert_eq!(zoom_glide_step(100, 120, 5.0), 120); + assert_eq!(zoom_glide_step(120, 120, 0.5), 120); + assert_eq!(zoom_glide_step(0, 10, 0.5), 3); + } + #[test] fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); diff --git a/crates/ui-egui/tests/wheel_zoom.rs b/crates/ui-egui/tests/wheel_zoom.rs new file mode 100644 index 0000000..397fb6b --- /dev/null +++ b/crates/ui-egui/tests/wheel_zoom.rs @@ -0,0 +1,186 @@ +//! Ctrl+wheel zoom steps along a ladder of percentages that always comes back to 100%. + +use egui::{Event, Modifiers, MouseWheelUnit, TouchPhase, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + let over_grid = h.state().grid.rect.unwrap().center(); + h.input_mut().events.push(Event::PointerMoved(over_grid)); + h.run_steps(2); + h +} + +fn zoom(h: &Harness) -> u16 { + h.state().session.active().unwrap().wb.active().unwrap().zoom +} + +fn set_zoom(h: &mut Harness, percent: u16) { + h.state_mut().run("view.zoom", json!({"percent": percent})).unwrap(); + h.run_steps(2); +} + +/// Turns the wheel `turns` notches at a time with Ctrl held (up zooms in), each turn `frames` +/// frames after the last, then lets egui's easing play out. +fn wheel_turns(h: &mut Harness, turns: &[f32], frames: usize) { + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + for &t in turns { + let delta = vec2(0.0, t); + h.input_mut().events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers: Modifiers::COMMAND }); + h.run_steps(frames); + } + h.run_steps(60); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +fn wheel(h: &mut Harness, notches: f32) { + wheel_turns(h, &[notches], 1); +} + +/// The harness turns egui's animations off (and the zoom glide follows that); this turns them on. +fn animated() -> Harness { + let mut h = harness(); + h.ctx.all_styles_mut(|s| s.animation_time = 0.1); + h.step(); + h +} + +/// Turns the wheel `notches` with Ctrl held and records the zoom over the next `frames` frames. +fn turn_and_watch(h: &mut Harness, notches: f32, frames: usize) -> Vec { + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + let delta = vec2(0.0, notches); + h.input_mut().events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers: Modifiers::COMMAND }); + (0..frames) + .map(|_| { + h.step(); + zoom(h) + }) + .collect() +} + +#[test] +fn with_animations_the_zoom_glides_to_the_step() { + let mut h = animated(); + let seen = turn_and_watch(&mut h, 1.0, 40); + assert!(seen.windows(2).all(|w| w[0] <= w[1]), "only ever towards the step: {seen:?}"); + let between = seen.iter().filter(|&&z| z > 100 && z < 120).count(); + assert!(between >= 3, "on its way over several frames: {seen:?}"); + assert_eq!(seen.last(), Some(&120), "and exactly on the step: {seen:?}"); + let seen = turn_and_watch(&mut h, -1.0, 40); + assert!(seen.windows(2).all(|w| w[0] >= w[1]) && seen.iter().any(|&z| z > 100 && z < 120), "{seen:?}"); + assert_eq!(seen.last(), Some(&100), "{seen:?}"); +} + +#[test] +fn a_notch_during_a_glide_steps_on_from_where_it_is_going() { + let mut h = animated(); + let seen = turn_and_watch(&mut h, 1.0, 6); + assert!(seen.last().is_some_and(|&z| z > 100 && z < 120), "still gliding: {seen:?}"); + turn_and_watch(&mut h, 1.0, 60); + assert_eq!(zoom(&h), 140); + let seen = turn_and_watch(&mut h, -1.0, 6); + assert!(seen.last().is_some_and(|&z| z > 120 && z < 140), "{seen:?}"); + turn_and_watch(&mut h, -1.0, 60); + assert_eq!(zoom(&h), 100); +} + +#[test] +fn a_zoom_set_another_way_stops_a_glide() { + let mut h = animated(); + let seen = turn_and_watch(&mut h, 1.0, 6); + assert!(seen.last().is_some_and(|&z| z > 100 && z < 120), "{seen:?}"); + h.state_mut().run("view.zoom", json!({"percent": 150})).unwrap(); + h.run_steps(60); + assert_eq!(zoom(&h), 150); + // Setting the percentage the glide had got to doesn't start it again. + let stopped_at = seen.last().copied().unwrap(); + set_zoom(&mut h, stopped_at); + h.run_steps(60); + assert_eq!(zoom(&h), stopped_at); +} + +#[test] +fn with_view_animations_off_a_notch_steps_at_once() { + let mut h = animated(); + h.state_mut().run("view.animations", json!({"on": false})).unwrap(); + let seen = turn_and_watch(&mut h, 1.0, 40); + assert!(seen.iter().all(|&z| z == 100 || z == 120), "no percentages in between: {seen:?}"); + assert_eq!(seen.last(), Some(&120)); +} + +#[test] +fn a_notch_out_returns_to_the_zoom_before_the_notch_in() { + let mut h = harness(); + assert_eq!(zoom(&h), 100); + // Multiplying the rounded zoom by egui's eased factor every frame drifted: 100 → 122 → 101 → 123 → 102. + for _ in 0..4 { + wheel(&mut h, 1.0); + assert_eq!(zoom(&h), 120); + wheel(&mut h, -1.0); + assert_eq!(zoom(&h), 100); + } + for expect in [120, 140, 170] { + wheel(&mut h, 1.0); + assert_eq!(zoom(&h), expect); + } + wheel(&mut h, -3.0); + assert_eq!(zoom(&h), 100); + wheel(&mut h, -2.0); + assert_eq!(zoom(&h), 70); + // Notches turned quickly, each before the last one has eased in. + wheel_turns(&mut h, &[1.0, 1.0, 1.0, 1.0], 3); + assert_eq!(zoom(&h), 140); + wheel_turns(&mut h, &[-1.0, -1.0], 3); + assert_eq!(zoom(&h), 100); +} + +#[test] +fn parts_of_a_notch_add_up_to_whole_steps() { + // A fine-grained wheel (or a Windows touchpad pinch) sends a notch in pieces, not always evenly. + let mut h = harness(); + wheel_turns(&mut h, &[0.25, 0.25, 0.25], 4); + assert_eq!(zoom(&h), 120, "three quarters of a notch step"); + wheel_turns(&mut h, &[-0.3, -0.4, -0.3], 4); + assert_eq!(zoom(&h), 100); + wheel_turns(&mut h, &[0.25, 0.25, 0.25, 0.25, 0.3, 0.4, 0.3], 4); + assert_eq!(zoom(&h), 140, "one step per notch, however it's divided"); + wheel_turns(&mut h, &[-0.5, -0.25, -0.25, -0.25, -0.25, -0.5], 4); + assert_eq!(zoom(&h), 100); + wheel_turns(&mut h, &[0.5], 4); + assert_eq!(zoom(&h), 100, "half a notch doesn't step"); + // What's left of a notch is forgotten after a pause (`wheel_turns` waits a second) rather + // than adding up with the next turn. + wheel_turns(&mut h, &[0.4], 4); + assert_eq!(zoom(&h), 100); +} + +#[test] +fn from_a_zoom_set_another_way_a_notch_goes_to_the_next_step_or_100() { + let mut h = harness(); + for (from, notches, to) in + [(112, 1.0, 120), (112, -1.0, 100), (121, -1.0, 100), (99, 1.0, 100), (101, -1.0, 100), (385, 1.0, 400), (400, 1.0, 400), (11, -1.0, 10)] + { + set_zoom(&mut h, from); + wheel(&mut h, notches); + assert_eq!(zoom(&h), to, "{from}% by {notches}"); + } + // Two notches out from 121%: 100%, then 85%. + set_zoom(&mut h, 121); + wheel(&mut h, -2.0); + assert_eq!(zoom(&h), 85); +} diff --git a/docs/decisions.md b/docs/decisions.md index 209b88d..7b0a99a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -47,3 +47,27 @@ current or preceding frame are protected from eviction. If the visible set excee new images show the existing placeholder until capacity is available; header-based admission avoids repeatedly decoding overflow images. More than 128 small visible pictures now keep stable texture handles instead of cycling through the cache every frame. + +## Ctrl+wheel zoom steps along its own ladder, not Excel's 15-point steps + +A Ctrl+wheel notch moves the zoom to the next step on `grid::WHEEL_ZOOM_LADDER`: 10, 12, 15, 18, +21, 25, 30, 35, 42, 50, 60, 70, 85, 100, 120, 140, 170, 200, 240, 280, 340, 400 (four steps per +doubling, 17–25% apart). A step less than 5% away is skipped, except 100% and the 10% / 400% +limits, which are never skipped: 121% out goes to 100%, and 99% or 101% towards 100% goes to 100%. +The wheel's turning is counted in notches (egui eases each notch in over several frames, and +fine-grained wheels send parts of one); a notch steps once 60% of it has turned. With animations +on, the zoom then glides to the step over about a tenth of a second (as egui eases a scroll notch), +and a notch turned during a glide steps on from the step it's going to. With View › Animations off +it goes to the step at once. Whether the zoom glide should have a setting of its own is left to the +maintainers. + +Before, each frame multiplied the whole-number zoom by egui's eased factor and rounded, so a notch +out never undid a notch in (100 → 122 → 101 → 123 → 102). Keeping the zoom unrounded fixed that +for a wheel with exact notches, but not for one that sends uneven parts of a notch. + +Excel (observed: Microsoft 365, Windows) steps 15 points a notch: 100 → 115 → 130, and from 112% +to 115% or 100%. That's a large relative step at low zoom and a small one near 400%; equal +relative steps keep each notch feeling the same, and the doublings land on 25, 50, 100, 200 and +400%. The status bar's Zoom In / Zoom Out buttons and Ctrl+Alt+= / Ctrl+Alt+- keep their 10% +steps, so the wheel and the buttons now step differently. Whether they should share one ladder +(this one, Excel's, or another) is left to the maintainers. \ No newline at end of file diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbc..cff695d 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-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Ctrl+wheel zoom steps along a ladder through 100%) · **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 @@ -160,6 +160,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) |