Files
effectcraft/crates/ui-egui/tests/ui_timeline_trim.rs
Elias 223b21b06a timeline: trim zones straddle layer bar ends (AE), trim wins over slip
The In/Out trim zones were 6 pt strips inside the bar only: at or just outside an end the pointer got the default cursor, and on footage/precomp layers just outside the end it hit the slip zone, which shows the same arrow but slips the source instead of trimming. Zones now reach 4 pt outside and up to 5 pt inside each end (short bars keep their middle third as body), registered after the slip zones so trimming wins there. The trim zones show the system resize cursor (upstream port: the fork also offers an owner-local cursor, left out here). Test: ui_timeline_trim.

(cherry picked from commit e2490f801067365addc03858bd555731cb188ad1)

(cherry picked from commit b704fc52c4fdb43816721e95d13f132a11062aa1)
(cherry picked from commit 17faf9cebf9e347534ceab37386e79979da8f786)

(cherry picked from commit 9b9d21aacc4d025e36e13a05e4416a49f448bdf1)
2026-10-12 19:22:15 +01:00

122 lines
6.1 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Trimming layers in the Timeline as in After Effects: the pointer over either end of a layer
//! bar (a few points inside it or just outside) shows the trim cursor, and dragging that end
//! moves the layer's In or Out point.
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{CursorIcon, Event, Modifiers, Pos2, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
/// A 10 s comp at 30 fps with a solid trimmed to 2–6 s and a 20 s precomp trimmed to 3–7 s (its
/// source runs past both ends, so the slip zones sit right beside its bar's ends).
fn harness() -> (Harness<'static, EffectcraftApp>, u64, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 320, "height": 180, "frameRate": 30, "duration": 20})).unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 10})).unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [solid], "in": 2.0, "out": 6.0})).unwrap();
let pre = s.execute("layer.addItem", json!({"item": "Inner"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [pre], "in": 3.0, "out": 7.0})).unwrap();
s.execute("layer.select", json!({"layers": []})).unwrap();
let app = EffectcraftApp::new(s);
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app);
h.run_steps(4);
(h, solid, pre)
}
fn bar(h: &Harness<'_, EffectcraftApp>, layer: u64) -> egui::Rect {
let e = h.state().auto.find(&format!("timeline.layer.{layer}.bar")).expect("layer bar").clone();
egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
/// The cursor shown with the pointer resting at `p`.
fn cursor_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) -> CursorIcon {
h.event(Event::PointerMoved(p));
h.run_steps(3);
h.output().platform_output.cursor_icon
}
/// (in, out, start) of `layer` in seconds.
fn timing(h: &Harness<'_, EffectcraftApp>, layer: u64) -> (f64, f64, f64) {
let comp = h.state().session.active_comp().unwrap();
let l = comp.layer(LayerId(layer)).unwrap();
(l.in_point.seconds(), l.out_point.seconds(), l.start_time.seconds())
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
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..=10 {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 10.0)));
h.step();
}
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
/// Both ends of every bar show the trim cursor, just inside and just outside the end; the middle
/// doesn't. The same holds for a selected layer, a twirled-open layer and a short bar.
fn assert_trim_cursor_at_ends(h: &mut Harness<'_, EffectcraftApp>, layer: u64, case: &str) {
let b = bar(h, layer);
let y = b.center().y;
for (what, x) in [("in, outside", b.min.x - 3.0), ("in, inside", b.min.x + 2.0), ("out, inside", b.max.x - 2.0), ("out, outside", b.max.x + 3.0)] {
assert_eq!(cursor_at(h, pos2(x, y)), CursorIcon::ResizeHorizontal, "{case}: layer {layer} {what} (bar {b:?})");
}
if b.width() > 40.0 {
assert_ne!(cursor_at(h, b.center()), CursorIcon::ResizeHorizontal, "{case}: the middle of the bar moves, it doesn't trim");
}
}
#[test]
fn bar_ends_show_the_trim_cursor() {
let (mut h, solid, pre) = harness();
assert_trim_cursor_at_ends(&mut h, solid, "unselected solid");
assert_trim_cursor_at_ends(&mut h, pre, "precomp with slip zones beside its ends");
h.state_mut().session.execute("layer.select", json!({"layers": [solid]})).unwrap();
h.run_steps(2);
assert_trim_cursor_at_ends(&mut h, solid, "selected");
h.state_mut().ui.timeline.open_layers.insert(solid);
h.run_steps(2);
assert_trim_cursor_at_ends(&mut h, solid, "twirled open");
assert_trim_cursor_at_ends(&mut h, pre, "below a twirled-open layer");
// Three frames long: still trims from both ends.
h.state_mut().session.execute("layer.timing", json!({"layers": [solid], "in": 2.0, "out": 2.1})).unwrap();
h.run_steps(2);
assert_trim_cursor_at_ends(&mut h, solid, "short bar");
}
/// The trim zones show the horizontal resize cursor; the bar body doesn't.
#[test]
fn trim_zones_show_the_resize_cursor() {
let (mut h, solid, _) = harness();
let b = bar(&h, solid);
assert_eq!(cursor_at(&mut h, pos2(b.max.x, b.center().y)), CursorIcon::ResizeHorizontal);
assert_ne!(cursor_at(&mut h, b.center()), CursorIcon::ResizeHorizontal, "trim cursor leaked onto the bar body");
}
#[test]
fn dragging_bar_ends_trims_in_and_out() {
let (mut h, solid, pre) = harness();
let b = bar(&h, solid);
let pps = b.width() as f64 / 4.0;
let y = b.center().y;
// Out end, grabbed just inside, one second to the left.
drag(&mut h, pos2(b.max.x - 2.0, y), pos2(b.max.x - 2.0 - pps as f32, y));
let (i, o, s) = timing(&h, solid);
assert!((i - 2.0).abs() < 1e-6 && (o - 5.0).abs() < 1.5 / 30.0 && s.abs() < 1e-6, "out trimmed to ~5 s: in {i} out {o} start {s}");
// In end, grabbed just inside, one second to the right.
let b = bar(&h, solid);
drag(&mut h, pos2(b.min.x + 2.0, y), pos2(b.min.x + 2.0 + pps as f32, y));
let (i, o2, s) = timing(&h, solid);
assert!((i - 3.0).abs() < 1.5 / 30.0 && (o2 - o).abs() < 1e-6 && s.abs() < 1e-6, "in trimmed to ~3 s: in {i} out {o2} start {s}");
// The precomp's Out end grabbed just outside the bar (over its slip zone) extends it.
let b = bar(&h, pre);
let y = b.center().y;
drag(&mut h, pos2(b.max.x + 3.0, y), pos2(b.max.x + 3.0 + pps as f32, y));
let (i, o, s) = timing(&h, pre);
assert!((i - 3.0).abs() < 1e-6 && (o - 8.0).abs() < 1.5 / 30.0 && s.abs() < 1e-6, "out extended to ~8 s, not slipped: in {i} out {o} start {s}");
}