diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7ae50ac0d..dad0af927 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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, f32) { +fn insertion(layer_rows: &[(Rect, T)], 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), @@ -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::(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::(group_drag_id())); + } + let Some(parent) = parent.filter(|_| valid) else { return }; + let siblings: Vec = 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 = 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; diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index c8b45e8da..aadb52ffe 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -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 = 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 { + 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"); +}