diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 21e83a0c..a15694ca 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -20,7 +20,12 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> { // Text editing pastes the system clipboard's text (passed as `text`) or copied text. return Ok(()); } - if s.state.clipboard.is_empty() && s.state.key_clipboard.is_empty() && s.state.effect_clipboard.is_empty() && s.state.link_clipboard.is_none() { + if s.state.clipboard.is_empty() + && s.state.key_clipboard.is_empty() + && s.state.effect_clipboard.is_empty() + && s.state.contents_clipboard.is_empty() + && s.state.link_clipboard.is_none() + { Err("the clipboard is empty".into()) } else { Ok(()) @@ -190,6 +195,7 @@ fn clear_clipboards(s: &mut Session) { s.state.clipboard.clear(); s.state.key_clipboard.clear(); s.state.effect_clipboard.clear(); + s.state.contents_clipboard.clear(); s.state.link_clipboard = None; s.state.clip_is_keys = false; } @@ -203,12 +209,22 @@ fn copy(s: &mut Session, p: &Value) -> Result { if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { s.state.link_clipboard = None; s.state.effect_clipboard.clear(); + s.state.contents_clipboard.clear(); return s.execute("keys.copy", json!({})); } // Effects selected (Effect Controls / timeline) → copy the effects. if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() { return s.execute("effect.copy", json!({})); } + // Shape items selected in a shape layer's Contents → copy them (not their layer). + if p.get("layers").is_none() { + let items = super::prop_groups::copy_contents(s); + if !items.is_empty() { + clear_clipboards(s); + s.state.contents_clipboard = items; + return Ok(json!({"contents": s.state.contents_clipboard.len()})); + } + } let (cid, ids) = layers_p(s, p)?; s.state.clip_is_keys = false; let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?; @@ -344,6 +360,14 @@ fn cut(s: &mut Session, p: &Value) -> Result { s.execute("keys.copy", json!({}))?; return s.execute("keys.delete", json!({})); } + // Shape items selected → copied, then removed from their layers (Paste moves them). + if p.get("layers").is_none() && super::effect::selected_effects(s).is_empty() { + let items = super::prop_groups::selected_contents(s); + if !items.is_empty() { + copy(s, p)?; + return super::prop_groups::cut_contents(s, &items); + } + } copy(s, p)?; delete(s, p) } @@ -361,6 +385,9 @@ fn paste(s: &mut Session, p: &Value) -> Result { if !s.state.effect_clipboard.is_empty() { return s.execute("effect.paste", p.clone()); } + if !s.state.contents_clipboard.is_empty() { + return super::prop_groups::paste_contents(s, p); + } let cid = super::comp_id(s, p)?; let clip = s.state.clipboard.clone(); let new = s.edit("Paste", None, |proj, st| { @@ -642,8 +669,8 @@ pub fn specs() -> Vec { vec![ cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo), cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo), - cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?} (keyframes when keys are selected)", layers_or_keys, cut), - cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?} (keyframes when keys are selected)", layers_or_keys, copy), + cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?} (keyframes, effects or shape items when they are selected)", layers_or_keys, cut), + cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?} (keyframes, effects or shape items when they are selected)", layers_or_keys, copy), cmd!( "edit.copyWithPropertyLinks", "Copy with Property Links", @@ -663,7 +690,15 @@ pub fn specs() -> Vec { |s, p| copy_links(s, p, true) ), cmd!("edit.copyExpressionOnly", "Copy Expression Only", ["Edit"], None, "{}", has_props_or_layers, copy_expression_only), - cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{} (layers, keyframes at the CTI, or property links / expressions)", has_clip, paste), + cmd!( + "edit.paste", + "Paste", + ["Edit"], + Some("Cmd+V"), + "{} (layers, keyframes at the CTI, effects, shape items into the selected shape layers, or property links / expressions)", + has_clip, + paste + ), cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed), cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete), cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, duplicate), diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 9eea8063..84f29901 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -301,6 +301,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { fx.iter().filter_map(|(l, u)| comp.layer(*l).and_then(|l| l.effects()).and_then(|e| e.groups().find(|g| g.uid == *u)).cloned()).collect(); s.state.clipboard.clear(); s.state.key_clipboard.clear(); + s.state.contents_clipboard.clear(); s.state.link_clipboard = None; s.state.clip_is_keys = false; let n = groups.len(); diff --git a/crates/engine/src/commands/prop_groups.rs b/crates/engine/src/commands/prop_groups.rs index 6ead8834..419a2b11 100644 --- a/crates/engine/src/commands/prop_groups.rs +++ b/crates/engine/src/commands/prop_groups.rs @@ -2,10 +2,10 @@ //! effect, mask, shape item or text animator (what scripting's `PropertyGroup.name`, `enabled`, //! `remove()`, `duplicate()` and `moveTo()` do, and what agents need without a UI selection). -use effectcraft_project::{GroupKind, ItemId, LayerId, Node, PropGroup, Uid}; +use effectcraft_project::{GroupKind, ItemId, LayerId, LayerSource, Node, PropGroup, Uid}; use serde_json::{Value, json}; -use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, str_p}; +use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, layers_p, str_p}; use crate::{EngineError, Result, Session, cmd}; /// Groups that can be renamed, removed, duplicated and reordered: instances in indexed lists. @@ -52,13 +52,13 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { fn remove(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = group_ref(s, p, "prop.removeGroup", true)?; - remove_groups(s, cid, &[(lid, uid)])?; + remove_groups(s, cid, &[(lid, uid)], "Delete")?; Ok(Value::Null) } /// Remove groups of comp `cid` in one undo step. -fn remove_groups(s: &mut Session, cid: ItemId, groups: &[(LayerId, Uid)]) -> Result<()> { - s.edit("Delete", None, |proj, st| { +fn remove_groups(s: &mut Session, cid: ItemId, groups: &[(LayerId, Uid)], label: &str) -> Result<()> { + s.edit(label, None, |proj, st| { for (lid, uid) in groups { // A group inside one removed before it went with it. if let Some(parent) = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid) { @@ -79,10 +79,101 @@ pub(crate) fn remove_selected(s: &mut Session) -> Result> { if groups.is_empty() { return Ok(None); } - remove_groups(s, cid, &groups)?; + remove_groups(s, cid, &groups, "Delete")?; Ok(Some(json!(groups.len()))) } +/// Shape items (groups, paths, paints and path operations) selected in the Contents of the +/// active comp's shape layers, top to bottom. An item inside another selected one goes with it, +/// so it isn't listed. +pub(crate) fn selected_contents(s: &Session) -> Vec<(LayerId, Uid)> { + let mut out = vec![]; + let Some(c) = s.active_comp().filter(|_| !s.state.selected_props.is_empty()) else { return out }; + for l in c.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)) { + if let Some(contents) = l.props.sub("contents") { + let selected = |u: Uid| s.state.selected_props.contains(&(l.id, u)); + let mut uids = vec![]; + collect_selected(contents, &selected, &mut uids, 0); + out.extend(uids.into_iter().map(|u| (l.id, u))); + } + } + out +} + +fn collect_selected(g: &PropGroup, selected: &dyn Fn(Uid) -> bool, out: &mut Vec, depth: usize) { + if depth > super::shape_stroke::MAX_DEPTH { + return; + } + for c in g.groups() { + if is_instance(c) && selected(c.uid) { + out.push(c.uid); + } else { + collect_selected(c, selected, out, depth + 1); + } + } +} + +/// Copies of the selected shape items (Edit ▸ Copy), top to bottom. +pub(crate) fn copy_contents(s: &Session) -> Vec { + let Some(c) = s.active_comp() else { return vec![] }; + selected_contents(s).into_iter().filter_map(|(lid, uid)| c.layer(lid)?.props.find_group(uid).cloned()).collect() +} + +/// Edit ▸ Cut's removal of the selected shape items (once copied). +pub(crate) fn cut_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + remove_groups(s, cid, items, "Cut")?; + Ok(json!({"contents": items.len()})) +} + +/// Edit ▸ Paste with shape items on the clipboard: copies go into each selected shape layer's +/// Contents, above its topmost selected item (in that item's group), else on top. Names stay +/// unique in their group ("Rectangle 2"…), and the copies are selected. +pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result { + let c = "edit.paste"; + let clip = s.state.contents_clipboard.clone(); + let (cid, ids) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let targets: Vec = + ids.into_iter().filter(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)).collect(); + if targets.is_empty() { + return Err(bad(c, "select a shape layer to paste shape items into")); + } + let sel = selected_contents(s); + let label = match clip.as_slice() { + [g] => format!("Paste {}", g.name), + _ => "Paste".to_string(), + }; + let pasted = s.edit(&label, None, |proj, st| { + let mut next = proj.next_id; + let mut out = vec![]; + for lid in &targets { + let l = layer_mut(proj, cid, *lid)?; + // Above the layer's topmost selected item, in its group. + let spot = sel.iter().find(|(sl, _)| sl == lid).and_then(|(_, u)| { + let parent = l.props.parent_of(*u)?; + Some((parent.uid, parent.children.iter().position(|n| n.uid() == *u)?)) + }); + let (parent, at) = match spot { + Some((pu, i)) => (l.props.find_group_mut(pu), i), + None => (l.props.sub_mut("contents"), 0), + }; + let parent = parent.ok_or_else(|| bad(c, "the layer has no contents"))?; + for (k, g) in clip.iter().enumerate() { + let mut g = g.clone(); + g.reassign_uids(&mut next); + g.name = super::effect::unique_name(parent, &g.name); + out.push((*lid, g.uid)); + parent.children.insert((at + k).min(parent.children.len()), g.into()); + } + } + proj.next_id = next + 1; + st.selected_props = out.clone(); + Ok(out) + })?; + Ok(json!({"contents": pasted.iter().map(|(_, u)| *u).collect::>()})) +} + fn duplicate(s: &mut Session, p: &Value) -> Result { let c = "prop.duplicateGroup"; let (cid, lid, uid) = group_ref(s, p, c, true)?; diff --git a/crates/engine/src/commands/shape_stroke.rs b/crates/engine/src/commands/shape_stroke.rs index 51ed0724..87fcd141 100644 --- a/crates/engine/src/commands/shape_stroke.rs +++ b/crates/engine/src/commands/shape_stroke.rs @@ -201,7 +201,7 @@ fn wave(s: &mut Session, p: &Value) -> Result { const PATHS: [&str; 4] = ["rect", "ellipse", "star", "path"]; /// Shape groups nest; deeper contents are left alone (never-crash recursion bound). -const MAX_DEPTH: usize = 64; +pub(crate) const MAX_DEPTH: usize = 64; /// The first Fill colour, Stroke colour and Stroke Width in `layer`'s contents at layer time /// `lt` (what the toolbar shows for a selected shape layer). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8c5b4816..361267a2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -194,6 +194,10 @@ pub struct EditorState { /// selected layers. #[serde(skip)] pub effect_clipboard: Vec, + /// Copied shape items (Edit ▸ Copy with groups, paths, paints or path operations selected in + /// a shape layer's Contents); Paste adds them to the selected shape layers. + #[serde(skip)] + pub contents_clipboard: Vec, /// Selected mask vertices (viewer Selection tool / pen). #[serde(default)] pub selected_vertices: Vec, diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 22b6cab4..eb09ff25 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -1055,3 +1055,59 @@ fn j_k_and_select_all_use_only_the_properties_the_timeline_shows() { assert_eq!(s.state.selected_keys.len(), 1); assert_eq!(s.state.selected_keys[0].prop, op); } + +/// A shape layer's Contents names, top to bottom. +fn contents_names(s: &Session, id: u64) -> Vec { + layer(s, id).props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect() +} + +/// The uid of the shape item `name` at the top level of a shape layer's Contents. +fn contents_item(s: &Session, id: u64, name: &str) -> u64 { + layer(s, id).props.sub("contents").unwrap().groups().find(|g| g.name == name).unwrap().uid +} + +/// Edit ▸ Copy / Cut / Paste with shape items selected copy the items, not their layer: Paste +/// puts them into the selected shape layer, above its selected item or on top, and Cut + Paste +/// moves them (#227). +#[test] +fn shape_contents_copy_cut_and_paste_between_shape_layers() { + let mut s = comp(); + let a = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("shape.newShape", json!({"layer": a, "kind": "ellipse"})).unwrap(); + let b = s.execute("layer.newShape", json!({"kind": "star", "name": "B"})).unwrap()["layer"].as_u64().unwrap(); + let n = s.active_comp().unwrap().layers.len(); + // Copy Rectangle 1 of A; paste into B (nothing of B's selected): on top of its Contents. + let rect = contents_item(&s, a, "Rectangle 1"); + s.execute("prop.select", json!({"layer": a, "prop": rect})).unwrap(); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["contents"], json!(1)); + assert!(s.state.clipboard.is_empty(), "not the layer"); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + let r = s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(contents_names(&s, b), ["Rectangle 1", "Polystar 1"]); + assert_eq!(s.active_comp().unwrap().layers.len(), n, "no new layer"); + let pasted = r["contents"][0].as_u64().unwrap(); + assert_ne!(pasted, rect); + assert_eq!(s.state.selected_props, vec![(effectcraft_project::LayerId(b), pasted)]); + // Again, with the pasted item selected: above it, with a unique name. + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(contents_names(&s, b), ["Rectangle 2", "Rectangle 1", "Polystar 1"]); + s.undo(); + assert_eq!(contents_names(&s, b), ["Rectangle 1", "Polystar 1"]); + assert_eq!(contents_names(&s, a), ["Ellipse 1", "Rectangle 1"], "the original stays"); + // Cut Ellipse 1 from A and paste it above B's Polystar 1: it moves. + s.execute("prop.select", json!({"layer": a, "prop": contents_item(&s, a, "Ellipse 1")})).unwrap(); + s.execute("edit.cut", json!({})).unwrap(); + assert_eq!(contents_names(&s, a), ["Rectangle 1"]); + assert_eq!(s.active_comp().unwrap().layers.len(), n, "the layer stays"); + s.execute("prop.select", json!({"layer": b, "prop": contents_item(&s, b, "Polystar 1")})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(contents_names(&s, b), ["Rectangle 1", "Ellipse 1", "Polystar 1"]); + s.undo(); + s.undo(); + assert_eq!(contents_names(&s, a), ["Ellipse 1", "Rectangle 1"]); + assert_eq!(contents_names(&s, b), ["Rectangle 1", "Polystar 1"]); + // Only shape layers take shape items. + let sol = solid(&mut s, "#ffffff"); + s.execute("layer.select", json!({"layers": [sol]})).unwrap(); + assert!(s.execute("edit.paste", json!({})).is_err()); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6cb70b82..e181398e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -620,6 +620,8 @@ fn clipboard_note(s: &effectcraft_engine::Session) -> String { n(st.key_clipboard.iter().map(|c| c.keys.len()).sum(), "keyframe", "keyframes") } else if !st.effect_clipboard.is_empty() { n(st.effect_clipboard.len(), "effect", "effects") + } else if !st.contents_clipboard.is_empty() { + n(st.contents_clipboard.len(), "shape item", "shape items") } else if st.link_clipboard.is_some() { "EffectCraft: property links".into() } else { diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index 27389247..8f11b027 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -1,6 +1,7 @@ //! Timeline rows for shape layers, masks and keyframing: the Contents "Add:" menu and the Classic //! 3D "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds and clicks below the -//! layers deselecting them (#205), and Mask Feather's linked values (#203). +//! layers deselecting them (#205), Mask Feather's linked values (#203), and copying shape items +//! between shape layers (#227). use effectcraft_engine::Session; use effectcraft_engine::project::LayerId; @@ -136,6 +137,40 @@ fn mask_feather_values_are_linked_and_not_negative() { assert_eq!(feather(&h).1, vec![0.0, 7.0], "unlinked"); } +/// Two shape layers, "Rect" (Rectangle 1) and "Oval" (Ellipse 1, selected): (harness with Rect's +/// Contents open, Rect, Oval, Rectangle 1's uid). +fn two_shape_layers() -> (Harness<'static, EffectcraftApp>, u64, u64, u64) { + let mut s = session(); + let a = s.execute("layer.newShape", json!({"kind": "rect", "name": "Rect"})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newShape", json!({"kind": "ellipse", "name": "Oval"})).unwrap()["layer"].as_u64().unwrap(); + let contents = s.active_comp().unwrap().layer(LayerId(a)).unwrap().props.sub("contents").unwrap().clone(); + let rect = contents.groups().next().unwrap().uid; + let mut h = harness(s); + h.state_mut().ui.timeline.open_groups.insert(contents.uid); + open_layer(&mut h, a); + (h, a, b, rect) +} + +fn contents_names(h: &Harness<'_, EffectcraftApp>, l: u64) -> Vec { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect() +} + +#[test] +fn ctrl_c_and_ctrl_v_copy_a_shape_group_into_another_shape_layer() { + let (mut h, a, b, rect) = two_shape_layers(); + click(&mut h, &format!("timeline.group.{rect}.name")); + assert_eq!(h.state().session.state.selected_props, vec![(LayerId(a), rect)]); + h.input_mut().events.push(Event::Copy); + h.step(); + click(&mut h, &format!("timeline.layer.{b}.row")); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(b)]); + h.input_mut().events.push(Event::Paste("EffectCraft: 1 shape item".into())); + h.run_steps(3); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 2, "no new layer"); + assert_eq!(contents_names(&h, b), ["Rectangle 1", "Ellipse 1"]); + assert_eq!(contents_names(&h, a), ["Rectangle 1"]); +} + #[test] fn toolbar_stroke_edits_the_selected_shape_layer() { let mut s = session();