Merge pull request #131 from storytold/fix/89-timeline-drop

Timeline: Project items land where they are dropped (#89)
This commit is contained in:
BFlat 2026-10-07 02:19:04 +07:00 committed by GitHub
commit 78f49e09ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 232 additions and 30 deletions

View File

@ -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),

View File

@ -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
),

View File

@ -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",

View File

@ -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() {

View File

@ -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);
}
}

View File

@ -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");
}