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] 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();