mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 11:37:02 -06:00
Properties: Layer Contents section for shape layers and a Roundness row for solids
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rc1XNFw6vDovyoYpwHj9Py (cherry picked from commit 33d849fb6c57cf1e7de76ed91a4fab41f9121fc8) # Conflicts: # crates/ui-egui/src/panels/properties.rs # crates/ui-egui/tests/ui/main.rs (cherry picked from commit a593488c211864735b3a6e8f087159235ed8184b) (cherry picked from commit 8457f32cc14a8b32d2e562c4972a71d02b573b79)
This commit is contained in:
@@ -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.<key>` for the first group, `properties.shape.<n>.<key>` 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.<uid>`.
|
||||
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<ShapeEntry<'a>>, 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<String> = 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(
|
||||
|
||||
@@ -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<String> {
|
||||
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<f64> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user