monitors: zoomed time ruler follows the playhead while playing

While the Program or Source monitor plays and its time ruler is zoomed
in, the zoom range follows the playhead the same way the Timeline does,
honouring Settings > Timeline > Timeline Playback Auto-Scrolling: page
scrolling moves the range so the playhead is near its left edge when it
runs off the right edge or jumps before the left one; smooth scrolling
keeps the playhead in the middle; no scrolling leaves the range alone.
The range keeps its width. Scrubbing, clicking and moving the playhead
while stopped never move it.
This commit is contained in:
ReReddo committed 2026-10-11 11:35:47 +03:00
1 parent a17b4292fe
commit ab62994511
3 files changed
+89 -1

No files matched your search

+27
View File
@@ -431,6 +431,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
// ---- mini timeline / scrub bar
let bar = Rect::from_min_size(pos2(rect.min.x + 14.0, settings_y + 28.0 + source_drag_h), vec2(rect.width() - 28.0, 22.0));
let zoom_bar = Rect::from_min_size(pos2(bar.min.x, bar.max.y + 2.0), vec2(bar.width(), 13.0));
follow_playhead(app, which, origin, duration, time);
time_zoom_bar(app, ui, zoom_bar, which, origin, duration, rate);
mini_timeline(app, ui, bar, which, origin, time, duration, rate, mark_in, mark_out);
@@ -624,6 +625,32 @@ fn ruler_shown(zoom: Option<[f64; 2]>, origin: Tick, end: Tick) -> (Tick, Tick)
(if s > 0.0 { at(s) } else { origin }, if e < 1.0 { at(e) } else { end })
}
/// While the monitor plays, a zoomed time ruler follows the playhead `time` the way the Timeline
/// does (Settings ▸ Timeline ▸ Timeline Playback Auto-Scrolling): with page scrolling, a playhead
/// running off the right edge or jumping before the left one brings it back near the left edge;
/// with smooth scrolling it stays in the middle once it gets there. The range keeps its width.
fn follow_playhead(app: &mut FilmcraftApp, which: Which, origin: Tick, end: Tick, time: Tick) {
let playing = if which == Which::Program { app.playback.playing } else { app.source_playback.clock.playing };
let mode = app.session.prefs.timeline.auto_scroll.as_str();
let zoom = monitor_view::view(app, which).time_zoom;
let len = end.0.saturating_sub(origin.0);
if !playing || mode == "noScroll" || zoom.is_none() || len <= 0 {
return;
}
let (s, e) = time_zoom_range(zoom);
let span = e - s;
let f = time.0.saturating_sub(origin.0) as f64 / len as f64;
let across = (f - s) / span;
let start = if mode == "smoothScroll" {
(!(0.0..=0.5).contains(&across)).then_some(f - span * 0.5)
} else {
(!(0.0..=0.95).contains(&across)).then_some(f - span * 0.05)
};
if let Some(ns) = start.map(|ns| ns.clamp(0.0, 1.0 - span)) {
monitor_view::view_mut(app, which).time_zoom = Some([ns, (ns + span).min(1.0)]);
}
}
/// The zoom scroll bar under the time ruler. Its thumb is the part of the clip or sequence
/// (`origin..end`) the ruler shows: drag a handle at either end to zoom, the middle to scroll, or
/// press beside the thumb to bring it there. A thumb filling the bar shows all of it.
+58
View File
@@ -112,6 +112,15 @@ fn first_media_item(v: &Value) -> Option<u64> {
}
}
fn item_duration(v: &Value, item: u64) -> Option<f64> {
match v {
Value::Object(m) if m.get("item").and_then(Value::as_u64) == Some(item) && m.contains_key("duration") => m["duration"].as_f64(),
Value::Object(m) => m.values().find_map(|v| item_duration(v, item)),
Value::Array(a) => a.iter().find_map(|v| item_duration(v, item)),
_ => None,
}
}
#[test]
fn view_menu_resolutions_and_display_modes() {
let mut d = Driver::demo();
@@ -285,6 +294,55 @@ fn time_zoom_bar_zooms_and_scrolls_the_monitor_ruler() {
assert!((d.rect("program.zoomBar")[2] - track[2]).abs() < 0.5);
}
/// While a monitor plays, its zoomed ruler pages to keep the playhead in view, keeping its width,
/// as the Timeline follows the playhead; stopped, or with auto-scrolling off, it stays put.
#[test]
fn zoomed_monitor_ruler_follows_the_playhead_while_playing() {
let mut d = Driver::demo();
let duration = num(&d.exec("sequence.inspect", json!({}))["duration"]);
d.ok("ui.set", json!({"program": {"time_zoom": [0.0, 0.25]}}));
// a playhead moved past the range while stopped leaves it alone
d.exec("playhead.set", json!({"time": (duration * 0.6) as i64}));
d.frames(2);
assert_eq!(d.program()["time_zoom"], json!([0.0, 0.25]));
// playing, the range pages forward: the playhead near its left edge
d.menu("playback.toggle");
d.menu("playback.toggle");
let z = d.program()["time_zoom"].clone();
let f = num(&d.exec("sequence.inspect", json!({}))["playhead"]) / duration;
let (s, e) = (num(&z[0]), num(&z[1]));
assert!(s <= f && f <= e && (e - s - 0.25).abs() < 1e-9, "playhead at {f} in {z}");
assert!((f - s - 0.25 * 0.05).abs() < 0.01, "paged to near the left edge: {f} in {z}");
// and back when the playhead is before it
d.exec("playhead.set", json!({"time": (duration * 0.1) as i64}));
d.menu("playback.toggle");
d.menu("playback.toggle");
let z = d.program()["time_zoom"].clone();
assert!(num(&z[0]) < 0.1 && num(&z[1]) > 0.1, "{z}");
// with auto-scrolling off the range stays put
d.exec("prefs.set", json!({"key": "timeline.autoScroll", "value": "noScroll"}));
d.ok("ui.set", json!({"program": {"time_zoom": [0.0, 0.25]}}));
d.exec("playhead.set", json!({"time": (duration * 0.6) as i64}));
d.menu("playback.toggle");
d.menu("playback.toggle");
assert_eq!(d.program()["time_zoom"], json!([0.0, 0.25]));
d.exec("prefs.set", json!({"key": "timeline.autoScroll", "value": "pageScroll"}));
// the Source monitor follows its own playback
let project = d.exec("project.inspect", json!({}));
let item = first_media_item(&project).expect("media item");
let source_duration = item_duration(&project, item).expect("media duration");
d.exec("source.open", json!({"item": item}));
d.ok("ui.set", json!({"source": {"time_zoom": [0.0, 0.25]}}));
d.exec("source.setPlayhead", json!({"time": (source_duration * 0.6) as i64}));
d.ok("ui.menu.invoke", json!({"id": "playback.toggle", "params": {"monitor": "source"}}));
d.frames(2);
d.ok("ui.menu.invoke", json!({"id": "playback.toggle", "params": {"monitor": "source"}}));
let z = d.ok("ui.inspect", json!({}))["ui"]["source"]["time_zoom"].clone();
assert!(num(&z[0]) > 0.5 && (num(&z[1]) - num(&z[0]) - 0.25).abs() < 1e-9, "{z}");
}
#[test]
fn magnification_pans_and_guides_from_rulers() {
let mut d = Driver::demo();
+4 -1
View File
@@ -131,7 +131,10 @@ the clip or sequence the ruler shows: drag a handle at either end to zoom in or
middle to scroll, or press beside the thumb to move it there. A thumb filling the bar shows the
whole duration; the ruler zooms in as far as five frames. The playhead, markers, In/Out and ticks
follow the part shown, and clicking or scrubbing the ruler sets a time within it; what lies
outside it is not drawn. Each monitor keeps its own range in its view state (`time_zoom`: start
outside it is not drawn. While the monitor plays, a zoomed ruler follows the playhead as the
Timeline does (Settings ▸ Timeline ▸ Timeline Playback Auto-Scrolling): it pages when the playhead
runs off the right edge or jumps before the left one, or keeps the playhead in the middle with
smooth scrolling, and keeps its width; scrubbing and clicking never move it. Each monitor keeps its own range in its view state (`time_zoom`: start
and end as fractions of the duration, `null` for all of it). Automation ids: `<monitor>.zoomBar`
(the thumb) and `<monitor>.zoomBar.track`.