mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
2 files changed
+124
-1
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in new issue
Block a user