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 <noreply@anthropic.com>
This commit is contained in:
pravdomil
2026-10-12 16:40:35 +02:00
co-authored by Claude Opus 5.5
parent 9efe75330c
commit f96663b1f5
2 changed files with 72 additions and 6 deletions
+26 -6
View File
@@ -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<u64>) {
}
}
/// 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(
+46
View File
@@ -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<u64>) {
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