diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/commands/animation.rs index aa74c3f2..a1060c85 100644 --- a/crates/engine/src/commands/animation.rs +++ b/crates/engine/src/commands/animation.rs @@ -338,7 +338,8 @@ fn reveal(s: &mut Session, p: &Value) -> Result { out.extend(found.into_iter().map(|u| json!({"layer": lid.0, "prop": u}))); } } - frontend(s, "timeline.revealProps", &json!({"props": out, "kind": kind}))?; + let layers: Vec = layers.iter().map(|l| l.0).collect(); + frontend(s, "timeline.revealProps", &json!({"props": out, "kind": kind, "layers": layers}))?; Ok(json!({"props": out})) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2b5b5850..175a1b8f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -156,33 +156,32 @@ pub fn reveal(app: &mut EffectcraftApp, kind: &str, now: f64, add: bool) { _ if pressed == "masks" => "maskPath", _ => pressed, }; + let targets = reveal_targets(app); 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() { + let mut kinds = tl.reveal.clone(); + if add { + if present { + kinds.retain(|k| k != kind); + } else { + kinds.retain(|k| k != "props" || !tl.reveal_props.is_empty()); + kinds.push(kind.to_string()); + } + } else if present && kinds.len() == 1 { + kinds.clear(); + } else { + kinds = vec![kind.to_string()]; + } + tl.apply_reveal(&targets, kinds); +} + +/// The layers a reveal shortcut acts on: the selected ones, else every layer of the active comp. +fn reveal_targets(app: &EffectcraftApp) -> Vec { + if app.session.state.selected_layers.is_empty() { app.session.active_comp().map(|c| c.layers.iter().map(|l| l.id.0).collect()).unwrap_or_default() } else { app.session.state.selected_layers.iter().map(|l| l.0).collect() - }; - if add { - if present { - tl.reveal.retain(|k| k != kind); - if tl.reveal.is_empty() { - tl.open_layers.clear(); - } - } else { - tl.reveal.retain(|k| k != "props" || !tl.reveal_props.is_empty()); - tl.reveal.push(kind.to_string()); - tl.open_layers.extend(sel); - } - return; } - if present && tl.reveal.len() == 1 { - tl.reveal.clear(); - tl.open_layers.clear(); - return; - } - tl.reveal = vec![kind.to_string()]; - tl.open_layers = sel.into_iter().collect(); } fn no_params(p: &Value) -> bool { @@ -316,8 +315,8 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V return run_engine(app, ctx, "anim.reveal", json!({"kind": "modified"})); } Some((k, _)) if k == "keyframes" && app.ui.timeline.reveal == ["props"] => { - app.ui.timeline.reveal.clear(); - app.ui.timeline.open_layers.clear(); + let targets = reveal_targets(app); + app.ui.timeline.apply_reveal(&targets, vec![]); return Ok(json!({"revealed": 0})); } _ => {} @@ -451,14 +450,18 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V app.ui.timeline.open_layers.clear(); app.ui.timeline.open_groups.clear(); app.ui.timeline.reveal.clear(); + app.ui.timeline.layer_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(); + let revealing = sel.iter().any(|l| tl.layer_reveal.contains_key(l)); tl.reveal.clear(); + for l in &sel { + tl.layer_reveal.remove(l); + } if !revealing && sel.iter().all(|l| tl.open_layers.contains(l)) { for l in &sel { tl.open_layers.remove(l); @@ -987,11 +990,33 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } "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() }; + let (found, with_props) = (uids("prop"), uids("layer")); + // The layers it looked at (`layers`), else those it found something on. Layers that + // have nothing to show close; the others' reveals stay. + let targets: Vec = match p.get("layers").and_then(Value::as_array) { + Some(l) => l.iter().filter_map(Value::as_u64).collect(), + None => with_props.clone(), + }; + // The targets' earlier picks go (U after UU shows the keyframed ones only). + let comp = app.session.active_comp_arc(); + let theirs = |u: u64| { + comp.as_ref().is_some_and(|c| { + targets + .iter() + .filter_map(|l| c.layer(effectcraft_engine::project::LayerId(*l))) + .any(|l| l.props.find(u).is_some() || l.props.find_group(u).is_some()) + }) + }; let tl = &mut app.ui.timeline; - tl.reveal_props = props.iter().filter_map(|x| x.get("prop").and_then(Value::as_u64)).collect(); - tl.open_layers = props.iter().filter_map(|x| x.get("layer").and_then(Value::as_u64)).collect(); - tl.reveal = if tl.reveal_props.is_empty() { vec![] } else { vec!["props".into()] }; - json!({"revealed": tl.reveal_props.len()}) + tl.reveal_props.retain(|u| !theirs(*u)); + tl.reveal_props.extend(found.iter().copied()); + let (shown, hidden): (Vec, Vec) = targets.into_iter().partition(|l| with_props.contains(l)); + tl.apply_reveal(&hidden, vec![]); + if !shown.is_empty() { + tl.apply_reveal(&shown, vec!["props".into()]); + } + json!({"revealed": found.len()}) } _ => return Err(format!("`{id}` is not a frontend command")), }) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 3fbea99a..99d336d6 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -562,8 +562,8 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { if !tl.open_layers.contains(&l.id.0) { continue; } - if !tl.reveal.is_empty() { - reveal_rows(app, l, &mut rows); + if let Some(kinds) = tl.layer_reveal.get(&l.id.0).filter(|k| !k.is_empty()) { + reveal_rows(app, l, kinds, &mut rows); continue; } for c in &l.props.children { @@ -612,9 +612,9 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { /// Rows of a twirled-open layer under the reveal shortcuts (one, or several added with Shift): /// masks, effects, the revealed properties in property-tree order, the Reveal Properties /// selection, then the waveform. -fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec) { +fn reveal_rows(app: &EffectcraftApp, l: &Layer, kinds: &[String], rows: &mut Vec) { let tl = &app.ui.timeline; - let has = |k: &str| tl.reveal.iter().any(|r| r == k); + let has = |k: &str| kinds.iter().any(|r| r == k); let group_rows = |rows: &mut Vec, g: &PropGroup, fx: bool| { for g in g.groups() { rows.push(Row { @@ -668,7 +668,7 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec) { } // 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 { + for kind in kinds { let Some((group, ids)) = reveal_targets(kind) else { continue }; let root = match group { "" => Some(&l.props), @@ -2366,7 +2366,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { match a { UiAct::ToggleLayer(id, all) => { let tl = &mut app.ui.timeline; + // Twirled by hand, the layer shows its whole tree again. tl.reveal.clear(); + tl.layer_reveal.remove(&id); let open = !tl.open_layers.contains(&id); if open { tl.open_layers.insert(id); @@ -2969,6 +2971,50 @@ mod tests { assert!(app.ui.timeline.reveal_props.len() > n_keys, "UU reveals modified properties too"); } + /// U and the other reveal shortcuts act on the selected layers only: other layers keep their + /// twirl state and their own reveal (#160). + #[test] + fn reveal_shortcuts_leave_unselected_layers_alone() { + let mut app = app(); + let ctx = egui::Context::default(); + let comp = app.session.active_comp().unwrap().clone(); + let id = |n: &str| comp.layers.iter().find(|l| l.name == n).unwrap().id.0; + let (plate, bx) = (id("Plate"), id("Box")); + let select = |app: &mut EffectcraftApp, l: u64| app.session.execute("layer.select", json!({"layers": [l]})).unwrap(); + // Plate shows Scale (S); then U on Box. + select(&mut app, plate); + crate::menus::invoke(&mut app, &ctx, "timeline.reveal.scale", json!({})).unwrap(); + select(&mut app, bx); + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + let tl = &app.ui.timeline; + assert!(tl.open_layers.contains(&plate) && tl.open_layers.contains(&bx), "{:?}", tl.open_layers); + assert_eq!(tl.layer_reveal.get(&plate), Some(&vec!["scale".to_string()]), "Plate keeps its reveal"); + assert_eq!(tl.layer_reveal.get(&bx), Some(&vec!["props".to_string()])); + let rows = labels(&app); + let at = |n: &str| rows.iter().position(|r| r == n).unwrap(); + assert_eq!(rows[at("Plate") + 1].trim(), "Scale", "{rows:?}"); + assert_eq!(rows[at("Box") + 1].trim(), "Position", "{rows:?}"); + // UU (modified), then U later: Box shows its keyframed properties only again. + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + assert!(labels(&app).iter().any(|r| r.trim() == ">Gaussian Blur"), "UU shows the effect"); + app.last_reveal = Some(("keyframes".into(), -10.0)); + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + app.last_reveal = Some(("keyframes".into(), -10.0)); + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + let rows = labels(&app); + assert!(!rows.iter().any(|r| r.trim() == ">Gaussian Blur") && rows.iter().any(|r| r.trim() == "Position"), "{rows:?}"); + // U again on Box (later than a double press) hides only Box's. + app.last_reveal = Some(("keyframes".into(), -10.0)); + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + let tl = &app.ui.timeline; + assert!(!tl.open_layers.contains(&bx) && tl.open_layers.contains(&plate), "{:?}", tl.open_layers); + // A layer twirled open by hand shows its whole tree, whatever was revealed elsewhere. + app.session.execute("layer.select", json!({"layers": []})).unwrap(); + app.ui.timeline.open_layers.insert(bx); + let rows = labels(&app); + assert!(rows.iter().any(|r| r.trim() == ">Transform"), "{rows:?}"); + } + #[test] fn cti_snaps_to_the_nearest_candidate() { assert_eq!(snap_time(&[0.0, 1.0, 2.0], 1.04, 0.1), 1.0); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index be8ef385..041ce593 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -344,6 +344,23 @@ impl Default for ViewerState { } } +impl TimelineState { + /// Show `kinds` on `layers` (twirled open; empty: twirled closed) and remember it as the last + /// reveal. Other layers keep theirs. + pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec) { + for id in layers { + if kinds.is_empty() { + self.open_layers.remove(id); + self.layer_reveal.remove(id); + } else { + self.open_layers.insert(*id); + self.layer_reveal.insert(*id, kinds.clone()); + } + } + self.reveal = kinds; + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TimelineState { /// Visible span start (seconds) and pixels per second; None = fit comp. @@ -361,8 +378,13 @@ pub struct TimelineState { /// Twirled-open layers and groups (by layer id / group uid). pub open_layers: BTreeSet, pub open_groups: BTreeSet, - /// "Reveal" filter: only show these property match ids (P/S/R/T/A…) — empty = normal. + /// The last reveal shortcut's filter (P/S/R/T/A…): what pressing it again toggles or adds to. pub reveal: Vec, + /// Each revealed layer's filter: an open layer without one shows its whole property tree. A + /// reveal shortcut sets it on the selected layers (all layers with none selected) and leaves + /// the others as they are. + #[serde(default)] + pub layer_reveal: BTreeMap>, /// Properties / groups (uids) shown by the `props` reveal (Animation ▸ Reveal Properties…). #[serde(default)] pub reveal_props: BTreeSet, @@ -426,6 +448,7 @@ impl Default for TimelineState { open_layers: BTreeSet::new(), open_groups: BTreeSet::new(), reveal: vec![], + layer_reveal: BTreeMap::new(), reveal_props: BTreeSet::new(), graph_mode: auto_graph(), graph_show_selected: true,