Timeline: drag effects, masks and shape items to reorder them (#2210)

Dragging an effect's (or mask's, shape item's, text animator's) row in
the Timeline drew a marquee. The row now drags: a line shows where it
lands among its siblings and the drop runs prop.moveGroup (one undo
step); Escape cancels, as the layer reorder drag does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-30 04:36:58 +07:00
co-authored by Claude Opus 5.5
parent f868cc68f3
commit 5d764e6473
2 changed files with 119 additions and 2 deletions
+67 -2
View File
@@ -1324,6 +1324,20 @@ fn layer_drag_id() -> egui::Id {
egui::Id::unique("tl-layer-drag")
}
/// An effect, mask, shape item or text animator being dragged to a new place among its
/// siblings (#2210).
#[derive(Clone)]
struct GroupDrag {
comp: ItemId,
owner: egui::Id,
layer: LayerId,
uid: u64,
}
fn group_drag_id() -> egui::Id {
egui::Id::unique("tl-group-drag")
}
/// A keyframe drag in progress: where the pointer started, the grabbed key's comp time
/// (seconds), how many frames the selected keys have moved so far and, for a Quick Offset
/// (Ctrl/Cmd+Alt), how many layers it staggers.
@@ -1522,7 +1536,7 @@ fn shift_range(ctx: &egui::Context, cid: ItemId, comp: &Comp, rows: &[Row], sele
/// 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) {
fn insertion<T>(layer_rows: &[(Rect, T)], 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),
@@ -1876,6 +1890,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
alt_scale_and_graph(app, ui, g, &ectx);
graph_empty_area(ui, g);
layer_reorder_drag(ui, g);
group_reorder_drag(ui, g);
quick_offset_follow(g);
key_drag_follow(app, g);
key_box_select(app, ui, g);
@@ -3550,6 +3565,7 @@ fn group_mask_mode(ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
/// Group row click and double-click.
fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let cid = g.tl.cid;
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let ui_actions = &mut g.ui_actions;
@@ -3558,7 +3574,12 @@ fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc:
let indent = rc.indent;
let RowKind::Group { uid, name, fx, .. } = &rc.row.kind else { return };
let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), Sense::click());
// Effects, masks, shape items and animators drag to a new place among their siblings.
let movable = !layer.switches.locked && layer.props.find_group(*uid).is_some_and(PropGroup::is_instance);
let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), if movable { Sense::click_and_drag() } else { Sense::click() });
if movable && gr.drag_started_by(egui::PointerButton::Primary) {
ui.ctx().data_mut(|d| d.insert_temp(group_drag_id(), GroupDrag { comp: cid, owner: gr.id, layer: layer.id, uid: *uid }));
}
app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
@@ -4472,6 +4493,50 @@ fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) {
// Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer
}
/// Group reorder drag (#2210): a line where the dragged effect, mask or shape item lands among
/// its siblings; the drop moves it there (`prop.moveGroup`). Escape cancels.
fn group_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) {
let ctx = g.tl.ctx.clone();
let comp = g.tl.comp.clone();
let Some(drag) = ctx.data(|d| d.get_temp::<GroupDrag>(group_drag_id())) else { return };
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 parent = comp.layer(drag.layer).and_then(|l| l.props.parent_of(drag.uid));
let valid = ui.is_enabled() && drag.comp == g.tl.cid && parent.is_some() && !escape && (dropped || (down && ctx.dragged_id() == Some(drag.owner)));
if dropped || !valid {
ctx.data_mut(|d| d.remove::<GroupDrag>(group_drag_id()));
}
let Some(parent) = parent.filter(|_| valid) else { return };
let siblings: Vec<u64> = parent.children.iter().map(Node::uid).collect();
let rows: Vec<(Rect, u64)> = g
.hit_rows
.iter()
.filter_map(|(r, row)| match &row.kind {
RowKind::Group { uid, .. } if row.layer == drag.layer && siblings.contains(uid) => Some((*r, *uid)),
_ => None,
})
.collect();
let Some(py) = ui.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())).map(|p| p.y) else { return };
if rows.is_empty() {
return;
}
let (at, line_y) = insertion(&rows, py, g.tl.rows_rect.min.y);
if !dropped {
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
g.tl.p.with_clip_rect(g.tl.rows_rect).line_segment([pos2(g.tl.rect.min.x, line_y), pos2(g.tl.graph_x0, line_y)], Stroke::new(2.0, g.tl.t.accent));
return;
}
// The sibling the drop lands before (or after, below the last visible one).
let Some(&(_, anchor)) = at.map_or(rows.last(), |i| rows.get(i)) else { return };
let from = siblings.iter().position(|u| *u == drag.uid);
let rest: Vec<u64> = siblings.iter().copied().filter(|u| *u != drag.uid).collect();
let to = if anchor == drag.uid { from } else { rest.iter().position(|u| *u == anchor).map(|i| i + usize::from(at.is_none())) };
if let Some(to) = to.filter(|to| Some(*to) != from) {
g.tl.actions.push(("prop.moveGroup".into(), json!({"layer": drag.layer.0, "prop": drag.uid, "index": to + 1})));
}
}
/// Quick Offset of layer bars follows the pointer.
fn quick_offset_follow(g: &mut Rw) {
let t = g.tl.t;
@@ -380,3 +380,55 @@ fn ctrl_v_pastes_svg_markup_as_a_shape_layer() {
assert_eq!(c.layers.len(), 4);
assert_eq!(c.layers[0].name, "Oval 2");
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, cancel: bool) {
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0)));
h.step();
}
if cancel {
h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
/// #2210: an effect's row drags to a new place in the layer's effect stack, as in After Effects
/// (not a marquee); Escape cancels the drag.
#[test]
fn effects_drag_to_a_new_place_in_the_timeline() {
let mut s = session();
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
for fx in ["Gaussian Blur", "Invert", "Tint"] {
s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap();
}
let effects = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().clone();
let uids: Vec<u64> = effects.groups().map(|g| g.uid).collect();
let mut h = harness(s);
h.state_mut().ui.timeline.open_groups.insert(effects.uid);
open_layer(&mut h, l);
let names = |h: &Harness<'_, EffectcraftApp>| -> Vec<String> {
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().groups().map(|g| g.name.clone()).collect()
};
let row = |h: &Harness<'_, EffectcraftApp>, uid: u64| {
let e = h.state().auto.find(&format!("timeline.group.{uid}.name")).unwrap_or_else(|| panic!("no row for {uid}"));
egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
};
// Tint above Gaussian Blur.
let (tint, blur) = (row(&h, uids[2]), row(&h, uids[0]));
drag(&mut h, tint.center(), pos2(blur.center().x, blur.min.y + 2.0), true);
assert_eq!(names(&h), ["Gaussian Blur", "Invert", "Tint"], "Escape cancels");
drag(&mut h, tint.center(), pos2(blur.center().x, blur.min.y + 2.0), false);
assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"]);
// Gaussian Blur (now second) below Invert, the last.
let (blur, invert) = (row(&h, uids[0]), row(&h, uids[1]));
drag(&mut h, blur.center(), pos2(invert.center().x, invert.max.y - 2.0), false);
assert_eq!(names(&h), ["Tint", "Invert", "Gaussian Blur"]);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"], "one undo step per drop");
}