Browse / github.com/storytold/filmcraft / patch
Timeline: the scroll wheel works as in Premiere on the zoom bar, the track headers and Windows
Timeline: the scroll wheel works as in Premiere on the zoom bar, the track headers and Windows ## Problem The Timeline's wheel differs from Premiere Pro in three places: - **Timeline Mouse Scrolling default.** Adobe's Timeline preferences page says: "By default, mouse scrolling is Horizontal for Windows and Vertical for macOS. Press the Ctrl key to switch to Vertical scrolling on Windows." FilmCraft defaulted to Vertical everywhere. - **Wheel over the zoom scroll bar.** Adobe's *Navigation controls in the timeline* says: "Expanding and contracting the bar is centered on the playhead. By positioning the mouse over the bar, you can scroll the mouse wheel to expand and contract the bar."
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- punkhop (imported from pull request #3333, open)
- Base
de529c88f4948d0803bef746a325707f2671a71d- Size
- +165 −23 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
cc3b7f15f51fafeed0a234fe
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/settings.rs crates/ui-egui/src/panels/timeline.rs crates/ui-egui/src/panels/timeline_hit.rs crates/ui-egui/tests/it/timeline_scroll_ui.rs
@@ -714,7 +714,7 @@ pub struct TimelinePrefs { pub still_image_unit: String, /// "Timeline Playback Auto-Scrolling": `noScroll` | `pageScroll` | `smoothScroll`. pub auto_scroll: String,- /// "Timeline Mouse Scrolling": `vertical` | `horizontal`.+ /// "Timeline Mouse Scrolling": `vertical` (the default on macOS) | `horizontal` (elsewhere, as Premiere on Windows). pub mouse_scrolling: String, /// Default Audio Tracks per media type: `useFile` | `mono` | `stereo` | `5.1` | `adaptive`. pub mono_media_tracks: String,@@ -744,7 +744,8 @@ impl Default for TimelinePrefs { still_image_duration: 5.0, still_image_unit: "seconds".into(), auto_scroll: "pageScroll".into(),- mouse_scrolling: "vertical".into(),+ // Premiere's defaults: Vertical on macOS, Horizontal on Windows (Ctrl + wheel then scrolls the tracks)+ mouse_scrolling: if cfg!(target_os = "macos") { "vertical" } else { "horizontal" }.into(), mono_media_tracks: "useFile".into(), stereo_media_tracks: "useFile".into(), surround_media_tracks: "useFile".into(),@@ -29,6 +29,11 @@ pub use super::timeline_hit::{Hit, ROLL_PX, hit, selection_trim_kind}; const TOP_H: f32 = 58.0; // timecode + toolbar (left) / ruler (right) const RULER_H: f32 = 44.0; const SCROLLBAR_H: f32 = 17.0;+/// Track height gained per point of Shift or Option + wheel over a track header (a wheel notch is 40 points).+const TRACK_H_PER_WHEEL_POINT: f32 = 0.25;+/// The shortest and tallest a track gets by hand (dragging its header edge, or the wheel).+const TRACK_H_MIN: f32 = 22.0;+const TRACK_H_MAX: f32 = 220.0; const DIVIDER_H: f32 = 5.0; const MASTER_H: f32 = 34.0; /// The selected gap between two clips (#648, #668).@@ -277,6 +282,9 @@ pub struct Layout { pub scroll: f64, /// The y of the divider between the video tracks (above) and the audio tracks (below). pub split_y: f32,+ /// Where the video and the audio rows show (a row scrolled out of view lies outside its span).+ pub video_band: egui::Rangef,+ pub audio_band: egui::Rangef, } impl Layout {@@ -524,7 +532,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let rect = Rect::from_min_max(pos2(content.min.x, top), pos2(content.max.x, top + ah)); rows.push(Row { track: tr.id, kind: TrackKind::Audio, index: i, rect, lane: app.ui.timeline.track_lanes.contains_key(&tr.id.0) }); }- let layout = Layout { content, ruler, rows: rows.clone(), pps, scroll, split_y };+ let layout = Layout { content, ruler, rows: rows.clone(), pps, scroll, split_y, video_band: video_area.y_range(), audio_band: audio_area.y_range() }; app.tl.layout = Some(layout.clone()); // ---- backgrounds@@ -781,13 +789,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } // ---- scroll bars- zoom_scrollbar(- app,- ui,- Rect::from_min_max(pos2(content.min.x, rect.max.y - SCROLLBAR_H + 2.0), pos2(content.max.x, rect.max.y - 2.0)),- seq.duration().seconds(),- &t,- );+ zoom_scrollbar(app, ui, zoom_bar_rect(rect, content), seq.duration().seconds(), &t); vertical_scrollbar( ui, Rect::from_min_max(pos2(content.max.x, video_area.min.y), pos2(rect.max.x, video_area.max.y)),@@ -1565,7 +1567,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: if eresp.dragged() { let d = eresp.drag_delta().y * if r.kind == TrackKind::Video { -1.0 } else { 1.0 }; let h = if r.kind == TrackKind::Video { &mut app.ui.timeline.video_track_h } else { &mut app.ui.timeline.audio_track_h };- *h = (*h + d).clamp(22.0, 220.0);+ *h = (*h + d).clamp(TRACK_H_MIN, TRACK_H_MAX); } } let resp = ui.interact(@@ -1832,6 +1834,11 @@ fn zoom_thumb(bar: Rect, seq_seconds: f64, scroll: f64, pps: f64) -> (f32, f32, (bar.min.x + at, w, if travel > 0.5 { range / travel as f64 } else { 0.0 }) } +/// The zoom scroll bar along the bottom of the Timeline, under the tracks.+fn zoom_bar_rect(rect: Rect, content: Rect) -> Rect {+ Rect::from_min_max(pos2(content.min.x, rect.max.y - SCROLLBAR_H + 2.0), pos2(content.max.x, rect.max.y - 2.0))+}+ /// Draw the zoom scroll bar: the thumb reflects the visible range, a press beside it centres the thumb under the pointer, and the thumb and track are /// registered for automation. fn zoom_scrollbar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, seq_seconds: f64, t: &Tokens) {@@ -2458,22 +2465,57 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & app.tl.pressed = Some(Pressed { pos: p, tick: layout.tick_at(p.x), grab }); } - // ---- wheel, as in Premiere Pro on macOS (checked in 26.5.2):- // wheel the tracks under the pointer, up and down- // Cmd + wheel the Timeline, sideways- // Option + wheel zoom about the pointer- // Settings ▸ Timeline ▸ Timeline Mouse Scrolling "Horizontal" swaps the first two. A sideways- // gesture (trackpad swipe, tilt wheel, Shift + wheel) always scrolls sideways; a pinch zooms.+ // ---- wheel, as in Premiere Pro (macOS checked in 26.5.2):+ // wheel the tracks under the pointer, up and down+ // Cmd (Ctrl) + wheel the Timeline, sideways+ // Option (Alt) + wheel zoom about the pointer+ // wheel over the zoom scroll bar zoom about the playhead+ // Shift or Option + wheel over a header that kind of track taller or shorter+ // Settings ▸ Timeline ▸ Timeline Mouse Scrolling "Horizontal" swaps the first two; it is+ // Premiere's default on Windows (and ours off macOS). A sideways gesture (trackpad swipe, tilt+ // wheel, Shift + wheel over the tracks) always scrolls sideways; a pinch zooms. if ui.rect_contains_pointer(rect) && let Some(p) = ctx.pointer_hover_pos() { let w = wheel_input(&ctx);- if (w.pinch - 1.0).abs() > 1e-4 || (w.alt && w.delta.y != 0.0) {- let f = if (w.pinch - 1.0).abs() > 1e-4 { w.pinch as f64 } else { (1.0_f64 + w.delta.y as f64 * 0.01).clamp(0.5, 2.0) };- let anchor_t = layout.tick_at(p.x).seconds();+ let pinch = (w.pinch - 1.0).abs() > 1e-4;+ // macOS turns Shift + wheel into a sideways wheel before anyone sees it: take either axis+ let notch = if w.delta.y.abs() >= w.delta.x.abs() { w.delta.y } else { w.delta.x };+ let on_zoom_bar = zoom_bar_rect(rect, layout.content).contains(p) && notch != 0.0 && (w.delta.y.abs() >= w.delta.x.abs() || w.shift);+ let resizing = p.x < layout.content.min.x && (w.shift || w.alt) && !pinch;+ if resizing {+ // only a row that shows: one scrolled out of view lies under the captions, the Mix row or the divider+ let kind = if layout.video_band.contains(p.y) {+ Some(TrackKind::Video)+ } else if layout.audio_band.contains(p.y) {+ Some(TrackKind::Audio)+ } else {+ None+ };+ let row = layout.rows.iter().find(|r| Some(r.kind) == kind && r.rect.y_range().contains(p.y));+ // FilmCraft keeps one height for all video tracks and one for all audio tracks; over+ // header space with no track the wheel does nothing+ if let Some(r) = row {+ let h = if r.kind == TrackKind::Video { &mut app.ui.timeline.video_track_h } else { &mut app.ui.timeline.audio_track_h };+ *h = (*h + notch * TRACK_H_PER_WHEEL_POINT).clamp(TRACK_H_MIN, TRACK_H_MAX);+ }+ } else if pinch || (w.alt && w.delta.y != 0.0) || on_zoom_bar {+ let f = if pinch { w.pinch as f64 } else { (1.0_f64 + if on_zoom_bar { notch } else { w.delta.y } as f64 * 0.01).clamp(0.5, 2.0) };+ let anchor = if on_zoom_bar && !w.alt && !pinch {+ // about the playhead, or the middle of the view when the playhead is out of it+ let ph = app.session.playhead();+ let x = layout.x_of(ph);+ if layout.content.x_range().contains(x) {+ (ph.seconds(), x)+ } else {+ (layout.tick_at(layout.content.center().x).seconds(), layout.content.center().x)+ }+ } else {+ (layout.tick_at(p.x).seconds(), p.x)+ }; let v = &mut app.ui.timeline; v.target_pps = (v.target_pps * f).clamp(0.05, 24_000.0);- app.tl.zoom_anchor = Some((anchor_t, p.x));+ app.tl.zoom_anchor = Some(anchor); } else if w.delta != egui::Vec2::ZERO { let wheel_sideways = (app.session.prefs.timeline.mouse_scrolling == "horizontal") != w.command; let sideways = if w.delta.x.abs() > w.delta.y.abs() {@@ -3595,6 +3637,8 @@ mod new_track_tests { pps: 100.0, scroll: 0.0, split_y: 200.0,+ video_band: egui::Rangef::new(0.0, 200.0),+ audio_band: egui::Rangef::new(205.0, 500.0), }; let at = |y| new_track_at(&seq, &layout, y, 50.0, 50.0); assert_eq!(at(40.0), Some((TrackKind::Video, Rect::from_min_max(pos2(0.0, 100.0), pos2(800.0, 150.0)))));@@ -3635,6 +3679,8 @@ mod new_track_tests { pps: 100.0, scroll: 0.0, split_y: 150.0,+ video_band: egui::Rangef::new(0.0, 150.0),+ audio_band: egui::Rangef::new(155.0, 500.0), }; assert_eq!(new_track_at(&seq, &layout, 10.0, 50.0, 50.0), Some((TrackKind::Video, Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 50.0))))); // scrolled to the bottom, V1..V3 fill the area as before@@ -386,6 +386,8 @@ mod tests { pps: 100.0, scroll: 0.0, split_y: TOP + 60.0,+ video_band: row.y_range(),+ audio_band: egui::Rangef::new(TOP + 60.0, TOP + 60.0), }; (seq, layout, track) }@@ -1,7 +1,9 @@ //! Headless UI tests of scrolling the Timeline: what the wheel does with each modifier, how far //! the view can go, and the zoom scroll bar. Premiere Pro 26.5.2 on macOS, checked in the app: the //! wheel moves the tracks up and down, Cmd + wheel moves the Timeline sideways, Option + wheel-//! zooms, and the view stops ten minutes past the end of the sequence.+//! zooms, and the view stops ten minutes past the end of the sequence. Adobe's Timeline+//! preferences page: Timeline Mouse Scrolling is Vertical on macOS and Horizontal on Windows; its+//! Timeline navigation page: the wheel over the zoom scroll bar zooms, centred on the playhead. use std::sync::mpsc::{Sender, channel}; @@ -97,6 +99,8 @@ impl Driver { /// The demo sequence with enough tracks to scroll both halves, at a known zoom. fn tall() -> Driver { let mut d = Driver::demo();+ // the macOS default, whatever this runs on (the other is checked on its own below)+ d.app().session.prefs.timeline.mouse_scrolling = "vertical".into(); d.exec("sequence.addTracks", json!({"video": 12, "audio": 12})); d.ok("ui.set", json!({"timeline": {"pps": 50.0, "scroll": 4.0}})); d.frames(4);@@ -104,6 +108,18 @@ impl Driver { d } + /// Turn the wheel over a named element (a track header, the zoom scroll bar), with modifiers.+ fn wheel_on(&mut self, id: &str, dx: f64, dy: f64, modifiers: Value) {+ self.ok("ui.scroll", json!({"id": id, "dx": dx, "dy": dy, "modifiers": modifiers}));+ self.frames(3);+ }++ /// (video track height, audio track height).+ fn heights(&mut self) -> (f32, f32) {+ let v = &self.app().ui.timeline;+ (v.video_track_h, v.audio_track_h)+ }+ fn seconds(&mut self) -> f64 { self.app().session.active_sequence().unwrap().duration().seconds() }@@ -168,8 +184,9 @@ fn cmd_wheel_moves_the_timeline_sideways_and_option_wheel_zooms() { /// sideways and Cmd + wheel moves the tracks. #[test] fn the_mouse_scrolling_setting_swaps_wheel_and_cmd_wheel() {+ let want = if cfg!(target_os = "macos") { "vertical" } else { "horizontal" };+ assert_eq!(filmcraft_engine::settings::TimelinePrefs::default().mouse_scrolling, want, "Premiere's default: Vertical on macOS, Horizontal on Windows"); let mut d = Driver::tall();- assert_eq!(d.app().session.prefs.timeline.mouse_scrolling, "vertical", "Premiere's default on macOS"); d.app().session.prefs.timeline.mouse_scrolling = "horizontal".into(); d.wheel(0.2, 0.0, -100.0, none()); assert_eq!(d.view(), (6.0, 50.0, 0.0, 0.0));@@ -179,6 +196,82 @@ fn the_mouse_scrolling_setting_swaps_wheel_and_cmd_wheel() { assert!((d.view().1 - 60.0).abs() < 1e-6, "Option + wheel zooms either way"); } +/// Adobe: "By positioning the mouse over the bar, you can scroll the mouse wheel to expand and+/// contract the bar", and "expanding and contracting the bar is centered on the playhead".+#[test]+fn the_wheel_over_the_zoom_scroll_bar_zooms_about_the_playhead() {+ let mut d = Driver::tall();+ // the playhead a long way left of the bar's middle, where the pointer is+ d.exec("playhead.set", json!({"seconds": 6.0}));+ d.frames(3);+ let playhead_x = |scroll: f64, pps: f64| (6.0 - scroll) * pps;+ let before = playhead_x(4.0, 50.0);+ d.wheel_on("timeline.zoomBar.track", 0.0, 20.0, none());+ let (scroll, pps, v, a) = d.view();+ assert!((pps - 60.0).abs() < 1e-6, "zoomed in by a fifth: {pps}");+ assert!((playhead_x(scroll, pps) - before).abs() < 0.5, "the playhead stays where it was: {scroll}");+ assert_eq!((v, a), (0.0, 0.0), "the tracks did not move");+ d.wheel_on("timeline.zoomBar.track", 0.0, -20.0, none());+ let (scroll, pps, ..) = d.view();+ assert!((pps - 48.0).abs() < 1e-6, "and out again: {pps}");+ assert!((playhead_x(scroll, pps) - before).abs() < 0.5);+ // the mouse scrolling setting and Cmd change nothing here+ d.app().session.prefs.timeline.mouse_scrolling = "horizontal".into();+ d.wheel_on("timeline.zoomBar.track", 0.0, 20.0, json!({"command": true}));+ assert!((d.view().1 - 57.6).abs() < 1e-6);+ // with the playhead out of view, about the middle of the view+ d.exec("playhead.set", json!({"seconds": 600.0}));+ d.frames(3);+ d.ok("ui.set", json!({"timeline": {"pps": 50.0, "scroll": 4.0}}));+ d.frames(3);+ let width = d.rect("timeline.tracks")[2] as f64;+ let middle = 4.0 + width / 2.0 / 50.0;+ // the pointer near the bar's left end, well away from the middle+ let bar = d.rect("timeline.zoomBar.track");+ d.ok("ui.scroll", json!({"x": bar[0] + 20.0, "y": bar[1] + bar[3] / 2.0, "dx": 0.0, "dy": 20.0}));+ d.frames(3);+ let (scroll, pps, ..) = d.view();+ assert!((scroll + width / 2.0 / pps - middle).abs() < 0.02, "the middle stays: {scroll}");+}++/// Premiere: Shift or Option + wheel over the track headers makes the tracks taller or shorter+/// (FilmCraft has one height for the video tracks and one for the audio tracks); over the tracks+/// themselves Shift + wheel still scrolls sideways and Option + wheel still zooms.+#[test]+fn shift_or_option_wheel_over_the_track_headers_changes_the_track_height() {+ let mut d = Driver::tall();+ let (vh, ah) = d.heights();+ d.wheel_on("timeline.track.V1.target", 0.0, 40.0, json!({"shift": true}));+ assert_eq!(d.heights(), (vh + 10.0, ah), "a notch up: the video tracks 10 points taller");+ assert_eq!(d.view(), (4.0, 50.0, 0.0, 0.0), "nothing scrolled or zoomed");+ // macOS hands Shift + wheel over as a sideways wheel+ d.wheel_on("timeline.track.V1.target", -40.0, 0.0, json!({"shift": true}));+ assert_eq!(d.heights(), (vh, ah));+ d.wheel_on("timeline.track.A1.target", 0.0, -40.0, json!({"alt": true}));+ assert_eq!(d.heights(), (vh, ah - 10.0), "Option: the audio tracks 10 points shorter");+ assert_eq!(d.view(), (4.0, 50.0, 0.0, 0.0));+ // no shorter than the smallest a drag allows, no taller than the largest+ d.wheel_on("timeline.track.A1.target", 0.0, -1e5, json!({"alt": true}));+ assert_eq!(d.heights().1, 22.0);+ d.wheel_on("timeline.track.V1.target", 0.0, 1e5, json!({"shift": true}));+ assert_eq!(d.heights().0, 220.0);+ // over header space where no track shows (the timecode, with tracks scrolled out of view above+ // it; the Mix row, with tracks out of view below it) nothing changes+ let at = d.heights();+ let tc = d.rect("timeline.timecode");+ let tracks = d.rect("timeline.tracks");+ for (x, y) in [(tc[0] + 10.0, tc[1] + tc[3] / 2.0), (tc[0] + 10.0, tracks[1] + tracks[3] - 17.0)] {+ for m in [json!({"shift": true}), json!({"alt": true})] {+ d.ok("ui.scroll", json!({"x": x, "y": y, "dx": 0.0, "dy": -40.0, "modifiers": m}));+ d.frames(3);+ assert_eq!(d.heights(), at, "at {x},{y} with {m}");+ }+ }+ // the plain wheel over a header still moves the tracks up and down+ d.wheel_on("timeline.track.V1.target", 0.0, 40.0, none());+ assert_eq!(d.view().2, 40.0);+}+ /// The view cannot leave the Timeline: scrolled 39 minutes past a 26 second sequence, nothing was /// in view and the scroll bar's thumb sat at the right end whatever was done to it. #[test]