From f9990f73876f4568a662f2d0b9d7fe8b6f2bc227 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 01:10:33 +0700 Subject: [PATCH] =?UTF-8?q?M13.20:=20every=20Timeline=20property=20shortcu?= =?UTF-8?q?t=20=E2=80=94=20double=20presses=20AA=20(Material=20Options),?= =?UTF-8?q?=20PP=20(paint=20/=20Roto=20Brush=20/=20Puppet),=20SS=20(select?= =?UTF-8?q?ed=20properties),=20RR=20(Time=20Remap),=20TT=20(Mask=20Opacity?= =?UTF-8?q?),=20MM=20(all=20mask=20properties;=20a=20single=20M=20now=20sh?= =?UTF-8?q?ows=20Mask=20Path),=20FF=20(missing=20effects),=20EE=20(express?= =?UTF-8?q?ions)=20next=20to=20LL=20and=20UU;=20Alt+Shift+A/P/S/R/T=20add?= =?UTF-8?q?=20or=20remove=20a=20keyframe=20at=20the=20current=20time=20on?= =?UTF-8?q?=20every=20selected=20layer=20in=20one=20undo=20step=20(keys.to?= =?UTF-8?q?ggleTransform:=20separated=20X/Y/Z=20Position,=20Orientation=20?= =?UTF-8?q?and=20X/Y/Z=20Rotation=20on=203D=20layers);=20Ctrl/Cmd+`=20twir?= =?UTF-8?q?ls=20the=20selected=20layers=20open=20or=20closed=20(from=20a?= =?UTF-8?q?=20reveal,=20their=20full=20tree)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/commands/prop.rs | 80 +++++++++++++++++----- crates/engine/src/tests_timeline.rs | 24 +++++++ crates/ui-egui/src/menus.rs | 70 +++++++++++++++++-- crates/ui-egui/src/panels/timeline.rs | 39 +++++++++-- crates/ui-egui/tests/ui_viewer.rs | 97 +++++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 839ae6f8..bf74400c 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -5,7 +5,7 @@ use effectcraft_project::{ItemId, LayerId, Property, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, merge_p, str_p}; +use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p}; use crate::{EngineError, KeyRef, Result, Session, cmd, query}; /// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid). @@ -170,22 +170,61 @@ fn add_key(s: &mut Session, p: &Value) -> Result { fn toggle_key(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.toggleKey")?; - with_prop(s, "Add/Remove Keyframe", None, cid, lid, uid, |pr, lt| { - if let Some(i) = key_at(&pr.keys, lt) { - if pr.keys.len() == 1 { - pr.value = pr.keys[0].value.clone(); - } - pr.keys.remove(i); - Ok(json!(false)) - } else { - let mut k = Keyframe::new(lt, pr.value_at(lt)); - if pr.hold_only { - k = k.hold(); - } - set_key(&mut pr.keys, k); - Ok(json!(true)) + with_prop(s, "Add/Remove Keyframe", None, cid, lid, uid, |pr, lt| Ok(json!(toggle_key_at(pr, lt)))) +} + +/// Add a keyframe at layer time `lt` (true), or remove the one there (false). +fn toggle_key_at(pr: &mut Property, lt: Tick) -> bool { + if let Some(i) = key_at(&pr.keys, lt) { + if pr.keys.len() == 1 { + pr.value = pr.keys[0].value.clone(); } - }) + pr.keys.remove(i); + false + } else { + let mut k = Keyframe::new(lt, pr.value_at(lt)); + if pr.hold_only { + k = k.hold(); + } + set_key(&mut pr.keys, k); + true + } +} + +/// Alt+Shift+A / P / S / R / T: add or remove a keyframe at the current time on that Transform +/// property of every selected layer, in one undo step. Position covers separated X / Y / Z; +/// Rotation on a 3D layer covers Orientation and X / Y / Z Rotation. +fn toggle_transform_key(s: &mut Session, p: &Value) -> Result { + const C: &str = "keys.toggleTransform"; + let which = str_p(p, "prop").ok_or_else(|| bad(C, "missing `prop` (anchor | position | scale | rotation | opacity)"))?.to_string(); + let (cid, layers) = layers_p(s, p)?; + let t = s.time_of(cid); + let mut added = vec![]; + s.edit("Add/Remove Keyframe", None, |proj, _| { + for lid in &layers { + let l = layer_mut(proj, cid, *lid)?; + let lt = l.layer_time(t); + let three = l.is_3d(); + let Some(tr) = l.props.sub_mut("transform") else { continue }; + let ids: Vec<&str> = match which.as_str() { + "anchor" => vec!["anchor"], + "position" if tr.get("positionX").is_some() => vec!["positionX", "positionY", "positionZ"], + "position" => vec!["position"], + "scale" => vec!["scale"], + "rotation" if three => vec!["orientation", "rotationX", "rotationY", "rotation"], + "rotation" => vec!["rotation"], + "opacity" => vec!["opacity"], + other => return Err(bad(C, format!("unknown prop `{other}` (anchor | position | scale | rotation | opacity)"))), + }; + for id in ids.into_iter().filter(|id| three || *id != "positionZ") { + if let Some(pr) = tr.get_mut(id) { + added.push(json!({"layer": lid.0, "prop": id, "key": toggle_key_at(pr, lt)})); + } + } + } + Ok(()) + })?; + Ok(Value::Array(added)) } fn set_expr(s: &mut Session, p: &Value) -> Result { @@ -607,6 +646,15 @@ pub fn specs() -> Vec { cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?}", has_layers, toggle_anim), cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time?, value?}", has_layers, add_key), cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key), + cmd!( + "keys.toggleTransform", + "Add or Remove Transform Keyframe", + [], + None, + "{layers?, prop: anchor|position|scale|rotation|opacity} (Alt+Shift+A/P/S/R/T)", + has_layers, + toggle_transform_key + ), cmd!("prop.setExpression", "Add Expression", ["Animation"], Some("Alt+Shift+="), "{layer?, path|prop, expression?, enabled?}", has_layers, set_expr), cmd!("prop.reset", "Reset Property", [], None, "{layer?, path|prop, default?}", has_layers, reset), cmd!("prop.select", "Select Property", [], None, "{layer?, path|prop, add?, selectKeys?}", has_layers, select_prop), diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 79af5d83..d33a3c1d 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -594,3 +594,27 @@ fn arrange_above_moves_layers_to_a_place_in_the_stack() { assert!(s.execute("layer.arrange", json!({"layers": ["B"], "above": "B"})).is_err()); assert!(s.execute("layer.arrange", json!({"layers": ["B"], "above": "Nope"})).is_err()); } + +#[test] +fn toggle_transform_key_covers_selected_layers_separated_position_and_3d_rotation() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "K", "width": 100, "height": 100, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "A", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"name": "B", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + let keys = |s: &Session, l: u64, path: &str| prop(s, l, path).keys.len(); + // Both selected layers get an Opacity key, in one undo step; again removes them. + let steps = s.history.undo.len(); + s.execute("keys.toggleTransform", json!({"prop": "opacity"})).unwrap(); + assert_eq!((keys(&s, a, "transform/opacity"), keys(&s, b, "transform/opacity")), (1, 1)); + assert_eq!(s.history.undo.len(), steps + 1); + s.execute("keys.toggleTransform", json!({"prop": "opacity"})).unwrap(); + assert_eq!(keys(&s, a, "transform/opacity"), 0); + // A 3D layer's Rotation key covers Orientation and X/Y/Z Rotation. + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "threeD", "value": true})).unwrap(); + s.execute("keys.toggleTransform", json!({"layers": [a], "prop": "rotation"})).unwrap(); + for p in ["orientation", "rotationX", "rotationY", "rotation"] { + assert_eq!(keys(&s, a, &format!("transform/{p}")), 1, "{p}"); + } + assert!(s.execute("keys.toggleTransform", json!({"prop": "skew"})).is_err()); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1cb29567..5a8f328d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -61,6 +61,14 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("timeline.reveal.masks", "Reveal Masks", [], Some("M")), uic!("timeline.reveal.feather", "Reveal Mask Feather", [], Some("F")), uic!("timeline.reveal.levels", "Reveal Audio Levels (press twice: Waveform)", [], Some("L")), + uic!("timeline.reveal.timeRemap", "Reveal Time Remap", [], None), + uic!("timeline.reveal.expressions", "Reveal Expressions", [], None), + uic!("timeline.reveal.maskOpacity", "Reveal Mask Opacity", [], None), + uic!("timeline.reveal.maskPath", "Reveal Mask Path", [], None), + uic!("timeline.reveal.material", "Reveal Material Options", [], None), + uic!("timeline.reveal.paint", "Reveal Paint, Roto Brush and Puppet", [], None), + uic!("timeline.reveal.selected", "Reveal Selected Properties", [], None), + uic!("timeline.reveal.missingEffects", "Reveal Missing Effects", [], None), uic!("timeline.reveal.waveform", "Reveal Audio Waveform", [], None), uic!("timeline.revealAdd.position", "Add Position to Revealed Properties", [], Some("Shift+P")), uic!("timeline.revealAdd.scale", "Add Scale to Revealed Properties", [], Some("Shift+S")), @@ -77,7 +85,13 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("timeline.search", "Search Timeline", [], None), uic!("flowchart.options", "Flowchart Options", [], None), uic!("flowchart.graph", "Flowchart Graph", [], None), - uic!("timeline.collapseAll", "Collapse All", [], Some("Cmd+`")), + uic!("timeline.collapseAll", "Collapse All", [], None), + uic!("timeline.twirlSelected", "Twirl Selected Layers Open / Closed", [], Some("Cmd+`")), + uic!("timeline.keyAt.anchor", "Add or Remove Anchor Point Keyframe", [], Some("Alt+Shift+A")), + uic!("timeline.keyAt.position", "Add or Remove Position Keyframe", [], Some("Alt+Shift+P")), + uic!("timeline.keyAt.scale", "Add or Remove Scale Keyframe", [], Some("Alt+Shift+S")), + uic!("timeline.keyAt.rotation", "Add or Remove Rotation Keyframe", [], Some("Alt+Shift+R")), + uic!("timeline.keyAt.opacity", "Add or Remove Opacity Keyframe", [], Some("Alt+Shift+T")), uic!("tool.selection", "Selection Tool", [], Some("V")), uic!("tool.hand", "Hand Tool", [], Some("H")), uic!("tool.zoom", "Zoom Tool", [], Some("Z")), @@ -110,12 +124,38 @@ pub fn panel_command_id(p: PanelKind) -> String { /// The reveal shortcuts (P, S, R, T, A, E, M, F, L…). `add` (Shift+key) adds the property to /// (or removes it from) those already revealed instead of replacing them. pub fn reveal(app: &mut EffectcraftApp, kind: &str, now: f64, add: bool) { - // AE's double-press shortcuts: L then L again quickly reveals the Waveform (LL). - let kind = match app.last_reveal.take() { - Some((k, t)) if k == "levels" && kind == "levels" && now - t < 0.6 => "waveform", - _ => kind, + // The double-press shortcuts: the same key again within 0.6 s reveals its second set (LL + // Waveform, TT Mask Opacity, MM all mask properties, EE Expressions, RR Time Remap, AA + // Material Options, PP paint / Roto Brush / Puppet, SS selected properties, FF missing + // effects). A single M shows Mask Path. + let double = |k: &str| -> Option<&'static str> { + Some(match k { + "levels" => "waveform", + "opacity" => "maskOpacity", + "masks" => "masks", + "effects" => "expressions", + "rotation" => "timeRemap", + "anchor" => "material", + "position" => "paint", + "scale" => "selected", + "feather" => "missingEffects", + _ => return None, + }) + }; + let pressed = kind; + // (With Shift a second press removes the property again instead.) + let again = !add && matches!(app.last_reveal.take(), Some((k, t)) if k == pressed && now - t < 0.6); + app.last_reveal = Some((if again { String::new() } else { pressed.to_string() }, now)); + let kind = match (again, double(pressed)) { + (true, Some(d)) => { + // The second press replaces what the first one revealed. + let first = if pressed == "masks" { "maskPath" } else { pressed }; + app.ui.timeline.reveal.retain(|k| k != first); + d + } + _ if pressed == "masks" => "maskPath", + _ => pressed, }; - app.last_reveal = Some((kind.to_string(), now)); let tl = &mut app.ui.timeline; let present = tl.reveal.iter().any(|k| k == kind); let sel: Vec = if app.session.state.selected_layers.is_empty() { @@ -208,6 +248,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V reveal(app, k, now, false); return Ok(Value::Null); } + if let Some(k) = id.strip_prefix("timeline.keyAt.") { + return run_engine(app, ctx, "keys.toggleTransform", json!({"prop": k})); + } if let Some(k) = id.strip_prefix("timeline.revealAdd.") { reveal(app, k, now, true); return Ok(json!({"revealed": app.ui.timeline.reveal})); @@ -358,6 +401,21 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V app.ui.timeline.open_groups.clear(); app.ui.timeline.reveal.clear(); } + // Ctrl+`: twirl the selected layers open (all closed ones) or closed (all open). From a + // reveal shortcut's view it opens their full property trees. + "timeline.twirlSelected" => { + let sel: Vec = app.session.state.selected_layers.iter().map(|l| l.0).collect(); + let tl = &mut app.ui.timeline; + let revealing = !tl.reveal.is_empty(); + tl.reveal.clear(); + if !revealing && sel.iter().all(|l| tl.open_layers.contains(l)) { + for l in &sel { + tl.open_layers.remove(l); + } + } else { + tl.open_layers.extend(sel); + } + } "timeline.workAreaBegin" => return app.session.execute("comp.workArea", json!({"set": "begin"})).map_err(|e| e.to_string()), "timeline.workAreaEnd" => return app.session.execute("comp.workArea", json!({"set": "end"})).map_err(|e| e.to_string()), "timeline.moveInToTime" => return timing(app, "moveInToTime"), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 87218934..79199195 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -423,6 +423,7 @@ fn prop_visible(p: &Property, layer: &Layer) -> bool { fn reveal_matches(p: &Property, path_matches: &[&str], kind: &str) -> bool { match kind { "animated" => p.is_animated() || p.has_expression(), + "expressions" => p.has_expression(), _ => path_matches.contains(&p.match_id.as_str()), } } @@ -497,8 +498,11 @@ fn reveal_targets(kind: &str) -> Option<(&'static str, &'static [&'static str])> "opacity" => ("transform", &["opacity"]), "anchor" => ("transform", &["anchor"]), "feather" => ("masks", &["feather"]), + "maskPath" => ("masks", &["path"]), + "maskOpacity" => ("masks", &["opacity"]), "levels" => ("audio", &["levels"]), - "animated" => ("", &[]), + "timeRemap" => ("", &["timeRemap"]), + "animated" | "expressions" => ("", &[]), _ => return None, }) } @@ -620,6 +624,28 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec) { { group_rows(rows, fx, true); } + // PP: paint, Roto Brush and Puppet effects; FF: effects whose plug-in is missing. + for (k, keep) in [ + ("paint", &(|id: &str| matches!(id, "ec.paint.paint" | "ec.matte.rotobrush" | "ec.distort.puppet")) as &dyn Fn(&str) -> bool), + ("missingEffects", &|id: &str| effectcraft_engine::effects::find(id).is_none()), + ] { + if has(k) + && let Some(fx) = l.effects() + { + let mut only = fx.clone(); + only.children.retain(|c| matches!(c, Node::Group(g) if matches!(&g.kind, GroupKind::Effect { effect } if keep(effect)))); + group_rows(rows, &only, true); + } + } + // AA: Material Options (3D layers). + if has("material") + && let Some(m) = l.props.sub("materialOptions") + && l.is_3d() + { + let mut only = PropGroup::new(0, "", ""); + only.children.push(Node::Group(m.clone())); + group_rows(rows, &only, false); + } // P/S/R/T/A/F/L/animated: matching properties, in tree order, each once. let mut wanted = std::collections::BTreeSet::new(); for kind in &tl.reveal { @@ -640,7 +666,12 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec) { collect_props(&l.props, &mut found, &|p| wanted.contains(&p.uid)); rows.extend(found.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } })); } - if has("props") { + // SS: the selected properties and groups (as Animation ▸ Reveal Properties shows them). + let selected: std::collections::BTreeSet = app.session.state.selected_props.iter().filter(|(lid, _)| *lid == l.id).map(|(_, u)| *u).collect(); + if has("props") || has("selected") { + let picked: std::collections::BTreeSet = + if has("props") { tl.reveal_props.iter().copied().chain(selected.iter().copied().filter(|_| has("selected"))).collect() } else { selected }; + let reveal_props = &picked; // Animation ▸ Reveal Properties…: the engine picked the uids. fn groups_in<'a>(g: &'a PropGroup, set: &std::collections::BTreeSet, out: &mut Vec<&'a PropGroup>) { for sg in g.groups() { @@ -652,7 +683,7 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec) { } } let mut groups = vec![]; - groups_in(&l.props, &tl.reveal_props, &mut groups); + groups_in(&l.props, reveal_props, &mut groups); for g in groups { let open = tl.open_groups.contains(&g.uid); rows.push(Row { @@ -665,7 +696,7 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec) { } } let mut found = vec![]; - collect_props(&l.props, &mut found, &|p| prop_visible(p, l) && tl.reveal_props.contains(&p.uid) && !wanted.contains(&p.uid)); + collect_props(&l.props, &mut found, &|p| prop_visible(p, l) && reveal_props.contains(&p.uid) && !wanted.contains(&p.uid)); rows.extend(found.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } })); } if has("waveform") diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index f7b1792c..835536bb 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -646,3 +646,100 @@ fn timeline_rename_commits_on_enter_and_on_click_away() { key(&mut h, egui::Key::Escape, Default::default()); assert_eq!(name(&h), "Hand"); } + +/// Press `k` twice within one frame (a quick double press: the harness' frames are long). +fn key_twice(h: &mut Harness<'_, EffectcraftApp>, k: egui::Key) { + for _ in 0..2 { + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }); + } + h.run_steps(2); +} + +#[test] +fn property_shortcuts_reveal_and_key_transform_properties() { + let mut h = harness(); + h.state_mut().session.execute("layer.select", json!({"layers": ["Box"]})).unwrap(); + h.run_steps(2); + let t = |h: &Harness<'_, EffectcraftApp>, p: &str| shown(h, prop_uid(h, "Box", &format!("transform/{p}"))); + let only = |h: &Harness<'_, EffectcraftApp>, want: &[&str]| { + for p in ["anchor", "position", "scale", "rotation", "opacity"] { + assert_eq!(t(h, p), want.contains(&p), "{p} shown? want {want:?}"); + } + }; + let none = egui::Modifiers::default(); + // P, then T: one property at a time; Shift+S adds Scale; P again hides. + key(&mut h, egui::Key::P, none); + only(&h, &["position"]); + key(&mut h, egui::Key::T, none); + only(&h, &["opacity"]); + key(&mut h, egui::Key::S, egui::Modifiers::SHIFT); + only(&h, &["scale", "opacity"]); + key(&mut h, egui::Key::A, none); + only(&h, &["anchor"]); + key(&mut h, egui::Key::R, none); + only(&h, &["rotation"]); + // Alt+Shift+P: a Position keyframe at the current time; again removes it. + let pos_keys = |h: &Harness<'_, EffectcraftApp>| { + let id = layer_id(h, "Box"); + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/position").unwrap().keys.len() + }; + let alt_shift = egui::Modifiers { alt: true, shift: true, ..Default::default() }; + key(&mut h, egui::Key::P, alt_shift); + assert_eq!(pos_keys(&h), 1); + key(&mut h, egui::Key::P, alt_shift); + assert_eq!(pos_keys(&h), 0); + // U: only keyframed properties (Opacity animated); U again later hides; UU modified ones. + h.state_mut().session.execute("prop.addKey", json!({"layer": "Box", "path": "transform/opacity", "time": 0, "value": 50})).unwrap(); + h.state_mut().session.execute("prop.addKey", json!({"layer": "Box", "path": "transform/opacity", "time": 1, "value": 100})).unwrap(); + h.run_steps(2); + key(&mut h, egui::Key::U, none); + only(&h, &["opacity"]); + h.run_steps(40); + key(&mut h, egui::Key::U, none); + only(&h, &[]); + h.state_mut().session.execute("prop.set", json!({"layer": "Box", "path": "transform/rotation", "value": 30})).unwrap(); + h.run_steps(40); + key_twice(&mut h, egui::Key::U); + assert!(t(&h, "opacity") && t(&h, "rotation"), "UU: animated and changed properties"); + assert!(!t(&h, "scale"), "an untouched property stays hidden"); + // Ctrl+`: twirl the selected layer open, then closed. + h.run_steps(40); + key(&mut h, egui::Key::Backtick, egui::Modifiers::COMMAND); + let id = layer_id(&h, "Box"); + assert!(h.state().ui.timeline.open_layers.contains(&id) && h.state().ui.timeline.reveal.is_empty()); + key(&mut h, egui::Key::Backtick, egui::Modifiers::COMMAND); + assert!(!h.state().ui.timeline.open_layers.contains(&id)); +} + +#[test] +fn double_press_shortcuts_reveal_their_second_set() { + let mut h = harness(); + h.state_mut().session.execute("layer.select", json!({"layers": ["Box"]})).unwrap(); + h.state_mut().session.execute("prop.setExpression", json!({"layer": "Box", "path": "transform/scale", "expression": "value"})).unwrap(); + h.run_steps(2); + let reveal = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.reveal.clone(); + // EE: properties with expressions (Scale), replacing E's effects. + key_twice(&mut h, egui::Key::E); + assert_eq!(reveal(&h), vec!["expressions"]); + assert!(shown(&h, prop_uid(&h, "Box", "transform/scale"))); + h.run_steps(40); + // TT: Mask Opacity; MM: every mask property; a single M: Mask Path. + key_twice(&mut h, egui::Key::T); + assert_eq!(reveal(&h), vec!["maskOpacity"]); + h.run_steps(40); + key(&mut h, egui::Key::M, Default::default()); + assert_eq!(reveal(&h), vec!["maskPath"]); + h.run_steps(40); + key_twice(&mut h, egui::Key::M); + assert_eq!(reveal(&h), vec!["masks"]); + h.run_steps(40); + // RR Time Remap, AA Material Options, PP paint/puppet, SS selected properties, FF missing effects. + for (k, want) in + [(egui::Key::R, "timeRemap"), (egui::Key::A, "material"), (egui::Key::P, "paint"), (egui::Key::S, "selected"), (egui::Key::F, "missingEffects")] + { + key_twice(&mut h, k); + assert_eq!(reveal(&h), vec![want], "{k:?}{k:?}"); + h.run_steps(40); + } +}