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 a608c175..fd93b991 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 727a9151..1ba362cd 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2268,7 +2268,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); } @@ -2337,7 +2338,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)); } } } @@ -3233,9 +3234,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), @@ -3359,7 +3373,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. @@ -3367,12 +3382,21 @@ 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); } } +/// 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( @@ -3940,6 +3964,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 e43a9ce9..27ce5e69 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(); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 40e1ac08..e60226b1 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