mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 18:41:26 +00:00
Reveal newly created mask controls in the Timeline (#3149)
* 3145: Reveal newly created mask controls in the Timeline * 3145: Document mask reveal contracts and regression cases * 3145: Make stale-event regression cases exhaustive
This commit is contained in:
11 files changed
+408
-5
No files matched your search
@@ -106,6 +106,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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!(
|
||||
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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}))
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,10 @@ fn new_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,7 @@ mod lottie;
|
||||
mod m137;
|
||||
mod m145;
|
||||
mod markers;
|
||||
mod mask_reveal;
|
||||
mod mask_warp;
|
||||
mod menu_cmds;
|
||||
mod model3d;
|
||||
|
||||
@@ -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::<Vec<_>>(), [mask]);
|
||||
assert_eq!(s.drain_events().iter().filter(|e| matches!(e, Event::Frontend { command, .. } if command == "timeline.revealMask")).count(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<u64> { props.iter().filter_map(|x| x.get(k).and_then(Value::as_u64)).collect() };
|
||||
|
||||
@@ -913,6 +913,7 @@ pub(crate) fn visible_props(app: &EffectcraftApp, comp: &Comp) -> Vec<serde_json
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Project the property tree into Timeline rows; explicit search takes precedence over collapse and shortcut reveals.
|
||||
fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
|
||||
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<Row>, 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(_) => {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user