From 472521954d4143bf782f1bde07e2172704388211 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:20:54 +0700 Subject: [PATCH 1/4] Project: folder twirls and label swatches take clicks (#152) Each Project panel row registered its click / drag area after the twirl, proxy indicator, label swatch and comment cell drawn on it, so the row sat on top and took their clicks: a folder's arrow selected the folder instead of opening it, and the swatch never opened the label menu. The row's area now goes in first and those widgets sit on top of it. A single click on the arrow twirls the folder; a click on the swatch opens the label menu (project.setLabel). Clicks, double-clicks and drags on the rest of the row work as before; a single click on the comment cell still selects the row, and a double-click there now edits the comment. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/project.rs | 9 ++- crates/ui-egui/tests/ui_project_rows.rs | 97 +++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/ui_project_rows.rs diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index d59ba5d9..1a9c2163 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -424,6 +424,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Flat rows like After Effects' Project panel (no stripes). if selected { t.row_selected } else { t.row }, ); + // The row's click / drag area goes first, so the twirl, proxy, label and comment widgets + // on top of it take their own clicks. + let mut resp = ui.interact(r.intersect(list), egui::Id::new(("pitem", id.0)), Sense::click_and_drag()); + app.auto.add(&format!("project.item.{}", id.0), r, &it.name); let _ = i; let x0 = r.min.x + 8.0 + 14.0 * *depth as f32; if it.is_folder() { @@ -533,6 +537,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("project.item.{}.comment", id.0), cell, &it.comment); if cresp.double_clicked() { ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "comment".into(), it.comment.clone()))); + } else { + // Other clicks select the row and open its context menu. + resp = resp.union(cresp); } it.comment.clone() } @@ -545,8 +552,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if dragging.is_some_and(|d| d != id.0) && drop_row == Some(*id) && it.is_folder() { lp.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); } - let resp = ui.interact(r.intersect(list), egui::Id::new(("pitem", id.0)), Sense::click_and_drag()); - app.auto.add(&format!("project.item.{}", id.0), r, &it.name); let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::click_and_drag()); app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name); let resp = resp.union(nresp); diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs new file mode 100644 index 00000000..cd15d46e --- /dev/null +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -0,0 +1,97 @@ +//! Project panel rows: a folder's twirl and an item's label swatch take their own clicks, over +//! the row's click / drag area (#152). + +use effectcraft_engine::Session; +use effectcraft_engine::color::Label; +use effectcraft_engine::project::{ItemId, ItemKind}; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +/// A closed folder holding a comp, and a solid at the project root: (harness, folder, comp, solid). +fn harness() -> (Harness<'static, EffectcraftApp>, ItemId, ItemId, ItemId) { + let mut s = Session::default(); + let comp = ItemId(s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.newSolid", json!({"name": "Solid", "color": "#406080"})).unwrap(); + let solid = s.project.items.values().find(|i| matches!(i.kind, ItemKind::Solid(_))).unwrap().id; + let folder = ItemId(s.execute("project.newFolder", json!({"name": "Folder"})).unwrap()["item"].as_u64().unwrap()); + s.execute("project.move", json!({"items": [comp.0], "folder": folder.0})).unwrap(); + s.execute("project.move", json!({"items": [solid.0], "folder": null})).unwrap(); + s.execute("project.select", json!({"items": []})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + (h, folder, comp, solid) +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn button(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, pressed: bool) { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); +} + +fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + button(h, p, true); + h.step(); + button(h, p, false); + h.run_steps(3); +} + +/// Both clicks in one frame: the harness' frame time is longer than a double-click's. +fn double_click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for _ in 0..2 { + button(h, p, true); + button(h, p, false); + } + h.run_steps(3); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + click_at(h, p); +} + +#[test] +fn folder_twirl_opens_and_closes_the_folder_on_one_click() { + let (mut h, folder, comp, _) = harness(); + let twirl = format!("project.item.{}.twirl", folder.0); + let comp_row = format!("project.item.{}", comp.0); + assert!(!h.state().ui.project_open_folders.contains(&folder.0)); + assert!(h.state().auto.find(&comp_row).is_none(), "the closed folder hides its comp"); + click(&mut h, &twirl); + assert!(h.state().ui.project_open_folders.contains(&folder.0), "one click on the twirl opens the folder"); + assert!(h.state().auto.find(&comp_row).is_some(), "the open folder shows its comp"); + assert!(h.state().session.state.project_selection.is_empty(), "the twirl does not select the folder"); + click(&mut h, &twirl); + assert!(!h.state().ui.project_open_folders.contains(&folder.0), "a second click closes it"); + // The row under the twirl still selects on a click and twirls on a double-click. + let name = center(&h, &format!("project.item.{}.name", folder.0)); + click_at(&mut h, name); + assert_eq!(h.state().session.state.project_selection, vec![folder]); + double_click_at(&mut h, name); + assert!(h.state().ui.project_open_folders.contains(&folder.0), "a double-click on the row opens the folder"); +} + +#[test] +fn label_swatch_opens_the_label_menu() { + let (mut h, _, _, solid) = harness(); + assert_ne!(h.state().session.project.item(solid).unwrap().label, Label::Green); + let green = h.state().session.prefs.label_name(Label::Green); + assert!(h.query_by_label(&green).is_none()); + click(&mut h, &format!("project.item.{}.label", solid.0)); + assert!(h.state().session.state.project_selection.is_empty(), "the swatch does not select the row"); + let entry = h.query_by_label(&green).expect("the label menu is open").rect(); + let undo = h.state().session.history.undo.len(); + click_at(&mut h, entry.center()); + assert_eq!(h.state().session.project.item(solid).unwrap().label, Label::Green); + assert_eq!(h.state().session.history.undo.len(), undo + 1); + assert!(h.query_by_label(&green).is_none(), "the menu closed after the choice"); +} From 1edf8581803e6b73bb4d19976891c65aca57a08a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:21:24 +0700 Subject: [PATCH 2/4] Project: new layers take their item's label colour (#153) build::default_label picked a new layer's label from its type alone (comps Sandstone, images Lavender, ...), ignoring the label of the Project item it was made from. A layer made from an item (footage, comp, solid, 3D model) now copies the item's label when it is created; every path that adds an item to a comp (layer.addItem from the menus, Project panel, Timeline and viewer drops, models, precomps, imports) builds its layer there. Layers keep their own label when the item's changes later. Layers without an item keep the type defaults. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_project_items.rs | 30 +++++++++++++++++++++++- crates/project/src/build.rs | 12 +++++----- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/tests_project_items.rs b/crates/engine/src/tests_project_items.rs index 4ab9ce2b..26eedd2a 100644 --- a/crates/engine/src/tests_project_items.rs +++ b/crates/engine/src/tests_project_items.rs @@ -1,6 +1,6 @@ //! Project panel item edits: move into folders, rename, label, comment, with undo. -use effectcraft_project::ItemId; +use effectcraft_project::{ItemId, LayerId}; use serde_json::json; use crate::Session; @@ -72,6 +72,34 @@ fn delete_and_duplicate_items() { 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(); diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index fe39082d..71edc7ff 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -715,15 +715,15 @@ pub fn audio(ids: &mut Ids) -> PropGroup { // ---------------------------------------------------------------- layers -/// Default label colour for a layer source (Labels preferences defaults). +/// Label colour for a new layer: its Project item's label when it has one (later changes to the +/// item's label leave existing layers alone), else the Labels preferences default for its type. pub fn default_label(src: &LayerSource, project: &Project) -> Label { + if let Some(it) = src.item().and_then(|i| project.item(i)) { + return it.label; + } match src { LayerSource::Comp { .. } => Label::Sandstone, - LayerSource::Footage { item } => match project.item(*item).map(|i| i.type_name()) { - Some("Image") | Some("Image Sequence") => Label::Lavender, - Some("Audio") => Label::SeaFoam, - _ => Label::Aqua, - }, + LayerSource::Footage { .. } => Label::Aqua, LayerSource::Solid { .. } => Label::Red, LayerSource::Text => Label::Red, LayerSource::Shape => Label::Blue, From 2448deeb40844388965409fca07ef74d802f454b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:21:34 +0700 Subject: [PATCH 3/4] Text: tracking grows lines from their paragraph alignment (#146) The per-line tracking compensation in text_geom used only the animators' Line Anchor, which defaults to 0% (left) when no animator has one, so tracking on centred or right-aligned text pushed the line to the right instead of spreading it around its alignment point. Without a Line Anchor, a line now grows from where its own paragraph's alignment pins it: 50% for centred, 100% for right-aligned (a justified paragraph by its last line), read per paragraph rather than from the document. The Line Anchor property still overrides it. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/text.rs | 22 +++++++++++++++------- crates/render/src/text/tests.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 3bfb5593..9fda6980 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -533,6 +533,15 @@ pub fn caret_maps(ctx: &EvalCtx, layer: &Layer, chars: usize) -> Vec { out } +/// A paragraph's alignment: 0 left, 1 centre, 2 right (a justified paragraph by its last line). +fn align_index(j: Justify) -> u8 { + match j { + Justify::Center | Justify::JustifyLastCenter => 1, + Justify::Right | Justify::JustifyLastRight => 2, + _ => 0, + } +} + /// Lay out and animate a text layer. pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let doc = source_text(ctx, layer)?; @@ -578,7 +587,9 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { }) .collect(); let advance = |gi: usize| lay.glyphs[gi].advance + vadv[gi]; - // Tracking (before / after each character) and Line Anchor, per line. + // Tracking (before / after each character) and Line Anchor, per line. Without a Line Anchor + // a line grows from where its paragraph's alignment pins it (left, centre or right). + let paras = doc.paras(); let mut shift = vec![0.0f64; n]; let mut li_start = 0; while li_start < n { @@ -588,7 +599,8 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { li_end += 1; } let mut pen = 0.0; - let (mut la, mut la_k) = (0.0, 0.0); + let justify = lay.layout.lines.get(line).and_then(|l| paras.get(l.para)).map_or(doc.justify, |p| p.justify); + let (mut la, mut la_k) = (50.0 * f64::from(align_index(justify)), 0.0); for gi in li_start..li_end { let x = &xfs[gi]; let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0; @@ -639,11 +651,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { // Path Options. let on_path = text_path(ctx, layer, text); - let align = match doc.justify { - Justify::Center | Justify::JustifyLastCenter => 1, - Justify::Right | Justify::JustifyLastRight => 2, - _ => 0, - }; + let align = align_index(doc.justify); let first_baseline = lay.line_boxes.first().map(|b| b[1]).unwrap_or(0.0); let mut arc = vec![0.0f64; n]; if let Some((pm, _, force, fm, lm)) = &on_path { diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 93913aa8..c50999b0 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -207,6 +207,39 @@ fn tracking_line_anchor_and_character_offset() { assert_eq!(substitute('a', &CharXf { char_value: Some((66.0, 1.0)), ..Default::default() }), Some('B')); } +/// Without a Line Anchor, tracking grows each line from where its paragraph's alignment pins it: +/// left text grows right, centred text around its centre, right text to the left (#146). +#[test] +fn tracking_grows_from_each_paragraphs_alignment() { + use effectcraft_keyframe::{Justify, ParaStyle}; + let tracked = |d: TextDoc, amount: f64| { + let (mut p, cid, comp) = setup(600, 300); + let mut l = text_layer(&mut p, &comp, d); + animator(&mut p, &mut l, &["tracking"], &["range"]); + set(&mut l, "text/animators/#1/properties/tracking", Value::Scalar(amount)); + p.comp_mut(cid).unwrap().layers.push(l); + glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]) + }; + // 4 chars × 500/1000 em × 40 px: 20 px of tracking each, half before and half after. + for (justify, x0, centre, x1) in [(Justify::Left, 10.0, 40.0, 70.0), (Justify::Center, -30.0, 0.0, 30.0), (Justify::Right, -70.0, -40.0, -10.0)] { + let d = TextDoc { justify, ..doc("ABCD", 40.0) }; + let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 500.0))); + let got = [w1.x0 - w0.x0, w1.center().x - w0.center().x, w1.x1 - w0.x1]; + assert!(got.iter().zip([x0, centre, x1]).all(|(g, w)| (g - w).abs() < 1.0), "{justify:?}: moved {got:?}, want {:?}", [x0, centre, x1]); + } + // Each paragraph by its own alignment: a left paragraph above a right-aligned one. + let mut d = doc("ABCD\nABCD", 40.0); + let left = d.base_para(); + d.set_paras(vec![left.clone(), ParaStyle { justify: Justify::Right, ..left }]); + let lines = |paths: Vec<(BezPath, CharXf)>| -> [kurbo::Rect; 2] { + let (a, b): (Vec<_>, Vec<_>) = paths.into_iter().partition(|g| bounds_of(std::slice::from_ref(g)).center().y < 10.0); + [bounds_of(&a), bounds_of(&b)] + }; + let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 500.0))); + assert!((a1.x0 - a0.x0 - 10.0).abs() < 1.0, "the left paragraph grows right: {} {}", a0.x0, a1.x0); + assert!((b1.x1 - b0.x1 + 10.0).abs() < 1.0, "the right paragraph grows left: {} {}", b0.x1, b1.x1); +} + #[test] fn anchor_grouping_line_rotates_around_line_centre() { let (mut p, cid, comp) = setup(600, 300); From 31fc517baad56a181667f650423e4ac8b615ea53 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:21:45 +0700 Subject: [PATCH 4/4] Edit: Delete removes selected animators, masks and shape groups (#151) 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 --- crates/engine/src/commands/edit.rs | 6 ++++ crates/engine/src/commands/prop_groups.rs | 35 ++++++++++++++---- crates/engine/src/tests_project_items.rs | 43 +++++++++++++++++++++++ 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index b021e4e6..d60daf49 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -147,6 +147,12 @@ fn delete(s: &mut Session, p: &Value) -> Result { if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() { return s.execute("effect.remove", json!({})); } + // Masks, shape items, text animators or trackers selected → remove them. + if p.get("layers").is_none() + && let Some(n) = super::prop_groups::remove_selected(s)? + { + return Ok(n); + } let (cid, ids) = layers_p(s, p)?; let ids = super::unlocked(s, cid, ids, "edit.clear")?; // Children of deleted layers are unparented where they are (like After Effects). diff --git a/crates/engine/src/commands/prop_groups.rs b/crates/engine/src/commands/prop_groups.rs index 7f5f2a2c..6ead8834 100644 --- a/crates/engine/src/commands/prop_groups.rs +++ b/crates/engine/src/commands/prop_groups.rs @@ -51,17 +51,38 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { } fn remove(s: &mut Session, p: &Value) -> Result { - let c = "prop.removeGroup"; - let (cid, lid, uid) = group_ref(s, p, c, true)?; + let (cid, lid, uid) = group_ref(s, p, "prop.removeGroup", true)?; + remove_groups(s, cid, &[(lid, uid)])?; + 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| { - let l = layer_mut(proj, cid, lid)?; - let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?; - parent.children.retain(|n| n.uid() != uid); - st.selected_props.retain(|(_, u)| *u != uid); - Ok(Value::Null) + 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) { + parent.children.retain(|n| n.uid() != *uid); + } + } + st.selected_props.retain(|(_, u)| !groups.iter().any(|(_, g)| g == u)); + Ok(()) }) } +/// Edit ▸ Clear with masks, shape items, text animators or trackers selected in the active comp: +/// removes those groups (not their layers) in one undo step. None when none are selected. +pub(crate) fn remove_selected(s: &mut Session) -> Result> { + let (Some(cid), Some(c)) = (s.active_comp_id(), s.active_comp()) else { return Ok(None) }; + let groups: Vec<(LayerId, Uid)> = + s.state.selected_props.iter().filter(|(lid, uid)| c.layer(*lid).and_then(|l| l.props.find_group(*uid)).is_some_and(is_instance)).copied().collect(); + if groups.is_empty() { + return Ok(None); + } + remove_groups(s, cid, &groups)?; + Ok(Some(json!(groups.len()))) +} + 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/tests_project_items.rs b/crates/engine/src/tests_project_items.rs index 26eedd2a..b9363f65 100644 --- a/crates/engine/src/tests_project_items.rs +++ b/crates/engine/src/tests_project_items.rs @@ -124,3 +124,46 @@ fn property_group_edits() { 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 { + 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"); +}