mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 02:27:00 -06:00
Selecting a property group also selects its layer, and edit.clear only looked for selected keys, mask vertices, puppet pins and effects before deleting the selected layers, so Delete with a text animator, mask or shape group selected deleted the whole layer. edit.clear now removes the selected removable groups (text animators, masks, shape items, trackers) of the active comp in one undo step before falling back to layers. It shares the removal with prop.removeGroup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
9.8 KiB
Rust
170 lines
9.8 KiB
Rust
//! Project panel item edits: move into folders, rename, label, comment, with undo.
|
|
|
|
use effectcraft_project::{ItemId, LayerId};
|
|
use serde_json::json;
|
|
|
|
use crate::Session;
|
|
|
|
#[test]
|
|
fn move_into_folder_rename_label_comment_with_undo() {
|
|
let mut s = Session::default();
|
|
s.execute("comp.new", json!({"name": "A", "width": 64, "height": 64, "frameRate": 24, "duration": 1})).unwrap();
|
|
let comp = s.active_comp_id().unwrap();
|
|
let f = ItemId(s.execute("project.newFolder", json!({"name": "Shots"})).unwrap()["item"].as_u64().unwrap());
|
|
let g = ItemId(s.execute("project.newFolder", json!({"name": "Inner", "parent": f.0})).unwrap()["item"].as_u64().unwrap());
|
|
|
|
s.execute("project.move", json!({"items": [comp.0], "folder": "Shots"})).unwrap();
|
|
assert_eq!(s.project.item(comp).unwrap().parent, Some(f));
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(s.project.item(comp).unwrap().parent, None);
|
|
s.execute("edit.redo", json!({})).unwrap();
|
|
// Out of the folder (to the root) via the selection.
|
|
s.execute("project.select", json!({"items": [comp.0]})).unwrap();
|
|
s.execute("project.move", json!({"folder": null})).unwrap();
|
|
assert_eq!(s.project.item(comp).unwrap().parent, None);
|
|
// A folder can't go into itself or its descendants.
|
|
assert!(s.execute("project.move", json!({"items": [f.0], "folder": g.0})).is_err());
|
|
assert!(s.execute("project.move", json!({"items": [comp.0], "folder": comp.0})).is_err());
|
|
|
|
s.execute("project.rename", json!({"item": comp.0, "name": "Hero"})).unwrap();
|
|
assert_eq!(s.project.item(comp).unwrap().name, "Hero");
|
|
assert!(s.execute("project.rename", json!({"item": comp.0, "name": " "})).is_err());
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(s.project.item(comp).unwrap().name, "A");
|
|
|
|
s.execute("project.setLabel", json!({"items": [comp.0], "label": "Pink"})).unwrap();
|
|
assert_eq!(s.project.item(comp).unwrap().label.name(), "Pink");
|
|
s.execute("project.setComment", json!({"item": comp.0, "comment": "final"})).unwrap();
|
|
assert_eq!(s.project.item(comp).unwrap().comment, "final");
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(s.project.item(comp).unwrap().comment, "");
|
|
}
|
|
|
|
#[test]
|
|
fn delete_and_duplicate_items() {
|
|
let mut s = Session::default();
|
|
s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "duration": 1})).unwrap();
|
|
let inner = s.active_comp_id().unwrap();
|
|
s.execute("layer.newNull", json!({})).unwrap();
|
|
s.execute("comp.new", json!({"name": "Outer", "width": 64, "height": 64, "duration": 1})).unwrap();
|
|
let outer = s.active_comp_id().unwrap();
|
|
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
|
|
s.execute("renderQueue.add", json!({"comp": inner.0})).unwrap();
|
|
// Duplicate: a new comp with fresh layer ids.
|
|
let r = s.execute("project.duplicate", json!({"items": [inner.0]})).unwrap();
|
|
let dup = ItemId(r["items"][0].as_u64().unwrap());
|
|
assert_eq!(s.project.item(dup).unwrap().name, "Inner 2");
|
|
let a = s.project.comp(inner).unwrap().layers[0].id;
|
|
let b = s.project.comp(dup).unwrap().layers[0].id;
|
|
assert_ne!(a, b);
|
|
// Delete: layers that use the item and its render items go too; undoable.
|
|
s.execute("project.delete", json!({"items": ["Inner"]})).unwrap();
|
|
assert!(s.project.item(inner).is_none());
|
|
assert!(s.project.comp(outer).unwrap().layers.is_empty());
|
|
assert!(s.project.render_queue.is_empty());
|
|
s.undo();
|
|
assert!(s.project.item(inner).is_some());
|
|
assert_eq!(s.project.comp(outer).unwrap().layers.len(), 1);
|
|
// Folders take their contents.
|
|
let f = ItemId(s.execute("project.newFolder", json!({"name": "F"})).unwrap()["item"].as_u64().unwrap());
|
|
s.execute("project.move", json!({"items": [dup.0], "folder": f.0})).unwrap();
|
|
s.execute("project.delete", json!({"items": [f.0]})).unwrap();
|
|
assert!(s.project.item(dup).is_none());
|
|
}
|
|
|
|
/// A new layer takes its Project item's label (#153); layers made before keep their own when the
|
|
/// item's label changes. Layers with no item keep their type's default.
|
|
#[test]
|
|
fn new_layers_take_their_items_label() {
|
|
use crate::color::Label;
|
|
use effectcraft_project::{Footage, FootageKind, ItemKind};
|
|
let mut s = Session::default();
|
|
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
|
|
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "duration": 1})).unwrap();
|
|
s.execute("file.importPlaceholder", json!({"name": "Clip"})).unwrap();
|
|
let clip = s.state.project_selection[0].0;
|
|
let model = std::sync::Arc::make_mut(&mut s.project)
|
|
.add_item("Model", Label::Aqua, None, ItemKind::Footage(Footage { kind: FootageKind::Model, path: "missing.glb".into(), ..Default::default() }))
|
|
.0;
|
|
let solid = s.execute("layer.newSolid", json!({"name": "Solid", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
|
let solid_item = s.active_comp().unwrap().layer(LayerId(solid)).unwrap().source.item().unwrap().0;
|
|
let label = |s: &Session, layer: &serde_json::Value| s.active_comp().unwrap().layer(LayerId(layer["layer"].as_u64().unwrap())).unwrap().label;
|
|
for (item, lab) in [(inner, "Blue"), (clip, "Green"), (model, "Orange"), (solid_item, "Fuchsia")] {
|
|
let before = s.execute("layer.addItem", json!({"item": item})).unwrap();
|
|
s.execute("project.setLabel", json!({"items": [item], "label": lab})).unwrap();
|
|
let after = s.execute("layer.addItem", json!({"item": item})).unwrap();
|
|
assert_eq!(label(&s, &after).name(), lab, "a new layer takes item {item}'s label");
|
|
assert_ne!(label(&s, &before).name(), lab, "a layer made before keeps its own");
|
|
}
|
|
let text = s.execute("layer.newText", json!({"text": "Hi"})).unwrap();
|
|
assert_eq!(label(&s, &text), Label::Red, "no item: the type's default");
|
|
}
|
|
|
|
#[test]
|
|
fn property_group_edits() {
|
|
let mut s = Session::default();
|
|
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 1})).unwrap();
|
|
let l = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap();
|
|
let a = s.execute("effect.apply", json!({"layer": l, "effect": "Gaussian Blur"})).unwrap()["effects"][0].as_u64().unwrap();
|
|
let b = s.execute("effect.apply", json!({"layer": l, "effect": "Invert"})).unwrap()["effects"][0].as_u64().unwrap();
|
|
let fx = |s: &Session| -> Vec<(u64, String, bool)> {
|
|
s.active_comp().unwrap().layers[0].props.sub("effects").unwrap().groups().map(|g| (g.uid, g.name.clone(), g.enabled)).collect()
|
|
};
|
|
s.execute("prop.renameGroup", json!({"layer": l, "prop": a, "name": "Soft"})).unwrap();
|
|
s.execute("prop.setGroupEnabled", json!({"layer": l, "prop": a, "value": false})).unwrap();
|
|
s.execute("prop.moveGroup", json!({"layer": l, "prop": b, "index": 1})).unwrap();
|
|
let d = s.execute("prop.duplicateGroup", json!({"layer": l, "prop": a})).unwrap()["prop"].as_u64().unwrap();
|
|
let v = fx(&s);
|
|
assert_eq!(v.iter().map(|x| x.0).collect::<Vec<_>>(), [b, a, d]);
|
|
assert_eq!(v[1].1, "Soft");
|
|
assert!(!v[1].2);
|
|
s.execute("prop.removeGroup", json!({"layer": l, "prop": a})).unwrap();
|
|
assert_eq!(fx(&s).len(), 2);
|
|
// Fixed groups can't be removed.
|
|
let tr = s.active_comp().unwrap().layers[0].props.sub("transform").unwrap().uid;
|
|
assert!(s.execute("prop.removeGroup", json!({"layer": l, "prop": tr})).is_err());
|
|
}
|
|
|
|
/// Delete with a text animator, mask or shape group selected removes just those groups, in one
|
|
/// undo step, and keeps their layers (#151). A fixed group selected still deletes the layer.
|
|
#[test]
|
|
fn delete_removes_selected_groups_not_their_layer() {
|
|
let mut s = Session::default();
|
|
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 1})).unwrap();
|
|
let t = s.execute("layer.newText", json!({"text": "ABCD"})).unwrap()["layer"].as_u64().unwrap();
|
|
s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["tracking"]})).unwrap();
|
|
let sh = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap();
|
|
s.execute("layer.addMask", json!({"layer": sh, "shape": "ellipse"})).unwrap();
|
|
let groups = |s: &Session, l: u64, path: &str| -> Vec<u64> {
|
|
s.active_comp().unwrap().layer(LayerId(l)).map_or(vec![], |l| l.props.group(path).unwrap().groups().map(|g| g.uid).collect())
|
|
};
|
|
let anim = groups(&s, t, "text/animators")[0];
|
|
let (mask, shape) = (groups(&s, sh, "masks")[0], groups(&s, sh, "contents")[0]);
|
|
let delete = |s: &mut Session| {
|
|
let undo = s.history.undo.len();
|
|
s.execute("edit.clear", json!({})).unwrap();
|
|
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
|
};
|
|
|
|
s.execute("prop.select", json!({"layer": t, "prop": anim, "selectKeys": false})).unwrap();
|
|
let before = s.project.clone();
|
|
delete(&mut s);
|
|
assert!(groups(&s, t, "text/animators").is_empty(), "the animator is gone");
|
|
assert_eq!(s.active_comp().unwrap().layers.len(), 2, "its layer stays");
|
|
assert!(s.state.selected_props.is_empty());
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(s.project, before);
|
|
|
|
s.execute("prop.select", json!({"layer": sh, "prop": mask, "selectKeys": false})).unwrap();
|
|
s.execute("prop.select", json!({"layer": sh, "prop": shape, "selectKeys": false, "add": true})).unwrap();
|
|
delete(&mut s);
|
|
assert!(groups(&s, sh, "masks").is_empty());
|
|
assert!(!groups(&s, sh, "contents").contains(&shape));
|
|
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
|
|
|
|
let tr = s.active_comp().unwrap().layer(LayerId(t)).unwrap().props.sub("transform").unwrap().uid;
|
|
s.execute("prop.select", json!({"layer": t, "prop": tr, "selectKeys": false})).unwrap();
|
|
delete(&mut s);
|
|
assert!(s.active_comp().unwrap().layer(LayerId(t)).is_none(), "a fixed group selected: the layer goes");
|
|
}
|