From c99c42eaa96a22754ecf2731f5f60be5d368958d Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Wed, 11 Nov 2026 12:07:31 -0300 Subject: [PATCH] ui: preserve line-wheel zoom compensation across smoothing frames (#6146) --- crates/ui-egui/src/scroll.rs | 47 +++++++++------ crates/ui-egui/src/tests.rs | 107 ++++++++++++++++++++++++++++++----- 2 files changed, 124 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/scroll.rs b/crates/ui-egui/src/scroll.rs index 73a1d81430..1cbbe64669 100644 --- a/crates/ui-egui/src/scroll.rs +++ b/crates/ui-egui/src/scroll.rs @@ -281,6 +281,9 @@ pub struct CanvasScroll { /// input time it last grew. Only used with animation effects off. zoom_pending: f32, zoom_time: f64, + /// Ctrl/⌘+Line-wheel zoom is being eased in over several frames by egui; compensation for + /// line scroll speed must persist across empty frames until easing completes. + line_zoom_smoothing: bool, } impl CanvasScroll { @@ -377,6 +380,7 @@ impl CanvasScroll { /// on top of the anchor correction that keeps the pointer's page point in place (#1743). pub fn cancel(&mut self) { self.hit_edge(); + self.line_zoom_smoothing = false; } /// Scrolling continues without new input: keep repainting. @@ -392,29 +396,40 @@ impl CanvasScroll { // Fingers on a touch screen are measured directly, never eased. if input.multi_touch().is_some() { self.zoom_pending = 0.0; + self.line_zoom_smoothing = false; return input.zoom_delta(); } if animate { self.zoom_pending = 0.0; - let factor = opts.line_scroll_speed / InputOptions::default().line_scroll_speed; - let has_line_wheel = input.events.iter().any(|e| { - matches!( - e, - Event::MouseWheel { - unit: MouseWheelUnit::Line, - modifiers, - .. - } if modifiers.matches_any(opts.zoom_modifier) - ) - }); - if has_line_wheel && factor > 0.0 && (factor - 1.0).abs() > 1e-4 { - let z = input.zoom_delta(); - if z.is_finite() && z > 0.0 { - return (z.ln() / factor).exp(); + let pinch: f32 = input.events.iter().filter_map(|e| if let Event::Zoom(factor) = e { Some(*factor) } else { None }).product(); + let pinch = if pinch.is_finite() && pinch > 0.0 { pinch } else { 1.0 }; + + for e in &input.events { + if let Event::MouseWheel { unit, modifiers, .. } = e + && modifiers.matches_any(opts.zoom_modifier) + { + match unit { + MouseWheelUnit::Line => self.line_zoom_smoothing = true, + MouseWheelUnit::Point | MouseWheelUnit::Page => self.line_zoom_smoothing = false, + } } } - return input.zoom_delta(); + + let total_z = input.zoom_delta(); + let wheel_z = if pinch != 1.0 && total_z.is_finite() && total_z > 0.0 { total_z / pinch } else { total_z }; + + let factor = opts.line_scroll_speed / InputOptions::default().line_scroll_speed; + if self.line_zoom_smoothing { + if (wheel_z - 1.0).abs() <= 1e-6 { + self.line_zoom_smoothing = false; + } else if factor > 0.0 && factor.is_finite() && (factor - 1.0).abs() > 1e-4 && wheel_z.is_finite() && wheel_z > 0.0 { + let comp_wheel_z = (wheel_z.ln() / factor).exp(); + return (pinch * comp_wheel_z).clamp(0.01, 100.0); + } + } + return (pinch * wheel_z).clamp(0.01, 100.0); } + self.line_zoom_smoothing = false; let mut zoom = 1.0_f32; let mut turned = 0.0_f32; for event in &input.events { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 033269bd40..927bbed645 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -8356,46 +8356,81 @@ fn mouse_wheel_scroll_speed_does_not_alter_ctrl_wheel_zoom_distance() { // 1. Establish baseline zoom with Normal speed a.ui.scroll_speed = crate::scroll::ScrollSpeed::Normal; let mut baseline_eased_line_zoom = 1.0; + let mut baseline_cumulative_line_zoom = 1.0; let mut baseline_discrete_line_zoom = 1.0; let mut baseline_eased_point_zoom = 1.0; + let mut baseline_cumulative_point_zoom = 1.0; let mut baseline_discrete_point_zoom = 1.0; + let mut baseline_pinch_zoom = 1.0; - // Line baseline: + // Line baseline across full smoothing: let ctx_line_base = egui::Context::default(); a.logic(&ctx_line_base); let opts_line_base = ctx_line_base.options(|o| o.input_options); + let mut canvas_scroll_base = crate::scroll::CanvasScroll::default(); ctx_line_base .run_ui(egui::RawInput { events: vec![ctrl_line_event()], time: Some(1.0), ..Default::default() }, |ui| { - let mut canvas_scroll = crate::scroll::CanvasScroll::default(); - baseline_eased_line_zoom = ui.input(|i| canvas_scroll.zoom(i, &opts_line_base, true)); + baseline_eased_line_zoom = ui.input(|i| canvas_scroll_base.zoom(i, &opts_line_base, true)); + baseline_cumulative_line_zoom *= baseline_eased_line_zoom; let mut canvas_scroll_discrete = crate::scroll::CanvasScroll::default(); baseline_discrete_line_zoom = ui.input(|i| canvas_scroll_discrete.zoom(i, &opts_line_base, false)); }) .drop_without_applying_deltas(); + for f in 1..25 { + ctx_line_base + .run_ui(egui::RawInput { time: Some(1.0 + f as f64 * (1.0 / 60.0)), ..Default::default() }, |ui| { + baseline_cumulative_line_zoom *= ui.input(|i| canvas_scroll_base.zoom(i, &opts_line_base, true)); + }) + .drop_without_applying_deltas(); + } - // Point baseline: + // Point baseline across full smoothing: let ctx_point_base = egui::Context::default(); a.logic(&ctx_point_base); let opts_point_base = ctx_point_base.options(|o| o.input_options); + let mut canvas_scroll_point_base = crate::scroll::CanvasScroll::default(); ctx_point_base .run_ui(egui::RawInput { events: vec![ctrl_point_event()], time: Some(1.0), ..Default::default() }, |ui| { - let mut canvas_scroll = crate::scroll::CanvasScroll::default(); - baseline_eased_point_zoom = ui.input(|i| canvas_scroll.zoom(i, &opts_point_base, true)); + baseline_eased_point_zoom = ui.input(|i| canvas_scroll_point_base.zoom(i, &opts_point_base, true)); + baseline_cumulative_point_zoom *= baseline_eased_point_zoom; let mut canvas_scroll_discrete = crate::scroll::CanvasScroll::default(); baseline_discrete_point_zoom = ui.input(|i| canvas_scroll_discrete.zoom(i, &opts_point_base, false)); }) .drop_without_applying_deltas(); + for f in 1..25 { + ctx_point_base + .run_ui(egui::RawInput { time: Some(1.0 + f as f64 * (1.0 / 60.0)), ..Default::default() }, |ui| { + baseline_cumulative_point_zoom *= ui.input(|i| canvas_scroll_point_base.zoom(i, &opts_point_base, true)); + }) + .drop_without_applying_deltas(); + } + + // Pinch baseline: + let ctx_pinch_base = egui::Context::default(); + a.logic(&ctx_pinch_base); + let opts_pinch_base = ctx_pinch_base.options(|o| o.input_options); + ctx_pinch_base + .run_ui(egui::RawInput { events: vec![egui::Event::Zoom(1.25)], time: Some(1.0), ..Default::default() }, |ui| { + let mut canvas_scroll = crate::scroll::CanvasScroll::default(); + baseline_pinch_zoom = ui.input(|i| canvas_scroll.zoom(i, &opts_pinch_base, true)); + }) + .drop_without_applying_deltas(); assert!((baseline_discrete_line_zoom - default_notch_zoom).abs() < 1e-3, "discrete line zoom equals notch zoom"); assert!(baseline_eased_line_zoom > 1.0 && baseline_eased_line_zoom < default_notch_zoom, "eased line zoom begins in first frame"); + assert!( + (baseline_cumulative_line_zoom - default_notch_zoom).abs() < 1e-3, + "cumulative completed eased line zoom matches default notch zoom ({baseline_cumulative_line_zoom} vs {default_notch_zoom})" + ); assert!(baseline_eased_point_zoom > 1.0, "point zoom applies in eased mode"); assert!(baseline_discrete_point_zoom > 1.0, "point zoom applies in discrete mode"); + assert!((baseline_pinch_zoom - 1.25).abs() < 1e-4, "pinch zoom matches pinch factor"); - // 2. Verify all scroll speeds preserve exact same zoom distance across animation settings + // 2. Verify all scroll speeds preserve exact same zoom distance across animation settings and smoothing frames for speed in [crate::scroll::ScrollSpeed::Slow, crate::scroll::ScrollSpeed::Normal, crate::scroll::ScrollSpeed::Fast] { a.ui.scroll_speed = speed; - // Test Line-wheel zoom invariance: + // Test Line-wheel zoom invariance across multiple frames: let ctx_line = egui::Context::default(); a.logic(&ctx_line); let opts = ctx_line.options(|o| o.input_options); @@ -8409,13 +8444,15 @@ fn mouse_wheel_scroll_speed_does_not_alter_ctrl_wheel_zoom_distance() { "scroll zoom speed remains unscaled default so point-wheel zoom is unchanged" ); + let mut canvas_scroll_eased = crate::scroll::CanvasScroll::default(); + let mut cumulative_line_zoom = 1.0; ctx_line .run_ui(egui::RawInput { events: vec![ctrl_line_event()], time: Some(1.0), ..Default::default() }, |ui| { - let mut canvas_scroll_eased = crate::scroll::CanvasScroll::default(); let eased_zoom = ui.input(|i| canvas_scroll_eased.zoom(i, &opts, true)); + cumulative_line_zoom *= eased_zoom; assert!( (eased_zoom - baseline_eased_line_zoom).abs() < 1e-5, - "eased line zoom delta for speed {speed:?} matches baseline ({eased_zoom} vs {baseline_eased_line_zoom})" + "first frame eased line zoom delta for speed {speed:?} matches baseline ({eased_zoom} vs {baseline_eased_line_zoom})" ); let mut canvas_scroll_discrete = crate::scroll::CanvasScroll::default(); @@ -8427,17 +8464,32 @@ fn mouse_wheel_scroll_speed_does_not_alter_ctrl_wheel_zoom_distance() { }) .drop_without_applying_deltas(); - // Test Point-wheel zoom invariance: + // Advance through subsequent empty frames until line smoothing completes + for f in 1..25 { + ctx_line + .run_ui(egui::RawInput { time: Some(1.0 + f as f64 * (1.0 / 60.0)), ..Default::default() }, |ui| { + cumulative_line_zoom *= ui.input(|i| canvas_scroll_eased.zoom(i, &opts, true)); + }) + .drop_without_applying_deltas(); + } + assert!( + (cumulative_line_zoom - baseline_cumulative_line_zoom).abs() < 1e-3, + "cumulative completed line zoom for speed {speed:?} matches baseline ({cumulative_line_zoom} vs {baseline_cumulative_line_zoom})" + ); + + // Test Point-wheel zoom invariance across multiple frames: let ctx_point = egui::Context::default(); a.logic(&ctx_point); let opts_point = ctx_point.options(|o| o.input_options); + let mut canvas_scroll_point = crate::scroll::CanvasScroll::default(); + let mut cumulative_point_zoom = 1.0; ctx_point .run_ui(egui::RawInput { events: vec![ctrl_point_event()], time: Some(1.0), ..Default::default() }, |ui| { - let mut canvas_scroll_eased = crate::scroll::CanvasScroll::default(); - let eased_zoom = ui.input(|i| canvas_scroll_eased.zoom(i, &opts_point, true)); + let eased_zoom = ui.input(|i| canvas_scroll_point.zoom(i, &opts_point, true)); + cumulative_point_zoom *= eased_zoom; assert!( (eased_zoom - baseline_eased_point_zoom).abs() < 1e-5, - "eased point zoom delta for speed {speed:?} matches baseline ({eased_zoom} vs {baseline_eased_point_zoom})" + "first frame eased point zoom delta for speed {speed:?} matches baseline ({eased_zoom} vs {baseline_eased_point_zoom})" ); let mut canvas_scroll_discrete = crate::scroll::CanvasScroll::default(); @@ -8448,5 +8500,32 @@ fn mouse_wheel_scroll_speed_does_not_alter_ctrl_wheel_zoom_distance() { ); }) .drop_without_applying_deltas(); + + for f in 1..25 { + ctx_point + .run_ui(egui::RawInput { time: Some(1.0 + f as f64 * (1.0 / 60.0)), ..Default::default() }, |ui| { + cumulative_point_zoom *= ui.input(|i| canvas_scroll_point.zoom(i, &opts_point, true)); + }) + .drop_without_applying_deltas(); + } + assert!( + (cumulative_point_zoom - baseline_cumulative_point_zoom).abs() < 1e-3, + "cumulative completed point zoom for speed {speed:?} matches baseline ({cumulative_point_zoom} vs {baseline_cumulative_point_zoom})" + ); + + // Test pinch gesture independence from scroll speed settings: + let ctx_pinch = egui::Context::default(); + a.logic(&ctx_pinch); + let opts_pinch = ctx_pinch.options(|o| o.input_options); + ctx_pinch + .run_ui(egui::RawInput { events: vec![egui::Event::Zoom(1.25)], time: Some(1.0), ..Default::default() }, |ui| { + let mut canvas_scroll = crate::scroll::CanvasScroll::default(); + let pinch_zoom = ui.input(|i| canvas_scroll.zoom(i, &opts_pinch, true)); + assert!( + (pinch_zoom - baseline_pinch_zoom).abs() < 1e-5, + "pinch zoom for speed {speed:?} matches baseline ({pinch_zoom} vs {baseline_pinch_zoom})" + ); + }) + .drop_without_applying_deltas(); } }