From b8b13b1cec63119ed5324020f0639302316bb277 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 1/3] 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()); +} + From b26f55a77512df80213de08910e25fa3d2001eef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 2/3] Shapes and timeline: toolbar Fill / Stroke edit selected shape layers; Alt+Shift+P reveals; click below layers deselects (#205) - With a shape layer selected, the toolbar shows its Fill, Stroke and Stroke Width (with any tool) and editing them changes the layer's fills and strokes, as in After Effects (new shape.fillStroke, one undo step per picker or drag). Shapes drawn without a stroke get one when a stroke colour or width is set, so changing the stroke now works. - Alt+Shift+P (A, S, R, T) reveals the property it keyed on each layer. - A click in the time graph below the last layer deselects the layers (it only deselected keyframes), so a shape tool draws a new shape layer instead of a mask on the selected image. Tests: toolbar_fill_and_stroke_paint_selected_shape_layers, toolbar_stroke_edits_the_selected_shape_layer, add_position_key_reveals_position, clicking_below_the_layers_deselects_them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- crates/engine/src/commands/mod.rs | 2 +- crates/engine/src/commands/shape_stroke.rs | 116 ++++++++++++++++++++- crates/engine/src/tests.rs | 34 ++++++ crates/ui-egui/src/header.rs | 50 ++++++++- crates/ui-egui/src/menus.rs | 33 +++++- crates/ui-egui/src/panels/timeline.rs | 7 +- crates/ui-egui/tests/ui_timeline_shapes.rs | 50 ++++++++- 7 files changed, 280 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 9594b8a4..7c8e9613 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -54,7 +54,7 @@ pub mod roto_cmds; mod scene_detect; pub mod scripts; mod settings; -mod shape_stroke; +pub mod shape_stroke; mod stubs; mod styles; mod text_anim; diff --git a/crates/engine/src/commands/shape_stroke.rs b/crates/engine/src/commands/shape_stroke.rs index 29b945a7..51ed0724 100644 --- a/crates/engine/src/commands/shape_stroke.rs +++ b/crates/engine/src/commands/shape_stroke.rs @@ -1,14 +1,16 @@ //! Shape Stroke options: the Dashes "+" / "−" buttons (Dash 2 / Gap 2, Dash 3 / Gap 3), Taper and //! Wave. Taper and Wave are ordinary properties of the stroke (`…/stroke/taper/startLength`); the //! commands here set several of them in one undo step and add the groups to strokes saved before -//! they existed. +//! they existed. Also the toolbar's Fill / Stroke options on selected shape layers. use effectcraft_keyframe::Value as KV; use effectcraft_project::build::{self, EXTRA_DASHES, Ids}; -use effectcraft_project::{ItemId, LayerId, PropGroup, Uid}; +use effectcraft_project::{ItemId, Layer, LayerId, LayerSource, Node, PropGroup, Uid}; +use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, bad, has_layers, layer_mut, layer_p, merge_p}; +use super::layer::color_p; +use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p}; use crate::{EngineError, Result, Session, cmd}; fn is_stroke(g: &PropGroup) -> bool { @@ -195,8 +197,116 @@ fn wave(s: &mut Session, p: &Value) -> Result { ) } +/// Path items a Fill or Stroke paints in their group. +const PATHS: [&str; 4] = ["rect", "ellipse", "star", "path"]; + +/// Shape groups nest; deeper contents are left alone (never-crash recursion bound). +const MAX_DEPTH: usize = 64; + +/// The first Fill colour, Stroke colour and Stroke Width in `layer`'s contents at layer time +/// `lt` (what the toolbar shows for a selected shape layer). +pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4]>, Option) { + let (mut fill, mut stroke, mut width) = (None, None, None); + if let Some(c) = layer.props.sub("contents") { + walk_groups(c, &mut |g| { + let at = |id: &str| g.get(id).map(|p| p.value_at(lt)); + let rgba = |v: KV| v.as_color().map(f64::from); + match g.match_id.as_str() { + "fill" if fill.is_none() => fill = at("color").map(rgba), + "stroke" if stroke.is_none() => { + stroke = at("color").map(rgba); + width = at("width").map(|v| v.as_f64()); + } + _ => {} + } + }); + } + (fill, stroke, width) +} + +/// Paint one contents group and its sub-groups: Fills take `fill`, Strokes `stroke`, Strokes and +/// Gradient Strokes `width`. With `add_stroke`, a group with a path and no stroke gets one (before +/// its fills, as new shapes have it). +#[allow(clippy::too_many_arguments)] +fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, stroke: Option<[f64; 4]>, width: Option, add_stroke: bool, depth: usize) { + if depth > MAX_DEPTH { + return; + } + if add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) { + let at = c.children.iter().position(|n| matches!(n, Node::Group(g) if matches!(g.match_id.as_str(), "fill" | "gfill"))).unwrap_or(c.children.len()); + c.children.insert(at, build::shape_stroke(ids, stroke.unwrap_or([1.0; 4]), width.unwrap_or(2.0)).into()); + } + for n in &mut c.children { + let Node::Group(g) = n else { continue }; + let set = |g: &mut PropGroup, id: &str, v: Option| { + if let (Some(v), Some(p)) = (v, g.get_mut(id)) { + p.set_value_at(lt, v); + } + }; + match g.match_id.as_str() { + "fill" => set(g, "color", fill.map(KV::Color)), + "stroke" => { + set(g, "color", stroke.map(KV::Color)); + set(g, "width", width.map(KV::Scalar)); + } + "gstroke" => set(g, "width", width.map(KV::Scalar)), + "group" => { + if let Some(sub) = g.sub_mut("contents") { + paint(sub, ids, lt, fill, stroke, width, add_stroke, depth + 1); + } + } + _ => {} + } + } +} + +/// The toolbar's Fill and Stroke options with shape layers selected (as in After Effects, they +/// change the layers' fills and strokes): `fill` sets every Fill's colour, `stroke` every Stroke's, +/// `strokeWidth` every stroke's width. A stroke colour or a width above 0 gives the paths that +/// have no stroke one. Without values, reports the first layer's paint. +fn fill_stroke(s: &mut Session, p: &Value) -> Result { + let c = "shape.fillStroke"; + let (cid, lids) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let shapes: Vec = lids.into_iter().filter(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape))).collect(); + if shapes.is_empty() { + return Err(bad(c, "select a shape layer")); + } + let rgba = |k: &str| color_p(p, k).map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]); + let (fill, stroke) = (rgba("fill"), rgba("stroke")); + let width = f_p(p, "strokeWidth").filter(|w| w.is_finite()).map(|w| w.max(0.0)); + if fill.is_some() || stroke.is_some() || width.is_some() { + let add_stroke = width.map_or(stroke.is_some(), |w| w > 0.0); + let t = s.time(); + s.edit("Fill and Stroke", merge_p(p), |proj, _| { + let mut next = proj.next_id; + for lid in &shapes { + let l = layer_mut(proj, cid, *lid)?; + let lt = l.layer_time(t); + if let Some(contents) = l.props.sub_mut("contents") { + paint(contents, &mut Ids(&mut next), lt, fill, stroke, width, add_stroke, 0); + } + } + proj.next_id = next; + Ok(()) + })?; + } + let l = shapes.first().and_then(|id| s.project.comp(cid)?.layer(*id)).ok_or(EngineError::NoComp)?; + let (fill, stroke, width) = first_paint(l, l.layer_time(s.time())); + Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width})) +} + pub fn specs() -> Vec { vec![ + cmd!( + "shape.fillStroke", + "Fill and Stroke", + [], + None, + "{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)", + has_layers, + fill_stroke + ), cmd!("shape.dashes.add", "Add Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_add), cmd!("shape.dashes.remove", "Remove Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_remove), cmd!( diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f505b2a5..8ae9fcd4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -183,6 +183,40 @@ fn text_shape_mask_matte_parent() { assert_eq!(info["layers"].as_array().unwrap().len(), 2); } +/// The toolbar's Fill / Stroke change a selected shape layer's paint (#205): a shape drawn +/// without a stroke gets one, later edits change it, and the Contents "Add:" items (#206) include +/// an empty Path and a Gradient Stroke that render. +#[test] +fn toolbar_fill_and_stroke_paint_selected_shape_layers() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); + let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let paint = |s: &mut Session| s.execute("shape.fillStroke", json!({})).unwrap(); + assert_eq!(paint(&mut s)["stroke"], serde_json::Value::Null, "drawn without a stroke"); + s.execute("shape.fillStroke", json!({"stroke": "#00ff00", "strokeWidth": 6})).unwrap(); + let p = paint(&mut s); + assert_eq!((p["stroke"][1].as_f64(), p["strokeWidth"].as_f64()), (Some(1.0), Some(6.0)), "{p}"); + s.execute("shape.fillStroke", json!({"fill": "#0000ff", "strokeWidth": 3})).unwrap(); + let p = paint(&mut s); + assert_eq!((p["fill"][2].as_f64(), p["strokeWidth"].as_f64()), (Some(1.0), Some(3.0)), "{p}"); + // One stroke, before the fill (paths, stroke, fill), and each change is one undo step. + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap().clone(); + let group = l.props.group("contents/group").unwrap(); + let order: Vec<&str> = group.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect(); + assert_eq!(order, ["rect", "stroke", "fill"]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(paint(&mut s)["strokeWidth"].as_f64(), Some(6.0)); + // Only shape layers. + s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap(); + assert!(s.execute("shape.fillStroke", json!({"fill": "#ffffff"})).is_err()); + for kind in ["path", "gstroke"] { + let r = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": kind})).unwrap(); + assert!(r["path"].as_str().is_some_and(|p| p.starts_with("contents/")), "{r}"); + } + let cid = s.active_comp_id().unwrap(); + let _ = s.render(cid, s.time(), Default::default()); +} + #[test] fn save_and_open_roundtrip() { let mut s = demo(); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 214d2712..2f4c98b0 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -9,6 +9,7 @@ use crate::state::Tool; use crate::theme::Tokens; use crate::widgets; use crate::{Dialog, EffectcraftApp}; +use effectcraft_engine::project::{LayerId, LayerSource}; pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; @@ -91,7 +92,40 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Tool options. x += 10.0; - if app.ui.tool.is_shape() || app.ui.tool == Tool::Pen { + // Selected shape layers: the options show their paint and edit it (as in After Effects). + let shape_layers: Vec = app + .session + .active_comp() + .map(|c| { + app.session.state.selected_layers.iter().filter(|id| c.layer(**id).is_some_and(|l| matches!(l.source, LayerSource::Shape))).map(|id| id.0).collect() + }) + .unwrap_or_default(); + if let Some(l) = shape_layers.first().and_then(|id| app.session.active_comp()?.layer(LayerId(*id))) { + let (fill, stroke, width) = effectcraft_engine::commands::shape_stroke::first_paint(l, l.layer_time(app.session.time())); + let rgb = |c: [f64; 4]| [c[0] as f32, c[1] as f32, c[2] as f32]; + if let Some(c) = fill { + app.ui.fill_color = rgb(c); + } + if let Some(c) = stroke { + app.ui.stroke_color = rgb(c); + } + app.ui.stroke_width = width.unwrap_or(0.0) as f32; + } + // One undo step per picker session or width drag: end the merge once both are done. + let picking = ["tool-fill-pop", "tool-stroke-pop"].iter().any(|k| ui.data(|d| d.get_temp::(egui::Id::new(*k).with("open")).unwrap_or(false))); + if !picking && !ui.input(|i| i.pointer.any_down()) && app.session.history.merge_key.as_deref().is_some_and(|k| k.starts_with("tool-")) { + app.session.history.merge_key = None; + } + if app.ui.tool.is_shape() || app.ui.tool == Tool::Pen || !shape_layers.is_empty() { + let paint = |app: &mut EffectcraftApp, key: &str, v: serde_json::Value| { + if shape_layers.is_empty() { + return; + } + let merge = format!("tool-{key}"); + if let Err(e) = app.session.execute("shape.fillStroke", serde_json::json!({"layers": shape_layers, key: v, "merge": merge})) { + app.ui.status = e.to_string(); + } + }; p.text(pos2(x, cy), Align2::LEFT_CENTER, "Fill:", Tokens::ui(12.0), t.text_dim); x += 28.0; let fr = Rect::from_center_size(pos2(x + 10.0, cy), vec2(20.0, 16.0)); @@ -99,7 +133,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if widgets::swatch(ui, fr, [c[0], c[1], c[2], 1.0], egui::Id::new("tool-fill"), &t).clicked() { widgets::open_popup(ui, egui::Id::new("tool-fill-pop")); } - color_popup(ui, egui::Id::new("tool-fill-pop"), fr.left_bottom(), &mut app.ui.fill_color); + if color_popup(ui, egui::Id::new("tool-fill-pop"), fr.left_bottom(), &mut app.ui.fill_color) { + let c = app.ui.fill_color; + paint(app, "fill", serde_json::json!(c)); + } + app.auto.add("header.fill", fr, "Fill Color"); x += 32.0; p.text(pos2(x, cy), Align2::LEFT_CENTER, "Stroke:", Tokens::ui(12.0), t.text_dim); x += 44.0; @@ -108,13 +146,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if widgets::swatch(ui, sr, [c[0], c[1], c[2], 1.0], egui::Id::new("tool-stroke"), &t).clicked() { widgets::open_popup(ui, egui::Id::new("tool-stroke-pop")); } - color_popup(ui, egui::Id::new("tool-stroke-pop"), sr.left_bottom(), &mut app.ui.stroke_color); + if color_popup(ui, egui::Id::new("tool-stroke-pop"), sr.left_bottom(), &mut app.ui.stroke_color) { + let c = app.ui.stroke_color; + paint(app, "stroke", serde_json::json!(c)); + } + app.auto.add("header.stroke", sr, "Stroke Color"); x += 26.0; let (r, v, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new("tool-stroke-w"), app.ui.stroke_width as f64, 0.2, (0.0, 1000.0), 0, " px", &t); if let Some(v) = v { app.ui.stroke_width = v as f32; + paint(app, "strokeWidth", serde_json::json!(v)); } + app.auto.add("header.strokeWidth", r, "Stroke Width"); x = r.max.x + 14.0; } if app.ui.tool.puppet_kind().is_some() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6fe8a6a3..de9f0ef4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -190,6 +190,35 @@ fn reveal_time_remap(app: &mut EffectcraftApp, params: &Value) { } } +/// After Alt+Shift+P (A, S, R, T) the Timeline shows the property on each layer it keyed, as in +/// After Effects: added to what a reveal shortcut shows, else with the layer's Transform twirled +/// open, else alone like P. +fn reveal_keyed(app: &mut EffectcraftApp, keyed: &Value, kind: &str) { + let layers: std::collections::BTreeSet = keyed.as_array().into_iter().flatten().filter_map(|k| k.get("layer").and_then(Value::as_u64)).collect(); + let Some(comp) = app.session.active_comp() else { return }; + let tl = &mut app.ui.timeline; + for id in layers { + let shown = tl.layer_reveal.get(&id).filter(|k| !k.is_empty()).cloned(); + match shown { + Some(mut kinds) => { + if !kinds.iter().any(|k| k == kind) { + kinds.push(kind.to_string()); + tl.layer_reveal.insert(id, kinds); + } + } + None if tl.open_layers.contains(&id) => { + if let Some(tr) = comp.layer(effectcraft_engine::project::LayerId(id)).and_then(|l| l.transform()) { + tl.open_groups.insert(tr.uid); + } + } + None => { + tl.open_layers.insert(id); + tl.layer_reveal.insert(id, vec![kind.to_string()]); + } + } + } +} + /// The layers a reveal shortcut acts on: the selected ones, else every layer of the active comp. fn reveal_targets(app: &EffectcraftApp) -> Vec { if app.session.state.selected_layers.is_empty() { @@ -314,7 +343,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V return Ok(Value::Null); } if let Some(k) = id.strip_prefix("timeline.keyAt.") { - return run_engine(app, ctx, "keys.toggleTransform", json!({"prop": k})); + let r = run_engine(app, ctx, "keys.toggleTransform", json!({"prop": k}))?; + reveal_keyed(app, &r, k); + return Ok(r); } if let Some(k) = id.strip_prefix("timeline.revealAdd.") { reveal(app, k, now, true); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 89322f71..8c2bbf0d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2215,9 +2215,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if graph_on { super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions); } - // Empty-area click in the graph: deselect keys; drag: box-select keys. + // Empty-area click in the graph: deselect keys (below the last row, the layers too, as in + // After Effects); drag: box-select keys. if empty.clicked() { actions.push(("keys.select".into(), json!({"keys": []}))); + let below = empty.interact_pointer_pos().is_some_and(|p| hit_rows.iter().all(|(r, _)| p.y > r.max.y)); + if below && !ui.input(|i| i.modifiers.shift || i.modifiers.command) { + actions.push(("layer.select".into(), json!({"layers": []}))); + } } if let (true, Some(origin), Some(cur)) = (empty.dragged(), empty.interact_pointer_pos().and_then(|_| ctx.input(|i| i.pointer.press_origin())), empty.interact_pointer_pos()) diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index dc62062d..246651bb 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -1,10 +1,11 @@ -//! Timeline rows for shape layers: the Contents "Add:" menu and the Classic 3D "Change -//! Renderer…" row (#206). +//! Timeline rows for shape layers and keyframing: the Contents "Add:" menu and the Classic 3D +//! "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds, clicks below the layers +//! deselecting them and the toolbar's Stroke on a selected shape layer (#205). use effectcraft_engine::Session; use effectcraft_engine::project::LayerId; use effectcraft_ui_egui::{Dialog, EffectcraftApp}; -use egui::{Event, Modifiers, PointerButton, Pos2, pos2}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use serde_json::json; @@ -78,3 +79,46 @@ fn classic_3d_shape_layers_offer_change_renderer() { assert!(h.state().auto.find(&format!("timeline.layer.{l}.changeRenderer")).is_none()); } +#[test] +fn add_position_key_reveals_position() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = harness(s); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.keyAt.position", json!({})).unwrap(); + h.run_steps(2); + let app = h.state(); + let position = app.session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone(); + assert_eq!(position.keys.len(), 1); + assert_eq!(app.ui.timeline.layer_reveal.get(&l), Some(&vec!["position".to_string()])); + assert!(app.auto.find(&format!("timeline.prop.{}.value.0", position.uid)).is_some(), "the Position row shows"); +} + +#[test] +fn clicking_below_the_layers_deselects_them() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = harness(s); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(l)]); + // In the time graph, below the only layer. + let bar = center(&h, &format!("timeline.layer.{l}.bar")); + click_at(&mut h, bar + egui::vec2(0.0, 80.0)); + assert!(h.state().session.state.selected_layers.is_empty()); +} + +#[test] +fn toolbar_stroke_edits_the_selected_shape_layer() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = harness(s); + // The Selection tool is active: the options show for the selected shape layer. + click(&mut h, "header.strokeWidth"); + h.input_mut().events.push(Event::Text("4".into())); + h.step(); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let width = layer.props.prop("contents/group/contents/stroke/width").map(|p| p.value.as_f64()); + assert_eq!(width, Some(4.0), "a stroke was added with the toolbar's width"); + assert_eq!(h.state().ui.stroke_width, 4.0); +} From b4d2d58eeb1d4d1dad2210760c2eeaf94e3f28a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 3/3] Masks and Project panel: selecting a mask selects its points; linked Mask Feather; selection box (#203) - Selecting a mask in the Timeline (its row or Mask Path), or a shape's Path, selects all its points, so dragging one in the viewer moves the whole mask, as in After Effects. Delete still removes the mask, and selecting another property deselects the points. - Mask Feather has the chain link (Constrain Proportions, on by default, Alt edits one value) and no longer takes negative values; Scale's link shows as a toggle too. - Dragging in the Project panel's empty area draws a selection box (Shift / Cmd adds); a click there deselects. Tests: selecting_a_mask_selects_its_points, mask_feather_values_are_linked_and_not_negative, dragging_in_the_empty_area_box_selects_items. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- crates/engine/src/commands/edit.rs | 7 ++- crates/engine/src/commands/prop.rs | 29 +++++++++++- crates/engine/src/tests.rs | 31 +++++++++++++ crates/ui-egui/src/panels/project.rs | 42 +++++++++++++++-- crates/ui-egui/src/panels/timeline.rs | 52 ++++++++++++++++++---- crates/ui-egui/src/state.rs | 5 +++ crates/ui-egui/tests/ui_project_rows.rs | 24 ++++++++++ crates/ui-egui/tests/ui_timeline_shapes.rs | 36 +++++++++++++-- 8 files changed, 208 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index d60daf49..21e83a0c 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -136,7 +136,12 @@ fn delete(s: &mut Session, p: &Value) -> Result { if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { return s.execute("keys.delete", json!({})); } - if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() { + // (Points selected with their mask, which selecting a mask does: the mask goes, below.) + let comp = s.active_comp(); + let loose = s.state.selected_vertices.iter().any(|v| { + !s.state.selected_props.contains(&(v.layer, v.mask)) && comp.and_then(|c| c.layer(v.layer)).is_some_and(|l| l.props.find_group(v.mask).is_some()) + }); + if loose && p.get("layers").is_none() { return s.execute("mask.deleteVertices", json!({})); } // Puppet pins selected → delete the pins (not their layer). diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 409210e8..6e82fc2a 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -1,12 +1,12 @@ //! Properties, keyframes and expressions (Animation menu + timeline/Effect Controls gestures). use effectcraft_keyframe::{Ease, Interp, Keyframe, easy_ease, key_at, set_key}; -use effectcraft_project::{ItemId, LayerId, Property, Uid}; +use effectcraft_project::{GroupKind, ItemId, Layer, LayerId, Property, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p}; -use crate::{EngineError, KeyRef, Result, Session, cmd, query}; +use crate::{EngineError, KeyRef, Result, Session, VertexRef, cmd, query}; /// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid). pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { @@ -293,11 +293,19 @@ fn select_prop(s: &mut Session, p: &Value) -> Result { let add = b_p(p, "add").unwrap_or(false); if !add { s.state.selected_props.clear(); + s.state.selected_vertices.clear(); } if !s.state.selected_layers.contains(&lid) { s.state.selected_layers = vec![lid]; } s.state.selected_props.push((lid, uid)); + // Selecting a mask (or its Mask Path, or a shape's Path) selects all its points, so the + // viewer drags the whole mask (as in AE, #203). + let t = s.time(); + if let Some((mask, n)) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| path_points(l, uid, l.layer_time(t))) { + s.state.selected_vertices.retain(|v| !(v.layer == lid && v.mask == mask)); + s.state.selected_vertices.extend((0..n).map(|index| VertexRef { layer: lid, mask, index })); + } // Selecting a property selects all its keys (as in AE). if b_p(p, "selectKeys").unwrap_or(true) && let Some(pr) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid)) @@ -311,6 +319,23 @@ fn select_prop(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// The mask or shape Path item that `uid` (the group, or its Path property) is, with its number +/// of points at layer time `lt`. +fn path_points(l: &Layer, uid: Uid, lt: Tick) -> Option<(Uid, usize)> { + let owner = match l.props.find_group(uid) { + Some(g) => g, + None => l.props.parent_of(uid).filter(|g| g.get("path").is_some_and(|p| p.uid == uid))?, + }; + let in_contents = owner.match_id == "path" && l.props.sub("contents").is_some_and(|c| c.find_group(owner.uid).is_some()); + if !matches!(owner.kind, GroupKind::Mask { .. }) && !in_contents { + return None; + } + match owner.get("path")?.value_at(lt) { + effectcraft_keyframe::Value::Path(p) => Some((owner.uid, p.vertices.len())), + _ => None, + } +} + // ---------------------------------------------------------------- keyframes fn select_keys(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8ae9fcd4..ef467b93 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -217,6 +217,37 @@ fn toolbar_fill_and_stroke_paint_selected_shape_layers() { let _ = s.render(cid, s.time(), Default::default()); } +/// Selecting a mask in the Timeline (its row or its Mask Path) selects all of its points, so the +/// viewer drags the whole mask (#203); a shape's Path item works the same way. +#[test] +fn selecting_a_mask_selects_its_points() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); + let m1 = s.execute("layer.addMask", json!({"layer": l, "shape": "rect"})).unwrap()["mask"].as_u64().unwrap(); + let m2 = s.execute("layer.addMask", json!({"layer": l, "shape": "ellipse"})).unwrap()["mask"].as_u64().unwrap(); + let points = |s: &Session, m: u64| s.state.selected_vertices.iter().filter(|v| v.mask == m).count(); + s.execute("prop.select", json!({"layer": l, "prop": m1, "selectKeys": false})).unwrap(); + assert_eq!((points(&s, m1), s.state.selected_vertices.len()), (4, 4)); + // Mask Path selects them too; Shift-adding another mask keeps the first. + let path = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().props.find_group(m2).unwrap().get("path").unwrap().uid; + s.execute("prop.select", json!({"layer": l, "prop": path, "add": true})).unwrap(); + assert_eq!((points(&s, m1), points(&s, m2)), (4, 4)); + // Another mask replaces them; another property deselects them. + s.execute("prop.select", json!({"layer": l, "prop": m2})).unwrap(); + assert_eq!((points(&s, m1), points(&s, m2)), (0, 4)); + // Delete removes the selected mask (not only its points). + s.execute("edit.clear", json!({})).unwrap(); + let masks = |s: &Session| s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().masks().unwrap().children.len(); + assert_eq!(masks(&s), 1); + s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + assert!(s.state.selected_vertices.is_empty()); + let sh = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap(); + let star = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "path"})).unwrap()["uid"].as_u64().unwrap(); + s.execute("prop.select", json!({"layer": sh, "prop": star})).unwrap(); + assert_eq!(s.state.selected_vertices.len(), 0, "an empty Path has no points"); +} + #[test] fn save_and_open_roundtrip() { let mut s = demo(); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 2f9963d9..8d84e71d 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -323,9 +323,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h)); let lp = p.with_clip_rect(list); // The list's empty area (under the rows and the scroll bar, which take their own clicks): - // a double-click imports, as in After Effects (File ▸ Import ▸ File...). - let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click()); - app.auto.add("project.empty", list, "Double-click to import files"); + // a click deselects, a drag draws a selection box, a double-click imports, as in After + // Effects (File ▸ Import ▸ File...). + let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click_and_drag()); + app.auto.add("project.empty", list, "Double-click to import files; drag to select items"); if overflow > 0.0 && ui.rect_contains_pointer(list) { let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift)); let d = if dx.abs() > 0.0 { @@ -718,6 +719,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if empty.double_clicked() { actions.push(("file.import".into(), json!({}))); } + // Selection box over the rows it touches (#203); Shift or Cmd/Ctrl adds to the selection. + let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command); + if empty.clicked() && !adding { + app.session.state.project_selection.clear(); + } + let box_id = egui::Id::new("proj-marquee"); + if let (Some(start), Some(end)) = (ctx.input(|i| i.pointer.press_origin()), empty.interact_pointer_pos()) + && empty.dragged() + { + let b = Rect::from_two_pos(start, end).intersect(list); + lp.rect_filled(b, 0.0, t.accent.gamma_multiply(0.12)); + lp.rect_stroke(b, 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + ctx.data_mut(|d| d.insert_temp(box_id, b)); + } + if empty.drag_stopped() + && let Some(b) = ctx.data(|d| d.get_temp::(box_id)) + { + ctx.data_mut(|d| d.remove::(box_id)); + // Rows sit at fixed heights from the top of the scrolled list. + let top = list.min.y - app.ui.project_scroll; + let picked = rows.iter().enumerate().filter(|(i, _)| { + let y = top + *i as f32 * ROW_H; + y < b.max.y && y + ROW_H > b.min.y + }); + let picked: Vec = picked.map(|(_, (id, _))| *id).collect(); + let sel = &mut app.session.state.project_selection; + if !adding { + sel.clear(); + } + for id in picked { + if !sel.contains(&id) { + sel.push(id); + } + } + } for (id, params) in actions { if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { app.ui.status = e; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 8c2bbf0d..233e1384 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2882,6 +2882,26 @@ fn fmt_num(v: f64, d: usize) -> String { format!("{v:.d$}") } +/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio +/// (Constrain Proportions); from 0 the others that are 0 too follow the value. +fn constrained(c: &[f64], d: usize, v: f64) -> Vec { + let Some(&old) = c.get(d) else { return c.to_vec() }; + c.iter() + .enumerate() + .map(|(e, &x)| { + if e == d { + v + } else if old.abs() > 1e-9 { + x * v / old + } else if x.abs() <= 1e-9 { + v + } else { + x + } + }) + .collect() +} + /// Inline value editor for a property row; pushes `prop.set` actions. fn value_editor( app: &mut EffectcraftApp, @@ -2929,21 +2949,35 @@ fn value_editor( let c = value.components(); let n = if prop.shown_dims > 0 && !(is_3d && c.len() == 3) { prop.shown_dims as usize } else { c.len() }; let pct = matches!(prop.ui, ParamUi::Percent); + // Mask Feather: two linked values, never negative (as in After Effects, #203). + let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. })); + let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) }; + // Scale and Mask Feather: the chain link (Constrain Proportions, on by default). + let linkable = feather || (pct && prop.match_id == "scale"); + let linked = linkable && !app.ui.timeline.unlinked.contains(&uid); + if linkable { + let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0)); + let resp = ui.interact(lr, egui::Id::new(("tl-link", uid)), Sense::click()).on_hover_text("Constrain Proportions"); + icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint }); + app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions"); + if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) { + app.ui.timeline.unlinked.insert(uid); + } + x += 18.0; + } for d in 0..n.min(c.len()) { let suffix = if pct && d + 1 == n { "%" } else { "" }; - let (r, nv, _) = - widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, (-1e9, 1e9), 1, suffix, &t); + let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t); app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name); if let Some(nv) = nv { let mut nc = c.clone(); - if pct && prop.match_id == "scale" && !ui.input(|i| i.modifiers.alt) { - // Constrain proportions (AE's chain link, on by default). - let k = if c[d].abs() > 1e-9 { nv / c[d] } else { 1.0 }; - for e in 0..n { - nc[e] = if e == d { nv } else { c[e] * k }; + // Alt edits one value of a linked pair. + let shown = if linked && !ui.input(|i| i.modifiers.alt) { n.min(c.len()) } else { 1 }; + let from = if shown == 1 { d } else { 0 }; + for (e, v) in constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { + if let Some(slot) = nc.get_mut(from + e) { + *slot = v; } - } else { - nc[d] = nv; } set(actions, json!(nc)); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 041ce593..398d724f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -413,6 +413,10 @@ pub struct TimelineState { /// Properties whose inline expression editor is collapsed. #[serde(default)] pub expr_closed: BTreeSet, + /// Scale and Mask Feather properties whose chain link (Constrain Proportions, on by default) + /// was turned off. + #[serde(default)] + pub unlinked: BTreeSet, /// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`, /// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name /// column is always shown; Modes follows `show_modes` (F4). @@ -458,6 +462,7 @@ impl Default for TimelineState { graph_reference: false, graph_transform_box: true, expr_closed: BTreeSet::new(), + unlinked: BTreeSet::new(), columns: default_tl_columns(), source_name: false, } diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index cd15d46e..074b41b3 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -95,3 +95,27 @@ fn label_swatch_opens_the_label_menu() { assert_eq!(h.state().session.history.undo.len(), undo + 1); assert!(h.query_by_label(&green).is_none(), "the menu closed after the choice"); } + +/// Dragging from the Project panel's empty area draws a selection box that selects the rows it +/// touches; a click there deselects (#203). +#[test] +fn dragging_in_the_empty_area_box_selects_items() { + let (mut h, folder, _, solid) = harness(); + let list = h.state().auto.find("project.empty").unwrap().rect; + let from = pos2(list[0] + 40.0, list[1] + list[3] - 6.0); + let to = center(&h, &format!("project.item.{}.name", folder.0)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + button(&mut h, from, true); + h.step(); + for k in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))); + h.step(); + } + button(&mut h, to, false); + h.run_steps(2); + let sel = h.state().session.state.project_selection.clone(); + assert!(sel.contains(&folder) && sel.contains(&solid), "{sel:?}"); + click_at(&mut h, from); + assert!(h.state().session.state.project_selection.is_empty()); +} diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index 246651bb..27389247 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -1,6 +1,6 @@ -//! Timeline rows for shape layers and keyframing: the Contents "Add:" menu and the Classic 3D -//! "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds, clicks below the layers -//! deselecting them and the toolbar's Stroke on a selected shape layer (#205). +//! Timeline rows for shape layers, masks and keyframing: the Contents "Add:" menu and the Classic +//! 3D "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds and clicks below the +//! layers deselecting them (#205), and Mask Feather's linked values (#203). use effectcraft_engine::Session; use effectcraft_engine::project::LayerId; @@ -106,6 +106,36 @@ fn clicking_below_the_layers_deselects_them() { assert!(h.state().session.state.selected_layers.is_empty()); } +#[test] +fn mask_feather_values_are_linked_and_not_negative() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.addMask", json!({"layer": l, "shape": "rect"})).unwrap(); + let feather = |h: &Harness<'_, EffectcraftApp>| { + let p = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("masks/#1/feather").unwrap().clone(); + (p.uid, p.value.components()) + }; + let mut h = harness(s); + h.state_mut().ui.timeline.open_layers.insert(l); + h.state_mut().ui.timeline.layer_reveal.insert(l, vec!["feather".into()]); + h.run_steps(2); + let (uid, _) = feather(&h); + let type_value = |h: &mut Harness<'_, EffectcraftApp>, d: usize, v: &str| { + click(h, &format!("timeline.prop.{uid}.value.{d}")); + h.input_mut().events.push(Event::Text(v.into())); + h.step(); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + type_value(&mut h, 0, "12"); + assert_eq!(feather(&h).1, vec![12.0, 12.0], "linked by default"); + type_value(&mut h, 1, "-5"); + assert_eq!(feather(&h).1, vec![0.0, 0.0], "never negative"); + click(&mut h, &format!("timeline.prop.{uid}.link")); + type_value(&mut h, 1, "7"); + assert_eq!(feather(&h).1, vec![0.0, 7.0], "unlinked"); +} + #[test] fn toolbar_stroke_edits_the_selected_shape_layer() { let mut s = session();