mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
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:
co-authored by
Claude Opus 5.5
parent
f868cc68f3
commit
5d764e6473
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user