M13.18: drag layer rows in the Timeline to reorder them — a line shows where they land, the selected layers move together (an unselected row moves alone), one undo step, nothing for a drop in place; layer.arrange {above: layer} for agents
This commit is contained in:
parent
bb973e2c76
commit
52cf74e997
|
|
@ -689,6 +689,18 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (cid, ids) = layers_p(s, p)?;
|
||||
let how = str_p(p, "to").unwrap_or("front").to_string();
|
||||
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||||
// `above`: put the layers right above that one (the timeline's drag to reorder).
|
||||
let above = match p.get("above") {
|
||||
Some(v) => {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let a = resolve_layer(comp, v).ok_or_else(|| bad("layer.arrange", "no such layer for `above`"))?;
|
||||
if ids.contains(&a) {
|
||||
return Err(bad("layer.arrange", "`above` must be a layer that isn't being moved"));
|
||||
}
|
||||
Some(a)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
s.edit("Arrange Layers", None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let n = comp.layers.len();
|
||||
|
|
@ -704,6 +716,7 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
});
|
||||
let at = match (how.as_str(), index) {
|
||||
_ if above.is_some() => comp.layers.iter().position(|l| Some(l.id) == above).unwrap_or(comp.layers.len()),
|
||||
(_, Some(i)) => i.saturating_sub(1).min(comp.layers.len()),
|
||||
("front", _) => 0,
|
||||
("back", _) => comp.layers.len(),
|
||||
|
|
@ -1144,7 +1157,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
p["frames"] = json!(1);
|
||||
slip(s, &p)
|
||||
}),
|
||||
cmd!("layer.arrange", "Arrange", ["Layer", "Arrange"], None, "{layers?, to: front|forward|backward|back, index?}", has_layers, arrange),
|
||||
cmd!("layer.arrange", "Arrange", ["Layer", "Arrange"], None, "{layers?, to: front|forward|backward|back, index?, above?: layer}", has_layers, arrange),
|
||||
cmd!(
|
||||
"layer.precompose",
|
||||
"Pre-compose...",
|
||||
|
|
|
|||
|
|
@ -571,3 +571,26 @@ fn time_stretch_holds_in_current_or_out() {
|
|||
assert!((ly.out_point.seconds() - 4.0).abs() < 1e-6 && (ly.in_point.seconds() - 2.0).abs() < 1e-6);
|
||||
assert!(s.execute("layer.timeStretch", json!({"layers": [l], "percent": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_above_moves_layers_to_a_place_in_the_stack() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Stack", "width": 100, "height": 100, "duration": 2})).unwrap();
|
||||
for n in ["D", "C", "B", "A"] {
|
||||
s.execute("layer.newSolid", json!({"name": n, "width": 10, "height": 10})).unwrap();
|
||||
}
|
||||
let names = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&s), ["A", "B", "C", "D"]);
|
||||
// A below C (one undo step), then B and D together above A, then C to the bottom.
|
||||
s.execute("layer.arrange", json!({"layers": ["A"], "above": "D"})).unwrap();
|
||||
assert_eq!(names(&s), ["B", "C", "A", "D"]);
|
||||
s.execute("layer.arrange", json!({"layers": ["B", "D"], "above": "A"})).unwrap();
|
||||
assert_eq!(names(&s), ["C", "B", "D", "A"]);
|
||||
s.execute("layer.arrange", json!({"layers": ["C"], "to": "back"})).unwrap();
|
||||
assert_eq!(names(&s), ["B", "D", "A", "C"]);
|
||||
s.undo();
|
||||
assert_eq!(names(&s), ["C", "B", "D", "A"]);
|
||||
// A layer can't go above itself; unknown targets fail.
|
||||
assert!(s.execute("layer.arrange", json!({"layers": ["B"], "above": "B"})).is_err());
|
||||
assert!(s.execute("layer.arrange", json!({"layers": ["B"], "above": "Nope"})).is_err());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -724,6 +724,15 @@ fn layer_icon(l: &Layer, project: &effectcraft_engine::project::Project) -> Icon
|
|||
}
|
||||
}
|
||||
|
||||
/// Layers being dragged to a new place in the stack (timeline rows).
|
||||
fn layer_drag_id() -> egui::Id {
|
||||
egui::Id::new("tl-layer-drag")
|
||||
}
|
||||
/// Set on the frame the reorder drag is released.
|
||||
fn layer_drop_id() -> egui::Id {
|
||||
egui::Id::new("tl-layer-drop")
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
|
|
@ -1210,10 +1219,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let lpn = lp.with_clip_rect(name_rect.intersect(lp.clip_rect()));
|
||||
lpn.text(pos2(name_rect.min.x, cy), Align2::LEFT_CENTER, display_name(app, layer), Tokens::ui(12.0), name_col);
|
||||
}
|
||||
// Row click → select; double-click → rename; drag → reorder (later).
|
||||
// Row click → select; double-click → rename; drag → reorder.
|
||||
let row_x0 = if vis.num { cw.num } else { cw.name + 16.0 };
|
||||
let row_resp =
|
||||
ui.interact(Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)), egui::Id::new(("row", layer.id.0)), Sense::click());
|
||||
let row_resp = ui.interact(
|
||||
Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)),
|
||||
egui::Id::new(("row", layer.id.0)),
|
||||
Sense::click_and_drag(),
|
||||
);
|
||||
if row_resp.drag_started() {
|
||||
// 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));
|
||||
}
|
||||
app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name);
|
||||
if row_resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
|
|
@ -1904,6 +1925,38 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
gp.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-box"), br));
|
||||
}
|
||||
// Layer reorder drag: a line where the layers will land; the release moves them there.
|
||||
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);
|
||||
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)
|
||||
&& let Some(last) = layer_rows.last()
|
||||
{
|
||||
// The first layer row whose middle is below the pointer: the layers go above it.
|
||||
let at = layer_rows.iter().position(|(rr, _)| py < rr.center().y);
|
||||
let line_y = at.map(|i| layer_rows[i].0.min.y).unwrap_or(last.0.max.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::<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());
|
||||
order.splice(at..at, moving.iter().copied());
|
||||
if order != comp.layers.iter().map(|l| l.id.0).collect::<Vec<_>>() {
|
||||
let params = match target {
|
||||
Some(t) => json!({"layers": moving, "above": t.0}),
|
||||
None => json!({"layers": moving, "to": "back"}),
|
||||
};
|
||||
actions.push(("layer.arrange".into(), params));
|
||||
}
|
||||
} else {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
if empty.drag_stopped()
|
||||
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("tl-box")))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -534,3 +534,29 @@ fn middle_and_hand_drags_pan_the_viewer_and_the_pan_stays_after_release() {
|
|||
let end = h.state().ui.viewer.pan;
|
||||
assert!((end[0] - after[0] + 50.0).abs() < 1.0 && (end[1] - after[1] + 30.0).abs() < 1.0, "{after:?} → {end:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_rows_drag_to_reorder_layers() {
|
||||
let mut h = harness();
|
||||
let names = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&h), ["Box", "Plate"]);
|
||||
let row = |h: &Harness<'_, EffectcraftApp>, name: &str| {
|
||||
let l = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0;
|
||||
rect(h, &format!("timeline.layer.{l}.row"))
|
||||
};
|
||||
// Drag "Box" (top) below "Plate": it becomes the bottom layer, in one undo step.
|
||||
let (from, plate) = (row(&h, "Box").center(), row(&h, "Plate"));
|
||||
let steps = h.state().session.history.undo.len();
|
||||
drag(&mut h, from, plate.center() + vec2(0.0, plate.height() * 0.4));
|
||||
assert_eq!(names(&h), ["Plate", "Box"]);
|
||||
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));
|
||||
assert_eq!(names(&h), ["Plate", "Box"]);
|
||||
assert_eq!(h.state().session.history.undo.len(), steps + 1);
|
||||
// And back to the top.
|
||||
let (from, top) = (row(&h, "Box").center(), row(&h, "Plate"));
|
||||
drag(&mut h, from, top.center() - vec2(0.0, top.height() * 0.4));
|
||||
assert_eq!(names(&h), ["Box", "Plate"]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue