mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #1108 from eliasx45/upstream/timeline-editing
This commit is contained in:
commit
aa7a8714bd
7 files changed
+338
-25
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user