Merge pull request #1044 from pravdomil/claude/timeline-chevron-expand-tree-93231f

This commit is contained in:
Brandon Thomas committed 2026-10-13 04:29:02 -04:00
commit e248c172e8
6 files changed
+182 -20

No files matched your search

+23 -10
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));
+15 -4
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);
+45 -6
View File
@@ -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<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);
}
}
/// 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]
@@ -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]))
+25
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();
+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