From f96663b1f58351e128cac73f5b14a1d58b1f040f Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:40:35 +0200 Subject: [PATCH 1/2] Timeline: Cmd/Alt-click a group twirl opens its whole tree Cmd-click (or Alt-click) the arrow of Effects, Contents, Masks or any other group in the Timeline twirls every group under it open, or closed, with it, as the layer's own arrow already did. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 32 +++++++++++++++---- crates/ui-egui/tests/ui_viewer.rs | 46 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f10429..afa656ac 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 07036ca8..bfe4a3f6 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 From aa6c47395cf9ed7a194f64d303b5fa20d8e9c7f5 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:52:10 +0200 Subject: [PATCH 2/2] Cmd/Alt-click twirls: Waveform row, Effect Controls, Project folders Cmd-click (or Alt-click) a twirl arrow now takes the whole tree with it in three more places: Audio's Waveform row in the Timeline, an effect or effect group in Effect Controls, and a folder in the Project panel. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/effect_controls.rs | 33 ++++++++++++++------ crates/ui-egui/src/panels/project.rs | 19 ++++++++--- crates/ui-egui/src/panels/timeline.rs | 19 +++++++++++ crates/ui-egui/tests/ui_effect_controls.rs | 28 +++++++++++++++++ crates/ui-egui/tests/ui_project_rows.rs | 25 +++++++++++++++ 5 files changed, 110 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index d8de61d8..9b70265d 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -646,6 +646,27 @@ impl<'a> FxScope<'a> { } } +/// Twirl `g` closed (it was `open`) or open; with `all` (Alt/Cmd-click) every group under it follows. +fn toggle_closed(app: &mut EffectcraftApp, g: &PropGroup, open: bool, all: bool) { + fn walk(g: &PropGroup, out: &mut Vec) { + for sg in g.groups() { + out.push(sg.uid); + walk(sg, out); + } + } + let mut uids = vec![g.uid]; + if all { + walk(g, &mut uids); + } + for u in uids { + if open { + app.ui.fx_closed.insert(u); + } else { + app.ui.fx_closed.remove(&u); + } + } +} + #[allow(clippy::too_many_arguments)] fn group_rows( app: &mut EffectcraftApp, @@ -705,11 +726,7 @@ fn group_rows( app.auto.add(&format!("effectControls.row.{}", sg.uid), r, &sg.name); app.auto.add(&format!("effectControls.group.{}.twirl", sg.uid), tw, &sg.name); if widgets::twirl(ui, tw, open, egui::Id::new(("ec-g", sg.uid)), &t).clicked() { - if open { - app.ui.fx_closed.insert(sg.uid); - } else { - app.ui.fx_closed.remove(&sg.uid); - } + toggle_closed(app, sg, open, ui.input(|i| i.modifiers.alt || i.modifiers.command)); } let name = p.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &sg.name, Tokens::ui(12.0), t.text); app.auto.add(&format!("effectControls.group.{}.name", sg.uid), name, &sg.name); @@ -1114,11 +1131,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("effectControls.effect.{}.fx", g.uid), fxr, &g.name); let tw = Rect::from_center_size(pos2(r.min.x + 32.0, r.center().y), vec2(12.0, 12.0)); if widgets::twirl(ui, tw, open, egui::Id::new(("ec-tw", g.uid)), &t).clicked() { - if open { - app.ui.fx_closed.insert(g.uid); - } else { - app.ui.fx_closed.remove(&g.uid); - } + toggle_closed(app, g, open, ui.input(|i| i.modifiers.alt || i.modifiers.command)); } app.auto.add(&format!("effectControls.effect.{}.twirl", g.uid), tw, &g.name); let rename_id = egui::Id::new(("ec-rename", g.uid)); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index e18f76bc..1fa13245 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -579,10 +579,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name) }); if tresp.clicked() { - if open { - app.ui.project_open_folders.remove(&id.0); - } else { - app.ui.project_open_folders.insert(id.0); + // Alt/Cmd-click twirls every folder inside it too. + let mut ids = vec![*id]; + if ui.input(|i| i.modifiers.alt || i.modifiers.command) { + let mut at = 0; + while let Some(f) = ids.get(at).copied() { + ids.extend(app.session.project.children(Some(f)).into_iter().filter(|c| c.is_folder()).map(|c| c.id)); + at += 1; + } + } + for f in ids { + if open { + app.ui.project_open_folders.remove(&f.0); + } else { + app.ui.project_open_folders.insert(f.0); + } } } app.auto.add(&format!("project.item.{}.twirl", id.0), tw, &it.name); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index afa656ac..258ca1ec 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3252,6 +3252,10 @@ enum UiAct { } fn walk_groups(g: &PropGroup, out: &mut Vec) { + // Audio's Waveform row is not in the property tree; it twirls along. + if g.match_id == "audio" { + out.push(WAVE_BIT | g.uid); + } for sg in g.groups() { out.push(sg.uid); walk_groups(sg, out); @@ -3804,6 +3808,21 @@ fn value_editor( mod tests { use super::*; + /// Alt/Cmd-click twirls Audio's Waveform row (not in the property tree) with the rest. + #[test] + fn the_waveform_row_twirls_with_its_tree() { + let mut root = PropGroup::new(1, "", ""); + let mut audio = PropGroup::new(2, "audio", "Audio"); + audio.children.push(PropGroup::new(3, "x", "X").into()); + root.children.push(audio.into()); + let mut uids = vec![]; + walk_groups(&root, &mut uids); + assert_eq!(uids, vec![2, WAVE_BIT | 2, 3]); + let mut uids = vec![]; + walk_groups(find_group(&root, 2).unwrap(), &mut uids); + assert_eq!(uids, vec![WAVE_BIT | 2, 3]); + } + /// #710: with Time Display set to Frames the ruler labels are frame numbers (as under the /// CTI, from Start Numbering Frames At); otherwise seconds:frames. #[test] diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index 3251aed0..380ae1f7 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -220,6 +220,34 @@ fn keyer_eyedropper_picks_the_screen_from_the_effect_input() { assert!(img.get(160, 90)[3] < 0.01, "{:?}", img.get(160, 90)); } +#[test] +fn command_clicking_an_effect_twirl_closes_and_opens_its_groups() { + let (app, x) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app); + let fx = h.state_mut().session.execute("effect.apply", json!({"layers": [x.small], "effect": "Lumetri Color"})).unwrap()["effects"][0].as_u64().unwrap(); + // Only this effect open, so its header is on screen. + let l = layer(&h.state().session, x.small); + let others: Vec = l.effects().unwrap().groups().map(|g| g.uid).filter(|u| *u != fx).collect(); + h.state_mut().ui.fx_closed.extend(others.iter().copied()); + h.run_steps(3); + let inner: Vec = l.effects().unwrap().groups().find(|g| g.uid == fx).unwrap().groups().map(|g| g.uid).collect(); + assert!(!inner.is_empty(), "the effect has twirl-down groups"); + let cmd_click = |h: &mut Harness<'_, EffectcraftApp>| { + let at = rect_of(h, &format!("effectControls.effect.{fx}.twirl")).center(); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)); + click(h, at); + h.event(egui::Event::ModifiersChanged(Default::default())); + h.step(); + }; + cmd_click(&mut h); + let closed = h.state().ui.fx_closed.clone(); + assert!(closed.contains(&fx) && inner.iter().all(|u| closed.contains(u)), "the effect and its groups twirl closed"); + cmd_click(&mut h); + let closed = h.state().ui.fx_closed.clone(); + assert!(!closed.contains(&fx) && !inner.iter().any(|u| closed.contains(u)), "and open again together"); + assert!(others.iter().all(|u| closed.contains(u)), "other effects stay as they were"); +} + fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { let e = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).find(|e| e.id == id).unwrap_or_else(|| panic!("no {id}")).clone(); egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 7d0ebf66..843e14b4 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -59,6 +59,31 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { click_at(h, p); } +#[test] +fn command_clicking_a_folder_twirl_opens_the_folders_inside_it() { + let (mut h, folder, _, _) = harness(); + let s = &mut h.state_mut().session; + let inner = s.execute("project.newFolder", json!({"name": "Inner"})).unwrap()["item"].as_u64().unwrap(); + let deep = s.execute("project.newFolder", json!({"name": "Deep"})).unwrap()["item"].as_u64().unwrap(); + s.execute("project.move", json!({"items": [inner], "folder": folder.0})).unwrap(); + s.execute("project.move", json!({"items": [deep], "folder": inner})).unwrap(); + s.execute("project.select", json!({"items": []})).unwrap(); + h.state_mut().ui.project_open_folders.clear(); + h.run_steps(3); + let p = center(&h, &format!("project.item.{}.twirl", folder.0)); + let cmd_click = |h: &mut Harness<'_, EffectcraftApp>| { + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + click_at(h, p); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + }; + cmd_click(&mut h); + let open = h.state().ui.project_open_folders.clone(); + assert!([folder.0, inner, deep].iter().all(|f| open.contains(f)), "the folder and every folder inside it open"); + cmd_click(&mut h); + assert!(h.state().ui.project_open_folders.is_empty(), "and close again together"); +} + #[test] fn folder_twirl_opens_and_closes_the_folder_on_one_click() { let (mut h, folder, comp, _) = harness();