Timeline: Project items land where they are dropped (#89)
A Project item (or Media Browser file) dropped in the Timeline always went above the selected layer, starting where new layers start. Now, as in After Effects, a line shows where it goes in the stack (above the first layer row whose middle is below the pointer, else below the last), and over the time graph a marker with a timecode tag shows its In point, which follows the pointer (frame-snapped) or, with Shift, the current time. The layer reorder drag shares the insertion logic. layer.addItem and layer.newModel take `index` (1-based stack position); mediaBrowser.import passes `time` and `index` on, one layer under the other. Tests: project_items_land_where_they_are_dropped_in_the_timeline (ui_drag_drop.rs, real pointer drags: outline, time graph, Shift, below the last layer) and add_item_at_a_stack_index_and_time (engine). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9e619d2893
commit
1a0940d50a
|
|
@ -24,13 +24,32 @@ pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
|
|||
}
|
||||
|
||||
/// Insert a new layer above the selection (or at the top) and select it.
|
||||
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer) -> Result<LayerId> {
|
||||
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result<LayerId> {
|
||||
insert_layer_at(proj, st, cid, layer, None)
|
||||
}
|
||||
|
||||
/// `index`: an `index` parameter, the layer's 1-based place in the stack (1 = top; past the
|
||||
/// bottom = the bottom), as 0-based.
|
||||
pub(crate) fn index_p(p: &Value, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get("index") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => {
|
||||
v.as_u64().filter(|i| *i >= 1).map(|i| Some(usize::try_from(i - 1).unwrap_or(usize::MAX))).ok_or_else(|| bad(cmd, "index: 1-based stack position"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`insert_layer`] at stack position `at` (0 = top) instead of above the selected layer.
|
||||
pub(crate) fn insert_layer_at(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer, at: Option<usize>) -> Result<LayerId> {
|
||||
// Text and shape layers in Advanced 3D comps get Geometry Options (extrusion, bevels).
|
||||
if proj.comp(cid).is_some_and(|c| c.renderer == effectcraft_project::Renderer::Advanced3D) {
|
||||
super::model3d::add_geometry_options(&mut proj.next_id, &mut layer);
|
||||
}
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0);
|
||||
let at = match at {
|
||||
Some(i) => i.min(comp.layers.len()),
|
||||
None => st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0),
|
||||
};
|
||||
let id = layer.id;
|
||||
comp.layers.insert(at, layer);
|
||||
st.selected_layers = vec![id];
|
||||
|
|
@ -258,12 +277,13 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData"));
|
||||
}
|
||||
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => {
|
||||
return super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time")}));
|
||||
return super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index")}));
|
||||
}
|
||||
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)),
|
||||
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
|
||||
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),
|
||||
};
|
||||
let index = index_p(p, "layer.addItem")?;
|
||||
let fr = comp.frame_rate;
|
||||
// Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time).
|
||||
let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() };
|
||||
|
|
@ -273,7 +293,7 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
l.start_time = start;
|
||||
l.in_point = start;
|
||||
l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration());
|
||||
insert_layer(proj, st, cid, l)
|
||||
insert_layer_at(proj, st, cid, l, index)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
|
@ -1242,7 +1262,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_layer_settings,
|
||||
layer_settings
|
||||
),
|
||||
cmd!("layer.addItem", "Add Footage to Comp", ["File"], Some("Cmd+/"), "{item: id|name, time?, duration? (s, for a still)}", has_comp, add_item),
|
||||
cmd!(
|
||||
"layer.addItem",
|
||||
"Add Footage to Comp",
|
||||
["File"],
|
||||
Some("Cmd+/"),
|
||||
"{item: id|name, time? (s, the In point), index? (1-based stack position; default above the selected layer), duration? (s, for a still)}",
|
||||
has_comp,
|
||||
add_item
|
||||
),
|
||||
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
|
||||
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
|
||||
cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use effectcraft_project::{FootageKind, ItemId, ItemKind, Layer, LayerSource, Nod
|
|||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer::{color_p, insert_layer};
|
||||
use super::layer::{color_p, index_p, insert_layer, insert_layer_at};
|
||||
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
|
|
@ -119,6 +119,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let name = str_p(p, "name").unwrap_or(&it.name).to_string();
|
||||
let fr = comp.frame_rate;
|
||||
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO));
|
||||
let index = index_p(p, "layer.newModel")?;
|
||||
let id = s.edit("New 3D Model Layer", None, |proj, st| {
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Model { item }, (comp.width, comp.height), None);
|
||||
let g = build::model_geometry_options(&mut Ids(&mut proj.next_id), scale, &clips);
|
||||
|
|
@ -128,7 +129,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
l.start_time = start;
|
||||
l.in_point = start;
|
||||
l.out_point = comp.duration.max(start + fr.frame_duration());
|
||||
insert_layer(proj, st, cid, l)
|
||||
insert_layer_at(proj, st, cid, l, index)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0, "item": item.0}))
|
||||
}
|
||||
|
|
@ -349,7 +350,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"3D Model Layer",
|
||||
[],
|
||||
None,
|
||||
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s)}",
|
||||
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position)}",
|
||||
has_comp,
|
||||
new_model
|
||||
),
|
||||
|
|
|
|||
|
|
@ -110,8 +110,13 @@ fn browser_import(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
&& s.active_comp_id().is_some()
|
||||
&& let Some(items) = r.get("items").and_then(Value::as_array)
|
||||
{
|
||||
for it in items {
|
||||
s.execute("layer.addItem", json!({"item": it}))?;
|
||||
// Where a drop put them (`time`, `index`): one under the other.
|
||||
for (k, it) in items.iter().enumerate() {
|
||||
let mut add = json!({"item": it, "time": p.get("time")});
|
||||
if let Some(i) = p.get("index").and_then(Value::as_u64) {
|
||||
add["index"] = json!(i.saturating_add(k as u64));
|
||||
}
|
||||
s.execute("layer.addItem", add)?;
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
|
|
@ -233,7 +238,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("mediaBrowser.go", "Go to Folder", [], None, "{path?: folder | \"..\", importableOnly?}", browser_enabled, browser_go),
|
||||
cmd!("mediaBrowser.addFavorite", "Add to Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, true)),
|
||||
cmd!("mediaBrowser.removeFavorite", "Remove from Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, false)),
|
||||
cmd!("mediaBrowser.import", "Import", [], None, "{paths, addToComp?}", browser_enabled, browser_import),
|
||||
cmd!(
|
||||
"mediaBrowser.import",
|
||||
"Import",
|
||||
[],
|
||||
None,
|
||||
"{paths, addToComp?, time?, index? (where the layers go, as in layer.addItem)}",
|
||||
browser_enabled,
|
||||
browser_import
|
||||
),
|
||||
cmd!(
|
||||
"mediaBrowser.action",
|
||||
"Media Browser Action",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,27 @@ fn demo_layer_cache_is_transparent() {
|
|||
}
|
||||
}
|
||||
|
||||
/// `layer.addItem` puts the layer at `index` (1-based) in the stack and starts it at `time`, as a
|
||||
/// drop in the Timeline does (#89).
|
||||
#[test]
|
||||
fn add_item_at_a_stack_index_and_time() {
|
||||
let mut s = Session::default();
|
||||
let clip = s.execute("comp.new", json!({"name": "Clip", "width": 16, "height": 16, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 32, "height": 32, "frameRate": 30, "duration": 4})).unwrap();
|
||||
for name in ["C", "B", "A"] {
|
||||
s.execute("layer.newSolid", json!({"name": name})).unwrap();
|
||||
}
|
||||
let names = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
|
||||
s.execute("layer.addItem", json!({"item": clip, "index": 2, "time": 1.01})).unwrap();
|
||||
assert_eq!(names(&s), ["A", "Clip", "B", "C"]);
|
||||
let l = &s.active_comp().unwrap().layers[1];
|
||||
assert_eq!(l.in_point, effectcraft_time::Tick::from_seconds_f64(1.0), "on the nearest frame");
|
||||
// Past the bottom: the bottom.
|
||||
s.execute("layer.addItem", json!({"item": clip, "index": 99})).unwrap();
|
||||
assert_eq!(names(&s).last().map(String::as_str), Some("Clip 2"));
|
||||
assert!(s.execute("layer.addItem", json!({"item": clip, "index": 0})).is_err(), "1-based");
|
||||
}
|
||||
|
||||
/// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103).
|
||||
#[test]
|
||||
fn audio_lock_and_shy_keep_cached_layers() {
|
||||
|
|
|
|||
|
|
@ -805,6 +805,18 @@ fn key_drag_id() -> egui::Id {
|
|||
egui::Id::new("tl-key-drag")
|
||||
}
|
||||
/// Set on the frame the reorder drag is released.
|
||||
/// Where layers dropped at height `py` land among the visible layer rows: above the first row
|
||||
/// whose middle is below it (its index in `layer_rows`), else below the last row. Returns that
|
||||
/// and the y of the line that shows it (`top` without rows).
|
||||
fn insertion(layer_rows: &[(Rect, LayerId)], py: f32, top: f32) -> (Option<usize>, f32) {
|
||||
let at = layer_rows.iter().position(|(r, _)| py < r.center().y);
|
||||
let y = match at {
|
||||
Some(i) => layer_rows.get(i).map_or(top, |(r, _)| r.min.y),
|
||||
None => layer_rows.last().map_or(top, |(r, _)| r.max.y),
|
||||
};
|
||||
(at, y)
|
||||
}
|
||||
|
||||
fn layer_drop_id() -> egui::Id {
|
||||
egui::Id::new("tl-layer-drop")
|
||||
}
|
||||
|
|
@ -2130,11 +2142,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let dropped = ctx.data_mut(|d| d.remove_temp::<bool>(layer_drop_id())).unwrap_or(false);
|
||||
let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect();
|
||||
if let Some(py) = ui.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())).map(|p| p.y)
|
||||
&& let Some(last) = layer_rows.last()
|
||||
&& !layer_rows.is_empty()
|
||||
{
|
||||
// The first layer row whose middle is below the pointer: the layers go above it.
|
||||
let at = layer_rows.iter().position(|(rr, _)| py < rr.center().y);
|
||||
let line_y = at.map(|i| layer_rows[i].0.min.y).unwrap_or(last.0.max.y);
|
||||
let (at, line_y) = insertion(&layer_rows, py, rows_rect.min.y);
|
||||
let target = at.and_then(|i| layer_rows[i..].iter().find(|(_, l)| !moving.contains(&l.0)).map(|(_, l)| *l));
|
||||
if dropped {
|
||||
ctx.data_mut(|d| d.remove::<Vec<u64>>(layer_drag_id()));
|
||||
|
|
@ -2298,25 +2308,55 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let mrows = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
|
||||
super::markers_ui::comp_markers(app, ui, &comp, tm, mstrip, mrows);
|
||||
|
||||
// Drop targets: footage/comps from the Project panel, effects from Effects & Presets.
|
||||
// Drop targets: effects from Effects & Presets go on the layer under the pointer. Footage and
|
||||
// comps from the Project panel and files from the Media Browser go where they are dropped: a
|
||||
// line shows the place in the stack, and over the time graph a marker shows the In point too
|
||||
// (Shift: at the current time), as in After Effects.
|
||||
if let Some(payload) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx)
|
||||
&& !matches!(*payload, crate::panels::DragPayload::Property { .. })
|
||||
&& ui.rect_contains_pointer(rows_rect)
|
||||
&& let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rows_rect))
|
||||
{
|
||||
p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
let hover_y = ctx.input(|i| i.pointer.hover_pos()).map(|p| p.y).unwrap_or(0.0);
|
||||
let target = hit_rows.iter().find(|(r, _)| hover_y >= r.min.y && hover_y < r.max.y).map(|(_, row)| row.layer);
|
||||
match payload.as_ref() {
|
||||
crate::panels::DragPayload::Item(id) => actions.push(("layer.addItem".into(), json!({"item": id}))),
|
||||
crate::panels::DragPayload::Effect(e) => {
|
||||
if let Some(l) = target {
|
||||
actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]})));
|
||||
}
|
||||
}
|
||||
crate::panels::DragPayload::Files(paths) => actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true}))),
|
||||
crate::panels::DragPayload::Property { .. } => {}
|
||||
let released = ctx.input(|i| i.pointer.any_released());
|
||||
if let crate::panels::DragPayload::Effect(e) = payload.as_ref() {
|
||||
p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||||
let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
|
||||
if released && let Some(l) = target {
|
||||
actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]})));
|
||||
}
|
||||
} else {
|
||||
let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect();
|
||||
let (at, line_y) = insertion(&layer_rows, ptr.y, rows_rect.min.y);
|
||||
let index = match at.and_then(|i| layer_rows.get(i)) {
|
||||
Some((_, l)) => comp.index_of(*l).unwrap_or(1),
|
||||
None => layer_rows.last().and_then(|(_, l)| comp.index_of(*l)).unwrap_or(comp.layers.len()) + 1,
|
||||
};
|
||||
let start = (ptr.x >= graph_x0 && !graph_on).then(|| {
|
||||
let t = if ctx.input(|i| i.modifiers.shift) { time } else { Tick::from_seconds_f64(tm.t(ptr.x)) };
|
||||
comp.frame_rate.snap_nearest(t).clamp(Tick::ZERO, comp.duration - comp.frame_duration())
|
||||
});
|
||||
let rp = p.with_clip_rect(rows_rect);
|
||||
rp.line_segment([pos2(rect.min.x, line_y), pos2(rows_rect.max.x, line_y)], Stroke::new(2.0, t.accent));
|
||||
if let Some(s) = start {
|
||||
let x = tm.x(s.seconds());
|
||||
rp.line_segment([pos2(x, rows_rect.min.y), pos2(x, rows_rect.max.y)], Stroke::new(1.0, t.accent));
|
||||
// The In point's time in a tag beside the marker.
|
||||
let g = rp.layout_no_wrap(super::timecode(&app.session, &comp, s), Tokens::ui(11.0), Color32::WHITE);
|
||||
let tag = Align2::LEFT_BOTTOM.anchor_size(pos2(x + 4.0, line_y - 3.0), g.size());
|
||||
rp.rect_filled(tag.expand(2.0), 2.0, t.accent);
|
||||
rp.galley(tag.min, g, Color32::WHITE);
|
||||
}
|
||||
if released {
|
||||
let (index, time) = (json!(index), json!(start.map(Tick::seconds)));
|
||||
match payload.as_ref() {
|
||||
crate::panels::DragPayload::Item(id) => actions.push(("layer.addItem".into(), json!({"item": id, "index": index, "time": time}))),
|
||||
crate::panels::DragPayload::Files(paths) => {
|
||||
actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time})))
|
||||
}
|
||||
crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Property { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if released {
|
||||
egui::DragAndDrop::clear_payload(&ctx);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
//! Dragging Project items into the Timeline (egui_kittest, real pointer drags): they land where
|
||||
//! they are dropped, between layers and, over the time graph, starting there (#89).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// Comp "Main" (4 s at 30 fps, current time 1 s) with solids Top, Middle and Bottom, and comp
|
||||
/// "Clip" in the Project panel to drag in.
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, u64) {
|
||||
let mut s = Session::default();
|
||||
let clip = s.execute("comp.new", json!({"name": "Clip", "width": 160, "height": 90, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
|
||||
for name in ["Bottom", "Middle", "Top"] {
|
||||
s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap();
|
||||
}
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
(h, clip)
|
||||
}
|
||||
|
||||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
|
||||
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
|
||||
}
|
||||
|
||||
/// Press on `from`, move to `to` in steps, release there (with `modifiers` held).
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) {
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.event(Event::ModifiersChanged(modifiers));
|
||||
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 });
|
||||
h.run_steps(3);
|
||||
h.event(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.step();
|
||||
}
|
||||
|
||||
/// Layer names top to bottom, and the In point of `name`.
|
||||
fn stack(h: &Harness<'_, EffectcraftApp>) -> Vec<String> {
|
||||
h.state().session.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn in_point(h: &Harness<'_, EffectcraftApp>, index: usize) -> f64 {
|
||||
h.state().session.active_comp().unwrap().layers[index].in_point.seconds()
|
||||
}
|
||||
|
||||
fn layer_id(h: &Harness<'_, EffectcraftApp>, name: &str) -> u64 {
|
||||
h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0
|
||||
}
|
||||
|
||||
/// #89: dropped on the layer outline, an item goes in between the layers there; over the time
|
||||
/// graph it also starts where it was dropped, or at the current time with Shift.
|
||||
#[test]
|
||||
fn project_items_land_where_they_are_dropped_in_the_timeline() {
|
||||
let (mut h, clip) = harness();
|
||||
let item = rect(&h, &format!("project.item.{clip}.name")).center();
|
||||
let frame = 1.0 / 30.0;
|
||||
|
||||
// Between Top and Middle in the outline: the lower half of Top's row.
|
||||
let top = rect(&h, &format!("timeline.layer.{}.row", layer_id(&h, "Top")));
|
||||
drag(&mut h, item, pos2(top.center().x, top.max.y - 2.0), Modifiers::NONE);
|
||||
assert_eq!(stack(&h), ["Top", "Clip", "Middle", "Bottom"]);
|
||||
// (Settings ▸ General ▸ Create Layers at Composition Start Time, on by default.)
|
||||
assert_eq!(in_point(&h, 1), 0.0, "starts where new layers start");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
|
||||
// Over the time graph at 2 s, on the upper half of Bottom's row: above Bottom, starting at 2 s.
|
||||
let bottom = layer_id(&h, "Bottom");
|
||||
let row = rect(&h, &format!("timeline.layer.{bottom}.row"));
|
||||
let bar = rect(&h, &format!("timeline.layer.{bottom}.bar"));
|
||||
let at_2s = bar.min.x + bar.width() * 0.5;
|
||||
drag(&mut h, item, pos2(at_2s, row.min.y + 2.0), Modifiers::NONE);
|
||||
assert_eq!(stack(&h), ["Top", "Middle", "Clip", "Bottom"]);
|
||||
assert!((in_point(&h, 2) - 2.0).abs() <= frame + 1e-9, "starts where it was dropped: {}", in_point(&h, 2));
|
||||
assert_eq!(
|
||||
Tick::from_seconds_f64(in_point(&h, 2)),
|
||||
h.state().session.active_comp().unwrap().frame_rate.snap_nearest(Tick::from_seconds_f64(in_point(&h, 2))),
|
||||
"on a frame"
|
||||
);
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
|
||||
// Shift: at the current time; below the last layer: at the bottom of the stack.
|
||||
let row = rect(&h, &format!("timeline.layer.{bottom}.row"));
|
||||
drag(&mut h, item, pos2(at_2s, row.max.y + 30.0), Modifiers::SHIFT);
|
||||
assert_eq!(stack(&h), ["Top", "Middle", "Bottom", "Clip"]);
|
||||
assert!((in_point(&h, 3) - 1.0).abs() < 1e-9, "Shift starts it at the current time");
|
||||
}
|
||||
Loading…
Reference in New Issue