Timeline: shape layers get the Contents "Add:" menu; Classic 3D shows Change Renderer (#206)

Trim Paths, Merge Paths, Repeater and the other path operations
existed (layer.addShapeItem) but no menu added them to a shape layer.
Like After Effects, a shape layer's Contents row now has "Add:" with
Group, Rectangle, Ellipse, Polystar, Path, Fill, Stroke, Gradient Fill,
Gradient Stroke and the path operations; with a shape group selected
they go into it. layer.addShapeItem gains the empty Path and Gradient
Stroke kinds.

A 3D text or shape layer in a Classic 3D comp now shows Geometry
Options with "Change Renderer…", which opens Composition Settings on
its 3D Renderer tab (Advanced 3D comps show the extrusion options).

Popup menus are as tall as the window allows and open upward near its
bottom: the 20-entry Add menu was cut at 420 px, hiding Trim Paths.

Tests: shape_contents_add_menu_adds_path_operations,
classic_3d_shape_layers_offer_change_renderer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
This commit is contained in:
Claude 2026-10-07 22:13:19 +00:00
parent c357a53e5e
commit b8b13b1cec
No known key found for this signature in database
4 changed files with 211 additions and 3 deletions

View File

@ -1075,7 +1075,9 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
"polygon" => build::shape_star(&mut ids, false, 5.0, [0.0, 0.0], 100.0, 0.0),
"fill" => build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]),
"stroke" => build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 2.0),
"path" => build::shape_path(&mut ids, Default::default()),
"gfill" | "gradientFill" => build::shape_gradient_fill(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default()),
"gstroke" | "gradientStroke" => build::shape_gradient_stroke(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default(), 2.0),
"trim" | "trimPaths" => build::shape_trim(&mut ids, 0.0, 100.0, 0.0),
"repeater" => build::shape_repeater(&mut ids, 3.0, [100.0, 0.0]),
k => build::shape_simple_op(&mut ids, k).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{k}`")))?,
@ -1404,7 +1406,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add (Shape)",
[],
None,
"{layer?, kind: group|rect|ellipse|star|polygon|fill|stroke|gfill|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
has_layers,
add_shape_item
),

View File

@ -47,6 +47,16 @@ enum RowKind {
/// Synthetic group uid for a layer's Audio > Waveform twirl (never a real property uid).
const WAVE_BIT: u64 = 1 << 60;
/// Synthetic group uid for the Geometry Options row of a 3D text or shape layer in a Classic 3D
/// comp, which only offers "Change Renderer…" (never a real property uid).
const GEOM_BIT: u64 = 1 << 59;
/// Classic 3D comps don't extrude: a 3D text or shape layer's Geometry Options row offers to
/// change the renderer instead, as in After Effects.
fn classic_geometry_row(comp: &Comp, l: &Layer) -> bool {
comp.renderer == effectcraft_engine::project::Renderer::Classic3D && l.is_3d() && matches!(l.source, LayerSource::Text | LayerSource::Shape)
}
/// Time navigator's visible-span bar and the work area bar.
const NAV_BAR: Color32 = Color32::from_rgb(0x55, 0x55, 0x55);
const WORK_AREA_BAR: Color32 = Color32::from_rgb(0x5c, 0x5c, 0x5c);
@ -597,8 +607,11 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
reveal_rows(app, l, kinds, &mut rows);
continue;
}
let classic_geometry = classic_geometry_row(comp, l);
for c in &l.props.children {
match c {
// The Change Renderer row (after Transform) stands in for the extrusion options.
Node::Group(g) if classic_geometry && g.match_id == "geometryOptions" => {}
Node::Group(g) if group_visible(g, l) => {
let open = tl.open_groups.contains(&g.uid);
rows.push(Row {
@ -631,6 +644,20 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
}
}
}
if classic_geometry && g.match_id == "transform" {
rows.push(Row {
layer: l.id,
depth: 1,
kind: RowKind::Group {
uid: GEOM_BIT | l.id.0,
name: "Geometry Options".into(),
open: false,
has_children: false,
fx: None,
eye: None,
},
});
}
}
Node::Prop(p) if prop_visible(p, l) => rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid: p.uid } }),
_ => {}
@ -1704,6 +1731,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
shape_add_popup(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
if *uid == GEOM_BIT | layer.id.0 {
let lr = lp.text(pos2(cw.switches + 6.0, cy), Align2::LEFT_CENTER, "Change Renderer…", Tokens::ui(11.5), t.accent);
let resp =
ui.interact(lr, egui::Id::new(("tl-change-renderer", layer.id.0)), Sense::click()).on_hover_text("Composition Settings ▸ 3D Renderer");
if resp.hovered() {
ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
}
app.auto.add(&format!("timeline.layer.{}.changeRenderer", layer.id.0), lr, "Change Renderer…");
if resp.clicked() {
ui_actions.push(UiAct::ChangeRenderer);
}
}
dash_buttons(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
// Mask mode + inverted inline.
if let Some(g) = layer.props.find_group(*uid)
@ -1727,7 +1767,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
let gr = ui.interact(gr_rect, egui::Id::new(("grow", uid)), Sense::click());
app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
if gr.clicked() && uid & WAVE_BIT == 0 {
if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
}
if gr.double_clicked() {
@ -2444,6 +2484,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
UiAct::SetTime(tt) => app.session.set_time(tt),
UiAct::EndMerge => app.session.history.merge_key = None,
UiAct::ChangeRenderer => {
if super::dialogs::open_comp_settings(app).is_ok() {
app.dialog_state.comp.tab = super::comp_settings::Tab::Renderer;
}
}
}
}
if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") {
@ -2561,6 +2606,8 @@ enum UiAct {
ToggleGroup(u64),
SetTime(Tick),
EndMerge,
/// Composition Settings on its 3D Renderer tab.
ChangeRenderer,
}
fn walk_groups(g: &PropGroup, out: &mut Vec<u64>) {
@ -2601,6 +2648,75 @@ fn dash_buttons(
}
}
/// The shape items the Contents row's "Add:" menu offers, as `layer.addShapeItem` kinds ("-" is
/// a separator), in After Effects' order.
const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[
("group", "Group (empty)"),
("-", ""),
("rect", "Rectangle"),
("ellipse", "Ellipse"),
("star", "Polystar"),
("path", "Path"),
("-", ""),
("fill", "Fill"),
("stroke", "Stroke"),
("gfill", "Gradient Fill"),
("gstroke", "Gradient Stroke"),
("-", ""),
("merge", "Merge Paths"),
("offset", "Offset Paths"),
("pucker", "Pucker & Bloat"),
("repeater", "Repeater"),
("round", "Round Corners"),
("trim", "Trim Paths"),
("twist", "Twist"),
("wiggle", "Wiggle Paths"),
("zigzag", "Zig Zag"),
];
/// A shape layer's Contents "Add:" pop-up menu: adds a shape, paint or path operation to the
/// selected shape group, else to the layer's contents.
#[allow(clippy::too_many_arguments)]
fn shape_add_popup(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
uid: u64,
x: f32,
cy: f32,
actions: &mut Vec<(String, serde_json::Value)>,
) {
if !matches!(layer.source, LayerSource::Shape) || layer.props.sub("contents").is_none_or(|c| c.uid != uid) {
return;
}
let t = app.tokens;
p.text(pos2(x + 6.0, cy), Align2::LEFT_CENTER, "Add:", Tokens::ui(11.5), t.text_dim);
let br = Rect::from_center_size(pos2(x + 40.0, cy), vec2(16.0, 16.0));
let resp = ui.interact(br, egui::Id::new(("tl-shapeadd", uid)), Sense::click()).on_hover_text("Add a shape, fill, stroke or path operation");
icons::paint(p, br.shrink(3.0), Icon::ChevronRight, if resp.hovered() { t.text } else { t.text_dim });
app.auto.add(&format!("timeline.group.{uid}.add"), br, "Add:");
let pop = egui::Id::new(("tl-shapeadd-pop", uid));
if resp.clicked() {
widgets::open_popup(ui, pop);
}
let opts: Vec<String> = SHAPE_ADD_ITEMS.iter().map(|(k, l)| if *k == "-" { "-".to_string() } else { l.to_string() }).collect();
if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &opts, None)
&& let Some((kind, _)) = SHAPE_ADD_ITEMS.get(i).filter(|(k, _)| *k != "-")
{
let mut params = json!({"layer": layer.id.0, "kind": kind});
// Into the selected shape group, as After Effects does.
let group = app.session.state.selected_props.iter().filter(|(l, _)| *l == layer.id).find_map(|(_, u)| {
let g = layer.props.find_group(*u)?;
(g.match_id == "group" && g.sub("contents").is_some()).then_some(g.uid)
});
if let Some(g) = group {
params["group"] = json!(g);
}
actions.push(("layer.addShapeItem".into(), params));
}
}
/// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus).
#[allow(clippy::too_many_arguments)]
fn text_anim_popups(

View File

@ -283,10 +283,20 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String]
if !open {
return None;
}
// As tall as the window allows (a shape layer's Add menu has 20 entries), scrolling beyond,
// and moved up when it would run past the bottom of the window (the Timeline's menus).
let screen = ui.ctx().content_rect();
let max_h = (screen.height() - 24.0).max(120.0);
let sp = ui.spacing();
let seps = options.iter().filter(|o| *o == "-").count() as f32;
let rows = options.len() as f32 - seps;
let est = (rows * (sp.interact_size.y + sp.item_spacing.y + 4.0) + seps * (2.0 * sp.item_spacing.y + 2.0) + 16.0).min(max_h + 12.0);
let pos = egui::pos2(pos.x, pos.y.min(screen.bottom() - est).max(screen.top()));
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(160.0);
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
// (An area's content is laid out in last frame's size: ask for the estimate.)
egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| {
for (i, o) in options.iter().enumerate() {
if o == "-" {
ui.separator();

View File

@ -0,0 +1,80 @@
//! Timeline rows for shape layers: the Contents "Add:" menu and the Classic 3D "Change
//! Renderer…" row (#206).
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
use egui::{Event, Modifiers, PointerButton, Pos2, pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn harness(s: Session) -> Harness<'static, EffectcraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
h
}
fn session() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
s
}
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
}
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let p = center(h, id);
click_at(h, p);
}
fn open_layer(h: &mut Harness<'_, EffectcraftApp>, id: u64) {
h.state_mut().ui.timeline.open_layers.insert(id);
h.run_steps(2);
}
#[test]
fn shape_contents_add_menu_adds_path_operations() {
let mut s = session();
let l = s.execute("layer.newShape", json!({"kind": "ellipse"})).unwrap()["layer"].as_u64().unwrap();
let contents = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().uid;
let mut h = harness(s);
open_layer(&mut h, l);
click(&mut h, &format!("timeline.group.{contents}.add"));
let entry = h.get_by_label("Trim Paths").rect();
assert!(entry.max.y <= 1000.0, "the whole menu fits in the window: {entry:?}");
click_at(&mut h, entry.center());
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
let items: Vec<&str> = layer.props.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect();
assert_eq!(items, ["group", "trim"]);
}
#[test]
fn classic_3d_shape_layers_offer_change_renderer() {
let mut s = session();
let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
let mut h = harness(s);
open_layer(&mut h, l);
click(&mut h, &format!("timeline.layer.{l}.changeRenderer"));
assert_eq!(h.state().dialog, Some(Dialog::CompSettings));
assert!(h.state().auto.find("dialog.comp.renderer").is_some(), "on the 3D Renderer tab");
// Advanced 3D shows the extrusion options instead.
h.state_mut().dialog = None;
h.state_mut().session.execute("comp.renderer", json!({"renderer": "advanced3d"})).unwrap();
h.run_steps(3);
assert!(h.state().auto.find(&format!("timeline.layer.{l}.changeRenderer")).is_none());
}