Merge pull request #1108 from eliasx45/upstream/timeline-editing

This commit is contained in:
Brandon Thomas committed 2026-10-13 04:29:48 -04:00
commit aa7a8714bd
7 files changed
+338 -25

No files matched your search

+2 -2
View File
@@ -1154,7 +1154,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.auto.add(&format!("effectControls.effect.{}.twirl", g.uid), tw, &g.name);
let rename_id = egui::Id::new(("ec-rename", g.uid));
let editing: Option<String> = ctx.data(|d| d.get_temp(rename_id));
let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.min.y + 4.0), vec2(40.0, 18.0));
let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.center().y - 9.0), vec2(40.0, 18.0));
if let Some(mut value) = editing.as_ref().cloned() {
let name_rect = Rect::from_min_max(pos2(tw.max.x + 6.0, r.min.y + 2.0), pos2(reset.min.x - 6.0, r.max.y - 2.0));
let response = ui.put(name_rect, egui::TextEdit::singleline(&mut value).id(rename_id.with("field")).font(Tokens::semibold(12.0)));
@@ -1191,7 +1191,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
actions.push(("effect.reset".into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
// About…: the effect's name and category (AE's About dialog).
let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.min.y + 4.0), vec2(44.0, 18.0));
let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.center().y - 9.0), vec2(44.0, 18.0));
let aresp = ui.interact(about, egui::Id::new(("ec-about", g.uid)), Sense::click());
bp.text(about.center(), Align2::CENTER_CENTER, crate::i18n::tr("About..."), Tokens::ui(11.5), if aresp.hovered() { t.hot_text } else { t.text_dim });
app.auto.add(&format!("effectControls.effect.{}.about", g.uid), about, "About");
+4 -1
View File
@@ -673,11 +673,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let cell = Rect::from_min_max(pos2(x0 + 27.0, r.min.y + 1.0), pos2((x0 + 33.0 + g.size().x).min(name_clip.max.x), r.max.y - 1.0));
lp.with_clip_rect(name_clip.expand2(vec2(3.0, 0.0))).rect_filled(cell, 0.0, Color32::from_rgb(0xa6, 0xa6, 0xa6));
}
lp.with_clip_rect(name_clip).text(
// Long names end in "…" before the label column instead of being cut mid-glyph.
widgets::text_fit(
&lp.with_clip_rect(name_clip),
pos2(x0 + 30.0, r.center().y),
Align2::LEFT_CENTER,
&it.name,
Tokens::ui(12.0),
name_clip.max.x - (x0 + 30.0) - 2.0,
if selected { Color32::from_gray(0x16) } else { t.text },
);
}
+44 -16
View File
@@ -69,6 +69,25 @@ pub(crate) fn bar_color(label: Color32, selected: bool) -> Color32 {
Color32::from_rgb(mix(label.r()), mix(label.g()), mix(label.b()))
}
/// How far a layer bar's trim zones reach past its ends and into it, in points. After Effects'
/// zones straddle each end, so the pointer needn't land inside the bar.
const TRIM_OUTSIDE: f32 = 4.0;
const TRIM_INSIDE: f32 = 5.0;
/// A layer bar's body (drag to move) and its In/Out trim zones. Short bars keep their middle
/// third as body; a bar too short for that is all trim zones (the Out one on top).
fn trim_zones(bar: Rect) -> (Rect, Rect, Rect) {
let inside = TRIM_INSIDE.min(bar.width() / 3.0).max(0.0);
let in_r = Rect::from_min_max(pos2(bar.min.x - TRIM_OUTSIDE, bar.min.y), pos2(bar.min.x + inside, bar.max.y));
let out_r = Rect::from_min_max(pos2(bar.max.x - inside, bar.min.y), pos2(bar.max.x + TRIM_OUTSIDE, bar.max.y));
(bar.shrink2(vec2(inside, 0.0)), in_r, out_r)
}
/// The trim cursor over a layer bar's ends: the horizontal resize arrow.
fn trim_cursor(ctx: &egui::Context) {
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
/// Darken part `r` of a layer bar and stripe it diagonally in its label colour (where the layer
/// runs past its source). Only the visible part is drawn.
fn stripes(p: &egui::Painter, r: Rect, label: Color32) {
@@ -1668,7 +1687,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
pr.line_segment([pos2(x, ruler.max.y - 10.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick));
let fno = (s / fd).round() as i64;
let label = ruler_label(fno, fps_i, &app.session.project.settings);
pr.text(pos2(x + 3.0, ruler.max.y - 16.0), Align2::LEFT_CENTER, label, Tokens::ui(10.0), t.tl_ruler_text);
// A label that would run under the marker bin is left out rather than cut to a stray digit.
let g = pr.layout_no_wrap(label, Tokens::ui(10.0), t.tl_ruler_text);
if x + 3.0 + g.size().x <= bin.min.x {
pr.galley(pos2(x + 3.0, ruler.max.y - 16.0 - g.size().y / 2.0), g, t.tl_ruler_text);
}
for k in 1..5 {
let xx = tm.x(s + secs_per_label * k as f64 / 5.0);
pr.line_segment([pos2(xx, ruler.max.y - 4.0), pos2(xx, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick.gamma_multiply(0.6)));
@@ -2217,8 +2240,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
continue;
}
let gresp = ui
.interact(gr, egui::Id::new(("ghost", side, layer.id.0)), Sense::drag())
.interact(gr, egui::Id::new(("ghost", side, layer.id.0)), Sense::click_and_drag())
.on_hover_text(crate::i18n::tr("Drag to slip the source"));
// Outside the bar it is empty track area: a plain click deselects.
if gresp.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
actions.push(("layer.select".into(), json!({"layers": []})));
}
app.auto.add(&format!("timeline.layer.{}.source{}", layer.id.0, side), gr, "Slip");
if gresp.hovered() || gresp.dragged() {
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
@@ -2245,16 +2272,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
app.auto.add(&format!("timeline.layer.{}.bar", layer.id.0), bar, &layer.name);
// Interactions: move body, trim edges.
let edge_w = 6.0;
let body = ui.interact(bar.shrink2(vec2(edge_w, 0.0)), egui::Id::new(("bar", layer.id.0)), Sense::click_and_drag());
let (in_r, out_r) =
(Rect::from_min_max(bar.min, pos2(bar.min.x + edge_w, bar.max.y)), Rect::from_min_max(pos2(bar.max.x - edge_w, bar.min.y), bar.max));
let (body_r, in_r, out_r) = trim_zones(bar);
let body = ui.interact(body_r, egui::Id::new(("bar", layer.id.0)), Sense::click_and_drag());
let lin = ui.interact(in_r, egui::Id::new(("bar-in", layer.id.0)), Sense::drag());
let lout = ui.interact(out_r, egui::Id::new(("bar-out", layer.id.0)), Sense::drag());
app.auto.add(&format!("timeline.layer.{}.bar.in", layer.id.0), in_r, "Trim In");
app.auto.add(&format!("timeline.layer.{}.bar.out", layer.id.0), out_r, "Trim Out");
if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() {
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
trim_cursor(&ctx);
}
if body.double_clicked()
&& let Some(a) = double_click_action(layer)
@@ -3012,14 +3037,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if graph_on {
super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions);
}
// Empty-area click in the graph: deselect keys (below the last row, the layers too, as in
// After Effects); drag: box-select keys.
if empty.clicked() {
actions.push(("keys.select".into(), json!({"keys": []})));
let below = empty.interact_pointer_pos().is_some_and(|p| hit_rows.iter().all(|(r, _)| p.y > r.max.y));
if below && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
actions.push(("layer.select".into(), json!({"layers": []})));
}
// A plain click on empty track area (beside a layer's bar, on a property row's empty span or
// below the last row) deselects the layers and their keys and properties, as in After
// Effects; Shift / Cmd keep the selection. Drag: box-select keys.
if empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
actions.push(("layer.select".into(), json!({"layers": []})));
}
if let (true, Some(origin), Some(cur)) =
(empty.dragged(), empty.interact_pointer_pos().and_then(|_| ctx.input(|i| i.pointer.press_origin())), empty.interact_pointer_pos())
@@ -3147,7 +3169,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift), "selectProperties": true})));
let mods = ui.input(|i| i.modifiers);
if keys.is_empty() && !(mods.shift || mods.command) {
// A marquee that catches nothing leaves nothing selected (layers included).
actions.push(("layer.select".into(), json!({"layers": []})));
} else {
actions.push(("keys.select".into(), json!({"keys": keys, "add": mods.shift, "selectProperties": true})));
}
}
// CTI.
+17 -6
View File
@@ -118,12 +118,23 @@ fn edited(app: &EffectcraftApp, ectx: &EvalCtx, lid: LayerId) -> Option<Edited>
Some(Edited { layer, doc, lay, m, inv, carets })
}
/// The topmost text layer whose text is under screen point `s`.
fn text_layer_at(app: &EffectcraftApp, ectx: &EvalCtx, map: &ViewerMap, s: Pos2) -> Option<Edited> {
super::viewer::selectable_layers(ectx.comp, ectx.time)
/// Type-tool clicks prefer a selected text layer, as viewer dragging does (#230).
/// Double-clicks retain topmost picking. Both use the same visibility/lock/time/solo filter.
fn text_layer_at(app: &EffectcraftApp, ectx: &EvalCtx, map: &ViewerMap, s: Pos2, prefer_selected: bool) -> Option<Edited> {
let mut topmost = None;
for e in super::viewer::selectable_layers(ectx.comp, ectx.time)
.filter(|l| matches!(l.source, LayerSource::Text))
.filter_map(|l| edited(app, ectx, l.id))
.find(|e| e.contains(map, s))
.filter(|e| e.contains(map, s))
{
if !prefer_selected || app.session.state.selected_layers.contains(&e.layer.id) {
return Some(e);
}
if topmost.is_none() {
topmost = Some(e);
}
}
topmost
}
fn exec(app: &mut EffectcraftApp, id: &str, p: Value) -> Option<Value> {
@@ -223,7 +234,7 @@ pub fn hook(
}
}
if !handled && type_tool {
if let Some(e) = text_layer_at(app, ectx, map, pos) {
if let Some(e) = text_layer_at(app, ectx, map, pos, true) {
let ci = e.hit(map, pos);
exec(app, "text.edit", json!({"layer": e.layer.id.0, "caret": ci}));
drag = Some(Drag::Select { layer: e.layer.id });
@@ -325,7 +336,7 @@ pub fn hook(
owns = true;
}
_ if !matches!(tool, Tool::Hand | Tool::Zoom) && !tool.is_pen() && !type_tool => {
if let Some(e) = text_layer_at(app, ectx, map, p) {
if let Some(e) = text_layer_at(app, ectx, map, p, false) {
exec(app, "text.edit", json!({"layer": e.layer.id.0}));
ui.memory_mut(|m| m.request_focus(focus_id()));
owns = true;
+106
View File
@@ -464,3 +464,109 @@ fn timeline_double_click_on_a_text_layer_edits_its_text() {
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "gamma", "typing replaced the text ({target})");
}
}
#[test]
fn duplicated_text_boxes_both_remain_editable() {
let mut a = app();
let original = a
.session
.execute("layer.newText", json!({"text": "alpha beta", "size": 40, "position": [40, 100], "justify": "left", "box": [40, 60, 250, 100]}))
.unwrap()["layer"]
.as_u64()
.unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| a);
h.run_steps(3);
h.state_mut().ui.focused = PanelKind::Composition;
key(&mut h, Key::D, Modifiers::COMMAND);
let duplicate = h.state().session.state.selected_layers[0].0;
assert_ne!(original, duplicate);
h.state_mut().session.execute("prop.set", json!({"layer": duplicate, "path": "transform/position", "value": [465, 110]})).unwrap();
h.run_steps(3);
for (layer, x, text) in [(original, 80.0, "original edited"), (duplicate, 380.0, "duplicate edited")] {
h.run_steps(40);
let comp = rect(&h, "viewer.comp");
let k = comp.width() / 640.0;
let p = comp.min + egui::vec2(x * k, 85.0 * k);
click(&mut h, p, Modifiers::NONE);
click(&mut h, p, Modifiers::NONE);
assert_eq!(edited(&h).0, layer);
type_text(&mut h, text);
key(&mut h, Key::Escape, Modifiers::NONE);
assert_eq!(layer_doc(&h.state().session, LayerId(layer)).unwrap().text, text);
}
}
#[test]
fn overlapping_duplicate_text_remains_editable_by_double_click_and_selected_type_tool() {
let mut a = app();
let original = a.session.execute("layer.newText", json!({"text": "alpha beta", "size": 40, "box": [40, 60, 250, 100]})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| a);
h.run_steps(3);
h.state_mut().ui.focused = PanelKind::Composition;
key(&mut h, Key::D, Modifiers::COMMAND);
let duplicate = h.state().session.state.selected_layers[0].0;
assert_ne!(original, duplicate);
let comp = rect(&h, "viewer.comp");
let p = comp.min + egui::vec2(80.0, 85.0) * (comp.width() / 640.0);
click(&mut h, p, Modifiers::NONE);
click(&mut h, p, Modifiers::NONE);
assert_eq!(edited(&h).0, duplicate, "double-click edits the topmost overlapping copy");
type_text(&mut h, "copy edited");
assert_eq!(edited(&h).1, "copy edited");
key(&mut h, Key::Z, Modifiers::COMMAND);
assert_eq!(edited(&h).1, "alpha beta", "undo restores the duplicate's text");
key(&mut h, Key::Escape, Modifiers::NONE);
h.state_mut().session.execute("layer.select", json!({"layers": [original]})).unwrap();
h.state_mut().ui.focused = PanelKind::Composition;
key(&mut h, Key::T, Modifiers::COMMAND);
h.run_steps(40);
click(&mut h, p, Modifiers::NONE);
assert_eq!(edited(&h).0, original, "the Type tool must honor the explicitly selected overlapping text layer");
key(&mut h, Key::A, Modifiers::COMMAND);
type_text(&mut h, "original edited");
assert_eq!(edited(&h).1, "original edited");
assert_eq!(layer_doc(&h.state().session, LayerId(duplicate)).unwrap().text, "alpha beta", "editing the original leaves the duplicate unchanged");
key(&mut h, Key::Z, Modifiers::COMMAND);
assert_eq!(edited(&h).1, "alpha beta");
}
#[test]
fn type_tool_selected_text_preference_skips_ineligible_layers_and_falls_back_to_topmost() {
for exclusion in ["hidden", "locked", "inactive", "unsoloed", "outside", "unselected"] {
let mut a = app();
let mut ids = vec![];
for _ in 0..2 {
ids.push(
a.session.execute("layer.newText", json!({"text": "alpha beta", "size": 40, "box": [40, 60, 250, 100]})).unwrap()["layer"].as_u64().unwrap(),
);
}
let original = ids[0];
let topmost = ids[1];
match exclusion {
"hidden" | "locked" => {
let switch = if exclusion == "hidden" { "video" } else { "locked" };
a.session.execute("layer.setSwitch", json!({"layers": [original], "switch": switch, "value": exclusion == "locked"})).unwrap();
}
"inactive" => {
let cid = a.session.active_comp_id().unwrap();
std::sync::Arc::make_mut(&mut a.session.project).comp_mut(cid).unwrap().layer_mut(LayerId(original)).unwrap().out_point =
effectcraft_engine::time::Tick::ZERO;
}
"unsoloed" => {
a.session.execute("layer.setSwitch", json!({"layers": [topmost], "switch": "solo", "value": true})).unwrap();
}
"outside" => {
a.session.execute("prop.set", json!({"layer": original, "path": "transform/position", "value": [500, 300]})).unwrap();
}
_ => {}
}
a.session.state.selected_layers = if exclusion == "unselected" { vec![] } else { vec![LayerId(original)] };
a.ui.tool = effectcraft_ui_egui::state::Tool::Type;
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| a);
h.run_steps(3);
let comp = rect(&h, "viewer.comp");
let p = comp.min + egui::vec2(80.0, 85.0) * (comp.width() / 640.0);
click(&mut h, p, Modifiers::NONE);
assert_eq!(edited(&h).0, topmost, "{exclusion} selected text must not override the eligible hit");
}
}
+121
View File
@@ -0,0 +1,121 @@
//! 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}");
}
@@ -88,6 +88,50 @@ fn timeline_empty_outline_marquee_selects_layers() {
drag(&mut h, pos2(area.min.x + 210.0, last.max.y + 25.0), pos2(area.min.x + 215.0, top.min.y + 2.0), Modifiers::NONE);
assert_eq!(h.state().session.state.selected_layers, ids);
}
/// A plain click on empty track area (beside a layer's bar or below the last layer) deselects
/// the layers, as in After Effects; Shift / Cmd keep the selection, a click on a bar selects
/// its layer and a key marquee that catches nothing leaves nothing selected.
#[test]
fn timeline_click_on_empty_track_area_deselects_layers() {
let mut h = harness();
let ids: Vec<LayerId> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect();
let select = |h: &mut Harness<'_, EffectcraftApp>, layers: &[LayerId]| {
let layers: Vec<u64> = layers.iter().map(|l| l.0).collect();
h.state_mut().session.execute("layer.select", json!({"layers": layers})).unwrap();
h.run_steps(2);
};
h.state_mut().session.execute("layer.timing", json!({"layers": [ids[1].0], "in": 1.5, "out": 2.5})).unwrap();
select(&mut h, &ids[..2]);
let bar = rect(&h, &format!("timeline.layer.{}.bar", ids[1].0));
let before = pos2(bar.min.x - 30.0, bar.center().y);
let after = pos2(bar.max.x + 30.0, bar.center().y);
click(&mut h, before, Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty(), "a click beside the bar deselects");
click(&mut h, bar.center(), Modifiers::NONE);
assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "a click on the bar selects its layer");
click(&mut h, after, Modifiers::SHIFT);
assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "Shift-click on empty keeps the selection");
click(&mut h, after, Modifiers::COMMAND);
assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "Cmd-click on empty keeps the selection");
click(&mut h, after, Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty());
// Below the last layer, in the track area and in the outline.
let last = rect(&h, &format!("timeline.layer.{}.bar", ids[3].0));
select(&mut h, &ids);
click(&mut h, pos2(last.center().x, last.max.y + 30.0), Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty(), "a click below the last layer deselects");
let outline = rect(&h, "timeline.layerMarquee");
select(&mut h, &ids);
click(&mut h, pos2(outline.min.x + 210.0, last.max.y + 30.0), Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty(), "so does one below the layers in the outline");
// A key marquee over empty track area that catches nothing.
select(&mut h, &ids[1..2]);
drag(&mut h, after, pos2(after.x + 60.0, after.y + 4.0), Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty(), "an empty marquee leaves nothing selected");
select(&mut h, &ids[1..2]);
drag(&mut h, after, pos2(after.x + 60.0, after.y + 4.0), Modifiers::SHIFT);
assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "a Shift marquee keeps the selection");
}
#[test]
fn project_clear_deletes_items_and_undo_restores_them() {
let mut h = harness();