From 4fccf2a3b7ea9afabd410d8eb2d0b105897454d5 Mon Sep 17 00:00:00 2001 From: WillPastor <47420496+WillPastor@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:28:03 -0400 Subject: [PATCH 1/2] Fix timeline layer drops after scrolling (#2492) Finish reordering from the stored egui drag owner and primary-button release even when wheel scrolling culls the originating row. Cancel disabled, interrupted or stale gestures before they can arrange layers. Cover scrolling in both directions, selected groups, Undo/Redo, later drags and cancellation boundaries, including opening Settings mid-drag. Make the existing no-op control cross the drag threshold. Fixes #2492. --- crates/ui-egui/src/panels/timeline.rs | 38 +++-- crates/ui-egui/tests/it/ui_viewer.rs | 217 +++++++++++++++++++++++++- 2 files changed, 238 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a45d8e708..b9c2f580a 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1293,6 +1293,13 @@ fn layer_icon(l: &Layer, project: &effectcraft_engine::project::Project) -> Icon } /// Layers being dragged to a new place in the stack (timeline rows). +#[derive(Clone)] +struct LayerDrag { + comp: ItemId, + owner: egui::Id, + layers: Vec, +} + fn layer_drag_id() -> egui::Id { egui::Id::unique("tl-layer-drag") } @@ -1502,12 +1509,6 @@ fn insertion(layer_rows: &[(Rect, LayerId)], py: f32, top: f32) -> (Option egui::Id { - egui::Id::unique("tl-layer-drop") -} - /// Whether a Project item can replace a layer's source (the same set `layer.replaceSource` /// accepts): compositions, footage and solids; not folders, data files or 3D models. fn replaceable_item(it: &effectcraft_engine::project::Item) -> bool { @@ -2903,13 +2904,10 @@ fn layer_row_actions(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc let is_sel = rc.is_sel; // Locked layers can't be selected or moved (unlock them with the lock switch). let locked = layer.switches.locked; - if row_resp.drag_started() && !locked { + if row_resp.drag_started_by(egui::PointerButton::Primary) && !locked { // The selected layers move together; an unselected row moves alone. let moving: Vec = if is_sel { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; - ctx.data_mut(|d| d.insert_temp(layer_drag_id(), moving)); - } - if row_resp.drag_stopped() { - ctx.data_mut(|d| d.insert_temp(layer_drop_id(), true)); + ctx.data_mut(|d| d.insert_temp(layer_drag_id(), LayerDrag { comp: cid, owner: row_resp.id, layers: moving })); } app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name); if row_resp.clicked() && !locked { @@ -4351,6 +4349,7 @@ fn graph_empty_area(ui: &mut egui::Ui, g: &mut Rw) { /// Layer reorder drag: a line where the layers land. fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { + let cid = g.tl.cid; let t = g.tl.t; let rect = g.tl.rect; let graph_x0 = g.tl.graph_x0; @@ -4360,16 +4359,25 @@ fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { let p = g.tl.p.clone(); let actions = &mut g.tl.actions; let hit_rows = &mut g.hit_rows; - if let Some(moving) = ctx.data(|d| d.get_temp::>(layer_drag_id())) { - let dropped = ctx.data_mut(|d| d.remove_temp::(layer_drop_id())).unwrap_or(false); + // The origin row can scroll out of view, so finish the gesture here, independently of + // its Response. Disabled UI, Escape or lost ownership cancels; only a primary release drops layers. + if let Some(drag) = ctx.data(|d| d.get_temp::(layer_drag_id())) { + let (down, released, escape) = + ctx.input(|i| (i.pointer.primary_down(), i.pointer.button_released(egui::PointerButton::Primary), i.key_pressed(egui::Key::Escape))); + let dropped = ctx.drag_stopped_id() == Some(drag.owner) && released; + let valid = ui.is_enabled() && drag.comp == cid && !escape && (dropped || (down && ctx.dragged_id() == Some(drag.owner))); + if dropped || !valid { + ctx.data_mut(|d| d.remove::(layer_drag_id())); + } + let moving = &drag.layers; 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) + && valid && !layer_rows.is_empty() { 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())); // Only when the order changes (no empty undo step for a drop in place). let mut order: Vec = comp.layers.iter().map(|l| l.id.0).filter(|id| !moving.contains(id)).collect(); let at = target.and_then(|t| order.iter().position(|id| *id == t.0)).unwrap_or(order.len()); @@ -4385,8 +4393,6 @@ fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { ctx.set_cursor_icon(egui::CursorIcon::Grabbing); p.with_clip_rect(rows_rect).line_segment([pos2(rect.min.x, line_y), pos2(graph_x0, line_y)], Stroke::new(2.0, t.accent)); } - } else if dropped { - ctx.data_mut(|d| d.remove::>(layer_drag_id())); } } // Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index 993925b59..9df6e62a0 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -1718,7 +1718,7 @@ fn timeline_rows_drag_to_reorder_layers() { assert_eq!(h.state().session.history.undo.len(), steps + 1); // Dropping a layer where it already is changes nothing. let b = row(&h, "Box").center(); - drag(&mut h, b, b + vec2(0.0, 3.0)); + drag(&mut h, b, b + vec2(20.0, 0.0)); assert_eq!(names(&h), ["Plate", "Box"]); assert_eq!(h.state().session.history.undo.len(), steps + 1); // And back to the top. @@ -1727,6 +1727,221 @@ fn timeline_rows_drag_to_reorder_layers() { assert_eq!(names(&h), ["Box", "Plate"]); } +fn timeline_layer_order(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect() +} + +fn timeline_layer_row(h: &Harness<'_, EffectcraftApp>, layer: u64) -> Rect { + rect(h, &format!("timeline.layer.{layer}.row")) +} + +fn start_timeline_row_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerMoved(from + vec2(20.0, 0.0))); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "the row drag started"); +} + +fn release_timeline_row_drag(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +fn scroll_timeline(h: &mut Harness<'_, EffectcraftApp>, dy: f32) { + let at = rect(h, "timeline.layerMarquee").center(); + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, dy), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + }); + h.run_steps(20); +} + +/// Wheel-scroll far enough to stop drawing the row that owns the held drag, then drop at +/// the other end of the stack. The release must work without that row's Response (#2492). +fn timeline_reorder_after_scroll(down: bool, multiple: bool) { + let mut h = harness(); + for i in 0..38 { + h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080", "width": 80, "height": 80})).unwrap(); + } + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(3); + let before = timeline_layer_order(&h); + let count = if multiple { 2 } else { 1 }; + let moving = if down { before[..count].to_vec() } else { before[before.len() - count..].to_vec() }; + if multiple { + h.state_mut().session.execute("layer.select", json!({"layers": moving})).unwrap(); + h.run_steps(2); + } + if !down { + scroll_timeline(&mut h, -10_000.0); + } + let origin = if down { before[0] } else { *before.last().unwrap() }; + let from = timeline_layer_row(&h, origin).center(); + // Setup fills the bounded Undo history; reserve room to count this gesture's steps. + h.state_mut().session.history.clear(); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + scroll_timeline(&mut h, if down { -10_000.0 } else { 10_000.0 }); + assert!(h.state().auto.find(&format!("timeline.layer.{origin}.row")).is_none(), "the origin row was culled"); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "scrolling keeps the held drag active"); + let target = if down { *before.last().unwrap() } else { before[0] }; + let row = timeline_layer_row(&h, target); + let to = row.center() + vec2(0.0, row.height() * if down { 0.4 } else { -0.4 }); + release_timeline_row_drag(&mut h, to); + + let mut expected: Vec = before.iter().copied().filter(|id| !moving.contains(id)).collect(); + let at = if down { expected.len() } else { 0 }; + expected.splice(at..at, moving); + assert_eq!(timeline_layer_order(&h), expected, "the offscreen origin still completes its drop"); + assert_eq!(h.state().session.history.undo.len(), steps + 1, "one reorder, including a selected group, is one undo step"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "release clears the drag feedback"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(timeline_layer_order(&h), before); + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(timeline_layer_order(&h), expected); + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(3); + + // A later, independent drag must not reuse the old gesture or selected group. + let i = if down { expected.len() - 1 } else { 0 }; + let j = if down { i - 1 } else { 1 }; + let from = timeline_layer_row(&h, expected[i]).center(); + let row = timeline_layer_row(&h, expected[j]); + drag(&mut h, from, row.center() + vec2(0.0, row.height() * if down { -0.4 } else { 0.4 })); + expected.swap(i, j); + assert_eq!(timeline_layer_order(&h), expected, "the next drag is independent"); + assert_eq!(h.state().session.history.undo.len(), steps + 2); +} + +#[test] +fn timeline_rows_drag_to_bottom_after_scrolling() { + for multiple in [false, true] { + timeline_reorder_after_scroll(true, multiple); + } +} + +#[test] +fn timeline_rows_drag_to_top_after_scrolling() { + for multiple in [false, true] { + timeline_reorder_after_scroll(false, multiple); + } +} + +#[test] +fn timeline_rows_drag_cancels_without_a_primary_release() { + for escape in [false, true] { + let mut h = harness(); + let before = timeline_layer_order(&h); + let from = timeline_layer_row(&h, before[0]).center(); + let row = timeline_layer_row(&h, before[1]); + let to = row.center() + vec2(0.0, row.height() * 0.4); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + h.input_mut().events.push(Event::PointerMoved(to)); + h.step(); + if escape { + key(&mut h, egui::Key::Escape, Default::default()); + } else { + // egui stops its drag on another button's release too. It isn't a layer drop. + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + h.step(); + } + } + assert_eq!(timeline_layer_order(&h), before, "cancellation must not arrange layers"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + release_timeline_row_drag(&mut h, to); + assert_eq!(timeline_layer_order(&h), before, "a later release must not replay a cancelled drag"); + assert_eq!(h.state().session.history.undo.len(), steps); + } +} + +#[test] +fn timeline_rows_drag_cancels_when_a_modal_opens() { + let mut h = harness(); + let before = timeline_layer_order(&h); + let from = timeline_layer_row(&h, before[0]).center(); + let row = timeline_layer_row(&h, before[1]); + let to = row.center() + vec2(0.0, row.height() * 0.4); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + let (modifiers, shortcut) = effectcraft_ui_egui::menus::parse_shortcut("Cmd+Alt+;").unwrap(); + key(&mut h, shortcut, modifiers); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Settings)); + assert!(!h.ctx.read_response(egui::Id::unique(("row", before[0]))).unwrap().enabled(), "the modal disables the Timeline row"); + assert!(h.ctx.input(|i| i.pointer.primary_down()), "the modal opened during the held drag"); + release_timeline_row_drag(&mut h, to); + assert_eq!(timeline_layer_order(&h), before, "a release behind the modal must not arrange layers"); + assert_eq!(h.state().session.history.undo.len(), steps); + key(&mut h, egui::Key::Escape, Default::default()); + assert!(h.state().dialog.is_none()); + assert_eq!(timeline_layer_order(&h), before, "closing the modal must not replay the drag"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + let from = timeline_layer_row(&h, before[0]).center(); + let row = timeline_layer_row(&h, before[1]); + drag(&mut h, from, row.center() + vec2(0.0, row.height() * 0.4)); + assert_eq!(timeline_layer_order(&h), [before[1], before[0]]); + assert_eq!(h.state().session.history.undo.len(), steps + 1); +} + +#[test] +fn timeline_rows_drag_does_not_replay_a_release_while_hidden() { + use effectcraft_ui_egui::dock::PanelKind; + let mut h = harness(); + let before = timeline_layer_order(&h); + let from = timeline_layer_row(&h, before[0]).center(); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + h.state_mut().close_panel(PanelKind::Timeline); + h.run_steps(2); + assert!(h.state().auto.find("timeline.layerMarquee").is_none()); + release_timeline_row_drag(&mut h, from); + h.state_mut().show_panel(PanelKind::Timeline); + h.run_steps(3); + assert_eq!(timeline_layer_order(&h), before); + assert_eq!(h.state().session.history.undo.len(), steps); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + let from = timeline_layer_row(&h, before[0]).center(); + let row = timeline_layer_row(&h, before[1]); + drag(&mut h, from, row.center() + vec2(0.0, row.height() * 0.4)); + assert_eq!(timeline_layer_order(&h), [before[1], before[0]]); + assert_eq!(h.state().session.history.undo.len(), steps + 1); +} + +#[test] +fn timeline_rows_drag_cancels_when_the_composition_changes() { + let mut h = harness(); + let original = h.state().session.active_comp_id().unwrap(); + let before = timeline_layer_order(&h); + h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 640, "height": 360, "duration": 4})).unwrap(); + h.state_mut().session.execute("layer.newSolid", json!({"name": "Other layer", "color": "#406080", "width": 80, "height": 80})).unwrap(); + let other = h.state().session.active_comp_id().unwrap(); + let other_before = timeline_layer_order(&h); + h.state_mut().session.execute("comp.open", json!({"comp": original.0})).unwrap(); + h.run_steps(3); + let from = timeline_layer_row(&h, before[0]).center(); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + h.state_mut().session.execute("comp.open", json!({"comp": other.0})).unwrap(); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + release_timeline_row_drag(&mut h, from); + assert_eq!(timeline_layer_order(&h), other_before); + h.state_mut().session.execute("comp.open", json!({"comp": original.0})).unwrap(); + h.run_steps(3); + assert_eq!(timeline_layer_order(&h), before); + assert_eq!(h.state().session.history.undo.len(), steps); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); +} + 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 } From 559958ea62ef80e2b51ac4a5ce77724433955507 Mon Sep 17 00:00:00 2001 From: WillPastor <47420496+WillPastor@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:32:33 -0400 Subject: [PATCH 2/2] Fix timeline drop targets and deleted drag layers (#2492) Resolve viewport-edge insertion through the full Timeline outline, skipping moving layers even below the viewport. Cancel a gesture if any captured layer disappears before its ID can be interpreted as a layer index. Cover intermediate drops, selected groups, deletion of the owner or another group member, Undo/Redo, and later independent drags. --- crates/ui-egui/src/panels/timeline.rs | 22 +++- crates/ui-egui/src/panels/timeline/tests.rs | 10 ++ crates/ui-egui/tests/it/ui_viewer.rs | 114 ++++++++++++++++++++ 3 files changed, 144 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b9c2f580a..7dff42172 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1509,6 +1509,20 @@ fn insertion(layer_rows: &[(Rect, LayerId)], py: f32, top: f32) -> (Option, moving: &[u64]) -> Option { + let (anchor, after) = match at { + Some(i) => (visible.get(i)?.1, false), + None => (visible.last()?.1, true), + }; + rows.iter() + .filter(|row| matches!(row.kind, RowKind::Layer)) + .skip_while(|row| row.layer != anchor) + .skip(usize::from(after)) + .find(|row| !moving.contains(&row.layer.0)) + .map(|row| row.layer) +} + /// Whether a Project item can replace a layer's source (the same set `layer.replaceSource` /// accepts): compositions, footage and solids; not folders, data files or 3D models. fn replaceable_item(it: &effectcraft_engine::project::Item) -> bool { @@ -4365,7 +4379,11 @@ fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { let (down, released, escape) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.button_released(egui::PointerButton::Primary), i.key_pressed(egui::Key::Escape))); let dropped = ctx.drag_stopped_id() == Some(drag.owner) && released; - let valid = ui.is_enabled() && drag.comp == cid && !escape && (dropped || (down && ctx.dragged_id() == Some(drag.owner))); + let valid = ui.is_enabled() + && drag.comp == cid + && drag.layers.iter().all(|id| comp.layer(LayerId(*id)).is_some()) + && !escape + && (dropped || (down && ctx.dragged_id() == Some(drag.owner))); if dropped || !valid { ctx.data_mut(|d| d.remove::(layer_drag_id())); } @@ -4376,7 +4394,7 @@ fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { && !layer_rows.is_empty() { 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)); + let target = layer_drop_target(&g.tl.rows, &layer_rows, at, moving); if dropped { // Only when the order changes (no empty undo step for a drop in place). let mut order: Vec = comp.layers.iter().map(|l| l.id.0).filter(|id| !moving.contains(id)).collect(); diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 0355ea5ed..d05787a18 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -535,3 +535,13 @@ fn disk_bar_range_covers_partial_frames() { let above_limit = TMap { start: 100_001.0, ..tm }; assert_eq!(disk_bar_range(&above_limit, clip, 1.0, i64::MAX), 100_000..100_000); } +#[test] +fn layer_drop_target_skips_moving_layers_beyond_the_viewport() { + let mut rows: Vec = (1..=6).map(|id| Row { layer: LayerId(id), depth: 0, kind: RowKind::Layer }).collect(); + rows.insert(4, Row { layer: LayerId(4), depth: 1, kind: RowKind::Prop { uid: 0 } }); + let visible: Vec<_> = (2..=4).map(|id| (Rect::NOTHING, LayerId(id))).collect(); + for at in [Some(2), None] { + assert_eq!(layer_drop_target(&rows, &visible, at, &[1, 4, 5]), Some(LayerId(6)), "skip moving headers and their properties across the viewport edge"); + assert_eq!(layer_drop_target(&rows, &visible, at, &[1, 4, 5, 6]), None, "the stack ends when no stationary layer follows"); + } +} diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index 9df6e62a0..c9024ac58 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -1835,6 +1835,120 @@ fn timeline_rows_drag_to_top_after_scrolling() { } } +/// An intermediate scroll must not turn the viewport's lower edge into the stack's end. +fn timeline_reorder_at_viewport_edge(below: bool, multiple: bool) { + let mut h = harness(); + for i in 0..38 { + h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080", "width": 80, "height": 80})).unwrap(); + } + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(3); + let before = timeline_layer_order(&h); + let moving = before[..if multiple { 2 } else { 1 }].to_vec(); + h.state_mut().session.execute("layer.select", json!({"layers": moving})).unwrap(); + h.run_steps(2); + h.state_mut().session.history.clear(); + let from = timeline_layer_row(&h, moving[0]).center(); + start_timeline_row_drag(&mut h, from); + scroll_timeline(&mut h, -200.0); + let visible = |h: &Harness<'_, EffectcraftApp>| { + before.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.layer.{id}.row")).is_some()).collect::>() + }; + // Leave space inside the viewport below the last rendered layer's midpoint. + for _ in 0..20 { + let last = timeline_layer_row(&h, *visible(&h).last().unwrap()); + if last.center().y < rect(&h, "timeline.layerMarquee").max.y - 3.0 { + break; + } + scroll_timeline(&mut h, -10.0); + } + let shown = visible(&h); + assert!(!shown.contains(&moving[0]), "the drag owner is culled"); + let last = *shown.last().unwrap(); + assert_ne!(last, *before.last().unwrap(), "layers remain below the viewport"); + let row = timeline_layer_row(&h, last); + let viewport = rect(&h, "timeline.layerMarquee"); + let y = if below { (row.center().y + row.height() * 0.4).min(viewport.max.y - 1.0) } else { row.min.y + row.height() * 0.2 }; + let to = pos2(row.center().x, y); + assert!(viewport.contains(to)); + assert_eq!(to.y > row.center().y, below); + let mut expected: Vec = before.iter().copied().filter(|id| !moving.contains(id)).collect(); + let at = expected.iter().position(|id| *id == last).unwrap() + usize::from(below); + expected.splice(at..at, moving); + release_timeline_row_drag(&mut h, to); + assert_eq!(timeline_layer_order(&h), expected, "the drop follows the indicated row boundary"); + assert_eq!(h.state().session.history.undo.len(), 1); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(timeline_layer_order(&h), before); + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(timeline_layer_order(&h), expected); +} + +#[test] +fn timeline_rows_drag_after_last_visible_row() { + for multiple in [false, true] { + timeline_reorder_at_viewport_edge(true, multiple); + } +} + +#[test] +fn timeline_rows_drag_before_last_visible_row() { + for multiple in [false, true] { + timeline_reorder_at_viewport_edge(false, multiple); + } +} + +/// A removed ID must not be reinterpreted as a one-based index by layer.arrange. +fn timeline_reorder_after_deletion(delete_owner: bool) { + let mut h = harness(); + for i in 0..6 { + h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080", "width": 80, "height": 80})).unwrap(); + } + let before = timeline_layer_order(&h); + let removed = *before.last().unwrap(); + assert!(removed > 0 && removed < before.len() as u64, "the missing ID would also resolve as a surviving layer index"); + let owner = if delete_owner { removed } else { before[0] }; + let moving = if delete_owner { vec![removed] } else { vec![owner, removed] }; + h.state_mut().session.execute("layer.select", json!({"layers": moving})).unwrap(); + h.run_steps(3); + h.state_mut().session.history.clear(); + let from = timeline_layer_row(&h, owner).center(); + start_timeline_row_drag(&mut h, from); + if !delete_owner { + // Changing the selection does not change the group captured by the held drag. + h.state_mut().session.execute("layer.select", json!({"layers": [removed]})).unwrap(); + h.run_steps(2); + } + key(&mut h, egui::Key::Delete, Default::default()); + assert!(h.ctx.input(|i| i.pointer.primary_down()), "Delete happened during the held drag"); + let survivors: Vec = before.iter().copied().filter(|id| *id != removed).collect(); + assert_eq!(timeline_layer_order(&h), survivors, "Delete removed only the selected layer"); + let steps = h.state().session.history.undo.len(); + let row = timeline_layer_row(&h, survivors[0]); + release_timeline_row_drag(&mut h, row.center() - vec2(0.0, row.height() * 0.4)); + assert_eq!(timeline_layer_order(&h), survivors, "a stale drag cannot move any surviving layer"); + assert_eq!(h.state().session.history.undo.len(), steps, "release adds no undo step"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + let from = timeline_layer_row(&h, survivors[0]).center(); + let row = timeline_layer_row(&h, survivors[1]); + drag(&mut h, from, row.center() + vec2(0.0, row.height() * 0.4)); + let mut expected = survivors; + expected.swap(0, 1); + assert_eq!(timeline_layer_order(&h), expected, "a later independent drag still works"); + assert_eq!(h.state().session.history.undo.len(), steps + 1); +} + +#[test] +fn timeline_rows_drag_cancels_when_the_owner_is_deleted() { + timeline_reorder_after_deletion(true); +} + +#[test] +fn timeline_rows_drag_cancels_when_a_group_member_is_deleted() { + timeline_reorder_after_deletion(false); +} + #[test] fn timeline_rows_drag_cancels_without_a_primary_release() { for escape in [false, true] {