Merge pull request #2519 from WillPastor/codex/2492-timeline-drag-release

Fix timeline layer drops after scrolling (#2492)
This commit is contained in:
BFlat
2026-10-30 01:21:15 +07:00
committed by GitHub
3 changed files with 379 additions and 16 deletions
+39 -15
View File
@@ -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<u64>,
}
fn layer_drag_id() -> egui::Id {
egui::Id::unique("tl-layer-drag")
}
@@ -1502,10 +1509,18 @@ fn insertion(layer_rows: &[(Rect, LayerId)], py: f32, top: f32) -> (Option<usize
(at, y)
}
/// The `egui::Id` of the flag the row drag's release sets, telling the same frame's reorder
/// drawing code to apply the drop.
fn layer_drop_id() -> egui::Id {
egui::Id::unique("tl-layer-drop")
/// Resolve a visible insertion boundary through the full outline, including offscreen layers.
fn layer_drop_target(rows: &[Row], visible: &[(Rect, LayerId)], at: Option<usize>, moving: &[u64]) -> Option<LayerId> {
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`
@@ -2903,13 +2918,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<u64> = 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 {
@@ -4355,6 +4367,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;
@@ -4364,16 +4377,29 @@ 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::<Vec<u64>>(layer_drag_id())) {
let dropped = ctx.data_mut(|d| d.remove_temp::<bool>(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::<LayerDrag>(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
&& 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::<LayerDrag>(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));
let target = layer_drop_target(&g.tl.rows, &layer_rows, at, moving);
if dropped {
ctx.data_mut(|d| d.remove::<Vec<u64>>(layer_drag_id()));
// Only when the order changes (no empty undo step for a drop in place).
let mut order: Vec<u64> = 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());
@@ -4389,8 +4415,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::<Vec<u64>>(layer_drag_id()));
}
}
// Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer
@@ -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<Row> = (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");
}
}
+330 -1
View File
@@ -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,335 @@ fn timeline_rows_drag_to_reorder_layers() {
assert_eq!(names(&h), ["Box", "Plate"]);
}
fn timeline_layer_order(h: &Harness<'_, EffectcraftApp>) -> Vec<u64> {
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<u64> = 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);
}
}
/// 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::<Vec<_>>()
};
// 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<u64> = 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<u64> = 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] {
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
}