ui: timeline view stops at the comp end; a ruler scrub past the edge scrolls with the CTI

Co-Authored-By: Claude <noreply@anthropic.com>

(cherry picked from commit 562bfa51bd6ee86d1f8f5558b1ad466c7e4b4517)

(cherry picked from commit 0779d6944ede8afdff6ab49ea5d2caad19dfbfa3)
(cherry picked from commit 38e224f5c5037b7c79a283ab058582a3ad200339)

(cherry picked from commit 5e7b68be23ace400d92672ebf41c59124d29b101)
This commit is contained in:
Elias 2026-10-10 09:47:05 +01:00 committed by Elias
parent c4fdbd6064
commit 520ea11059
2 changed files with 42 additions and 0 deletions

View File

@ -1305,6 +1305,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let span = ((graph_x1 - graph_x0 - 6.0) as f64 / pps).max(0.0);
app.ui.timeline.start = page_to_cti(app.ui.timeline.start, span, comp.duration.seconds(), app.session.time().seconds());
}
// As in After Effects, the ruler never scrolls past the comp's end: zoomed in, the last
// visible time is at most the comp duration.
let visible_secs = ((graph_x1 - graph_x0 - 6.0).max(1.0) as f64 / pps.max(1e-6)).max(0.0);
let max_start = (comp.duration.seconds() - visible_secs).max(0.0);
app.ui.timeline.start = app.ui.timeline.start.clamp(0.0, max_start);
let tm = TMap { x0: graph_x0 + 6.0, start: app.ui.timeline.start, pps };
ctx.data_mut(|d| d.insert_temp(tl_map_id(), (tm.x0, tm.start, tm.pps)));
let top = rect.min.y;
@ -1591,6 +1596,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let tt = Tick::from_seconds_f64(secs);
app.session.set_time(tt);
app.stop();
// Dragged past either edge of the ruler (over the layer outline, say), the view scrolls so
// the current time indicator stays in sight (After Effects).
if app.ui.timeline.pps.is_some() {
let now = app.session.time().seconds();
let span = tm.t(bin.min.x) - tm.start;
if now < tm.start {
app.ui.timeline.start = now.max(0.0);
} else if now > tm.start + span {
app.ui.timeline.start = (now - span).max(0.0);
}
}
// Ctrl/Cmd-drag scrubs the audio too (After Effects).
if rresp.dragged()
&& ui.input(|i| i.modifiers.command)

View File

@ -169,3 +169,29 @@ fn azerty_physical_zoom_keys_zoom_the_graph() {
let (_, span2) = view(&h);
assert!(span2 > span1 * 1.2, "Physical+Minus zooms the graph out: {span1} -> {span2}");
}
#[test]
fn zoomed_in_the_ruler_stops_at_the_comp_end_and_a_scrub_past_the_edge_scrolls() {
let mut h = harness(false);
for _ in 0..4 {
send(&mut h, "ui.key", json!({"key": "="}));
}
// Scrolled as far right as it goes: the view ends at the comp's end, not past it (AE).
h.state_mut().ui.timeline.start = 9.9;
h.run_steps(3);
let (start, span) = view(&h);
assert!(span < 9.0, "zoomed in: {span}");
assert!((start + span - 10.0).abs() < 0.05, "the view ends at 10 s: {start} + {span}");
// A click at the ruler's right end puts the CTI there.
let ruler = h.state().auto.find("timeline.ruler").unwrap().rect;
let y = ruler[1] + ruler[3] - 4.0;
send(&mut h, "ui.click", json!({"x": ruler[0] + ruler[2] - 26.0, "y": y}));
let t = h.state().session.time().seconds();
assert!(t > start + span * 0.9, "the CTI jumps to the click near the end: {t}");
// Dragged from the ruler out over the layer outline, the view scrolls with the CTI.
send(&mut h, "ui.drag", json!({"from": {"x": ruler[0] + 40.0, "y": y}, "to": {"x": ruler[0] - 200.0, "y": y}, "steps": 20}));
let (start2, span2) = view(&h);
let t2 = h.state().session.time().seconds();
assert!(t2 < start, "the CTI moved left of the old view: {t2} vs {start}");
assert!(t2 >= start2 - 1e-6 && t2 <= start2 + span2, "the CTI stays in view: {t2} in {start2}+{span2}");
}