diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 5e81bd68..df680410 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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) diff --git a/crates/ui-egui/tests/ui_time_zoom_controls.rs b/crates/ui-egui/tests/ui_time_zoom_controls.rs index 4dfc8fa6..4ab725d6 100644 --- a/crates/ui-egui/tests/ui_time_zoom_controls.rs +++ b/crates/ui-egui/tests/ui_time_zoom_controls.rs @@ -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}"); +}