Merge PR #5547: fix(zoom): Restore smooth wheel zoom

# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/wheel_nav.rs
This commit is contained in:
David Mann
2026-10-25 10:20:45 +01:00
8 changed files with 668 additions and 58 deletions
+1
View File
@@ -223,6 +223,7 @@ pub struct General {
pub always_create_smart_objects_when_placing: bool,
/// Scroll wheel zooms instead of panning.
pub zoom_with_scroll_wheel: bool,
/// Wheel zoom eases over the frames of a notch; off, a notch is one instant ×1.1 step.
pub animated_zoom: bool,
pub zoom_resizes_windows: bool,
pub use_legacy_free_transform: bool,
+17 -3
View File
@@ -98,7 +98,14 @@ struct ZoomCapture {
/// One capture shared with the colour sampler. Space/middle drag always pans; S takes
/// precedence over the persistent Zoom/Hand tool, so a sampler click never zooms.
pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: Rect, size: Vec2, sampler: &mut bool) -> (Rect, Response, bool, Option<Rect>) {
pub(crate) fn interact(
ui: &mut Ui,
state: &mut CameraRawPreviewState,
viewport: Rect,
size: Vec2,
sampler: &mut bool,
animated_zoom: bool,
) -> (Rect, Response, bool, Option<Rect>) {
let sampling = *sampler;
let response = ui.interact(viewport, ui.id().with("cr-sample-preview"), Sense::click_and_drag());
let modifiers = ui.input(|i| i.modifiers);
@@ -111,11 +118,18 @@ pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport:
// Reuse the canvas wheel policy: retain the modifiers of the wheel event while egui
// smooths it, even if Alt is released before the final part of the notch arrives.
let cr_hovered = response.hovered();
let wheel = crate::wheel_nav::read(ui.ctx(), false, true, ui.id().with("cr-wheel"), cr_hovered);
let cr_wheel = ui.id().with("cr-wheel");
let wheel = crate::wheel_nav::read(ui.ctx(), false, true, animated_zoom, cr_wheel, cr_hovered);
if cr_hovered {
ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO);
match wheel {
Some(crate::wheel_nav::Wheel::Zoom(factor)) => state.zoom_at(state.scale(viewport, size) * factor, at, viewport, size),
Some(crate::wheel_nav::Wheel::Zoom(factor)) => {
let zoom = state.scale(viewport, size) * factor;
if zoom != zoom.clamp(MIN_ZOOM, MAX_ZOOM) {
crate::wheel_nav::settle(ui.ctx(), cr_wheel);
}
state.zoom_at(zoom, at, viewport, size);
}
Some(crate::wheel_nav::Wheel::SnapZoom(n)) => {
let dir = if n > 0 { 1 } else { -1 };
let mut nz = state.scale(viewport, size);
+3 -1
View File
@@ -1334,6 +1334,7 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) {
let ok_label = if d.target == Target::OpenRaw { tl!("Open") } else { tl!("OK") };
let footer_h = 48.0;
let body = ERect::from_min_max(pos2(full.left(), title.bottom()), pos2(full.right(), full.bottom() - footer_h));
let animated_zoom = app.session.prefs().general.animated_zoom;
let scope = &mut app.ui.camera_raw_scope;
// Clip both pixels and scope overlays before painting the settings dock/footer.
let preview_body = ERect::from_min_max(body.min, pos2((body.right() - PANEL_W).max(body.left() + 1.0), body.bottom()));
@@ -1348,7 +1349,8 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) {
let mut preview = ui.new_child(egui::UiBuilder::new().max_rect(view));
preview.set_clip_rect(view.intersect(ui.clip_rect()));
preview.painter().rect_filled(view, 0.0, t.canvas);
let (r, response, panning, zoom_box) = super::camera_raw_preview_ui::interact(&mut preview, navigation, view, size, &mut scope.sampler_tool);
let (r, response, panning, zoom_box) =
super::camera_raw_preview_ui::interact(&mut preview, navigation, view, size, &mut scope.sampler_tool, animated_zoom);
let tex = if d.show_before { d.before_tex.as_ref() } else { d.tex.as_ref() };
if let Some(tex) = tex {
// Never tile a checker across the offscreen extent of a highly zoomed image.
+6
View File
@@ -3556,6 +3556,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
&ctx,
app.session.prefs().general.zoom_with_scroll_wheel,
app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch,
app.session.prefs().general.animated_zoom,
wheel_owner,
hover_here,
app.session.prefs().interface.enable_animations,
@@ -3924,6 +3925,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
let over_bars = ctx.data(|d| d.get_temp::<bool>(bars_id)).unwrap_or(false);
let nav_hovered = response.hovered() || free_hover || over_bars;
// Interface › Enable Animations off: the wheel moves the canvas without a glide (#5721).
// General › Animated Zoom: the wheel zoom eases over the frames of a notch, or is one instant step.
// Enhanced Controls › Enable Gestures off: two fingers on a touch screen neither zoom nor
// turn the view (#204); the mouse and trackpad are unaffected.
let touch_gestures = app.session.prefs().enhanced_controls.touch_gestures;
@@ -3931,6 +3933,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
&ctx,
app.session.prefs().general.zoom_with_scroll_wheel,
app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch,
app.session.prefs().general.animated_zoom,
wheel_owner,
nav_hovered,
app.session.prefs().interface.enable_animations,
@@ -3943,6 +3946,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// At a limit the view stays put, as in Photoshop: a notch past 12800 % neither
// zooms nor slides the image towards the pointer.
let nz = crate::zoom_levels::clamp(view.zoom * f, view.doc_size);
if nz != view.zoom * f {
crate::wheel_nav::settle(&ctx, wheel_owner);
}
if nz != view.zoom {
// Over the pasteboard the anchor is the image's nearest point (#3720).
let at = wheel_anchor(&xf, p, view.doc_size);
+40
View File
@@ -492,6 +492,8 @@ fn the_wheel_reaches_12800_percent_and_stops_dead_there() {
#[test]
fn alt_scroll_zooms_in_steps_around_the_pointer() {
let mut h = harness();
// Animated Zoom off: a notch is one instant step (the default eases it, see the next test).
h.state_mut().session.edit_prefs(|p| p.general.animated_zoom = false);
let r = h.state().last_canvas_rect;
let p = pos2(r.center().x + 120.0, r.center().y - 70.0);
h.hover_at(p);
@@ -535,6 +537,44 @@ fn alt_scroll_zooms_in_steps_around_the_pointer() {
assert!((zoom(&h) / z2 - 1.25).abs() < 1e-3, "pinch zooms: {z2} -> {}", zoom(&h));
}
/// Animated Zoom on (the default): a notch eases in over several frames but is still ×1.1 in
/// total, around the pointer.
#[test]
fn animated_alt_scroll_zoom_still_lands_on_one_step_around_the_pointer() {
let mut h = harness();
assert!(h.state().session.prefs().general.animated_zoom, "on by default");
let r = h.state().last_canvas_rect;
let p = pos2(r.center().x + 120.0, r.center().y - 70.0);
h.hover_at(p);
h.run_steps(2);
let (z0, d0) = (zoom(&h), doc_at(&h, p));
wheel(&h, 1.0, Modifiers::ALT);
h.run_steps(40);
let (z1, d1) = (zoom(&h), doc_at(&h, p));
assert!((z1 / z0 - 1.1).abs() < 1e-3, "one ⌥ notch is ×1.1: {z0} -> {z1}");
assert!((d1[0] - d0[0]).abs() < 0.05 && (d1[1] - d0[1]).abs() < 0.05, "centred on the pointer: {d0:?} -> {d1:?}");
}
/// Animated Zoom on: notches flicked past 12800 % stop there, and the first notch back zooms out
/// at once by its ×1/1.1. The rest of the notches the limit swallowed must not eat it.
#[test]
fn the_first_animated_notch_back_from_the_limit_zooms_out() {
let mut h = harness();
{
let v = &mut h.state_mut().ui.views[0];
(v.zoom, v.fit_pending) = (crate::zoom_levels::MAX / 1.05, false);
}
let r = h.state().last_canvas_rect;
h.hover_at(r.center());
h.run_steps(2);
wheel(&h, 3.0, Modifiers::ALT);
h.step();
assert_eq!(zoom(&h), crate::zoom_levels::MAX, "the flick reaches the limit");
wheel(&h, -1.0, Modifiers::ALT);
h.run_steps(40);
assert!((zoom(&h) - crate::zoom_levels::MAX / 1.1).abs() < 1e-2, "one notch back: {}", zoom(&h));
}
#[test]
fn alt_scroll_zooms_while_a_temporary_tool_is_held() {
let mut h = harness();
+11 -2
View File
@@ -904,6 +904,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let mut events: Vec<ToolEvent> = Vec::new();
let mut mods = egui::Modifiers::NONE;
let pinch_zooms = app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch;
let animated_zoom = app.session.prefs().general.animated_zoom;
egui::Area::new(egui::Id::new("liquify-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| {
let Some(d) = app.distort.liquify.as_mut() else { return };
d.upload(ctx);
@@ -1038,10 +1039,18 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
// The wheel zooms about the pointer, ×1.1 a notch, and so does a trackpad pinch (unless
// Preferences › Enhanced Controls › Zoom with Trackpad Pinch is off); a sideways scroll
// pans (#7210). Read every frame, as `wheel_nav` asks, so its modifier state follows.
let wheel = crate::wheel_nav::read(ctx, true, pinch_zooms, egui::Id::new("liquify-preview-wheel"), resp.hovered());
let wheel_id = egui::Id::new("liquify-preview-wheel");
let wheel = crate::wheel_nav::read(ctx, true, pinch_zooms, animated_zoom, wheel_id, resp.hovered());
if let Some(hp) = resp.hover_pos() {
let zoom = match wheel {
Some(crate::wheel_nav::Wheel::Zoom(f)) => Some((d.zoom * f).clamp(ZOOM_MIN, ZOOM_MAX)),
Some(crate::wheel_nav::Wheel::Zoom(f)) => {
let z = d.zoom * f;
// At a limit, drop the rest of an eased notch, or it would swallow the first notch back.
if z != z.clamp(ZOOM_MIN, ZOOM_MAX) {
crate::wheel_nav::settle(ctx, wheel_id);
}
Some(z.clamp(ZOOM_MIN, ZOOM_MAX))
}
Some(crate::wheel_nav::Wheel::SnapZoom(n)) => {
Some((0..n.unsigned_abs()).fold(d.zoom, |z, _| crate::zoom_levels::step_in_range(z, n.signum(), ZOOM_MIN, ZOOM_MAX)))
}
+10 -3
View File
@@ -2081,16 +2081,21 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
// The Navigator has its own zoom target: an unmodified wheel over the preview
// should zoom even when the document canvas is configured to scroll on wheel.
// Use the canvas wheel classifier to honour one ×1.1 step per notch without
// applying egui's smoothed wheel tail a second time.
// Use the canvas wheel classifier to honour one ×1.1 step per notch,
// eased over its frames or instant as General › Animated Zoom says.
let pinch_zooms = app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch;
let animated_zoom = app.session.prefs().general.animated_zoom;
let touch_gestures = app.session.prefs().enhanced_controls.touch_gestures;
let navigator_wheel = egui::Id::new("pc-navigator-wheel");
if resp.hovered()
&& let Some(crate::wheel_nav::Wheel::Zoom(factor)) =
crate::wheel_nav::read_with(&ctx, true, pinch_zooms, egui::Id::new("pc-navigator-wheel"), true, true, touch_gestures)
crate::wheel_nav::read_with(&ctx, true, pinch_zooms, animated_zoom, navigator_wheel, true, true, touch_gestures)
{
let view = &mut app.ui.views[idx];
let next = crate::zoom_levels::clamp(view.zoom * factor, view.doc_size);
if next != view.zoom * factor {
crate::wheel_nav::settle(&ctx, navigator_wheel);
}
if next != view.zoom {
view.zoom = next;
view.zoom_animating = false;
@@ -2297,6 +2302,8 @@ mod navigator_wheel_tests {
app.sync_views();
app.ui.views[0].zoom = 1.0;
app.ui.views[0].fit_pending = false;
// Animated Zoom off: a notch is one instant step, so one frame shows the whole ×1.1.
app.session.edit_prefs(|p| p.general.animated_zoom = false);
let ctx = egui::Context::default();
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
crate::wheel_nav::configure(&ctx);
+580 -49
View File
@@ -1,7 +1,7 @@
//! Mouse wheel and trackpad navigation over the canvas (#293, #635), as in Photoshop:
//!
//! - The wheel scrolls up and down; ⇧ or ⌘/Ctrl + wheel scrolls sideways.
//! - ⌥/Alt + wheel zooms around the pointer, 10% per wheel notch, applied the frame it arrives.
//! - ⌥/Alt + wheel zooms around the pointer, 10% per wheel notch (eased or instant, see below).
//! ⇧/Shift + ⌥/Alt + wheel snaps to the next zoom ladder level instead.
//! - Preferences › General › Zoom with Scroll Wheel swaps the two: the wheel zooms and
//! ⌥/Alt + wheel scrolls; ⇧/Shift + wheel snaps to the next zoom ladder level.
@@ -20,14 +20,22 @@
//! before egui sees them, so Ctrl + a notched mouse wheel scrolls sideways and the pinch still
//! zooms.
//!
//! The target behaviour is exact ×1.1 steps, one per notch, with no easing in between
//! (measured frame by frame on a screen recording). egui smooths a wheel notch over several
//! frames, so zooming reads the wheel events themselves and ignores that smoothed tail; panning
//! keeps the smoothed delta. The Alt state is taken from the wheel events (and the Alt key's own
//! press and release, `wheel_events`) and kept until the next wheel event: releasing Alt while
//! the tail is still settling must not turn it into a pan.
//! Either way a wheel notch zooms by exactly ×1.1 in total. How that step is spread over frames
//! is set by Preferences › General › Animated Zoom (`general.animatedZoom`, on by default):
//!
//! - On: the zoom is eased over several frames (the curve of egui's wheel smoothing, fed by the zoom
//! notches only), so the factors of the frames of one notch multiply to ×1.1. An unfinished notch
//! is superseded, not carried, when ⇧ snap zoom or a pan takes over or the pointer leaves.
//! - Off: one instant ×1.1 step per notch, applied the frame it arrives with no easing in between
//! (the Photoshop behaviour, measured frame by frame on a screen recording). Zooming reads the
//! wheel events themselves and ignores egui's smoothed tail.
//!
//! In both modes panning keeps the smoothed delta, and ⇧/Shift snap zoom reads the wheel events
//! and stays instant. The Alt state is taken from the wheel events (and the Alt key's own press
//! and release, `wheel_events`) and kept until the next wheel event: releasing Alt while the tail
//! is still settling must not turn it into a pan.
use egui::{Context, Event, Id, InputState, Key, Modifiers, MouseWheelUnit, RawInput, Vec2};
use egui::{Context, Event, Id, InputState, Key, Modifiers, MouseWheelUnit, RawInput, TouchPhase, Vec2};
/// Zoom factor of one wheel notch: every step is exactly ×1.1.
pub const NOTCH: f32 = 1.1;
@@ -50,10 +58,11 @@ pub enum Wheel {
/// Inputs of one frame's wheel gesture.
#[derive(Clone, Copy, Debug)]
pub struct Input {
/// Smoothed scroll delta (points, content direction); what panning uses.
/// Smoothed scroll delta (points, content direction); what panning uses. In the animated zoom
/// modes it is the eased zoom delta of that mode's own wheel events only.
pub scroll: Vec2,
/// This frame's wheel events for the gesture in progress, unsmoothed (points, content
/// direction); what zooming uses.
/// direction); what the instant zoom and the snap zoom use.
pub raw: Vec2,
/// egui's pinch zoom factor (1 = none).
pub zoom_delta: f32,
@@ -65,6 +74,9 @@ pub struct Input {
pub zoom_with_wheel: bool,
/// Preferences › Enhanced Controls › Zoom with Trackpad Pinch.
pub pinch_zooms: bool,
/// Preferences › General › Animated Zoom: the wheel zoom follows the smoothed delta (`scroll`)
/// across frames; off, it is one instant step from this frame's wheel events (`raw`).
pub animated_zoom: bool,
/// Points per wheel notch (egui's `line_scroll_speed`).
pub notch: f32,
}
@@ -92,17 +104,21 @@ pub fn classify(i: Input) -> Option<Wheel> {
let steps = n.clamp(-10.0, 10.0).round() as i32;
if steps == 0 { None } else { Some(Wheel::SnapZoom(steps)) }
};
let scroll_finite = i.scroll.x.is_finite() && i.scroll.y.is_finite();
match (i.alt, i.zoom_with_wheel) {
// ⇧ + ⌥ + scroll: snap to the zoom ladder instead of continuous ×1.1.
(true, false) if i.shift => snap(i.raw.y + i.raw.x),
// ⌥ + scroll: continuous ×1.1 zoom. The smoothed tail of a notch is ignored.
(true, false) => step(i.raw.y + i.raw.x),
// ⌥ + scroll: continuous ×1.1 zoom. Animated Zoom on: follow the smoothed delta across frames.
(true, false) if i.animated_zoom && scroll_finite => step(i.scroll.y + i.scroll.x),
// Animated Zoom off: one instant step from the wheel events. The smoothed tail is ignored.
(true, false) if !i.animated_zoom => step(i.raw.y + i.raw.x),
// ⇧ + scroll wheel with the preference on: snap to the zoom ladder.
// Some systems deliver Shift+wheel as horizontal (delta in x, y = 0).
(false, true) if i.shift => snap(i.raw.y + i.raw.x),
// ⌘/Ctrl folds the wheel into x: those still scroll sideways.
(false, true) if i.raw.y != 0.0 || i.scroll.y != 0.0 => step(i.raw.y),
_ if !(i.scroll.x.is_finite() && i.scroll.y.is_finite()) || i.scroll == Vec2::ZERO => None,
(false, true) if i.animated_zoom && scroll_finite && i.scroll.y != 0.0 => step(i.scroll.y),
(false, true) if !i.animated_zoom && (i.raw.y != 0.0 || i.scroll.y != 0.0) => step(i.raw.y),
_ if !scroll_finite || i.scroll == Vec2::ZERO => None,
_ => Some(Wheel::Pan(i.scroll)),
}
}
@@ -197,12 +213,130 @@ fn active_snap_owner_id() -> Id {
const SNAP_IDLE_TIMEOUT: f64 = 0.5;
/// egui's own wheel smoothing (`InputState::begin_pass`): 90 % of a notch is released in 0.1 s.
/// The eased zoom uses the same curve, so it paces like the eased pan.
const EASE_REACH: f32 = 0.90;
const EASE_REACH_SECS: f32 = 0.1;
/// An unfinished eased notch belongs to the frames that follow it. It is dropped, never carried
/// into a later notch, once its owner was not driven for more than this many passes (the pointer
/// left the viewport, or another gesture took over there)...
const ZOOM_EASE_MAX_GAP_PASSES: u64 = 3;
/// ...or, when at least one pass was skipped, for this long (seconds). A single slow frame is
/// not a gap: the owner was driven in the pass right before it, so the notch still finishes.
const ZOOM_EASE_STALE_SECS: f64 = 0.5;
#[derive(Clone, Copy)]
struct ZoomEase {
residual: f32,
time: f64,
frame: u64,
released: f32,
}
fn zoom_ease_id(owner: Id) -> Id {
Id::new(("wheel-zoom-ease", owner))
}
/// Eases `delta` (points of this viewport's zoom-mode wheel events) over the next frames and
/// returns what is released this frame. Same curve as egui's wheel smoothing, but fed by the
/// zoom notches only, so the tail of a pan notch never leaks in. What is released adds up to
/// exactly what was fed. A second call in the same frame returns the cached result.
fn ease_zoom_delta(ctx: &Context, owner: Id, delta: f32) -> f32 {
let id = zoom_ease_id(owner);
let frame = ctx.cumulative_pass_nr();
let (now, dt) = ctx.input(|i| (i.time, i.stable_dt.min(0.1)));
let prev = ctx.data(|d| d.get_temp::<ZoomEase>(id));
if let Some(e) = prev.filter(|e| e.frame == frame) {
return e.released;
}
// Fresh: driven in the previous pass (a slow frame, however long, keeps its notch), or within a
// few passes and a short time (a one-pass hover flicker). Anything older is stale.
let fresh = |e: &ZoomEase| {
let gap = frame.saturating_sub(e.frame);
e.residual.is_finite() && (gap <= 1 || (gap <= ZOOM_EASE_MAX_GAP_PASSES && now - e.time < ZOOM_EASE_STALE_SECS))
};
let carried = prev.filter(fresh).map_or(0.0, |e| e.residual);
let mut residual = carried + if delta.is_finite() { delta } else { 0.0 };
let t = egui::emath::exponential_smooth_factor(EASE_REACH, EASE_REACH_SECS, dt);
let released = if residual.abs() < 1.0 { residual } else { t * residual };
residual -= released;
ctx.data_mut(|d| d.insert_temp(id, ZoomEase { residual, time: now, frame, released }));
// Callers do not repaint on their own after consuming the zoom: keep frames coming until the
// notch has been fully released, or a reactive UI would stop below ×1.1.
if residual != 0.0 {
ctx.request_repaint();
}
released
}
/// A zoom limit stopped the eased zoom of `owner`: drop what is left of the notch. It would no
/// longer zoom, only cancel the first notch back (flick to 12800 %, and the notch back did
/// nothing). This frame's release stays cached, so a second read in the same frame is unchanged.
pub fn settle(ctx: &Context, owner: Id) {
let id = zoom_ease_id(owner);
ctx.data_mut(|d| {
if let Some(e) = d.get_temp::<ZoomEase>(id) {
d.insert_temp(id, ZoomEase { residual: 0.0, ..e });
}
});
}
/// Whether a trackpad touch is in progress (egui's `InTouch`), at the start and at the end of a
/// frame: the start is kept, so every read in one frame sees the same states.
#[derive(Clone, Copy)]
struct Touch {
frame: u64,
at_start: bool,
at_end: bool,
}
/// Per viewport, as egui keeps its wheel state per viewport.
fn touch_id(ctx: &Context) -> Id {
Id::new(("wheel-nav-touch", ctx.viewport_id()))
}
/// This frame's wheel events (in order) with, for each, whether egui leaves it unsmoothed: during a
/// trackpad touch, or a point delta under 8 points (`WheelState::on_wheel_event`). Those are
/// already smooth, and easing them again would only add latency; the rest (notches, and the
/// big pixel steps browsers report for a mouse wheel) is what Animated Zoom eases.
fn mark_unsmoothed(ctx: &Context, events: Vec<(MouseWheelUnit, Vec2, Modifiers, TouchPhase)>) -> Vec<(MouseWheelUnit, Vec2, Modifiers, bool)> {
let frame = ctx.cumulative_pass_nr();
let (horizontal, vertical) = ctx.options(|o| (o.input_options.horizontal_scroll_modifier, o.input_options.vertical_scroll_modifier));
// Not read in the previous pass (no canvas then): a touch could have ended unseen, and a
// stuck one would leave every later notch un-eased.
let id = touch_id(ctx);
let mut touch = match ctx.data(|d| d.get_temp::<Touch>(id)) {
Some(t) if t.frame == frame => t.at_start,
Some(t) if t.frame.saturating_add(1) == frame => t.at_end,
_ => false,
};
let at_start = touch;
let mut wheel = Vec::with_capacity(events.len());
for (unit, delta, modifiers, phase) in events {
match phase {
TouchPhase::Start => touch = true,
TouchPhase::End | TouchPhase::Cancel => touch = false,
TouchPhase::Move => {}
}
// egui folds a scroll onto one axis by its modifiers before it measures the step.
let step = match (modifiers.matches_any(horizontal), modifiers.matches_any(vertical)) {
(true, false) | (false, true) => (delta.x + delta.y).abs(),
_ => delta.length(),
};
let instant = touch || (unit == MouseWheelUnit::Point && step < 8.0);
wheel.push((unit, delta, modifiers, instant));
}
ctx.data_mut(|d| d.insert_temp(id, Touch { frame, at_start, at_end: touch }));
wheel
}
/// Read this frame's wheel input. Call it every frame (hovered or not) so the Alt and Shift
/// states of the gesture follow the latest wheel event. `owner` scopes the snap-zoom
/// accumulator per viewport. `hovered` gates accumulation: only the viewport receiving
/// navigation may add raw input to the snap residual.
pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool) -> Option<Wheel> {
read_with(ctx, zoom_with_wheel, pinch_zooms, owner, hovered, true, true)
pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, animated_zoom: bool, owner: Id, hovered: bool) -> Option<Wheel> {
read_with(ctx, zoom_with_wheel, pinch_zooms, animated_zoom, owner, hovered, true, true)
}
/// [`read`], choosing how a plain wheel scroll pans: `smooth` glides it over a few frames (egui's
@@ -210,16 +344,29 @@ pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id,
/// in the frame the wheel turns and stops there (#5721), as scroll areas already do.
/// `touch_gestures` is Preferences › Enhanced Controls › Enable Gestures: off, a touch screen's
/// two-finger pinch doesn't zoom ([`pinch_factor`]).
pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool, smooth: bool, touch_gestures: bool) -> Option<Wheel> {
/// `animated_zoom` is Preferences › General › Animated Zoom: on, the wheel zoom is eased over the
/// frames of a notch; off, a notch is one instant ×1.1 step.
#[allow(clippy::too_many_arguments)]
pub fn read_with(
ctx: &Context,
zoom_with_wheel: bool,
pinch_zooms: bool,
animated_zoom: bool,
owner: Id,
hovered: bool,
smooth: bool,
touch_gestures: bool,
) -> Option<Wheel> {
let notch = ctx.options(|o| o.input_options.line_scroll_speed);
// --- Modifier tracking (always runs for all callers) ---
let (scroll, zoom_delta, latest_alt, latest_shift, wheel, page) = ctx.input(|i| {
let wheel = wheel_events(i);
let latest_alt = wheel.last().map(|(_, _, m)| m.alt && !m.command && !m.ctrl);
let latest_shift = wheel.last().map(|(_, _, m)| m.shift && !m.command && !m.ctrl);
let latest_alt = wheel.last().map(|(_, _, m, _)| m.alt && !m.command && !m.ctrl);
let latest_shift = wheel.last().map(|(_, _, m, _)| m.shift && !m.command && !m.ctrl);
(i.smooth_scroll_delta, pinch_factor(i, touch_gestures), latest_alt, latest_shift, wheel, i.content_rect().height())
});
let wheel = mark_unsmoothed(ctx, wheel);
let alt = match latest_alt {
Some(a) => {
ctx.data_mut(|d| d.insert_temp(alt_id(), a));
@@ -242,9 +389,10 @@ pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner:
// --- Input classification (hovered viewport only) ---
// The wheel events that belong to the gesture in progress: Shift+Alt (snap zoom), Alt-only
// (continuous zoom), Shift-only (snap zoom with zoom_with_wheel), or plain (scroll/zoom).
let raw = wheel
// `instant`: the part of `raw` egui leaves unsmoothed (a trackpad; see `wheel_events`).
let (raw, instant) = wheel
.iter()
.filter(|(_, _, m)| {
.filter(|(_, _, m, _)| {
if alt && shift {
m.alt && m.shift && !m.command && !m.ctrl
} else if alt {
@@ -255,18 +403,22 @@ pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner:
!m.alt && !m.shift && !m.command && !m.ctrl
}
})
.fold(Vec2::ZERO, |sum, (unit, delta, _)| {
sum + *delta
.fold((Vec2::ZERO, Vec2::ZERO), |(sum, instant), (unit, delta, _, unsmoothed)| {
let d = *delta
* match unit {
MouseWheelUnit::Point => 1.0,
MouseWheelUnit::Line => notch,
MouseWheelUnit::Page => page,
}
};
(sum + d, if *unsmoothed { instant + d } else { instant })
});
let snap_mode = shift && ((alt && !zoom_with_wheel) || (!alt && zoom_with_wheel));
let has_pinch = pinch_zooms && zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0;
if snap_mode && !has_pinch {
// Shift snap zoom takes over: drop this owner's unfinished eased notch, so a continuous
// zoom started shortly after Shift is released cannot carry its residual.
ctx.data_mut(|d| d.remove::<ZoomEase>(zoom_ease_id(owner)));
let dy = raw.y + raw.x;
if dy == 0.0 {
return None;
@@ -316,9 +468,24 @@ pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner:
d.remove::<f64>(snap_time_id(owner));
});
}
// Unsmoothed, a plain scroll pans by this frame's own wheel events (none: no tail).
let scroll = if smooth || alt || shift { scroll } else { raw };
classify(Input { scroll, raw, zoom_delta, alt, shift, zoom_with_wheel, pinch_zooms, notch })
// Zoom-mode input: ⌥ + wheel, or the plain wheel with Zoom with Scroll Wheel. With Animated
// Zoom on it eases from this mode's own wheel events: egui's smoothed delta has no notion of
// modifiers, so it would also carry the unfinished tail of a pan notch into the zoom.
let wheel_zooms = !shift && (alt != zoom_with_wheel);
let scroll = if animated_zoom && wheel_zooms {
// Eased as egui eases a wheel: what it leaves unsmoothed (a trackpad) zooms at once.
let along = |v: Vec2| if alt { v.y + v.x } else { v.y };
let eased = ease_zoom_delta(ctx, owner, along(raw - instant)) + along(instant);
// The plain wheel zooms through `y`; its sideways part (⌘/Ctrl + wheel, a trackpad's
// horizontal scroll) is not a zoom event, so it pans like any scroll: smoothed, or
// with Enable Animations off at once (#5721).
if alt { Vec2::new(0.0, eased) } else { Vec2::new(if smooth { scroll.x } else { raw.x }, eased) }
} else {
ctx.data_mut(|d| d.remove::<ZoomEase>(zoom_ease_id(owner)));
// Unsmoothed, a plain scroll pans by this frame's own wheel events (none: no tail).
if smooth || alt || shift { scroll } else { raw }
};
classify(Input { scroll, raw, zoom_delta, alt, shift, zoom_with_wheel, pinch_zooms, animated_zoom, notch })
}
}
@@ -332,7 +499,7 @@ fn is_alt_key(key: Key) -> bool {
/// tapped), while the Alt key's own press and release still arrive; the menu bar follows those
/// too (`menu_keys`). Alt is down from its press until its release, starting from whether it
/// was down before this frame's first Alt key event (egui's `keys_down` when there is none).
fn wheel_events(i: &InputState) -> Vec<(MouseWheelUnit, Vec2, Modifiers)> {
fn wheel_events(i: &InputState) -> Vec<(MouseWheelUnit, Vec2, Modifiers, TouchPhase)> {
let first_alt_key = i.events.iter().find_map(|e| match e {
// A repeat means the key was already down; a release means it was down until then.
Event::Key { key, pressed, repeat, .. } if is_alt_key(*key) => Some(!*pressed || *repeat),
@@ -343,9 +510,9 @@ fn wheel_events(i: &InputState) -> Vec<(MouseWheelUnit, Vec2, Modifiers)> {
for e in &i.events {
match e {
Event::Key { key, pressed, .. } if is_alt_key(*key) => alt_key = *pressed,
Event::MouseWheel { unit, delta, modifiers, .. } => {
Event::MouseWheel { unit, delta, phase, modifiers } => {
let modifiers = Modifiers { alt: modifiers.alt || alt_key, ..*modifiers };
wheel.push((*unit, *delta, modifiers));
wheel.push((*unit, *delta, modifiers, *phase));
}
_ => {}
}
@@ -378,7 +545,7 @@ mod tests {
use super::*;
fn input(scroll: Vec2, alt: bool) -> Input {
Input { scroll, raw: scroll, zoom_delta: 1.0, alt, shift: false, zoom_with_wheel: false, pinch_zooms: true, notch: 40.0 }
Input { scroll, raw: scroll, zoom_delta: 1.0, alt, shift: false, zoom_with_wheel: false, pinch_zooms: true, animated_zoom: true, notch: 40.0 }
}
fn wheel(delta: Vec2, modifiers: Modifiers) -> Event {
@@ -390,7 +557,13 @@ mod tests {
/// Feed `events` through an egui context set up like the app's (`legacy`: the Windows pinch
/// fold on) and add up what the wheel did to the view over the following second.
///
/// `gesture` runs with Animated Zoom off (a notch is one instant step); see [`gesture_with`].
fn gesture(frames: Vec<Vec<Event>>, zoom_with_wheel: bool, legacy: bool) -> (Vec2, f32, i32) {
gesture_with(frames, zoom_with_wheel, legacy, false)
}
fn gesture_with(frames: Vec<Vec<Event>>, zoom_with_wheel: bool, legacy: bool, animated_zoom: bool) -> (Vec2, f32, i32) {
let owner = Id::new("test-gesture");
let ctx = Context::default();
configure(&ctx);
@@ -402,7 +575,7 @@ mod tests {
if legacy {
fold_legacy_pinch(&ctx, &mut raw);
}
let mut out = ctx.run_ui(raw, |ui| match read(ui.ctx(), zoom_with_wheel, true, owner, true) {
let mut out = ctx.run_ui(raw, |ui| match read(ui.ctx(), zoom_with_wheel, true, animated_zoom, owner, true) {
Some(Wheel::Pan(d)) => pan += d,
Some(Wheel::Zoom(f)) => zoom *= f,
Some(Wheel::SnapZoom(n)) => snaps += n,
@@ -435,7 +608,7 @@ mod tests {
let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
let mut pan = Vec2::ZERO;
let mut out = ctx.run_ui(raw, |ui| {
if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), false, true, owner, true, smooth, true) {
if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), false, true, true, owner, true, smooth, true) {
pan = d;
}
});
@@ -457,6 +630,39 @@ mod tests {
assert!(smooth[1..].iter().any(|p| p.y != 0.0), "and the rest follows");
}
/// Interface › Enable Animations off only stops the *pan* gliding. A plain wheel that zooms
/// (Zoom with Scroll Wheel) with Animated Zoom on must still ease over the frames of a notch,
/// exactly as it does with the pan smoothing on.
#[test]
fn animated_wheel_zoom_keeps_its_eased_tail_when_pan_smoothing_is_off() {
let frames = |smooth: bool| -> Vec<f32> {
let owner = Id::new("test-animated-zoom-unsmoothed-pan");
let ctx = Context::default();
configure(&ctx);
(0..60)
.map(|n| {
let events = if n == 0 { vec![wheel(Vec2::new(0.0, 1.0), Modifiers::NONE)] } else { Vec::new() };
let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
let mut factor = 1.0;
let mut out = ctx.run_ui(raw, |ui| {
if let Some(Wheel::Zoom(f)) = read_with(ui.ctx(), true, true, true, owner, true, smooth, true) {
factor = f;
}
});
out.textures_delta.clear();
factor
})
.collect()
};
let unsmoothed = frames(false);
assert!(unsmoothed[0] > 1.0 && unsmoothed[0] < NOTCH - 1e-3, "the first frame carries only part of it: {:?}", &unsmoothed[..4]);
assert!(unsmoothed[1..].iter().any(|f| *f > 1.0), "and the rest follows: {unsmoothed:?}");
let product: f32 = unsmoothed.iter().product();
assert!((product - NOTCH).abs() < 1e-4, "still x1.1 in total: {product}");
let smoothed = frames(true);
assert!(unsmoothed.iter().zip(&smoothed).all(|(a, b)| (a - b).abs() < 1e-6), "same pacing as with pan smoothing on");
}
#[test]
fn plain_scroll_pans() {
assert_eq!(classify(input(Vec2::new(3.0, -40.0), false)), Some(Wheel::Pan(Vec2::new(3.0, -40.0))));
@@ -474,17 +680,340 @@ mod tests {
assert!((f - 1.21).abs() < 1e-4, "{f}");
}
/// Animated Zoom off: a notch is one instant step, and egui's smoothed tail does nothing.
#[test]
fn a_notch_zooms_the_frame_it_arrives_and_its_smoothed_tail_does_nothing() {
// egui keeps feeding a smoothed delta for several frames after the wheel event. Zooming
// must not follow it (that would ease the zoom), nor turn it into a pan.
let off = |i: Input| Input { animated_zoom: false, ..i };
let tail = off(Input { raw: Vec2::ZERO, ..input(Vec2::new(0.0, 12.0), true) });
assert_eq!(classify(tail), None);
let tail = off(Input { raw: Vec2::ZERO, zoom_with_wheel: true, ..input(Vec2::new(0.0, 12.0), false) });
assert_eq!(classify(tail), None);
// The frame the notch arrives carries it whole, whatever the smoothed delta is.
let arrival = off(Input { scroll: Vec2::new(0.0, 12.0), raw: Vec2::new(0.0, 40.0), ..input(Vec2::ZERO, true) });
assert!(matches!(classify(arrival), Some(Wheel::Zoom(f)) if (f - 1.1).abs() < 1e-5));
// Plain scrolling without the preference still pans from the smoothed delta.
let pan = off(Input { raw: Vec2::ZERO, ..input(Vec2::new(0.0, 12.0), false) });
assert_eq!(classify(pan), Some(Wheel::Pan(Vec2::new(0.0, 12.0))));
}
/// Animated Zoom on: the zoom follows egui's smoothed delta over the frames of a notch, and
/// never turns that tail into a pan.
#[test]
fn animated_zoom_follows_the_smoothed_tail_of_a_notch() {
let tail = Input { raw: Vec2::ZERO, ..input(Vec2::new(0.0, 12.0), true) };
assert_eq!(classify(tail), None);
assert!(matches!(classify(tail), Some(Wheel::Zoom(f)) if f > 1.0));
let tail = Input { raw: Vec2::ZERO, zoom_with_wheel: true, ..input(Vec2::new(0.0, 12.0), false) };
assert_eq!(classify(tail), None);
assert!(matches!(classify(tail), Some(Wheel::Zoom(f)) if f > 1.0));
// The size of the step comes from the smoothed delta, not the raw one.
let part = Input { scroll: Vec2::new(0.0, 20.0), raw: Vec2::new(0.0, 40.0), ..input(Vec2::ZERO, true) };
assert!(matches!(classify(part), Some(Wheel::Zoom(f)) if (f - 1.1f32.powf(0.5)).abs() < 1e-5));
// Plain scrolling without the preference still pans from the smoothed delta.
let pan = Input { raw: Vec2::ZERO, ..input(Vec2::new(0.0, 12.0), false) };
assert_eq!(classify(pan), Some(Wheel::Pan(Vec2::new(0.0, 12.0))));
// ⇧ + snap zoom stays on the wheel events and instant, in both modes.
for animated_zoom in [false, true] {
let tail = Input { shift: true, zoom_with_wheel: true, raw: Vec2::ZERO, animated_zoom, ..input(Vec2::new(12.0, 0.0), false) };
assert_eq!(classify(tail), None, "animated_zoom {animated_zoom}");
let snap = Input { shift: true, zoom_with_wheel: true, raw: Vec2::new(0.0, 40.0), animated_zoom, ..input(Vec2::new(0.0, 12.0), false) };
assert_eq!(classify(snap), Some(Wheel::SnapZoom(1)), "animated_zoom {animated_zoom}");
}
}
/// The factor of every frame of one notch, through a real egui context (60 frames at 60 fps).
fn zoom_frames(modifiers: Modifiers, zoom_with_wheel: bool, animated_zoom: bool) -> Vec<f32> {
let owner = Id::new("test-zoom-frames");
let ctx = Context::default();
configure(&ctx);
(0..60)
.map(|n| {
let events = if n == 0 { vec![wheel(Vec2::new(0.0, 1.0), modifiers)] } else { Vec::new() };
let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
let mut factor = 1.0;
let mut out = ctx.run_ui(raw, |ui| {
if let Some(Wheel::Zoom(f)) = read(ui.ctx(), zoom_with_wheel, true, animated_zoom, owner, true) {
factor = f;
}
});
out.textures_delta.clear();
factor
})
.collect()
}
/// The property the eased zoom relies on: however egui spreads a notch over frames, the
/// factors of those frames multiply to exactly ×1.1 (and ×1/1.1 for a notch the other way).
#[test]
fn the_eased_frames_of_one_notch_multiply_to_ten_percent() {
for (modifiers, zoom_with_wheel) in [(Modifiers::ALT, false), (Modifiers::NONE, true)] {
let frames = zoom_frames(modifiers, zoom_with_wheel, true);
assert!(frames.iter().all(|f| f.is_finite() && *f >= 1.0), "{frames:?}");
assert!(frames.iter().filter(|f| **f != 1.0).count() > 1, "eased over several frames: {frames:?}");
let product: f32 = frames.iter().product();
assert!((product - NOTCH).abs() < 1e-4, "{modifiers:?} zoom_with_wheel {zoom_with_wheel}: {product} from {frames:?}");
}
// The other way round: ×1/1.1.
let owner = Id::new("test-zoom-frames-out");
let ctx = Context::default();
configure(&ctx);
let mut product = 1.0f32;
for n in 0..60 {
let events = if n == 0 { vec![wheel(Vec2::new(0.0, -1.0), Modifiers::ALT)] } else { Vec::new() };
let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
if let Some(Wheel::Zoom(f)) = read(ui.ctx(), false, true, true, owner, true) {
product *= f;
}
});
out.textures_delta.clear();
}
assert!((product - 1.0 / NOTCH).abs() < 1e-4, "{product}");
}
/// Animated Zoom changes the pacing of a notch, never its size: off, the whole ×1.1 lands in
/// the frame the notch arrives and nothing follows; on, that frame carries only part of it.
#[test]
fn animated_zoom_changes_the_pacing_of_a_notch_not_its_size() {
for (modifiers, zoom_with_wheel) in [(Modifiers::ALT, false), (Modifiers::NONE, true)] {
let instant = zoom_frames(modifiers, zoom_with_wheel, false);
assert!((instant[0] - NOTCH).abs() < 1e-5, "the whole notch at once: {:?}", &instant[..4]);
assert!(instant[1..].iter().all(|f| *f == 1.0), "no tail after it: {instant:?}");
let eased = zoom_frames(modifiers, zoom_with_wheel, true);
assert!(eased[0] > 1.0 && eased[0] < NOTCH - 1e-3, "the first frame carries only part of it: {:?}", &eased[..4]);
assert!(eased[1..].iter().any(|f| *f > 1.0), "and the rest follows");
}
}
/// Animated Zoom is only about the wheel zoom: ⇧ snap zoom and plain scrolling are the same
/// in both modes.
#[test]
fn animated_zoom_leaves_snap_zoom_and_panning_alone() {
let notch = Vec2::new(0.0, 1.0);
let shift_alt = Modifiers { shift: true, alt: true, ..Modifiers::NONE };
for animated_zoom in [false, true] {
let (pan, zoom, snaps) = gesture_with(vec![vec![wheel(notch, shift_alt)]], false, false, animated_zoom);
assert!(pan == Vec2::ZERO && zoom == 1.0 && snaps == 1, "⇧ + ⌥: {pan:?} {zoom} {snaps}");
let (pan, zoom, snaps) = gesture_with(vec![vec![wheel(notch, Modifiers::NONE)]], false, false, animated_zoom);
assert!(near(pan, Vec2::new(0.0, 40.0)) && zoom == 1.0 && snaps == 0, "plain wheel pans: {pan:?} {zoom} {snaps}");
// A notch zooms ×1.1 in total in either mode.
let (pan, zoom, _) = gesture_with(vec![vec![wheel(notch, Modifiers::ALT)]], false, false, animated_zoom);
assert!(pan == Vec2::ZERO && (zoom - NOTCH).abs() < 1e-4, "animated_zoom {animated_zoom}: {pan:?} {zoom}");
}
}
/// A pan notch still gliding when an ⌥ notch arrives (and the same the other way round with
/// Zoom with Scroll Wheel): the zoom is that one notch's ×1.1, not the sum of both tails.
#[test]
fn a_pan_tail_does_not_leak_into_the_animated_zoom() {
let notch = Vec2::new(0.0, 1.0);
// Wheel pans, ⌥ zooms: plain notch, then ⌥ notch one frame later.
let frames = vec![vec![wheel(notch, Modifiers::NONE)], vec![wheel(notch, Modifiers::ALT)]];
let (pan, zoom, snaps) = gesture_with(frames, false, false, true);
assert!((zoom - NOTCH).abs() < 1e-4, "only the ⌥ notch zooms: {zoom}");
assert!(pan.y > 0.0 && pan.y < 40.0 && snaps == 0, "the plain notch only panned its first frame: {pan:?} {snaps}");
// Wheel zooms, ⌥ pans: ⌥ notch, then plain notch.
let frames = vec![vec![wheel(notch, Modifiers::ALT)], vec![wheel(notch, Modifiers::NONE)]];
let (_, zoom, snaps) = gesture_with(frames, true, false, true);
assert!((zoom - NOTCH).abs() < 1e-4, "only the plain notch zooms: {zoom}");
assert_eq!(snaps, 0);
}
/// The zoom factor of each of `frames` frames with Animated Zoom on and ⌥ zooming. `time` gives a
/// frame's timestamp and `hovered` whether the owner is hovered in it.
fn run_zoom(frames: u32, events: impl Fn(u32) -> Vec<Event>, time: impl Fn(u32) -> f64, hovered: impl Fn(u32) -> bool) -> Vec<f32> {
let owner = Id::new("test-run-zoom");
let ctx = Context::default();
configure(&ctx);
(0..frames)
.map(|n| {
let raw = RawInput { time: Some(time(n)), predicted_dt: 1.0 / 60.0, events: events(n), ..Default::default() };
let mut factor = 1.0;
let mut out = ctx.run_ui(raw, |ui| {
if let Some(Wheel::Zoom(f)) = read(ui.ctx(), false, true, true, owner, hovered(n)) {
factor = f;
}
});
out.textures_delta.clear();
factor
})
.collect()
}
/// A slow frame in the middle of an eased notch (a big image redrawing) must not drop what is
/// left of it: the notch still adds up to exactly ×1.1.
#[test]
fn a_slow_frame_does_not_cut_an_eased_notch_short() {
let notch = Vec2::new(0.0, 1.0);
let time = |n: u32| if n == 0 { 0.0 } else { 0.7 + f64::from(n - 1) / 60.0 };
let frames = run_zoom(60, |n| if n == 0 { vec![wheel(notch, Modifiers::ALT)] } else { Vec::new() }, time, |_| true);
let product: f32 = frames.iter().product();
assert!((product - NOTCH).abs() < 1e-4, "still x1.1 in total: {product} from {frames:?}");
}
/// What is left of an eased notch when the pointer leaves the viewport is dropped: a notch
/// made after it comes back is ×1.1 on its own, not ×1.1 plus the old remainder.
#[test]
fn an_unfinished_notch_is_not_carried_into_a_notch_after_the_pointer_left() {
let notch = Vec2::new(0.0, 1.0);
let events = |n: u32| if n == 0 || n == 40 { vec![wheel(notch, Modifiers::ALT)] } else { Vec::new() };
let frames = run_zoom(100, events, |n| f64::from(n) / 60.0, |n| !(1..30).contains(&n));
let first: f32 = frames[..40].iter().product();
assert!(first > 1.0 && first < NOTCH - 1e-3, "the first notch was left unfinished: {first}");
let second: f32 = frames[40..].iter().product();
assert!((second - NOTCH).abs() < 1e-4, "the second notch is ×1.1 on its own: {second}");
}
/// Zoom with Scroll Wheel + Animated Zoom (the defaults of both): ⌘/Ctrl + wheel still scrolls
/// sideways, as it does with Animated Zoom off.
#[test]
fn animated_zoom_keeps_ctrl_wheel_sideways_scroll_with_zoom_with_wheel() {
let (pan, zoom, snaps) = gesture_with(vec![vec![wheel(Vec2::new(0.0, 1.0), CTRL)]], true, false, true);
assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0 && snaps == 0, "{pan:?} {zoom} {snaps}");
}
/// Shift snap zoom interrupting an unfinished animated notch drops that notch's residual: a
/// continuous zoom started right after Shift is released is its own ×1.1 and nothing more.
#[test]
fn shift_snap_zoom_drops_the_unfinished_eased_notch() {
let notch = Vec2::new(0.0, 1.0);
let shift_alt = Modifiers { shift: true, ..Modifiers::ALT };
let owner = Id::new("test-snap-interrupts-ease");
let ctx = Context::default();
configure(&ctx);
let (mut zoom, mut zoom_after_first, mut snaps) = (1.0f32, 1.0f32, 0i32);
for n in 0..60u32 {
// ⌥ notch (eases), ⇧⌥ notch (snap zoom takes over), then ⌥ notch again within 0.5 s.
let events = match n {
0 | 2 => vec![wheel(notch, Modifiers::ALT)],
1 => vec![wheel(notch, shift_alt)],
_ => Vec::new(),
};
let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| match read(ui.ctx(), false, true, true, owner, true) {
Some(Wheel::Zoom(f)) => zoom *= f,
Some(Wheel::SnapZoom(s)) => snaps += s,
_ => {}
});
out.textures_delta.clear();
if n == 0 {
zoom_after_first = zoom;
}
}
assert_eq!(snaps, 1);
let second = zoom / zoom_after_first;
assert!((second - NOTCH).abs() < 1e-4, "the second ⌥ notch zooms ×1.1 on its own: {second}");
}
/// A trackpad's ⌥ + two-finger scroll comes in points and is already smooth: egui leaves it
/// unsmoothed because smoothing adds latency, so Animated Zoom must not ease it either. Each
/// frame zooms by its own events, at once.
#[test]
fn animated_zoom_does_not_ease_a_trackpad() {
let frames =
run_zoom(30, |n| if n == 0 { vec![wheel_point(Vec2::new(0.0, 4.0), Modifiers::ALT)] } else { Vec::new() }, |n| f64::from(n) / 60.0, |_| true);
assert!((frames[0] - NOTCH.powf(0.1)).abs() < 1e-6, "all of it in the frame it arrives: {:?}", &frames[..4]);
assert!(frames[1..].iter().all(|f| *f == 1.0), "no tail after it: {frames:?}");
}
/// Zoom with Scroll Wheel + Animated Zoom with Enable Animations off: the wheel zooms eased, but
/// a sideways wheel still pans without a glide (#5721), all of it in the frame it turns.
#[test]
fn animated_zoom_keeps_the_sideways_wheel_unsmoothed_with_animations_off() {
let owner = Id::new("test-sideways-unsmoothed");
let ctx = Context::default();
configure(&ctx);
let pans: Vec<Vec2> = (0..30)
.map(|n| {
let events = if n == 0 { vec![wheel(Vec2::new(1.0, 0.0), Modifiers::NONE)] } else { Vec::new() };
let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
let mut pan = Vec2::ZERO;
let mut out = ctx.run_ui(raw, |ui| {
if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), true, true, true, owner, true, false, true) {
pan = d;
}
});
out.textures_delta.clear();
pan
})
.collect();
assert!(near(pans[0], Vec2::new(40.0, 0.0)), "the whole notch at once: {:?}", &pans[..4]);
assert!(pans[1..].iter().all(|p| *p == Vec2::ZERO), "no glide after it: {pans:?}");
}
/// A diagonal point step with ⌥ is measured as egui measures it, folded onto one axis: (5, 5)
/// is a 10-point step, which egui smooths, so Animated Zoom eases it.
#[test]
fn a_diagonal_alt_point_step_is_measured_like_egui() {
let frames =
run_zoom(60, |n| if n == 0 { vec![wheel_point(Vec2::new(5.0, 5.0), Modifiers::ALT)] } else { Vec::new() }, |n| f64::from(n) / 60.0, |_| true);
assert!(frames[0] < NOTCH.powf(0.25) - 1e-4 && frames[1] > 1.0, "eased: {:?}", &frames[..4]);
}
/// A touch whose end nobody read (no canvas in that pass, e.g. the last document closed
/// during a trackpad scroll) must not leave later mouse notches un-eased.
#[test]
fn a_touch_end_nobody_read_does_not_stick() {
let owner = Id::new("test-touch-unread-end");
let ctx = Context::default();
configure(&ctx);
let point = |phase: TouchPhase| Event::MouseWheel { unit: MouseWheelUnit::Point, delta: Vec2::ZERO, phase, modifiers: Modifiers::ALT };
let mut first = 1.0;
for n in 0..10u32 {
let events = match n {
0 => vec![point(TouchPhase::Start)],
2 => vec![point(TouchPhase::End)],
5 => vec![wheel(Vec2::new(0.0, 1.0), Modifiers::ALT)],
_ => Vec::new(),
};
let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
// Frames 2-3: nothing reads the wheel.
if !(2..4).contains(&n)
&& let Some(Wheel::Zoom(f)) = read(ui.ctx(), false, true, true, owner, true)
&& n == 5
{
first = f;
}
});
out.textures_delta.clear();
}
assert!(first > 1.0 && first < NOTCH - 1e-3, "the notch is eased: {first}");
}
/// Quick back-and-forth wheeling still lands on whole ×1.1 steps: three notches in and one
/// out a frame later is ×1.1², whatever was still easing.
#[test]
fn a_quick_notch_back_nets_out() {
for back_at in [1, 3] {
let events = |n: u32| match n {
0 => vec![wheel(Vec2::new(0.0, 3.0), Modifiers::ALT)],
n if n == back_at => vec![wheel(Vec2::new(0.0, -1.0), Modifiers::ALT)],
_ => Vec::new(),
};
let total: f32 = run_zoom(60, events, |n| f64::from(n) / 60.0, |_| true).iter().product();
assert!((total - NOTCH * NOTCH).abs() < 1e-4, "back at frame {back_at}: {total}");
}
}
/// A browser reports a mouse notch as a big pixel step (about 100 points in Chrome), which
/// egui smooths like a notch: Animated Zoom eases it too. Within a trackpad touch (egui's
/// `InTouch`) even big point steps zoom at once.
#[test]
fn animated_zoom_eases_big_pixel_steps_but_not_a_touch() {
let point = |dy: f32, phase: TouchPhase| Event::MouseWheel { unit: MouseWheelUnit::Point, delta: Vec2::new(0.0, dy), phase, modifiers: Modifiers::ALT };
let browser = run_zoom(60, |n| if n == 0 { vec![point(100.0, TouchPhase::Move)] } else { Vec::new() }, |n| f64::from(n) / 60.0, |_| true);
assert!(browser[0] > 1.0 && browser[0] < NOTCH.powf(2.5) - 1e-3, "eased: {:?}", &browser[..4]);
let total: f32 = browser.iter().product();
assert!((total - NOTCH.powf(2.5)).abs() < 1e-3, "{total}");
let events = |n: u32| match n {
0 => vec![point(0.0, TouchPhase::Start)],
1 => vec![point(20.0, TouchPhase::Move)],
_ => Vec::new(),
};
let touch = run_zoom(30, events, |n| f64::from(n) / 60.0, |_| true);
assert!((touch[1] - NOTCH.powf(0.5)).abs() < 1e-5, "at once: {:?}", &touch[..4]);
assert!(touch[2..].iter().all(|f| *f == 1.0), "no tail: {touch:?}");
}
#[test]
@@ -696,7 +1225,7 @@ mod tests {
for s in [f32::NAN, f32::INFINITY, -f32::INFINITY, 1e30, -1e30] {
for alt in [false, true] {
for notch in [0.0, -1.0, f32::NAN, 40.0] {
for zd in [f32::NAN, 0.0, -1.0, f32::INFINITY, 1.0] {
for (zd, animated_zoom) in [f32::NAN, 0.0, -1.0, f32::INFINITY, 1.0].into_iter().flat_map(|zd| [(zd, false), (zd, true)]) {
let i = Input {
scroll: Vec2::new(0.0, s),
raw: Vec2::new(0.0, s),
@@ -705,6 +1234,7 @@ mod tests {
shift: false,
zoom_with_wheel: !alt,
pinch_zooms: true,
animated_zoom,
notch,
};
if let Some(Wheel::Zoom(f)) = classify(i) {
@@ -721,6 +1251,7 @@ mod tests {
shift: false,
zoom_with_wheel: true,
pinch_zooms: true,
animated_zoom,
notch,
}
};
@@ -749,7 +1280,7 @@ mod tests {
for n in 0..60 {
let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events: frames.next().unwrap_or_default(), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), zoom_with_wheel, true, owner, true) {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), zoom_with_wheel, true, true, owner, true) {
snaps += s;
}
});
@@ -854,10 +1385,10 @@ mod tests {
let raw =
RawInput { time: Some(0.0), predicted_dt: 1.0 / 60.0, events: vec![wheel_point(Vec2::new(0.0, 30.0), Modifiers::SHIFT)], ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_a, true) {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, true, owner_a, true) {
snaps_a += s;
}
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_b, false) {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, true, owner_b, false) {
snaps_b += s;
}
});
@@ -872,10 +1403,10 @@ mod tests {
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_a, false) {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, true, owner_a, false) {
snaps_a += s;
}
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_b, true) {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, true, owner_b, true) {
snaps_b += s;
}
});
@@ -890,8 +1421,8 @@ mod tests {
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| {
let _ = read(ui.ctx(), true, true, owner_a, false);
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_b, true) {
let _ = read(ui.ctx(), true, true, true, owner_a, false);
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, true, owner_b, true) {
snaps_b += s;
}
});
@@ -906,10 +1437,10 @@ mod tests {
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_a, true) {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, true, owner_a, true) {
snaps_a += s;
}
let _ = read(ui.ctx(), true, true, owner_b, false);
let _ = read(ui.ctx(), true, true, true, owner_b, false);
});
out.textures_delta.clear();
assert_eq!(snaps_a, 0, "A: fresh start after switch, 30 points, no snap yet");
@@ -924,10 +1455,10 @@ mod tests {
let raw =
RawInput { time: Some(0.0), predicted_dt: 1.0 / 60.0, events: vec![wheel_point(Vec2::new(0.0, 20.0), Modifiers::SHIFT)], ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner, true) {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, true, owner, true) {
total_snaps += s;
}
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner, true) {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, true, owner, true) {
total_snaps += s;
}
});
@@ -941,7 +1472,7 @@ mod tests {
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner, true) {
if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, true, owner, true) {
total_snaps += s;
}
});
@@ -992,7 +1523,7 @@ mod tests {
for (n, events) in (0u32..).zip(frames) {
let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
if let Some(Wheel::Zoom(f)) = read_with(ui.ctx(), false, true, owner, true, true, touch_gestures) {
if let Some(Wheel::Zoom(f)) = read_with(ui.ctx(), false, true, true, owner, true, true, touch_gestures) {
zoom *= f;
}
});