diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f104290..afa656ac9 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2177,7 +2177,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if *has_children { let tw = Rect::from_center_size(pos2(indent, cy), vec2(13.0, 13.0)); if widgets::twirl(ui, tw, *open, egui::Id::new(("gtw", uid)), &t).clicked() { - ui_actions.push(UiAct::ToggleGroup(*uid)); + // Alt/Cmd-click twirls the whole tree under the group (as on a layer). + ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, ui.input(|i| i.modifiers.alt || i.modifiers.command))); } app.auto.add(&format!("timeline.group.{uid}.twirl"), tw, name); } @@ -2246,7 +2247,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); actions.push(("window.panel".into(), json!({"panel": "EffectControls"}))); } else { - ui_actions.push(UiAct::ToggleGroup(*uid)); + ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, false)); } } } @@ -3103,9 +3104,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - UiAct::ToggleGroup(uid) => { - if !app.ui.timeline.open_groups.remove(&uid) { - app.ui.timeline.open_groups.insert(uid); + UiAct::ToggleGroup(layer, uid, all) => { + let tl = &mut app.ui.timeline; + let open = !tl.open_groups.remove(&uid); + if open { + tl.open_groups.insert(uid); + } + if all && let Some(g) = comp.layer(LayerId(layer)).and_then(|l| find_group(&l.props, uid)) { + let mut uids = vec![]; + walk_groups(g, &mut uids); + for u in uids { + if open { + tl.open_groups.insert(u); + } else { + tl.open_groups.remove(&u); + } + } } } UiAct::SetTime(tt) => app.session.set_time(tt), @@ -3229,7 +3243,8 @@ fn layer_cells( enum UiAct { ToggleLayer(u64, bool), - ToggleGroup(u64), + /// Layer, group uid, and whether the whole tree under the group follows. + ToggleGroup(u64, u64, bool), SetTime(Tick), EndMerge, /// Composition Settings on its 3D Renderer tab. @@ -3243,6 +3258,11 @@ fn walk_groups(g: &PropGroup, out: &mut Vec) { } } +/// The group `uid` anywhere under `g`. +fn find_group(g: &PropGroup, uid: u64) -> Option<&PropGroup> { + g.groups().find_map(|sg| if sg.uid == uid { Some(sg) } else { find_group(sg, uid) }) +} + /// A stroke's Dashes "+" / "−" buttons (add or remove a Dash/Gap pair). #[allow(clippy::too_many_arguments)] fn dash_buttons( diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 07036ca81..bfe4a3f67 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -1570,6 +1570,52 @@ fn timeline_twirl_arrows_open_layers_and_groups() { assert!(!shown(&h, prop_uid(&h, "Box", "transform/position"))); } +#[test] +fn command_clicking_a_group_twirl_opens_its_whole_tree() { + // Cmd-click (or Alt-click) the arrow of Effects, Contents, Masks…: every group under it + // twirls open with it, and closed again with it. + let mut h = harness(); + let id = layer_id(&h, "Box"); + h.state_mut().session.execute("effect.apply", json!({"effect": "Lumetri Color", "layers": [id]})).unwrap(); + fn walk(g: &effectcraft_engine::project::PropGroup, out: &mut Vec) { + for sg in g.groups() { + out.push(sg.uid); + walk(sg, out); + } + } + let (effects, inner, transform) = { + let comp = h.state().session.active_comp().unwrap().clone(); + let props = &comp.layer(LayerId(id)).unwrap().props; + let fx = props.sub("effects").unwrap(); + let mut inner = vec![]; + walk(fx, &mut inner); + (fx.uid, inner, props.sub("transform").unwrap().uid) + }; + assert!(inner.len() > 1, "the effect has twirl-down groups of its own"); + let at = rect(&h, &format!("timeline.layer.{id}.twirl")).center(); + click(&mut h, at); + let at = rect(&h, &format!("timeline.group.{effects}.twirl")).center(); + let cmd_click = |h: &mut Harness<'_, EffectcraftApp>| { + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::COMMAND)); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::COMMAND }); + h.step(); + } + h.input_mut().events.push(Event::ModifiersChanged(Default::default())); + h.step(); + }; + cmd_click(&mut h); + let open = h.state().ui.timeline.open_groups.clone(); + assert!(open.contains(&effects), "Effects twirls open"); + assert!(inner.iter().all(|u| open.contains(u)), "and so does everything under it"); + assert!(!open.contains(&transform), "other groups stay as they were"); + cmd_click(&mut h); + let open = h.state().ui.timeline.open_groups.clone(); + assert!(!open.contains(&effects) && !inner.iter().any(|u| open.contains(u)), "Cmd-click again closes the tree"); +} + #[test] fn timeline_rename_commits_on_enter_and_on_click_away() { // The rename field (double-click a name, or Enter on a selected layer) used to grab the