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); +}