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/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/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/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/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..ef467b93 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -183,6 +183,71 @@ 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()); +} + +/// 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/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/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 8af70d55..233e1384 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() { @@ -2175,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()) @@ -2444,6 +2489,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 +2611,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 +2653,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( @@ -2761,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, @@ -2808,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/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_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 new file mode 100644 index 00000000..27389247 --- /dev/null +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -0,0 +1,154 @@ +//! 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; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::{Event, Key, 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()); +} + +#[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 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(); + 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); +}