mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Shapes: Copy, Cut and Paste shape items between shape layers (#227)
Edit > Copy with a shape group (or a path, fill, stroke or path operation) selected in a shape layer's Contents copied the whole layer, and Paste made a new layer. As in After Effects, Copy now copies the selected items, Paste puts them into each selected shape layer's Contents (above its selected item, in that item's group, else on top, with names kept unique there: "Rectangle 2"), and Cut removes them, so Cut + Paste moves them. Pasting with no shape layer selected is an error rather than a new layer. Copying anything else clears the shape item clipboard, as it does the other clipboards. Test: shape_contents_copy_cut_and_paste_between_shape_layers, ctrl_c_and_ctrl_v_copy_a_shape_group_into_another_shape_layer. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9968950b71
commit
95bb346c89
8 files changed
+236
-12
No files matched your search
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
|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),
|
||||
|
||||
@@ -301,6 +301,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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();
|
||||
|
||||
@@ -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<Value> {
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Option<Value>> {
|
||||
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<Uid>, 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<PropGroup> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<LayerId> =
|
||||
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::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.duplicateGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
|
||||
@@ -201,7 +201,7 @@ fn wave(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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).
|
||||
|
||||
@@ -194,6 +194,10 @@ pub struct EditorState {
|
||||
/// selected layers.
|
||||
#[serde(skip)]
|
||||
pub effect_clipboard: Vec<effectcraft_project::PropGroup>,
|
||||
/// 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<effectcraft_project::PropGroup>,
|
||||
/// Selected mask vertices (viewer Selection tool / pen).
|
||||
#[serde(default)]
|
||||
pub selected_vertices: Vec<VertexRef>,
|
||||
|
||||
@@ -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<String> {
|
||||
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());
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String> {
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user