diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index e565034e2..aaecadb22 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -3,13 +3,22 @@ //! //! - **Layer Transform** (all layers but cameras/lights): keyframe navigator (◀ ◆ ▶) or //! stopwatch, scrubbable values, linked Scale, `Nx+N°` Rotation (revolutions and degrees scrub on their own), Reset. +//! - **Layer Contents** (shape layers): like After Effects 2024+, the first few shape groups by +//! name with their paths' properties (Rectangle: Size, Roundness; Ellipse: Size; Polystar: +//! Points, radii, roundness) and the group's Fill colour/opacity and Stroke colour/width, in +//! the Layer Transform row style; Reset restores them all. Automation ids +//! `properties.shape.` for the first group, `properties.shape..` for the n-th. +//! - **Solid** (solid layers, beyond After Effects): a Roundness row; rounding runs +//! `layer.roundCorners`, which turns the solid into a shape layer drawing a rounded rectangle +//! (one undo step; a scrub continues on the new layer's Roundness row). Solids it cannot +//! convert without changing their pixels say why on hover. //! - **Text** (text layers): font family/style, size, leading, tracking, stroke width, fill and //! stroke with enable checkboxes; "More" opens the Character panel. //! - **Paragraph** (text layers): the seven alignment buttons; "More" opens the Paragraph panel. //! - **Text Animation** (text layers): Add Animator menu. use effectcraft_engine::keyframe::{Justify, TextDoc, Value}; -use effectcraft_engine::project::{Layer, LayerSource, Property}; +use effectcraft_engine::project::{Layer, LayerSource, PropGroup, Property}; use effectcraft_engine::render::EvalCtx; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; @@ -86,11 +95,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // ---------------------------------------------------------------- Layer Transform if let Some(tr) = layer.transform().cloned() { section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform"); - let rr = Rect::from_min_size(pos2(x0 + w - 40.0, y), vec2(40.0, ROW_H)); - let resp = ui.interact(rr, egui::Id::new("props-reset"), Sense::click()); - p.text(rr.right_center(), Align2::RIGHT_CENTER, crate::i18n::tr("Reset"), Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.accent }); - app.auto.add("properties.transform.reset", rr, "Reset Transform"); - if resp.clicked() { + if reset_button(app, ui, &p, x0, w, y, "props-reset", "properties.transform.reset", "Reset Transform") { for pr in tr.props() { actions.push(("prop.reset".into(), json!({"layer": layer.id.0, "prop": pr.uid}))); } @@ -100,12 +105,23 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if pr.three_d_only && !layer.is_3d() { continue; } - transform_row(app, ui, &p, &layer, pr, &ectx, Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)), &mut actions); + transform_row(app, ui, &p, &layer, pr, &Row::of(pr), &ectx, Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)), &mut actions); y += ROW_H; } y += 10.0; } + // ---------------------------------------------------------------- Layer Contents / Solid + if matches!(layer.source, LayerSource::Shape) + && let Some(contents) = layer.props.sub("contents") + { + divider(&p, x0, w, y - 4.0, &t); + y = shape_section(app, ui, &p, &layer, contents, &ectx, x0, w, y + 4.0, &mut actions); + } else if matches!(layer.source, LayerSource::Solid { .. }) { + divider(&p, x0, w, y - 4.0, &t); + y = solid_section(app, ui, &p, &layer, &ectx, x0, w, y + 4.0, &mut actions); + } + // ---------------------------------------------------------------- Text / Paragraph / Animation // While the layer's text is edited in the viewer, the text sections show and change the // selected characters. @@ -180,6 +196,17 @@ fn section_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter app.auto.add(auto, r, title); } +/// The "Reset" link at the right of a section header; whether it was clicked. +#[allow(clippy::too_many_arguments)] +fn reset_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, w: f32, y: f32, id: &str, auto: &str, label: &str) -> bool { + let t = app.tokens; + let rr = Rect::from_min_size(pos2(x0 + w - 40.0, y), vec2(40.0, ROW_H)); + let resp = ui.interact(rr, egui::Id::new(id), Sense::click()); + p.text(rr.right_center(), Align2::RIGHT_CENTER, crate::i18n::tr("Reset"), Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.accent }); + app.auto.add(auto, rr, label); + resp.clicked() +} + fn divider(p: &egui::Painter, x0: f32, w: f32, y: f32, t: &Tokens) { p.line_segment([pos2(x0 - 4.0, y), pos2(x0 + w + 4.0, y)], Stroke::new(1.0, t.separator)); } @@ -189,14 +216,50 @@ fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) { p.add(egui::Shape::convex_polygon(vec![pos2(c.x + dx, c.y), pos2(c.x - dx, c.y - h), pos2(c.x - dx, c.y + h)], col, Stroke::NONE)); } -/// One transform row: keyframe navigator or stopwatch, name, value fields. +/// How a property row names itself: its automation id prefix, the undo merge key of its edits, +/// the egui id of its value field, the label shown, and the value range and suffix when they +/// differ from what the property's name implies. +struct Row { + auto: String, + merge: String, + wid: egui::Id, + label: String, + range: Option<(f64, f64)>, + suffix: Option<&'static str>, +} + +impl Row { + /// A Layer Transform row: `properties.prop.`. + fn of(pr: &Property) -> Row { + Row { + auto: format!("properties.prop.{}", pr.uid), + merge: format!("props-{}", pr.uid), + wid: egui::Id::new(("props-v", pr.uid)), + label: pr.name.clone(), + range: None, + suffix: None, + } + } +} + +/// One property row: keyframe navigator or stopwatch, name, value fields. #[allow(clippy::too_many_arguments)] -fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, pr: &Property, ectx: &EvalCtx, r: Rect, actions: &mut Actions) { +fn transform_row( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + pr: &Property, + row: &Row, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { let t = app.tokens; let cy = r.center().y; let uid = pr.uid; let lid = layer.id.0; - let base = format!("properties.prop.{uid}"); + let base = row.auto.as_str(); if pr.is_animated() { // ◀ ◆ ▶: previous key, add/remove key at the CTI, next key. let now = ectx.time; @@ -224,7 +287,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, } } } - app.auto.add(&format!("{base}.{what}Key"), cr, &pr.name); + app.auto.add(&format!("{base}.{what}Key"), cr, &row.label); if resp.clicked() { match what { "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), @@ -237,14 +300,14 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, let swr = Rect::from_center_size(pos2(r.min.x + 16.0, cy), vec2(14.0, 14.0)); let resp = ui.interact(swr, egui::Id::new(("props-sw", uid)), Sense::click()); icons::paint(p, swr, Icon::Stopwatch, if resp.hovered() { t.text } else { t.text_dim }); - app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name); + app.auto.add(&format!("{base}.stopwatch"), swr, &row.label); if resp.clicked() { actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid, "selected": true}))); } } - p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text); + p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &row.label, Tokens::ui(12.0), t.text); let vx = r.min.x + (r.width() * 0.5).max(130.0); - let merge = format!("props-{uid}"); + let merge = row.merge.as_str(); // With several layers selected the edit applies to all: a scrub by the same change, a typed // value as it is. let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging()); @@ -254,21 +317,22 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, let value = ectx.value(layer, pr); let is_scale = pr.name == "Scale"; let pct = is_scale || pr.name == "Opacity"; - let suffix = if pct { "%" } else { "" }; + let suffix = row.suffix.unwrap_or(if pct { "%" } else { "" }); + let wid = row.wid; match &value { Value::Scalar(v) if pr.name == "Rotation" || pr.name.ends_with(" Rotation") => { let (_, deg) = super::fx_widgets::split_angle(*v); - let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, decimals(deg), &t); - app.auto.add(&format!("{base}.value"), dr, &pr.name); - app.auto.add(&format!("{base}.revolutions"), rr, &pr.name); + let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), wid, *v, decimals(deg), &t); + app.auto.add(&format!("{base}.value"), dr, &row.label); + app.auto.add(&format!("{base}.revolutions"), rr, &row.label); if let Some(nv) = nv { set(actions, json!(nv)); } } Value::Scalar(v) => { - let range = if pr.name == "Opacity" { (0.0, 100.0) } else { (-1e9, 1e9) }; - let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &t); - app.auto.add(&format!("{base}.value"), vr, &pr.name); + let range = row.range.unwrap_or(if pr.name == "Opacity" { (0.0, 100.0) } else { (-1e9, 1e9) }); + let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), wid, *v, 0.5, range, decimals(*v), suffix, &t); + app.auto.add(&format!("{base}.value"), vr, &row.label); if let Some(nv) = nv { set(actions, json!(nv)); } @@ -290,9 +354,9 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, } } for d in 0..n.min(c.len()) { - let (vr, nv, _) = - widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &t); - app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name); + let range = row.range.unwrap_or((-1e9, 1e9)); + let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), wid.with(d), c[d], 1.0, range, decimals(c[d]), suffix, &t); + app.auto.add(&format!("{base}.value.{d}"), vr, &row.label); if let Some(nv) = nv { let mut nc = c.clone(); if linked && c[d].abs() > 1e-9 { @@ -307,10 +371,195 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x = vr.max.x + 10.0; } } + Value::Color(c) => { + let sw = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0)); + let c4 = [c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32]; + let pop = wid.with("pop"); + if widgets::swatch(ui, sw, c4, wid, &t).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("{base}.value"), sw, &row.label); + let mut rgb = [c4[0], c4[1], c4[2]]; + if crate::widgets::color_popup(ui, pop, sw.left_bottom(), &mut rgb) { + set(actions, json!([rgb[0], rgb[1], rgb[2], c[3]])); + } + } _ => {} } } +/// Shape groups the Layer Contents section lists at most (After Effects lists the first few). +const SHAPE_GROUPS: usize = 4; + +/// A shape group with paths, as the Layer Contents section lists it: its name and its paths, +/// Fill and Stroke (direct items of its Contents). +struct ShapeEntry<'a> { + name: &'a str, + items: Vec<&'a PropGroup>, +} + +fn is_path(g: &PropGroup) -> bool { + matches!(g.match_id.as_str(), "rect" | "ellipse" | "star") +} + +/// The groups with paths below `contents` (itself first, as `name`), in Contents order. +fn shape_entries<'a>(contents: &'a PropGroup, name: &'a str, out: &mut Vec>, depth: usize) { + if depth > 32 { + return; + } + let items: Vec<&PropGroup> = contents.groups().filter(|g| is_path(g) || matches!(g.match_id.as_str(), "fill" | "stroke")).collect(); + if items.iter().any(|g| is_path(g)) { + out.push(ShapeEntry { name, items }); + } + for g in contents.groups().filter(|g| g.match_id == "group") { + if let Some(c) = g.sub("contents") { + shape_entries(c, &g.name, out, depth + 1); + } + } +} + +/// The rows of a shape item: (automation key, property, label, range, suffix). +type ItemRow = (&'static str, &'static str, &'static str, Option<(f64, f64)>, Option<&'static str>); + +fn item_rows(g: &PropGroup) -> &'static [ItemRow] { + const POS: Option<(f64, f64)> = Some((0.0, 1e9)); + match g.match_id.as_str() { + "rect" => &[("size", "size", "Size", POS, None), ("roundness", "roundness", "Roundness", POS, None)], + "ellipse" => &[("ellipseSize", "size", "Size", POS, None)], + "star" => &[ + ("points", "points", "Points", Some((3.0, 100.0)), None), + ("outerRadius", "outerRadius", "Outer Radius", POS, None), + ("innerRadius", "innerRadius", "Inner Radius", POS, None), + ("outerRoundness", "outerRoundness", "Outer Roundness", None, Some("%")), + ("innerRoundness", "innerRoundness", "Inner Roundness", None, Some("%")), + ], + "fill" => &[("fillColor", "color", "Fill Color", None, None), ("fillOpacity", "opacity", "Fill Opacity", None, None)], + "stroke" => &[("strokeColor", "color", "Stroke Color", None, None), ("strokeWidth", "width", "Stroke Width", POS, None)], + _ => &[], + } +} + +/// Layer Contents section of a shape layer; returns the y below it. +#[allow(clippy::too_many_arguments)] +fn shape_section( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + contents: &PropGroup, + ectx: &EvalCtx, + x0: f32, + w: f32, + mut y: f32, + actions: &mut Actions, +) -> f32 { + let t = app.tokens; + let lid = layer.id.0; + let mut entries = vec![]; + shape_entries(contents, "Contents", &mut entries, 0); + section_header(app, ui, p, x0, w, y, "Layer Contents", "properties.shape"); + let shown: Vec<&Property> = + entries.iter().take(SHAPE_GROUPS).flat_map(|e| e.items.iter().flat_map(|g| item_rows(g).iter().filter_map(|r| g.get(r.1)))).collect(); + if !shown.is_empty() && reset_button(app, ui, p, x0, w, y, "props-shape-reset", "properties.shape.reset", "Reset Layer Contents") { + for pr in &shown { + actions.push(("prop.reset".into(), json!({"layer": lid, "prop": pr.uid}))); + } + } + y += ROW_H + 2.0; + if entries.is_empty() { + p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, crate::i18n::tr("No shapes yet"), Tokens::ui(12.0), t.text_faint); + return y + ROW_H + 10.0; + } + // The first rectangle's Roundness shares the Solid section's field: a scrub that turns a + // solid into a shape layer carries on here (one undo step). + let mut first_round = true; + for (n, e) in entries.iter().take(SHAPE_GROUPS).enumerate() { + let gr = Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)); + p.text(pos2(x0, gr.center().y), Align2::LEFT_CENTER, e.name, Tokens::semibold(11.0), t.text_dim); + let prefix = if n == 0 { "properties.shape".to_string() } else { format!("properties.shape.{}", n + 1) }; + app.auto.add(&format!("{prefix}.group"), gr, e.name); + y += ROW_H; + let mut used: Vec = vec![]; + for g in &e.items { + for (key, prop, label, range, suffix) in item_rows(g) { + let Some(pr) = g.get(prop) else { continue }; + // A second rectangle in one group: `roundness2`. + let mut k = key.to_string(); + let mut i = 1; + while used.contains(&k) { + i += 1; + k = format!("{key}{i}"); + } + used.push(k.clone()); + let shared = *key == "roundness" && std::mem::take(&mut first_round); + let row = Row { + auto: format!("{prefix}.{k}"), + merge: if shared { format!("props-roundness-{lid}") } else { format!("props-{}", pr.uid) }, + wid: if shared { egui::Id::new(("props-roundness", lid)) } else { egui::Id::new(("props-v", pr.uid)) }, + label: label.to_string(), + range: *range, + suffix: *suffix, + }; + transform_row(app, ui, p, layer, pr, &row, ectx, Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)), actions); + y += ROW_H; + } + } + y += 4.0; + } + if entries.len() > SHAPE_GROUPS { + let more = entries.len() - SHAPE_GROUPS; + let r = Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)); + p.text(r.left_center(), Align2::LEFT_CENTER, format!("+{more} more in the Timeline"), Tokens::ui(11.0), t.text_faint); + app.auto.add("properties.shape.more", r, "More shape groups"); + y += ROW_H; + } + y + 10.0 +} + +/// Solid section: Roundness (0) whose change runs `layer.roundCorners`; returns the y below it. +#[allow(clippy::too_many_arguments)] +fn solid_section( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + ectx: &EvalCtx, + x0: f32, + w: f32, + mut y: f32, + actions: &mut Actions, +) -> f32 { + let t = app.tokens; + let lid = layer.id.0; + section_header(app, ui, p, x0, w, y, "Solid", "properties.solid"); + y += ROW_H + 2.0; + let r = Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)); + let cy = r.center().y; + let vx = r.min.x + (r.width() * 0.5).max(130.0); + match effectcraft_engine::commands::round_corners::convertible_solid(ectx.project, ectx.comp, layer) { + Ok(_) => { + p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Roundness"), Tokens::ui(12.0), t.text); + let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-roundness", lid)), 0.0, 0.5, (0.0, 1e9), 0, "", &t); + app.auto.add("properties.solid.roundness", vr, "Roundness"); + if let Some(v) = nv.filter(|v| *v > 0.0) { + actions.push(("layer.roundCorners".into(), json!({"layer": lid, "roundness": v, "merge": format!("props-roundness-{lid}")}))); + } + y += ROW_H; + p.text(pos2(r.min.x + 42.0, y + 8.0), Align2::LEFT_CENTER, crate::i18n::tr("Rounding makes it a shape layer"), Tokens::ui(11.0), t.text_faint); + y += 16.0; + } + Err(why) => { + p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Roundness"), Tokens::ui(12.0), t.text_faint); + p.text(pos2(vx, cy), Align2::LEFT_CENTER, "0", Tokens::ui(12.0), t.text_faint); + let resp = ui.interact(r, egui::Id::new(("props-roundness-off", lid)), Sense::hover()); + let _ = resp.on_hover_text(why); + app.auto.add("properties.solid.roundness", r, "Roundness (unavailable)"); + y += ROW_H; + } + } + y + 10.0 +} + /// Text section; returns the y below it. #[allow(clippy::too_many_arguments)] fn text_section( diff --git a/crates/ui-egui/tests/ui_properties.rs b/crates/ui-egui/tests/ui_properties.rs new file mode 100644 index 000000000..6f4fa4926 --- /dev/null +++ b/crates/ui-egui/tests/ui_properties.rs @@ -0,0 +1,151 @@ +//! Headless Properties panel checks (egui_kittest): a solid's Roundness row rounds it into a +//! shape layer (one undo step, also when scrubbed), and a shape layer's Layer Contents section +//! lists its paths, Fill and Stroke with automation ids. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{Layer, LayerId, LayerSource}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui_kittest::Harness; +use serde_json::json; + +fn layer(s: &Session, id: u64) -> Layer { + s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone() +} + +fn ids(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).map(|e| e.id.clone()).collect() +} + +fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { + let e = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).find(|e| e.id == id).unwrap_or_else(|| panic!("no {id}")).clone(); + egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, pos: egui::Pos2) { + h.event(egui::Event::PointerMoved(pos)); + h.step(); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.step(); + h.step(); +} + +/// Click a hot number and type a value over it. +fn type_into(h: &mut Harness<'_, EffectcraftApp>, id: &str, text: &str) { + let p = rect_of(h, id).center(); + click(h, p); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(text.into())); + h.step(); + h.key_press(egui::Key::Enter); + h.run_steps(3); +} + +/// Drag one pixel per frame. +fn slow_drag(h: &mut Harness<'_, EffectcraftApp>, from: egui::Pos2, dx: f32) { + h.event(egui::Event::PointerMoved(from)); + h.step(); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=dx as i32 { + h.event(egui::Event::PointerMoved(from + egui::vec2(k as f32, 0.0))); + h.step(); + } + let to = from + egui::vec2(dx, 0.0); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); +} + +fn roundness(s: &Session, id: u64) -> Vec { + let l = layer(s, id); + effectcraft_engine::commands::round_corners::rect_paths(l.props.sub("contents").unwrap()) + .iter() + .map(|r| r.get("roundness").unwrap().value.as_f64()) + .collect() +} + +/// A 640×360 comp with a 200×100 red solid, selected, and the Properties panel shown. +fn harness() -> (Harness<'static, EffectcraftApp>, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 4})).unwrap(); + let id = s.execute("layer.newSolid", json!({"name": "Red", "color": "#e02020", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [id]})).unwrap(); + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::Properties); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + h.run_steps(4); + (h, id) +} + +#[test] +fn solid_roundness_row_makes_a_rounded_shape_layer() { + let (mut h, id) = harness(); + assert!(ids(&h).contains(&"properties.solid.roundness".to_string()), "{:?}", ids(&h)); + type_into(&mut h, "properties.solid.roundness", "20"); + let l = layer(&h.state().session, id); + assert!(matches!(l.source, LayerSource::Shape), "{:?}", l.source); + assert_eq!(roundness(&h.state().session, id), vec![20.0]); + // Now the Layer Contents section, in the Layer Transform row style. + h.run_steps(3); + let have = ids(&h); + for want in [ + "properties.shape", + "properties.shape.group", + "properties.shape.reset", + "properties.shape.size.value.0", + "properties.shape.roundness.value", + "properties.shape.roundness.stopwatch", + "properties.shape.fillColor.value", + "properties.shape.fillOpacity.value", + ] { + assert!(have.contains(&want.to_string()), "missing {want}: {have:?}"); + } + assert!(!have.contains(&"properties.solid.roundness".to_string())); + type_into(&mut h, "properties.shape.roundness.value", "8"); + assert_eq!(roundness(&h.state().session, id), vec![8.0]); + // Edits of the field fold into one step (as a Layer Transform field's do): one undo + // brings the solid back. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(matches!(layer(&h.state().session, id).source, LayerSource::Solid { .. })); +} + +/// Scrubbing the solid's Roundness converts it and carries on on the shape layer's Roundness +/// row: one undo step for the whole gesture. +#[test] +fn a_roundness_scrub_is_one_undo_step() { + let (mut h, id) = harness(); + let from = rect_of(&h, "properties.solid.roundness").center(); + slow_drag(&mut h, from, 30.0); + let r = roundness(&h.state().session, id); + assert!(r.len() == 1 && r[0] > 5.0, "{r:?}"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(matches!(layer(&h.state().session, id).source, LayerSource::Solid { .. })); +} + +#[test] +fn shape_layers_list_their_groups_paths_fill_and_stroke() { + let (mut h, _) = harness(); + let s = &mut h.state_mut().session; + let l = s.execute("shape.newShape", json!({"kind": "star", "size": [100, 100], "strokeWidth": 3, "stroke": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("shape.newShape", json!({"layer": l, "kind": "ellipse", "size": [60, 40]})).unwrap(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + h.run_steps(4); + let have = ids(&h); + // Newest group first: the ellipse, then the star. + for want in [ + "properties.shape.ellipseSize.value.0", + "properties.shape.2.points.value", + "properties.shape.2.outerRadius.value", + "properties.shape.2.innerRoundness.value", + "properties.shape.2.strokeColor.value", + "properties.shape.2.strokeWidth.value", + ] { + assert!(have.contains(&want.to_string()), "missing {want}: {have:?}"); + } + type_into(&mut h, "properties.shape.2.points.value", "7"); + let st = layer(&h.state().session, l); + let points = st.props.prop("contents/#2/contents/star/points").unwrap().value.as_f64(); + assert_eq!(points, 7.0); +}