From 1a0940d50aef92f28fd5978aff74811ab21080ed Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 02:18:38 +0700 Subject: [PATCH] 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 --- crates/engine/src/commands/layer.rs | 38 +++++++-- crates/engine/src/commands/model3d.rs | 7 +- crates/engine/src/commands/panels_cmds.rs | 19 ++++- crates/engine/src/tests.rs | 21 +++++ crates/ui-egui/src/panels/timeline.rs | 78 +++++++++++++----- crates/ui-egui/tests/ui_drag_drop.rs | 99 +++++++++++++++++++++++ 6 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 crates/ui-egui/tests/ui_drag_drop.rs diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index b8653f4d..58d35fec 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -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 { +pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result { + 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> { + 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) -> Result { // 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 { 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 { 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 { 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), diff --git a/crates/engine/src/commands/model3d.rs b/crates/engine/src/commands/model3d.rs index 5de5a2c2..5cd3b86d 100644 --- a/crates/engine/src/commands/model3d.rs +++ b/crates/engine/src/commands/model3d.rs @@ -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 { 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 { 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 { "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 ), diff --git a/crates/engine/src/commands/panels_cmds.rs b/crates/engine/src/commands/panels_cmds.rs index 325e0da0..d7179b3e 100644 --- a/crates/engine/src/commands/panels_cmds.rs +++ b/crates/engine/src/commands/panels_cmds.rs @@ -110,8 +110,13 @@ fn browser_import(s: &mut Session, p: &Value) -> Result { && 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 { 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", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6ae18dd4..19a13514 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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::>(); + 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() { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 21e66dd1..0100cb26 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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, 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::(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::>(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::(&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); } } diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs new file mode 100644 index 00000000..08d4ac0f --- /dev/null +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -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 { + 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"); +}