From 85d197e0b9de91087617e72a029ff66bb4bf57ca Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Fri, 30 Oct 2026 16:07:37 +1100 Subject: [PATCH] Make wheel frame-stepping an opt-in that consumes the smoothed tail. Main routes viewer scrolling through viewer_scroll, so a raw-event scrub fought the wheel zoom. With general.viewerWheelScrubbing on, an unmodified wheel steps frames and the rest of that notch does not zoom or pan. The preference stays off, so the wheel still zooms. --- crates/engine/src/settings/prefs/dialog.rs | 1 + crates/engine/src/settings/prefs/sections.rs | 3 + crates/ui-egui/src/panels.rs | 36 ++++++++++-- crates/ui-egui/src/panels/viewer.rs | 33 ++++++++++- crates/ui-egui/tests/it/ui_viewer.rs | 59 ++++++++++++++++++++ docs/preferences.md | 1 + 6 files changed, 125 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index d330ce190..c4ff1d546 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -87,6 +87,7 @@ pub fn pages() -> Vec { s("general.recentItems", "Recent Projects Shown", Kind::Int(1, 30, ""), true), Section("Options"), s("general.showToolTips", "Show Tool Tips", B, true), + s("general.viewerWheelScrubbing", "Scrub Composition Frames with Mouse Wheel", B, true), s("general.createLayersAtCompStart", "Create Layers at Composition Start Time", B, true), s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, true), s("general.defaultSpatialLinear", "Default Spatial Interpolation to Linear", B, true), diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index 4cba41851..ac2fab1cc 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -31,6 +31,9 @@ page!(General { /// Path Point and Handle Size (px). path_point_size: u32 = 5, show_tool_tips: bool = true, + /// Step frames with an unmodified mouse wheel over the Composition viewer. + /// Off: the wheel zooms, which is what current After Effects does. + viewer_wheel_scrubbing: bool = false, /// Create New Layers at Composition Start Time (off: at the current time). create_layers_at_comp_start: bool = true, switches_affect_nested_comps: bool = true, diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index f6a5b5321..47e36315a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -184,6 +184,13 @@ pub(crate) struct ViewerScroll { /// Factor to zoom by around the pointer: a pinch, Ctrl/Cmd+scroll, a mouse wheel, or /// Alt + a trackpad scroll. pub zoom: f32, + /// The gesture came from a trackpad (or is still the smoothed tail of one). + pub trackpad: bool, + /// Unmodified mouse-wheel lines this frame. Zero for a trackpad, a point-unit wheel, or + /// Ctrl/Cmd/Alt+wheel. + pub wheel_lines: f32, + /// This frame has a Ctrl/Cmd/Alt mouse-wheel event, which keeps zooming. + pub wheel_modified: bool, } /// Splits the frame's scroll input into a viewer pan and a zoom: a mouse wheel zooms and a @@ -218,13 +225,32 @@ pub(crate) fn viewer_scroll(ctx: &egui::Context) -> ViewerScroll { let (scroll, pinch, alt) = ctx.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers.alt)); let pinch = if pinch.is_finite() && pinch > 0.0 { pinch } else { 1.0 }; let scroll = if scroll.x.is_finite() && scroll.y.is_finite() { scroll } else { egui::Vec2::ZERO }; - if trackpad && !alt { - ViewerScroll { pan: scroll, zoom: pinch } + let (wheel_lines, wheel_modified) = ctx.input(|i| { + let mut lines = 0.0; + let mut modified = false; + if !trackpad { + for event in &i.events { + if let egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta, modifiers, source, .. } = event + && !matches!(source, egui::MouseWheelSource::Trackpad | egui::MouseWheelSource::Momentum) + { + if modifiers.command || modifiers.ctrl || modifiers.alt { + modified = true; + } else { + lines += delta.y; + } + } + } + } + (lines, modified) + }); + let (pan, zoom) = if trackpad && !alt { + (scroll, pinch) } else if alt { - ViewerScroll { pan: egui::Vec2::ZERO, zoom: pinch * (scroll.y / 300.0).exp() } + (egui::Vec2::ZERO, pinch * (scroll.y / 300.0).exp()) } else { - ViewerScroll { pan: egui::vec2(scroll.x, 0.0), zoom: pinch * (scroll.y / 300.0).exp() } - } + (egui::vec2(scroll.x, 0.0), pinch * (scroll.y / 300.0).exp()) + }; + ViewerScroll { pan, zoom, trackpad, wheel_lines, wheel_modified } } pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index d084dd006..86a3cbe64 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -870,6 +870,24 @@ fn phase_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &m resp } +/// Frames to step when wheel scrubbing is on, or `None` when this gesture should zoom or pan. +/// A scrub notch's smoothed tail stays a scrub after the wheel event itself is gone, so that +/// tail does not zoom or pan (#1218). +fn wheel_scrub_frames(ctx: &egui::Context, pref: bool, scroll: &super::ViewerScroll) -> Option { + let tail_id = egui::Id::unique("viewer-wheel-scrub-tail"); + let was_tail: bool = ctx.data(|d| d.get_temp(tail_id)).unwrap_or(false); + let moving = scroll.pan != egui::Vec2::ZERO || (scroll.zoom - 1.0).abs() > 3e-4; + let scrub = pref && !scroll.trackpad && !scroll.wheel_modified && (scroll.wheel_lines.abs() >= 0.01 || (was_tail && moving)); + ctx.data_mut(|d| { + if scrub { + d.insert_temp(tail_id, true); + } else { + d.remove::(tail_id); + } + }); + scrub.then_some((scroll.wheel_lines.round() as i64).clamp(-30, 30)) +} + fn phase_hover(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) { let area = v.area; let zoom = v.zoom; @@ -951,12 +969,21 @@ fn phase_hover(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp }; ctx.set_cursor_icon(cursor); // The mouse wheel and a trackpad pinch zoom around the pointer; a two-finger trackpad - // scroll pans. + // scroll pans. With general.viewerWheelScrubbing, an unmodified wheel steps frames + // instead, including the smoothed tail of that notch so it does not zoom or pan (#1218). let scroll = super::viewer_scroll(&ctx); - if scroll.pan != egui::Vec2::ZERO && area.contains(hp) { + let scrub = wheel_scrub_frames(&ctx, app.session.prefs.general.viewer_wheel_scrubbing, &scroll); + if let Some(frames) = scrub { + if frames != 0 && area.contains(hp) { + if app.playback.playing { + let _ = crate::menus::invoke(app, &ctx, "playback.stop", json!({})); + } + let _ = app.session.execute("time.step", json!({"frames": frames})); + } + } else if scroll.pan != egui::Vec2::ZERO && area.contains(hp) { app.ui.viewer.pan = [app.ui.viewer.pan[0] + scroll.pan.x, app.ui.viewer.pan[1] + scroll.pan.y]; } - if (scroll.zoom - 1.0).abs() > 3e-4 && area.contains(hp) { + if scrub.is_none() && (scroll.zoom - 1.0).abs() > 3e-4 && area.contains(hp) { let nz = (zoom * scroll.zoom).clamp(0.01, 32.0); let before = map.to_comp(hp); let new_origin = hp - vec2(before[0] as f32 * nz, before[1] as f32 * nz); diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index 48bc36a44..e5f3590e6 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -55,6 +55,65 @@ fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { } /// Comp pixel → screen point. +/// #1218: frame stepping is optional, and consumes the whole wheel gesture including its +/// smoothed tail. Ctrl/Cmd+wheel still zooms. +#[test] +fn mouse_wheel_over_the_monitor_scrubs_frames() { + let mut h = harness(); + h.state_mut().session.execute("prefs.set", json!({"key": "general.viewerWheelScrubbing", "value": true})).unwrap(); + let p = rect(&h, "viewer.area").center(); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + let t0 = h.state().session.time(); + let line = |dy: f32, modifiers: egui::Modifiers| Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, dy), + modifiers, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + }; + let z0 = h.state().ui.viewer.zoom; + h.input_mut().events.push(line(1.0, egui::Modifiers::NONE)); + h.step(); + let t1 = h.state().session.time(); + for _ in 0..20 { + assert_eq!(h.state().ui.viewer.zoom, z0, "frame wheel must never zoom"); + h.step(); + } + assert!(t1 > t0, "wheel up advanced the playhead ({t0:?} -> {t1:?})"); + h.input_mut().events.push(line(-2.0, egui::Modifiers::NONE)); + h.step(); + assert!(h.state().session.time() < t1, "two lines back moves before the single line forward"); + let z0 = h.state().ui.viewer.zoom; + let t2 = h.state().session.time(); + let mods = egui::Modifiers { ctrl: true, command: true, ..Default::default() }; + h.input_mut().events.push(line(3.0, mods)); + h.step(); + assert_eq!(h.state().session.time(), t2, "Ctrl+wheel must not scrub"); + assert_ne!(h.state().ui.viewer.zoom, z0, "Ctrl+wheel still zooms"); +} + +#[test] +fn mouse_wheel_zooms_by_default_without_stepping_frames() { + let mut h = harness(); + assert!(!h.state().session.prefs.general.viewer_wheel_scrubbing); + let p = rect(&h, "viewer.area").center(); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + let t0 = h.state().session.time(); + let z0 = h.state().ui.viewer.zoom; + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + }); + h.run_steps(20); + assert_eq!(h.state().session.time(), t0); + assert_ne!(h.state().ui.viewer.zoom, z0); +} + fn screen(h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> Pos2 { let c = rect(h, "viewer.comp"); let z = c.width() / 640.0; diff --git a/docs/preferences.md b/docs/preferences.md index 6d692a936..2b80ffa0f 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -35,6 +35,7 @@ Commands (CLI, MCP, control channel): - `general.pathPointSize`: Path Point and Handle Size - `general.recentItems`: Recent Projects Shown - `general.showToolTips`: Show Tool Tips +- `general.viewerWheelScrubbing`: Scrub Composition Frames with Mouse Wheel (off by default). On: an unmodified mouse wheel over the Composition viewer steps frames, and the smoothed tail of that notch does not zoom or pan. Ctrl/Cmd+wheel and a trackpad still zoom and pan. - `general.createLayersAtCompStart`: Create Layers at Composition Start Time - `general.defaultSpatialLinear`: Default Spatial Interpolation to Linear - `startup.showHomeOnLaunch`: Show Home Screen When Launching