diff --git a/crates/engine/src/cmd/frontend.rs b/crates/engine/src/cmd/frontend.rs index 74468e081..2dc894d5f 100644 --- a/crates/engine/src/cmd/frontend.rs +++ b/crates/engine/src/cmd/frontend.rs @@ -106,6 +106,7 @@ pub fn specs() -> Vec { fe!("layer.openLayer", "Open Layer", ["Layer"], None, "{layer?}", has_layers), fe!("effect.manage", "Manage Effects...", ["Effect"], None, "{}", always), fe!("anim.browsePresets", "Browse Presets...", ["Animation"], None, "{}", always), + fe!("timeline.revealMask", "Reveal Created Mask", [], None, "{comp, layer, mask, projectEpoch} — engine-supplied creation identity", has_comp), // Project panel: delete the selected items without the prompt that Delete shows when // compositions use them. The UI runs `project.delete` (agents can run it directly). fe!( diff --git a/crates/engine/src/cmd/layer/contents.rs b/crates/engine/src/cmd/layer/contents.rs index d8824fc9e..baf7684be 100644 --- a/crates/engine/src/cmd/layer/contents.rs +++ b/crates/engine/src/cmd/layer/contents.rs @@ -4,6 +4,7 @@ use super::{ layer_p, merge_p, str_p, }; +/// Add a tool-shaped mask in one document edit, then request its Timeline controls separately. pub(super) fn add_mask(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.addMask")?; let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?.clone(); @@ -31,6 +32,10 @@ pub(super) fn add_mask(s: &mut Session, p: &Value) -> Result { proj.next_id = next; Ok(uid) })?; + s.events.push(crate::Event::Frontend { + command: "timeline.revealMask".into(), + params: json!({"comp": cid.0, "layer": lid.0, "mask": uid, "projectEpoch": s.script_ui.project_epoch}), + }); Ok(json!({"mask": uid})) } diff --git a/crates/engine/src/cmd/mask.rs b/crates/engine/src/cmd/mask.rs index 778dfb54c..f43f5b06c 100644 --- a/crates/engine/src/cmd/mask.rs +++ b/crates/engine/src/cmd/mask.rs @@ -174,6 +174,10 @@ fn new_mask(s: &mut Session, p: &Value) -> Result { st.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: n - 1 }]; Ok(uid) })?; + s.events.push(crate::Event::Frontend { + command: "timeline.revealMask".into(), + params: json!({"comp": cid.0, "layer": lid.0, "mask": uid, "projectEpoch": s.script_ui.project_epoch}), + }); Ok(json!({"mask": uid})) } diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index ae314d65d..e007d5a72 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -293,6 +293,16 @@ impl Session { path.push(p); } path.reverse(); + // Across branches, the same UID can name different masks on either side. Keep only + // pending reveals whose masks also existed at the shared ancestor. + let mut common = a.current; + while !path.contains(&common) { + let Some(parent) = a.parent.get(common).copied().flatten() else { break }; + common = parent; + } + if let Some(project) = a.state.get(common) { + self.retain_mask_reveals_in(project); + } // Continue past the target: the working line where it runs on, else each branch's own // continuation (its first child). let mut new_line = path.clone(); diff --git a/crates/engine/src/session/comps.rs b/crates/engine/src/session/comps.rs index 4fda6628c..51d87e018 100644 --- a/crates/engine/src/session/comps.rs +++ b/crates/engine/src/session/comps.rs @@ -22,10 +22,26 @@ impl Session { /// when the snapshot was taken; if one is open after the replacement it is treated as an /// unverified document replacement, so it never inherits an old composition. pub fn sanitize_after_replacement(&mut self, before: Option<&commands::text_edit::EditorSnapshot>) { + let project = self.project.clone(); + self.retain_mask_reveals_in(&project); self.sanitize_state(); commands::text_edit::after_replacement(self, before); } + /// Drop reveals for removed masks before the restored allocator can reuse their IDs. + pub(crate) fn retain_mask_reveals_in(&mut self, project: &crate::Project) { + self.events.retain(|event| { + let Event::Frontend { command, params } = event else { return true }; + if command != "timeline.revealMask" { + return true; + } + let comp = params.get("comp").and_then(Value::as_u64).map(ItemId).and_then(|id| project.comp(id)); + let layer = params.get("layer").and_then(Value::as_u64).map(LayerId).and_then(|id| comp.and_then(|c| c.layer(id))); + let mask = params.get("mask").and_then(Value::as_u64); + layer.and_then(|l| l.masks()).is_some_and(|m| m.groups().any(|g| Some(g.uid) == mask)) + }); + } + /// Drop selections that no longer exist. Each comp keeps its own selection (#1001); a layer id /// counts only inside the comp that owns it, so the active comp never sees another comp's layers. /// Validate property UIDs too: undo can remove a control and rewind the allocator, so keeping diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 66769dabf..0f0f16a18 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -30,6 +30,7 @@ mod lottie; mod m137; mod m145; mod markers; +mod mask_reveal; mod mask_warp; mod menu_cmds; mod model3d; diff --git a/crates/engine/src/tests/mask_reveal.rs b/crates/engine/src/tests/mask_reveal.rs new file mode 100644 index 000000000..dea57015f --- /dev/null +++ b/crates/engine/src/tests/mask_reveal.rs @@ -0,0 +1,80 @@ +use crate::{Event, Session}; +use effectcraft_project::{LayerId, Project}; +use serde_json::json; + +/// The frontend notification must remain separate from document history and saved mask values. +#[test] +fn mask_reveal_creation_notifies_once_and_keeps_one_undo_step() { + for (command, params) in [("layer.addMask", json!({})), ("mask.new", json!({"vertices": [[1, 2], [10, 2], [10, 12]], "closed": true}))] { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 64, "height": 36})).unwrap(); + let layer = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap(); + let comp = s.active_comp_id().unwrap(); + s.drain_events(); + let before = s.project.to_json(); + let mask = s.execute(command, params).unwrap()["mask"].as_u64().unwrap(); + let notices: Vec<_> = s + .drain_events() + .into_iter() + .filter_map(|e| match e { + Event::Frontend { command, params } if command == "timeline.revealMask" => Some(params), + _ => None, + }) + .collect(); + assert_eq!(notices, [json!({"comp": comp.0, "layer": layer, "mask": mask, "projectEpoch": s.script_ui.project_epoch})]); + s.execute("prop.set", json!({"layer": layer, "path": "masks/#1/feather", "value": [4, 6]})).unwrap(); + let reopened = Project::from_json(&s.project.to_json()).unwrap(); + assert_eq!(reopened.comp(comp).unwrap().layer(LayerId(layer)).unwrap().props.prop("masks/#1/feather").unwrap().value.components(), [4.0, 6.0]); + assert!(s.undo()); + assert!(s.undo()); + assert_eq!(s.project.to_json(), before); + assert!(s.redo()); + assert_eq!(s.active_comp().unwrap().layer(LayerId(layer)).unwrap().masks().unwrap().groups().count(), 1); + s.drain_events(); + s.execute("mask.addVertex", json!({"layer": layer, "mask": mask, "point": [5, 8]})).unwrap(); + assert!(!s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "timeline.revealMask"))); + } +} + +/// Invalid tool inputs must not leave either a partial mask or a queued reveal behind. +#[test] +fn mask_reveal_failed_creation_emits_no_notice_and_keeps_document() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 64, "height": 36})).unwrap(); + s.execute("layer.newSolid", json!({})).unwrap(); + s.drain_events(); + let before = s.project.to_json(); + for (command, params) in [("layer.addMask", json!({"shape": "invalid"})), ("mask.new", json!({"vertices": []}))] { + assert!(s.execute(command, params).is_err()); + assert_eq!(s.project.to_json(), before); + assert!(s.drain_events().is_empty()); + } +} + +/// Mask creation must use the same notification contract for still and video footage. +#[test] +fn mask_reveal_image_and_video_layers_keep_their_source_and_mask() { + use effectcraft_project::{Footage, FootageKind, ItemKind, LayerSource}; + for kind in [FootageKind::Still, FootageKind::Video] { + for command in ["layer.addMask", "mask.new"] { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 64, "height": 36})).unwrap(); + let layer = LayerId(s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap()); + let comp = s.active_comp_id().unwrap(); + let item = s + .edit("Synthetic footage", None, |project, _| { + let item = + project.add_item("Clip", Default::default(), None, ItemKind::Footage(Footage { kind, width: 64, height: 36, ..Default::default() })); + project.comp_mut(comp).unwrap().layer_mut(layer).unwrap().source = LayerSource::Footage { item }; + Ok(item) + }) + .unwrap(); + s.drain_events(); + let mask = s.execute(command, json!({"vertices": [[2, 3], [20, 3], [20, 12]]})).unwrap()["mask"].as_u64().unwrap(); + let layer = s.active_comp().unwrap().layer(layer).unwrap(); + assert_eq!(layer.source, LayerSource::Footage { item }); + assert_eq!(layer.masks().unwrap().groups().map(|g| g.uid).collect::>(), [mask]); + assert_eq!(s.drain_events().iter().filter(|e| matches!(e, Event::Frontend { command, .. } if command == "timeline.revealMask")).count(), 1); + } + } +} diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index f22b0f079..c23439b75 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -502,6 +502,30 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.show_panel(PanelKind::EffectsPresets); Value::Null } + "timeline.revealMask" => { + let id = |key: &str| p.get(key).and_then(Value::as_u64).ok_or_else(|| format!("timeline.revealMask: need `{key}` as an unsigned integer")); + let (comp, layer, mask, epoch) = (id("comp")?, id("layer")?, id("mask")?, id("projectEpoch")?); + if epoch != app.session.script_ui.project_epoch || app.session.active_comp_id().map(|c| c.0) != Some(comp) { + return Ok(json!({"revealed": false})); + } + let masks = app.session.active_comp().and_then(|c| c.layer(effectcraft_engine::project::LayerId(layer))).and_then(|l| l.masks()); + let Some(masks) = masks.filter(|m| m.groups().any(|g| g.uid == mask && matches!(g.kind, effectcraft_engine::project::GroupKind::Mask { .. }))) + else { + return Ok(json!({"revealed": false})); + }; + let masks_uid = masks.uid; + let tl = &mut app.ui.timeline; + tl.open_layers.insert(layer); + tl.open_groups.extend([masks_uid, mask]); + // Add to a shortcut's existing reveal without changing the last pressed shortcut. + if let Some(kinds) = tl.layer_reveal.get_mut(&layer).filter(|k| !k.is_empty()) { + tl.reveal_props.insert(mask); + if !kinds.iter().any(|k| k == "props") { + kinds.push("props".into()); + } + } + json!({"revealed": true}) + } "timeline.revealProps" => { let props = p.get("props").and_then(Value::as_array).cloned().unwrap_or_default(); let uids = |k: &str| -> Vec { props.iter().filter_map(|x| x.get(k).and_then(Value::as_u64)).collect() }; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a34009658..7454daaa0 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -913,6 +913,7 @@ pub(crate) fn visible_props(app: &EffectcraftApp, comp: &Comp) -> Vec Vec { let tl = &app.ui.timeline; let search = tl.search.trim().to_lowercase(); @@ -1106,6 +1107,9 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, kinds: &[String], rows: &mut Vec let mut groups = vec![]; groups_in(&l.props, reveal_props, &mut groups); for g in groups { + if shown_groups.contains(&g.uid) { + continue; + } let open = tl.open_groups.contains(&g.uid); rows.push(Row { layer: l.id, @@ -1176,6 +1180,19 @@ fn revealed_rows( } } } + // Whole groups already emitted above own their properties, including under mixed reveals. + fn props(rows: &mut Vec, t: &Tree<'_>, g: &PropGroup) { + if t.skip.contains(&g.uid) { + return; + } + for child in &g.children { + match child { + Node::Prop(p) if (t.wanted)(p) => rows.push(Row { layer: t.l.id, depth: 1, kind: RowKind::Prop { uid: p.uid } }), + Node::Group(g) => props(rows, t, g), + _ => {} + } + } + } let wanted = |p: &Property| shown.contains(&p.uid) && prop_visible(p, l); let tree = Tree { l, wanted: &wanted, skip, open, full }; for c in &l.props.children { @@ -1185,11 +1202,7 @@ fn revealed_rows( group(rows, &tree, e, 1); } } - Node::Group(g) => { - let mut found = vec![]; - collect_props(g, &mut found, &wanted); - rows.extend(found.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } })); - } + Node::Group(g) => props(rows, &tree, g), Node::Prop(p) if wanted(p) => rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid: p.uid } }), Node::Prop(_) => {} } diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 5aa7cedc5..afbae14a1 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -684,3 +684,224 @@ fn layer_drop_target_skips_moving_layers_beyond_the_viewport() { assert_eq!(layer_drop_target(&rows, &visible, at, &[1, 4, 5, 6]), None, "the stack ends when no stationary layer follows"); } } + +fn mask_reveal_app() -> (EffectcraftApp, u64) { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 64, "height": 36})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"name": "Plate"})).unwrap()["layer"].as_u64().unwrap(); + s.drain_events(); + (EffectcraftApp::new(s), layer) +} + +fn mask_reveal_add(app: &mut EffectcraftApp) -> u64 { + let mask = app.session.execute("layer.addMask", json!({})).unwrap()["mask"].as_u64().unwrap(); + app.handle_events(&egui::Context::default()); + mask +} + +/// Whole-mask rows and shortcut-selected properties must coexist without rendering controls twice. +#[test] +fn mask_reveal_opens_controls_and_preserves_other_properties_without_duplicates() { + for kinds in [vec![], vec!["position"], vec!["feather"], vec!["masks"], vec!["masks", "feather"]] { + let (mut app, layer) = mask_reveal_app(); + app.ui.timeline.reveal = vec!["rotation".into()]; + if !kinds.is_empty() { + app.ui.timeline.layer_reveal.insert(layer, kinds.iter().map(|s| s.to_string()).collect()); + } + mask_reveal_add(&mut app); + mask_reveal_add(&mut app); + let names = labels(&app); + for name in [">Mask 1", ">Mask 2"] { + assert_eq!(names.iter().filter(|r| r.trim() == name).count(), 1, "{kinds:?}: {names:?}"); + } + assert_eq!(names.iter().filter(|r| r.trim() == "Mask Feather").count(), 2, "{names:?}"); + assert_eq!(names.iter().filter(|r| r.trim() == "Mask Path").count(), 2, "{names:?}"); + if kinds.contains(&"position") { + assert_eq!(names.iter().filter(|r| r.trim() == "Position").count(), 1, "{names:?}"); + } + assert_eq!(app.ui.timeline.reveal, ["rotation"]); + } +} + +/// Revealing controls is a presentation change; explicit search and the active drawing selection retain authority. +#[test] +fn mask_reveal_preserves_search_selection_focus_document_and_revision() { + let (mut app, layer) = mask_reveal_app(); + app.ui.timeline.search = "position".into(); + app.ui.timeline.open_layers.insert(999); + app.ui.timeline.layer_reveal.insert(999, vec!["scale".into()]); + app.ui.focused = crate::dock::PanelKind::Composition; + let selected = app.session.state.selected_layers.clone(); + let mask = app.session.execute("mask.new", json!({"vertices": [[2, 3]]})).unwrap()["mask"].as_u64().unwrap(); + let vertices = app.session.state.selected_vertices.clone(); + let before = app.session.project.to_json(); + let revision = app.session.revision; + app.handle_events(&egui::Context::default()); + assert_eq!(app.session.project.to_json(), before); + assert_eq!(app.session.revision, revision); + assert_eq!(app.session.state.selected_layers, selected); + assert_eq!(app.session.state.selected_vertices, vertices); + assert_eq!(app.ui.focused, crate::dock::PanelKind::Composition); + assert_eq!(app.ui.timeline.search, "position"); + assert_eq!(app.ui.timeline.layer_reveal.get(&999).unwrap(), &["scale"]); + assert!(app.ui.timeline.open_layers.contains(&999)); + assert!(!labels(&app).iter().any(|r| r.trim() == ">Mask 1")); + app.ui.timeline.search.clear(); + assert!(labels(&app).iter().any(|r| r.trim() == ">Mask 1")); + assert!(app.ui.timeline.open_groups.contains(&mask)); + assert!(app.ui.timeline.open_layers.contains(&layer)); +} + +/// Queued events can outlive their target or project, so stale delivery must leave Timeline presentation untouched. +#[test] +fn mask_reveal_ignores_undone_deleted_inactive_and_replaced_project_events() { + #[derive(Clone, Copy, Debug)] + enum Stale { + Undo, + Delete, + Inactive, + Replace, + } + for stale in [Stale::Undo, Stale::Delete, Stale::Inactive, Stale::Replace] { + let (mut app, layer) = mask_reveal_app(); + let mask = app.session.execute("layer.addMask", json!({})).unwrap()["mask"].as_u64().unwrap(); + match stale { + Stale::Undo => { + assert!(app.session.undo()); + } + Stale::Delete => { + app.session.execute("mask.remove", json!({"mask": mask})).unwrap(); + } + Stale::Inactive => { + app.session.execute("comp.new", json!({"name": "Other"})).unwrap(); + } + Stale::Replace => { + let project = (*app.session.project).clone(); + let comp = app.session.active_comp_id().unwrap(); + app.session.replace_project(project, None); + app.session.execute("comp.open", json!({"comp": comp.0})).unwrap(); + } + } + let before = serde_json::to_value(&app.ui.timeline).unwrap(); + app.handle_events(&egui::Context::default()); + // Opening a composition can update its zoom, but must not reveal the stale mask. + assert!(app.ui.timeline.open_layers.is_empty(), "{stale:?}"); + assert!(app.ui.timeline.open_groups.is_empty(), "{stale:?}"); + assert!(app.ui.timeline.layer_reveal.is_empty(), "{stale:?}"); + if !matches!(stale, Stale::Inactive | Stale::Replace) { + assert_eq!(serde_json::to_value(&app.ui.timeline).unwrap(), before); + } + assert!(!app.ui.timeline.open_layers.contains(&layer)); + } +} + +/// Adding an unkeyed mask must retain the visible Position key and restore its controls on redo. +#[test] +fn mask_reveal_undo_redo_restores_visible_controls_and_keeps_keyframe_scope() { + let (mut app, layer) = mask_reveal_app(); + app.session.execute("prop.toggleAnimation", json!({"path": "transform/position"})).unwrap(); + app.ui.timeline.layer_reveal.insert(layer, vec!["position".into()]); + mask_reveal_add(&mut app); + let visible = visible_props(&app, app.session.active_comp().unwrap()); + assert_eq!(app.session.execute("keys.selectAll", json!({"visible": visible})).unwrap(), json!(1)); + assert!(app.session.undo()); + app.handle_events(&egui::Context::default()); + assert!(!labels(&app).iter().any(|r| r.trim() == ">Mask 1")); + assert!(app.session.redo()); + app.handle_events(&egui::Context::default()); + assert!(labels(&app).iter().any(|r| r.trim() == ">Mask 1")); + assert!(labels(&app).iter().any(|r| r.trim() == "Position")); +} + +/// Repeated delivery must converge to the same presentation, while malformed external arguments fail without mutation. +#[test] +fn mask_reveal_validates_arguments_and_repeated_delivery_is_idempotent() { + let (mut app, layer) = mask_reveal_app(); + let mask = mask_reveal_add(&mut app); + let comp = app.session.active_comp_id().unwrap().0; + let epoch = app.session.script_ui.project_epoch; + let params = json!({"comp": comp, "layer": layer, "mask": mask, "projectEpoch": epoch}); + let ctx = egui::Context::default(); + let before = serde_json::to_value(&app.ui.timeline).unwrap(); + for _ in 0..2 { + assert_eq!(crate::menus::frontend(&mut app, &ctx, "timeline.revealMask", params.clone()).unwrap(), json!({"revealed": true})); + assert_eq!(serde_json::to_value(&app.ui.timeline).unwrap(), before); + } + for bad in [json!({}), json!({"comp": -1, "layer": layer, "mask": mask, "projectEpoch": epoch})] { + assert!(crate::menus::frontend(&mut app, &ctx, "timeline.revealMask", bad).is_err()); + assert_eq!(serde_json::to_value(&app.ui.timeline).unwrap(), before); + } +} + +/// An ID reused by paste or another history branch must not inherit the removed mask's pending reveal. +#[test] +fn mask_reveal_does_not_follow_reused_ids_after_undo_or_history_jump() { + for route in ["undo", "ancestor", "branch"] { + let (mut app, layer) = mask_reveal_app(); + let source = mask_reveal_add(&mut app); + app.ui.timeline = Default::default(); + app.session.state.selected_props = vec![(LayerId(layer), source)]; + app.session.execute("edit.copy", json!({})).unwrap(); + assert_eq!(app.session.state.mask_clipboard.len(), 1); + let ancestor = app.session.history_tree().into_iter().find(|n| n.current).unwrap().id; + let branch = if route == "branch" { + app.session.execute("edit.paste", json!({"layers": [layer]})).unwrap(); + let id = app.session.history_tree().into_iter().find(|n| n.current).unwrap().id; + assert!(app.session.undo()); + Some(id) + } else { + None + }; + // Folder allocation aligns the creation UID with paste's increment-first allocator. + app.session.execute("project.newFolder", json!({})).unwrap(); + let removed = app.session.execute("layer.addMask", json!({"layer": layer})).unwrap()["mask"].as_u64().unwrap(); + app.session.events.push(effectcraft_engine::Event::Toast { message: "keep unrelated events".into(), error: false }); + if route == "undo" { + assert!(app.session.undo()); + assert!(app.session.undo()); + } else { + assert!(app.session.goto_history(branch.as_deref().unwrap_or(&ancestor))); + } + let replacement = if route == "branch" { + app.session.active_comp().unwrap().layer(LayerId(layer)).unwrap().masks().unwrap().groups().last().unwrap().uid + } else { + app.session.execute("edit.paste", json!({"layers": [layer]})).unwrap()["masks"][0].as_u64().unwrap() + }; + assert_eq!(removed, replacement, "this regression must actually reuse the UID"); + app.handle_events(&egui::Context::default()); + assert!(app.ui.timeline.open_layers.is_empty(), "{route}"); + assert!(app.ui.timeline.open_groups.is_empty(), "{route}"); + assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("keep unrelated events")); + } +} + +/// History sanitation must retain notifications for masks that survive restoration, even after later edits. +#[test] +fn mask_reveal_keeps_valid_queued_creations_after_an_unrelated_undo() { + let (mut app, layer) = mask_reveal_app(); + for _ in 0..2 { + app.session.execute("layer.addMask", json!({})).unwrap(); + } + app.session.execute("prop.set", json!({"path": "transform/opacity", "value": 50})).unwrap(); + assert!(app.session.undo()); + app.handle_events(&egui::Context::default()); + let names = labels(&app); + assert!(app.ui.timeline.open_layers.contains(&layer)); + for name in [">Mask 1", ">Mask 2"] { + assert_eq!(names.iter().filter(|r| r.trim() == name).count(), 1, "{names:?}"); + } +} + +/// Collapsing a whole-mask row must not leave duplicate flat properties visible through another reveal filter. +#[test] +fn mask_reveal_collapsing_a_mask_hides_its_children_under_mixed_filters() { + let (mut app, layer) = mask_reveal_app(); + app.ui.timeline.layer_reveal.insert(layer, vec!["masks".into(), "feather".into()]); + let mask = mask_reveal_add(&mut app); + app.ui.timeline.open_groups.remove(&mask); + let names = labels(&app); + assert_eq!(names.iter().filter(|r| r.trim() == ">Mask 1").count(), 1); + assert!(!names.iter().any(|r| r.trim() == "Mask Feather")); + app.ui.timeline.open_groups.insert(mask); + assert_eq!(labels(&app).iter().filter(|r| r.trim() == "Mask Feather").count(), 1); +} diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index c98b4af19..6a7f50a10 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -526,3 +526,31 @@ fn text_animate_menu_adds_an_animator_with_the_property() { let a = animators(&h).unwrap().groups().next().unwrap().clone(); assert!(has(&a, "opacity"), "Opacity was added: {:?}", a.sub("properties")); } + +/// Both creation commands must expose editable widgets while preserving Position and the drawing panel's focus. +#[test] +fn mask_reveal_created_controls_are_editable_without_leaving_the_viewer() { + for command in ["layer.addMask", "mask.new"] { + let mut s = session(); + let layer = s.execute("layer.newSolid", json!({"name": "Plate"})).unwrap()["layer"].as_u64().unwrap(); + let position = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().uid; + let mut h = harness(s); + h.state_mut().ui.timeline.layer_reveal.insert(layer, vec!["position".into()]); + h.state_mut().ui.focused = effectcraft_ui_egui::dock::PanelKind::Composition; + let ctx = h.ctx.clone(); + let result = + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, command, json!({"layer": layer, "vertices": [[10, 10], [50, 10], [50, 40]]})).unwrap(); + let mask = result["mask"].as_u64().unwrap(); + h.run_steps(3); + let feather = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("masks/#1/feather").unwrap().uid; + assert!(h.state().auto.find(&format!("timeline.group.{mask}.name")).is_some()); + assert!(h.state().auto.find(&format!("timeline.prop.{position}.value.0")).is_some()); + assert_eq!(h.state().ui.focused, effectcraft_ui_egui::dock::PanelKind::Composition); + click(&mut h, &format!("timeline.prop.{feather}.value.0")); + h.input_mut().events.push(Event::Text("12".into())); + h.step(); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(feather).unwrap().value.components(), [12.0, 12.0]); + } +}