From 6b8200ff786f9c68b3b01811b74bc10140b17b80 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:02:01 +0700 Subject: [PATCH] Timeline: reveal shortcuts leave unselected layers alone (#160) U (and P, S, R, T, A, E, M, F, L) kept one reveal filter for the whole Timeline and replaced the set of open layers with the selected ones, so revealing on one layer closed every other layer and dropped their reveals. Each layer now has its own filter (TimelineState::layer_reveal; an open layer without one shows its whole tree). A shortcut applies to the selected layers (all layers with none selected) through apply_reveal and leaves the others as they are; `reveal` stays the last shortcut's filter for toggling and Shift-adding. U's results replace only the target layers' earlier picks, so U after UU shows the keyframed properties again. anim.reveal tells the Timeline which layers it looked at, so a layer with nothing keyframed closes. Twirling a layer by hand, Ctrl+` and Collapse All clear the filters they cover. Test: reveal_shortcuts_leave_unselected_layers_alone. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/animation.rs | 3 +- crates/ui-egui/src/menus.rs | 81 ++++++++++++++++--------- crates/ui-egui/src/panels/timeline.rs | 56 +++++++++++++++-- crates/ui-egui/src/state.rs | 25 +++++++- 4 files changed, 130 insertions(+), 35 deletions(-) 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,