Merge #1761: Timeline: a shape group's inner Contents draws no row, as After Effects elides it (@elZanco)

# Conflicts:
#	docs/ui-parity.md
This commit is contained in:
aiongg
2026-10-24 02:12:25 +00:00
3 changed files with 87 additions and 2 deletions
+61
View File
@@ -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<Row> {
/// 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<Row>) {
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<Row> {
}
}
}
/// 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<Row>) {
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<u64>, 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<Row>, l: &Layer, g: &PropGroup, depth: usize, open: &std::collections::BTreeSet<u64>) {
for c in &g.children {
match c {
@@ -1208,6 +1224,11 @@ fn push_group(rows: &mut Vec<Row>, 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::<Vec<_>>()
);
}
#[test]
fn columns_toggle_and_layout() {
let mut app = app();
@@ -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();
+3 -2
View File
@@ -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 |