diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 5c89d346..2231a080 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -777,15 +777,22 @@ 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). 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); 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 + /// brings its contents. A shape group's inner Contents is elided (as in AE): no row, and + /// its items list at the same depth as the group itself. fn all(l: &Layer, g: &PropGroup, depth: usize, out: &mut Vec) { for c in &g.children { match c { + // A shape group's inner Contents is elided (as in AE): no row, items at the same depth. + Node::Group(sg) if g.match_id == "group" && sg.match_id == "contents" => all(l, sg, depth, out), Node::Group(sg) => { out.push(group_row(l, sg, depth, g)); all(l, sg, depth + 1, out); @@ -795,9 +802,14 @@ fn search_rows(l: &Layer, q: &str) -> Vec { } } } + /// The children of `g` that match `q`: a name-matching group brings its whole contents + /// (`all`); otherwise the search recurses and keeps only subtrees with hits. A shape + /// group's inner Contents is searched through without a row of its own (as in AE). fn rec(l: &Layer, g: &PropGroup, depth: usize, q: &str, out: &mut Vec) { for c in &g.children { match c { + // A shape group's inner Contents is elided (as in AE): search through it, no row. + Node::Group(sg) if g.match_id == "group" && sg.match_id == "contents" => rec(l, sg, depth, q, out), Node::Group(sg) if depth > 1 || group_visible(sg, l) => { // An effect group also matches the name the row draws, so a search finds // `高斯模糊` in a Chinese interface and `Gaussian Blur` in an English one. @@ -1201,6 +1213,10 @@ fn collect_props(g: &PropGroup, out: &mut Vec, f: &dyn Fn(&Property) -> boo } } +/// The rows for the children of `g` at `depth`: groups recurse into when their twirl-down is +/// 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. fn push_group(rows: &mut Vec, l: &Layer, g: &PropGroup, depth: usize, open: &std::collections::BTreeSet) { for c in &g.children { match c { @@ -1208,6 +1224,11 @@ fn push_group(rows: &mut Vec, l: &Layer, g: &PropGroup, depth: usize, open: Node::Group(sg) if g.match_id == "animator" && matches!(sg.match_id.as_str(), "selectors" | "properties") => { push_group(rows, l, sg, depth, open); } + // A shape group's inner Contents (AE's elided `ADBE Vectors Group`): the group's items + // (path, fill/stroke, Transform) list directly under it, with no "Contents" row. + Node::Group(sg) if g.match_id == "group" && sg.match_id == "contents" => { + push_group(rows, l, sg, depth, open); + } Node::Group(sg) => { let o = open.contains(&sg.uid); let fx = matches!(sg.kind, GroupKind::Effect { .. }).then_some(sg.enabled); @@ -4652,6 +4673,46 @@ mod tests { assert!(labels(&app).iter().any(|r| r.trim() == "Scale")); } + /// A shape group's inner Contents (AE's elided `ADBE Vectors Group`) draws no row: the + /// search shows the full row hierarchy with the group's items — path, fill and Transform — + /// listing directly under the group, and the only "Contents" row the layer's own. + #[test] + fn shape_group_inner_contents_is_elided() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 320, "height": 180, "duration": 4})).unwrap(); + s.execute("layer.newShape", json!({"kind": "rect"})).unwrap(); + let mut app = EffectcraftApp::new(s); + app.ui.timeline.search = "rect".into(); + assert_eq!( + labels(&app), + [ + "Shape Layer 1", + " >Contents", + " >Rectangle 1", + " >Rectangle Path 1", + " Size", + " Position", + " Roundness", + " >Fill 1", + " Composite", + " Fill Rule", + " Color", + " Opacity", + " >Transform", + " Anchor Point", + " Position", + " Scale", + " Skew", + " Skew Axis", + " Rotation", + " Opacity", + ] + .iter() + .map(|r| r.to_string()) + .collect::>() + ); + } + #[test] fn columns_toggle_and_layout() { let mut app = app(); diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index 3993a6f9..bc42b446 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -75,6 +75,29 @@ fn shape_contents_add_menu_adds_path_operations() { assert_eq!(items, ["group", "trim"]); } +/// A shape group's inner Contents is elided in After Effects: opening the group lists its items +/// (here the path) directly, with no "Contents" row between them. +#[test] +fn shape_group_contents_has_no_inner_contents_row() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let contents = layer.props.sub("contents").unwrap().clone(); + let group = contents.groups().next().unwrap().clone(); + let inner = group.sub("contents").unwrap().clone(); + let item = inner.groups().next().unwrap().clone(); + let mut h = harness(s); + h.state_mut().ui.timeline.open_layers.insert(l); + h.state_mut().ui.timeline.open_groups.insert(contents.uid); + h.state_mut().ui.timeline.open_groups.insert(group.uid); + h.run_steps(2); + let name = |uid: u64| format!("timeline.group.{uid}.name"); + assert!(h.state().auto.find(&name(contents.uid)).is_some(), "the layer's Contents row"); + assert!(h.state().auto.find(&name(group.uid)).is_some(), "the shape group row"); + assert!(h.state().auto.find(&name(inner.uid)).is_none(), "no inner Contents row"); + assert!(h.state().auto.find(&name(item.uid)).is_some(), "the path lists directly under the group"); +} + #[test] fn classic_3d_shape_layers_offer_change_renderer() { let mut s = session(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fa7a9219..df4c5e0e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (shape group Contents row elided; path-editing layer bounds #1220; closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging, modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence @@ -17,7 +17,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. |---|---|---:|---| | Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) | | Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; panels move into their own native windows on any screen (#486 open: no dragging tabs between those windows); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed | -| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a hidden null, camera or light has no box or handles in the viewer and isn't picked there; other selected hidden layers keep their layer controls and paths, #1769); a row control (a switch, the label swatch, a Modes cell) clicked on one of several selected layers acts on the whole selection, with the layers the switch doesn't apply to left out (#1849) | +| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a hidden null, camera or light has no box or handles in the viewer and isn't picked there; other selected hidden layers keep their layer controls and paths, #1769); a row control (a switch, the label swatch, a Modes cell) clicked on one of several selected layers acts on the whole selection, with the layers the switch doesn't apply to left out (#1849); a shape group's inner Contents (After Effects' elided Vectors group) draws no row — the group's path, fill/stroke and Transform list directly under it | | Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu | | Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed | | Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #491 (Linux multi-layer edits) is closed | @@ -50,6 +50,7 @@ Effects running; most of the rest parallelises. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | A shape group's inner Contents (After Effects' elided Vectors group) draws no row: the group's path, fill/stroke and Transform list directly under the group | | 2026-10-13 | patch | Layer bounds are suppressed during path-point editing (#1220); shape/mask interaction regressions | | 2026-10-12 | minor | Closed issues (#302, #320, #491, #511, #553 and others) marked as closed instead of open; panels in their own windows (#486) | | 2026-10-12 | minor | Percentage guides and effect colour labels (26.5) added; rows updated |