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 <noreply@anthropic.com>
This commit is contained in:
pravdomil 2026-10-12 16:52:10 +02:00
parent f96663b1f5
commit aa6c47395c
5 changed files with 110 additions and 14 deletions

View File

@ -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<u64>) {
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));

View File

@ -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);

View File

@ -3252,6 +3252,10 @@ enum UiAct {
}
fn walk_groups(g: &PropGroup, out: &mut Vec<u64>) {
// 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]

View File

@ -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<u64> = 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<u64> = 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]))

View File

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