Timeline: the view follows the CTI past its edges when scrubbing and during playback (#468)

The ruler scrub set the time under the pointer and never moved the visible span, so with
the timeline zoomed in a scrub couldn't go past the edge, and a preview ran the CTI out of
view. As in After Effects:

- zoomed in, a scrub held past either end of the ruler scrolls the timeline that way,
  faster the further past, with the CTI riding the edge (repainting while held);
- during playback, when the CTI leaves the visible span the view pages to start at it
  (not past the comp's end).

Tests: a unit test of the paging and edge speed, and a kittest that holds a scrub past the
right end (the view scrolls, the CTI stays at the edge), then past the left end, then plays
from beyond the view (it pages to the CTI). (#468's Shift+F and black-layer items need
more information and aren't part of this.)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
Claude committed 2026-10-10 19:54:17 +00:00
1 parent 7b38d4d74e
commit 3effa9a2ea
2 files changed
+124 -1

No files matched your search

+59 -1
View File
@@ -212,6 +212,29 @@ fn edge_scroll_speed(rows: Rect, y: f32, zone: f32) -> f32 {
(depth / zone).clamp(-3.0, 3.0) * 400.0
}
/// Autoscroll speed (points per second, left negative) of a ruler scrub held at `x`, past the
/// left (`x0`) or right (`x1`) end of the visible time span: faster the further past.
fn edge_scroll_speed_x(x0: f32, x1: f32, x: f32) -> f32 {
let depth = if x < x0 {
x - x0
} else if x > x1 {
x - x1
} else {
return 0.0;
};
(depth / 40.0).clamp(-3.0, 3.0) * 400.0 + depth.signum() * 100.0
}
/// The timeline's first visible time (`start`, showing `span` seconds of a `duration` comp)
/// paged so the CTI at `cti` is in view: unchanged while it is, else the page that starts at
/// the CTI (as After Effects pages the Timeline during a preview).
fn page_to_cti(start: f64, span: f64, duration: f64, cti: f64) -> f64 {
if !(start.is_finite() && span.is_finite() && cti.is_finite()) || (cti >= start && cti <= start + span) {
return start;
}
cti.min(duration - span).max(0.0)
}
/// The text of an expression being edited when its pick whip was pressed, and the selection in
/// it (characters): the pick whip's reference goes in there.
type WhipInto = (String, [usize; 2]);
@@ -1217,6 +1240,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let pps = app.ui.timeline.pps.unwrap_or(fit_pps);
if app.ui.timeline.pps.is_none() {
app.ui.timeline.start = 0.0;
} else if app.playback.playing {
// During playback the view pages to keep the CTI in sight (After Effects).
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());
}
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)));
@@ -1482,7 +1509,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if ((rresp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down())) || rresp.clicked())
&& let Some(pt) = rresp.interact_pointer_pos()
{
let mut secs = tm.t(pt.x).max(0.0);
let mut x = pt.x;
// Zoomed in, a scrub held past either end of the ruler scrolls the timeline that way
// (faster the further), with the CTI at the edge (After Effects).
let mut tm = tm;
if rresp.dragged() && app.ui.timeline.pps.is_some() {
let speed = edge_scroll_speed_x(tm.x0, graph_x1, x);
if speed != 0.0 {
let dt = f64::from(ui.input(|i| i.stable_dt).clamp(0.0, 0.1));
let span = f64::from(graph_x1 - tm.x0) / pps;
let cur = app.ui.timeline.start;
let hi = (comp.duration.seconds() - span).max(0.0).max(cur);
app.ui.timeline.start = (cur + f64::from(speed) * dt / pps).clamp(0.0, hi);
tm.start = app.ui.timeline.start;
ui.ctx().request_repaint();
}
x = x.clamp(tm.x0, graph_x1);
}
let mut secs = tm.t(x).max(0.0);
// Shift-drag: snap to keyframes, in/out points, markers and the work area (8 px).
if ui.input(|i| i.modifiers.shift) {
let cands = snap_candidates(&comp, &build_rows(app, &comp));
@@ -3619,6 +3663,20 @@ fn value_editor(
mod tests {
use super::*;
/// #468: playback pages the view to the CTI; a scrub past either end scrolls that way.
#[test]
fn the_view_pages_to_the_cti() {
assert_eq!(page_to_cti(10.0, 5.0, 60.0, 12.0), 10.0, "in view: unchanged");
assert_eq!(page_to_cti(10.0, 5.0, 60.0, 16.0), 16.0, "past the right end: the page from the CTI");
assert_eq!(page_to_cti(10.0, 5.0, 60.0, 3.0), 3.0, "before the start");
assert_eq!(page_to_cti(10.0, 5.0, 60.0, 58.0), 55.0, "not past the comp's end");
assert_eq!(page_to_cti(10.0, 80.0, 60.0, 95.0), 0.0);
assert_eq!(page_to_cti(10.0, f64::NAN, 60.0, 95.0), 10.0);
assert_eq!(edge_scroll_speed_x(100.0, 500.0, 300.0), 0.0);
assert!(edge_scroll_speed_x(100.0, 500.0, 501.0) > 0.0 && edge_scroll_speed_x(100.0, 500.0, 99.0) < 0.0);
assert!(edge_scroll_speed_x(100.0, 500.0, 600.0) > edge_scroll_speed_x(100.0, 500.0, 520.0));
}
/// A comp with two solids; "Box" has an animated Position and a Gaussian Blur.
fn app() -> EffectcraftApp {
let mut s = effectcraft_engine::Session::default();
+65
View File
@@ -311,3 +311,68 @@ fn dragging_out_of_the_marker_bin_adds_a_comp_marker() {
h.run_steps(2);
assert_eq!(markers(&h), vec![13.0, 20.0]);
}
/// #468: zoomed in, a CTI scrub held past the right end of the ruler scrolls the timeline (the
/// CTI rides the edge), past the left end scrolls it back; during playback the view pages to
/// keep the CTI in sight.
#[test]
fn the_timeline_follows_the_cti_past_its_edges() {
let (mut h, _) = harness();
// (Opening the comp showed all of it.)
h.state_mut().ui.timeline.pps = Some(200.0);
h.run_steps(2);
let ruler = rect(&h, "timeline.ruler");
let (s0, e0) = visible(&h);
assert_eq!(s0, 0.0);
let y = ruler.max.y - 4.0;
let from = pos2(ruler.center().x, y);
let past = pos2(ruler.max.x + 60.0, y);
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=4 {
h.event(Event::PointerMoved(from + (past - from) * (k as f32 / 4.0)));
h.step();
}
// Held still past the edge: it keeps scrolling (the view is read once the button is up).
h.run_steps(30);
let release = |h: &mut Harness<'_, EffectcraftApp>, at: Pos2| {
h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
};
release(&mut h, past);
let (s1, e1) = visible(&h);
let cti = h.state().session.time().seconds();
assert!(s1 > 0.5, "the view scrolled right: {s0}..{e0} → {s1}..{e1}");
assert!(cti > e0 && cti >= s1 && cti <= e1 + 0.05, "the CTI rides the right edge: {cti} in {s1}..{e1}");
// Pressed again and held past the left end: back to the left.
let left = pos2(ruler.min.x - 60.0, y);
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=4 {
h.event(Event::PointerMoved(from + (left - from) * (k as f32 / 4.0)));
h.step();
}
h.run_steps(5);
release(&mut h, left);
let (s2, _) = visible(&h);
let cti = h.state().session.time().seconds();
assert!(s2 < s1 && (cti - s2).abs() < 0.05, "the view scrolled back left: {s1} → {s2}, CTI {cti}");
// Playback past the visible span pages the view to the CTI.
let (s3, e3) = visible(&h);
let t = e3 + 20.0;
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(t));
let now = h.ctx.input(|i| i.time);
h.state_mut().play(now);
assert!(h.state().playback.playing);
h.step();
h.state_mut().stop();
h.step();
let cti = h.state().session.time().seconds();
let (s4, e4) = visible(&h);
assert!(s4 > s3 && cti >= s4 && cti <= e4, "paged from {s3}..{e3} to {s4}..{e4} for the CTI at {cti}");
}