From 9424c9eeb1770fae6b94d0744e9c7db791d15ee0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 00:52:26 +0700 Subject: [PATCH] Timeline: every shape Contents item has an eye switch, as in After Effects (#959) Groups, paths, fills, strokes and operators in a shape layer's Contents show the Video eye in the A/V column (a group's Transform doesn't). Clicking it runs prop.setGroupEnabled, which the shape renderer already honours; layer-style eyes keep layer.style.toggle. Row building and search results share eye_of. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 28 +++++++++++++------ crates/ui-egui/tests/it/ui_timeline_shapes.rs | 27 ++++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 30b93e62e..792b94306 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -795,12 +795,11 @@ fn display_name(app: &EffectcraftApp, l: &Layer) -> String { /// layer whose names contain `q` (lowercase), each with its enclosing groups. A matching group /// brings its whole contents. fn search_rows(l: &Layer, q: &str) -> Vec { - /// The row for a group in search results: depth, its effect's enabled flag, and the eye - /// switch for layer-style groups (not Blending Options). + /// The row for a group in search results: depth, its effect's enabled flag, and its eye + /// switch ([`eye_of`]). fn group_row(l: &Layer, g: &PropGroup, depth: usize, parent: &PropGroup) -> Row { let fx = matches!(g.kind, GroupKind::Effect { .. }).then_some(g.enabled); - let eye = - (parent.match_id == effectcraft_engine::project::styles::GROUP && g.match_id != effectcraft_engine::project::styles::BLENDING).then_some(g.enabled); + let eye = eye_of(parent, g); Row { layer: l.id, depth, kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open: true, has_children: !g.children.is_empty(), fx, eye } } } /// Every visible child of `g` at `depth`, groups recursed into whole — a matching group @@ -1235,6 +1234,14 @@ fn collect_props(g: &PropGroup, out: &mut Vec, f: &dyn Fn(&Property) -> boo /// in `open`, text animators list their selectors and properties directly (as in AE), and a /// shape group's inner Contents (AE's elided `ADBE Vectors Group`) is skipped so its items /// list directly under the group. +/// The eye switch of group `g` inside `parent`, as in After Effects: layer styles (not Blending +/// Options) and every item in a shape layer's Contents (#959), which the renderer skips when off. +fn eye_of(parent: &PropGroup, g: &PropGroup) -> Option { + let style = parent.match_id == effectcraft_engine::project::styles::GROUP && g.match_id != effectcraft_engine::project::styles::BLENDING; + let shape_item = parent.match_id == "contents" && g.match_id != "transform"; + (style || shape_item).then_some(g.enabled) +} + fn push_group(rows: &mut Vec, l: &Layer, g: &PropGroup, depth: usize, open: &std::collections::BTreeSet) { for c in &g.children { match c { @@ -1250,9 +1257,7 @@ fn push_group(rows: &mut Vec, l: &Layer, g: &PropGroup, depth: usize, open: Node::Group(sg) => { let o = open.contains(&sg.uid); let fx = matches!(sg.kind, GroupKind::Effect { .. }).then_some(sg.enabled); - // Layer style groups have eye switches (not Blending Options). - let eye = (g.match_id == effectcraft_engine::project::styles::GROUP && sg.match_id != effectcraft_engine::project::styles::BLENDING) - .then_some(sg.enabled); + let eye = eye_of(g, sg); rows.push(Row { layer: l.id, depth, @@ -3388,11 +3393,16 @@ fn group_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: & app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name); } if let Some(en) = eye { - // Layer style eye switch in the A/V column, like AE. + // A layer style's or shape item's eye switch in the A/V column, like AE. let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(15.0, 15.0)); lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::unique(("geye", uid)), Sense::click()).clicked() { - actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid}))); + let style = layer.props.sub(effectcraft_engine::project::styles::GROUP).is_some_and(|s| s.find_group(*uid).is_some()); + actions.push(if style { + ("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})) + } else { + ("prop.setGroupEnabled".into(), json!({"layer": layer.id.0, "prop": uid, "value": !*en})) + }); } app.auto.add(&format!("timeline.group.{uid}.eye"), er, name); } diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index b57791997..c8b45e8da 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -237,6 +237,33 @@ fn contents_names(h: &Harness<'_, EffectcraftApp>, l: u64) -> Vec { h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect() } +/// #959: every item in a shape layer's Contents has an eye switch, as in After Effects; it turns +/// the item off (the renderer skips it), undoably. The group's Transform has none. +#[test] +fn shape_contents_items_have_eye_switches() { + let (mut h, a, _, rect) = two_shape_layers(); + let enabled = |h: &Harness<'_, EffectcraftApp>| { + h.state().session.active_comp().unwrap().layer(LayerId(a)).unwrap().props.sub("contents").unwrap().find_group(rect).unwrap().enabled + }; + let eye = format!("timeline.group.{rect}.eye"); + assert!(enabled(&h)); + click(&mut h, &eye); + assert!(!enabled(&h), "the eye turns Rectangle 1 off"); + click(&mut h, &eye); + assert!(enabled(&h), "and on again"); + click(&mut h, &eye); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(enabled(&h), "one undo step"); + // Inside the group: its path and fill have eyes, its Transform doesn't. + h.state_mut().ui.timeline.open_groups.insert(rect); + h.run_steps(3); + let group = h.state().session.active_comp().unwrap().layer(LayerId(a)).unwrap().props.find_group(rect).unwrap().clone(); + let transform = group.sub("transform").expect("a shape group has a Transform").uid; + assert!(h.state().auto.find(&format!("timeline.group.{transform}.eye")).is_none(), "no eye on Transform"); + let items = group.sub("contents").expect("the group's items").groups().map(|g| g.uid).collect::>(); + assert!(!items.is_empty() && items.iter().all(|u| h.state().auto.find(&format!("timeline.group.{u}.eye")).is_some()), "every item has an eye"); +} + #[test] fn ctrl_c_and_ctrl_v_copy_a_shape_group_into_another_shape_layer() { let (mut h, a, b, rect) = two_shape_layers();