collective

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

diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs--- a/crates/engine/src/settings.rs+++ b/crates/engine/src/settings.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(),diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs--- a/crates/ui-egui/src/panels/timeline.rs+++ b/crates/ui-egui/src/panels/timeline.rs@@ -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 beforediff --git a/crates/ui-egui/src/panels/timeline_hit.rs b/crates/ui-egui/src/panels/timeline_hit.rs--- a/crates/ui-egui/src/panels/timeline_hit.rs+++ b/crates/ui-egui/src/panels/timeline_hit.rs@@ -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)     }diff --git a/crates/ui-egui/tests/it/timeline_scroll_ui.rs b/crates/ui-egui/tests/it/timeline_scroll_ui.rs--- a/crates/ui-egui/tests/it/timeline_scroll_ui.rs+++ b/crates/ui-egui/tests/it/timeline_scroll_ui.rs@@ -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]