From b8b13b1cec63119ed5324020f0639302316bb277 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH] Timeline: shape layers get the Contents "Add:" menu; Classic 3D shows Change Renderer (#206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- crates/engine/src/commands/layer.rs | 4 +- crates/ui-egui/src/panels/timeline.rs | 118 ++++++++++++++++++++- crates/ui-egui/src/widgets.rs | 12 ++- crates/ui-egui/tests/ui_timeline_shapes.rs | 80 ++++++++++++++ 4 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/ui_timeline_shapes.rs diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 2fe07616..5881b329 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1075,7 +1075,9 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result { "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 { "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 ), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 8af70d55..89322f71 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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 { 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 { } } } + 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) { @@ -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 = 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( diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 703375e8..c737953f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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(); diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs new file mode 100644 index 00000000..dc62062d --- /dev/null +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -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()); +} +