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.
This commit is contained in:
Vikram Bhaskaran committed 2026-10-30 16:07:37 +11:00
1 parent e56c916085
commit 85d197e0b9
6 files changed
+125 -8

No files matched your search

@@ -87,6 +87,7 @@ pub fn pages() -> Vec<Page> {
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),
@@ -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,
+31 -5
View File
@@ -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) {
+30 -3
View File
@@ -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<i64> {
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::<bool>(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);
+59
View File
@@ -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;
+1
View File
@@ -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