M0.9: AE-style Properties panel, context tab titles, AE default workspace; headless UI snapshot tool

- Properties panel (panels/properties.rs): Layer Transform with keyframe navigator
  or stopwatch, AE value formatting (integers, %, Nx+N° rotation), linked Scale,
  Reset; Text (font/style/size/leading/tracking/stroke, fill/stroke toggles);
  Paragraph (7 alignments); Text Animation > Add Animator; scrolling.
- Tabs name what they show: Composition <comp>, <comp> timeline, Effect
  Controls <layer>, Properties: <layer>.
- Default workspace: full-height right column, Preview over Properties.
- engine: time.go nextKey/prevKey accept prop uid; layer.setText supports all
  seven justify modes, applyFill/applyStroke and Auto leading (fixes the
  Paragraph 'justify' button mapping to Justify All).
- examples/snapshot: render the real UI headless (egui_kittest + wgpu) with
  scripted control-channel steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 21:19:23 -04:00
1 parent 214345df13
commit 4f47a01fed
12 files changed
+765 -65

No files matched your search

+2 -1
View File
@@ -23,5 +23,6 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
## Running and looking at the app
- `cargo run -p effectcraft -- --control 9877` opens the desktop app with the JSON-lines control server (`docs/control-protocol.md`).
- For UI work, **look at the result**: drive via the control channel and take `ui.screenshot`; or headless: `cargo run -p effectcraft-cli -- snapshot out.png`.
- For UI work, **look at the result**. Headless (no window, works while the user is busy on the machine): `cargo run -p effectcraft-ui-egui --example snapshot -- --out ui.png --step '{"method":"engine.execute","params":{"command":"layer.select","params":{"layers":["#2"]}}}'` (steps are control-channel requests; `--script steps.jsonl`; `{"method":"snap","params":{"path":…}}` for intermediate shots). Or drive the live app via the control channel and take `ui.screenshot` (needs the window visible on screen).
- After Effects reference: drive AE via ExtendScript (`osascript -e 'tell application "Adobe After Effects 2026" to DoScriptFile "x.jsx"'`) and capture only AE's window (`screencapture -l <windowid>`); keep captures in `plan/aftereffects/ref/` (gitignored), never commit them, never capture the Home screen.
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; keep every `Cargo.toml` valid (the `crates/*` glob).
Generated
+1
View File
@@ -1249,6 +1249,7 @@ dependencies = [
"effectcraft-color",
"effectcraft-engine",
"effectcraft-geom",
"effectcraft-host",
"effectcraft-keyframe",
"effectcraft-path",
"effectcraft-project",
+17 -4
View File
@@ -95,8 +95,17 @@ fn text_doc_from(p: &Value, base: TextDoc) -> TextDoc {
if let Some(v) = f_p(p, "tracking") {
d.tracking = v;
}
if let Some(v) = f_p(p, "leading") {
d.leading = Some(v);
match p.get("leading") {
Some(Value::Number(n)) => d.leading = n.as_f64(),
// "auto" or null: Auto Leading (120% of the font size).
Some(Value::String(_) | Value::Null) => d.leading = None,
_ => {}
}
if let Some(v) = b_p(p, "applyFill") {
d.apply_fill = v;
}
if let Some(v) = b_p(p, "applyStroke") {
d.apply_stroke = v;
}
if let Some(v) = b_p(p, "allCaps") {
d.all_caps = v;
@@ -108,10 +117,14 @@ fn text_doc_from(p: &Value, base: TextDoc) -> TextDoc {
d.faux_italic = v;
}
if let Some(j) = str_p(p, "justify") {
// AE's seven Paragraph alignment buttons.
d.justify = match j.to_ascii_lowercase().as_str() {
"center" | "centre" => Justify::Center,
"right" => Justify::Right,
"justify" | "justifyall" => Justify::JustifyAll,
"justify" | "justifyleft" | "justifylastleft" => Justify::JustifyLastLeft,
"justifycenter" | "justifylastcenter" => Justify::JustifyLastCenter,
"justifyright" | "justifylastright" => Justify::JustifyLastRight,
"justifyall" => Justify::JustifyAll,
_ => Justify::Left,
};
}
@@ -927,7 +940,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Text",
[],
None,
"{layer?, text?, size?, font?, style?, fill?, stroke?, strokeWidth?, tracking?, leading?, justify?, allCaps?, fauxBold?, fauxItalic?}",
"{layer?, text?, size?, font?, style?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, allCaps?, fauxBold?, fauxItalic?}",
has_layers,
set_text
),
+12 -2
View File
@@ -56,13 +56,23 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
} else {
comp.layers.iter().filter(|l| s.state.selected_layers.contains(&l.id)).collect()
};
// `prop` (uid) narrows the search to one property: the Properties panel's ◀ ▶ arrows.
let only = p.get("prop").and_then(Value::as_u64);
if only.is_some() {
times.clear();
}
for l in layers {
l.props.walk("", &mut |_, pr| {
if only.is_some_and(|u| u != pr.uid) {
return;
}
for k in &pr.keys {
times.push(l.comp_time(k.time));
}
});
times.extend(l.markers.iter().map(|m| l.comp_time(m.time)));
if only.is_none() {
times.extend(l.markers.iter().map(|m| l.comp_time(m.time)));
}
}
times.sort();
times.dedup();
@@ -93,6 +103,6 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("time.layerOut", "Go to Layer Out Point", [], Some("O"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerOut"}))),
cmd!("time.nextKey", "Go to Next Keyframe or Marker", [], Some("K"), "{}", has_comp, |s, _| go(s, &json!({"to": "nextKey"}))),
cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{}", has_comp, |s, _| go(s, &json!({"to": "prevKey"}))),
cmd!("time.go", "Go To", [], None, "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey}", has_comp, go),
cmd!("time.go", "Go To", [], None, "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid}", has_comp, go),
]
}
+55
View File
@@ -114,3 +114,58 @@ fn time_navigation() {
s.execute("time.set", json!({"frame": 45})).unwrap();
assert_eq!(s.execute("time.step", json!({"frames": 5})).unwrap()["frame"], 50);
}
#[test]
fn time_navigation_per_property() {
let mut s = demo();
// An animated property and the comp times of its keys.
let comp = s.active_comp().unwrap().clone();
let mut found = None;
for l in &comp.layers {
l.props.walk("", &mut |_, pr| {
if found.is_none() && pr.keys.len() >= 2 {
found = Some((pr.uid, pr.keys.iter().map(|k| l.comp_time(k.time)).collect::<Vec<_>>()));
}
});
}
let (uid, times) = found.expect("demo has an animated property");
s.set_time(effectcraft_time::Tick::ZERO);
let half = comp.frame_duration().0 / 2;
let expect = times.iter().copied().filter(|t| t.0 > half).min().unwrap();
s.execute("time.go", json!({"to": "nextKey", "prop": uid})).unwrap();
let snap = |t| comp.frame_rate.snap(t);
assert_eq!(s.time(), snap(expect));
// Going back from past the last key lands on the last key.
s.set_time(comp.duration);
s.execute("time.go", json!({"to": "prevKey", "prop": uid})).unwrap();
assert_eq!(s.time(), snap(*times.iter().max().unwrap()));
}
#[test]
fn set_text_paragraph_fill_and_leading() {
use effectcraft_keyframe::{Justify, Value as KValue};
let mut s = demo();
let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
let doc = |s: &Session| {
let l = s.active_comp().unwrap().layer(crate::project::LayerId(t)).unwrap().clone();
match &l.props.prop("text/sourceText").unwrap().value {
KValue::Text(d) => (**d).clone(),
v => panic!("{v:?}"),
}
};
for (key, j) in [
("justifyLeft", Justify::JustifyLastLeft),
("justifyCenter", Justify::JustifyLastCenter),
("justifyRight", Justify::JustifyLastRight),
("justifyAll", Justify::JustifyAll),
("center", Justify::Center),
] {
s.execute("layer.setText", json!({"layer": t, "justify": key})).unwrap();
assert_eq!(doc(&s).justify, j, "{key}");
}
s.execute("layer.setText", json!({"layer": t, "leading": 50, "applyFill": false, "applyStroke": true})).unwrap();
let d = doc(&s);
assert_eq!((d.leading, d.apply_fill, d.apply_stroke), (Some(50.0), false, true));
s.execute("layer.setText", json!({"layer": t, "leading": "auto"})).unwrap();
assert_eq!(doc(&s).leading, None);
}
+1
View File
@@ -31,3 +31,4 @@ kurbo = { workspace = true }
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
image = { workspace = true }
pollster = "0.4"
effectcraft-host = { workspace = true }
+130
View File
@@ -0,0 +1,130 @@
//! Headless UI snapshot: runs the real [`EffectcraftApp`] without a window (egui_kittest + wgpu)
//! and writes PNGs. Works regardless of window focus, Spaces or display sleep, so agents can
//! look at the UI at any time.
//!
//! ```text
//! cargo run -p effectcraft-ui-egui --example snapshot -- [--out ui.png] [--size 1680x1020]
//! [--scale 2] [--settle 1.5] [--empty] [--script steps.jsonl | --step '<json>']...
//! ```
//!
//! Each script step is a control-channel request (`{"method":"engine.execute","params":{...}}`,
//! see `docs/control-protocol.md`), run in order with the app settling in between. The extra
//! method `{"method":"snap","params":{"path":"x.png"}}` writes an intermediate snapshot.
//! Replies are printed to stdout as JSON lines.
use std::sync::mpsc;
use std::time::{Duration, Instant};
use effectcraft_ui_egui::{ControlRequest, EffectcraftApp};
use egui_kittest::Harness;
use serde_json::{Value, json};
struct Args {
out: String,
size: (f32, f32),
scale: f32,
settle: f64,
demo: bool,
steps: Vec<Value>,
}
fn parse_args() -> Result<Args, String> {
let mut a = Args { out: "ui.png".into(), size: (1680.0, 1020.0), scale: 2.0, settle: 1.5, demo: true, steps: Vec::new() };
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
let mut val = || it.next().ok_or_else(|| format!("{arg} needs a value"));
match arg.as_str() {
"--out" => a.out = val()?,
"--size" => {
let v = val()?;
let (w, h) = v.split_once('x').ok_or("--size WxH")?;
a.size = (w.parse().map_err(|_| "bad width")?, h.parse().map_err(|_| "bad height")?);
}
"--scale" => a.scale = val()?.parse().map_err(|_| "bad --scale")?,
"--settle" => a.settle = val()?.parse().map_err(|_| "bad --settle")?,
"--empty" => a.demo = false,
"--step" => a.steps.push(serde_json::from_str(&val()?).map_err(|e| format!("--step: {e}"))?),
"--script" => {
let path = val()?;
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with("//")) {
a.steps.push(serde_json::from_str(line).map_err(|e| format!("{path}: {e}: {line}"))?);
}
}
"-h" | "--help" => {
return Err("usage: snapshot [--out ui.png] [--size WxH] [--scale 2] [--settle secs] [--empty] [--script f.jsonl | --step json]...".into());
}
_ => return Err(format!("unknown argument {arg}")),
}
}
Ok(a)
}
/// Step the app for `secs` of wall time so background frame renders land in the viewer.
fn settle(h: &mut Harness<'_, EffectcraftApp>, secs: f64) {
let end = Instant::now() + Duration::from_secs_f64(secs);
while Instant::now() < end {
h.step();
std::thread::sleep(Duration::from_millis(30));
}
h.step();
}
fn snap(h: &mut Harness<'_, EffectcraftApp>, path: &str) -> Result<(), String> {
let img = h.render()?;
img.save(path).map_err(|e| format!("{path}: {e}"))?;
println!("{}", json!({"snapshot": path, "width": img.width(), "height": img.height()}));
Ok(())
}
fn main() {
let args = match parse_args() {
Ok(a) => a,
Err(e) => {
eprintln!("{e}");
std::process::exit(2);
}
};
let mut session = effectcraft_host::session();
if args.demo {
let _ = session.execute("file.openDemoProject", json!({}));
}
let (tx, rx) = mpsc::channel::<ControlRequest>();
let mut harness = Harness::builder()
.with_size(egui::vec2(args.size.0, args.size.1))
.with_pixels_per_point(args.scale)
.wgpu()
.build_eframe(move |_cc| EffectcraftApp::new(session).with_control(rx));
settle(&mut harness, args.settle);
for step in &args.steps {
let method = step["method"].as_str().unwrap_or_default().to_string();
let params = step.get("params").cloned().unwrap_or(json!({}));
if method == "snap" {
if let Err(e) = snap(&mut harness, params["path"].as_str().unwrap_or("snap.png")) {
eprintln!("{e}");
}
continue;
}
let (reply_tx, reply_rx) = mpsc::channel();
let _ = tx.send(ControlRequest { method: method.clone(), params, reply: reply_tx });
// Requests are answered on a later frame (some wait for input to be processed).
let deadline = Instant::now() + Duration::from_secs(10);
let reply = loop {
harness.step();
if let Ok(v) = reply_rx.try_recv() {
break v;
}
if Instant::now() > deadline {
break json!({"ok": false, "error": "no reply within 10 s"});
}
std::thread::sleep(Duration::from_millis(10));
};
println!("{}", json!({"method": method, "reply": reply}));
settle(&mut harness, 0.3);
}
settle(&mut harness, args.settle.min(1.0));
if let Err(e) = snap(&mut harness, &args.out) {
eprintln!("{e}");
std::process::exit(1);
}
}
+18 -16
View File
@@ -240,19 +240,12 @@ pub fn workspace(name: &str) -> DockNode {
),
tabs(&[Timeline, RenderQueue], 0),
),
// Default: Project/Effect Controls | Composition | Info, Audio, Preview, Effects & Presets…
_ => vsplit(
Ratio(0.56),
hsplit(
FixedA(300.0),
tabs(&[Project, EffectControls], 0),
hsplit(
FixedB(290.0),
tabs(&[Composition, Layer], 0),
vsplit(Ratio(0.42), tabs(&[Info, Audio, Preview], 2), tabs(&[EffectsPresets, Properties, Character, Paragraph, Align], 0)),
),
),
tabs(&[Timeline, RenderQueue], 0),
// Default (as in After Effects): Project/Effect Controls | Composition over the Timeline,
// and a full-height right column with Preview above the Properties panel.
_ => hsplit(
FixedB(300.0),
vsplit(Ratio(0.56), hsplit(FixedA(300.0), tabs(&[Project, EffectControls], 0), tabs(&[Composition, Layer], 0)), tabs(&[Timeline, RenderQueue], 0)),
vsplit(FixedA(250.0), tabs(&[Preview, Info, Audio], 0), tabs(&[Properties, EffectsPresets, Character, Paragraph, Align], 0)),
),
}
}
@@ -411,7 +404,15 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
}
/// Draw a group's frame + tab strip. Returns actions (tab clicks, panel menu, focus).
pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &Tokens, reg: &mut crate::automation::Registry) -> Vec<DockAction> {
/// `title` gives a tab's label, which may name the comp or layer it shows (After Effects style).
pub fn draw_group_chrome(
ui: &mut egui::Ui,
g: &Group,
focused: PanelKind,
t: &Tokens,
reg: &mut crate::automation::Registry,
title: &dyn Fn(PanelKind) -> String,
) -> Vec<DockAction> {
let mut actions = Vec::new();
let painter = ui.painter().clone();
painter.rect_filled(g.rect, t.radius, t.panel_bg);
@@ -429,7 +430,8 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T
let text_y = strip.min.y + 16.0;
for (i, p) in g.panels.iter().enumerate() {
let is_active = i == g.active;
let galley = painter.layout_no_wrap(p.title().to_string(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text });
let label = title(*p);
let galley = painter.layout_no_wrap(label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text });
let menu_w = if is_active { 20.0 } else { 0.0 };
let w = galley.size().x + 16.0 + menu_w;
if x + w > strip.max.x - 20.0 && i > g.active {
@@ -445,7 +447,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T
}
let tab = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h));
let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), i)), Sense::click());
reg.add(&format!("panel.tab.{}", p.id()), tab, p.title());
reg.add(&format!("panel.tab.{}", p.id()), tab, &label);
let label_x = tab.min.x + 8.0;
let label_w = galley.size().x;
let col = if is_active || resp.hovered() { t.tab_text_active } else { t.tab_text };
+18 -1
View File
@@ -467,14 +467,31 @@ impl EffectcraftApp {
}
}
/// Tab labels that name what the panel shows, as After Effects does: "Composition Intro",
/// "Effect Controls Title", "Properties: Title", and the Timeline tab named after its comp.
fn tab_titles(&self) -> Vec<(PanelKind, String)> {
let mut out = Vec::new();
let Some(comp) = self.session.active_comp() else { return out };
let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default();
out.push((PanelKind::Composition, format!("Composition {cname}")));
out.push((PanelKind::Timeline, cname));
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name)));
out.push((PanelKind::Properties, format!("Properties: {}", l.name)));
}
out
}
fn dock_area(&mut self, ui: &mut egui::Ui, body: egui::Rect) {
let t = self.tokens;
let mut dock = std::mem::replace(&mut self.ui.dock, dock::DockNode::Tabs { panels: vec![], active: 0 });
let mut groups = Vec::new();
dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto);
let mut actions = Vec::new();
let titles = self.tab_titles();
let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string());
for g in &groups {
actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto));
actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title));
}
self.ui.dock = dock;
for g in &groups {
+1 -40
View File
@@ -1,5 +1,4 @@
//! Effect Controls (the selected layer's effects with all parameters) and the Properties panel
//! (essential properties: transform + text/shape basics).
//! Effect Controls: the selected layer's effects with all parameters.
use effectcraft_engine::keyframe::Value;
use effectcraft_engine::project::{GroupKind, Layer, Node, ParamUi, PropGroup, Property};
@@ -376,41 +375,3 @@ impl EffectKind for PropGroup {
}
}
}
/// Properties panel: the selected layer's transform (and text basics), always visible.
pub fn properties(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let ctx = ui.ctx().clone();
let Some(layer) = selected_layer(app) else {
p.text(rect.center(), Align2::CENTER_CENTER, "Select a layer", Tokens::ui(12.0), t.text_faint);
return;
};
let Some(cid) = app.session.active_comp_id() else { return };
let comp = app.session.project.comp(cid).cloned().unwrap_or_else(|| effectcraft_engine::project::Comp::new(1, 1, Default::default(), Default::default()));
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref() };
let mut y = rect.min.y + 8.0;
p.text(pos2(rect.min.x + 10.0, y + 8.0), Align2::LEFT_CENTER, &layer.name, Tokens::semibold(13.0), t.text);
p.text(pos2(rect.min.x + 10.0, y + 24.0), Align2::LEFT_CENTER, layer.source.type_name(), Tokens::ui(11.0), t.text_dim);
y += 40.0;
let mut actions: Actions = vec![];
if let Some(tr) = layer.transform().cloned() {
p.text(pos2(rect.min.x + 10.0, y + 10.0), Align2::LEFT_CENTER, "Layer Transform", Tokens::semibold(12.0), t.text);
y += 24.0;
for pr in tr.props() {
if pr.three_d_only && !layer.is_3d() {
continue;
}
let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), 24.0));
prop_row(app, ui, &p, &layer, pr, &ectx, r, 16.0, &mut actions);
y += 24.0;
}
}
for (id, params) in actions {
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
app.ui.status = e;
}
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ pub mod effects_presets;
pub mod info;
pub mod misc;
pub mod project;
pub mod properties;
pub mod text_panels;
pub mod timeline;
pub mod viewer;
@@ -58,7 +59,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
PanelKind::Character => text_panels::character(app, ui, rect),
PanelKind::Paragraph => text_panels::paragraph(app, ui, rect),
PanelKind::Align => text_panels::align(app, ui, rect),
PanelKind::Properties => effect_controls::properties(app, ui, rect),
PanelKind::Properties => properties::show(app, ui, rect),
PanelKind::Audio => misc::audio(app, ui, rect),
PanelKind::History => misc::history(app, ui, rect),
PanelKind::Markers => misc::markers(app, ui, rect),
+508
View File
@@ -0,0 +1,508 @@
//! Properties panel: the selected layer's essential properties in one context-sensitive stack,
//! like After Effects' Properties panel. Sections appear by layer type:
//!
//! - **Layer Transform** (all layers but cameras/lights): keyframe navigator (◀ ◆ ▶) or
//! stopwatch, scrubbable values, linked Scale, `Nx+N°` Rotation, Reset.
//! - **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::render::EvalCtx;
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json;
use crate::dock::PanelKind;
use crate::icons::{self, Icon};
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
type Actions = Vec<(String, serde_json::Value)>;
const ROW_H: f32 = 22.0;
const PAD: f32 = 12.0;
/// Text Animation › Add Animator entries: (menu label, `layer.addTextAnimator` property).
const ANIMATORS: &[(&str, &str)] = &[
("Anchor Point", "anchor"),
("Position", "position"),
("Scale", "scale"),
("Skew", "skew"),
("Rotation", "rotation"),
("Opacity", "opacity"),
("Fill Color", "fillColor"),
("Stroke Color", "strokeColor"),
("Stroke Width", "strokeWidth"),
("Tracking", "tracking"),
("Line Spacing", "lineSpacing"),
("Character Offset", "characterOffset"),
("Blur", "blur"),
];
/// AE's seven Paragraph alignment buttons: (justify, `layer.setText` key).
const ALIGNS: [(Justify, &str); 7] = [
(Justify::Left, "left"),
(Justify::Center, "center"),
(Justify::Right, "right"),
(Justify::JustifyLastLeft, "justifyLeft"),
(Justify::JustifyLastCenter, "justifyCenter"),
(Justify::JustifyLastRight, "justifyRight"),
(Justify::JustifyAll, "justifyAll"),
];
/// Value text the way AE prints it: whole numbers without decimals, otherwise one decimal.
fn decimals(v: f64) -> usize {
if (v - v.round()).abs() < 1e-6 { 0 } else { 1 }
}
/// Rotation as AE shows it: revolutions and remaining degrees (`1x+30°`, `-0x-45°`).
pub fn split_rotation(deg: f64) -> (i64, f64) {
let rev = (deg / 360.0).trunc();
(rev as i64, deg - rev * 360.0)
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let ctx = ui.ctx().clone();
let comp = app.session.active_comp().cloned();
let layer = comp.as_ref().and_then(|c| app.session.state.selected_layers.first().and_then(|id| c.layer(*id)).cloned());
let (Some(comp), Some(cid), Some(layer)) = (comp, app.session.active_comp_id(), layer) else {
p.text(rect.center(), Align2::CENTER_CENTER, "Select a layer to see its properties", Tokens::ui(12.0), t.text_faint);
return;
};
let project = app.session.project.clone();
let expr = app.session.expr.clone();
let now = app.session.time();
let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref() };
let x0 = rect.min.x + PAD;
let w = rect.width() - 2.0 * PAD;
// Vertical scrolling: content height is known after drawing, so clamp with last frame's.
let scroll_id = egui::Id::new("props-scroll");
let (mut scroll, content_h): (f32, f32) = ui.data(|d| d.get_temp(scroll_id)).unwrap_or((0.0, 0.0));
if ui.rect_contains_pointer(rect) {
scroll -= ui.input(|i| i.smooth_scroll_delta.y);
}
scroll = scroll.clamp(0.0, (content_h - rect.height()).max(0.0));
let top = rect.min.y + 8.0 - scroll;
let mut y = top;
let mut actions: Actions = vec![];
// ---------------------------------------------------------------- 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, "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() {
for pr in tr.props() {
actions.push(("prop.reset".into(), json!({"layer": layer.id.0, "prop": pr.uid})));
}
}
y += ROW_H + 2.0;
for pr in tr.props() {
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);
y += ROW_H;
}
y += 10.0;
}
// ---------------------------------------------------------------- Text / Paragraph / Animation
if matches!(layer.source, LayerSource::Text)
&& let Some(doc) = effectcraft_engine::render::text::source_text(&ectx, &layer)
{
divider(&p, x0, w, y - 4.0, &t);
y = text_section(app, ui, &p, &layer, &doc, x0, w, y + 4.0, &mut actions);
divider(&p, x0, w, y - 4.0, &t);
y = paragraph_section(app, ui, &p, &layer, &doc, x0, w, y + 4.0, &mut actions);
divider(&p, x0, w, y - 4.0, &t);
section_header(app, ui, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation");
y += ROW_H + 10.0;
let br = Rect::from_min_size(pos2(x0, y), vec2(116.0, 24.0));
let resp = ui.interact(br, egui::Id::new("props-add-animator"), Sense::click());
p.rect_stroke(br, 4.0, Stroke::new(1.0, if resp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside);
icons::paint(&p, Rect::from_center_size(pos2(br.min.x + 14.0, br.center().y), vec2(10.0, 10.0)), Icon::Plus, t.text);
p.text(pos2(br.min.x + 26.0, br.center().y), Align2::LEFT_CENTER, "Add Animator", Tokens::ui(12.0), t.text);
app.auto.add("properties.addAnimator", br, "Add Animator");
let pop = egui::Id::new("props-add-animator-pop");
if resp.clicked() {
widgets::open_popup(ui, pop);
}
let labels: Vec<String> = ANIMATORS.iter().map(|(l, _)| l.to_string()).collect();
if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &labels, None) {
actions.push(("layer.addTextAnimator".into(), json!({"layer": layer.id.0, "property": ANIMATORS[i].1})));
}
y += 24.0;
}
y += 40.0;
let content_h = y - top;
ui.data_mut(|d| d.insert_temp(scroll_id, (scroll, content_h)));
if content_h > rect.height() {
// Thin scroll indicator.
let frac = rect.height() / content_h;
let bar_h = (rect.height() * frac).max(24.0);
let by = rect.min.y + (rect.height() - bar_h) * (scroll / (content_h - rect.height()).max(1.0));
p.rect_filled(Rect::from_min_size(pos2(rect.max.x - 5.0, by), vec2(3.0, bar_h)), 1.5, t.text_faint.gamma_multiply(0.6));
}
for (id, params) in actions {
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
app.ui.status = e;
}
}
}
fn section_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, w: f32, y: f32, title: &str, auto: &str) {
let t = app.tokens;
p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, title, Tokens::semibold(12.0), t.text);
let r = Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H));
let _ = ui;
app.auto.add(auto, r, title);
}
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));
}
fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) {
let (dx, h) = (if left { -3.5 } else { 3.5 }, 4.0);
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.
#[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) {
let t = app.tokens;
let cy = r.center().y;
let uid = pr.uid;
let lid = layer.id.0;
let base = format!("properties.prop.{uid}");
if pr.is_animated() {
// ◀ ◆ ▶: previous key, add/remove key at the CTI, next key.
let now = ectx.time;
let half = ectx.comp.frame_duration().0 / 2;
let on_key = pr.keys.iter().any(|k| (layer.comp_time(k.time).0 - now.0).abs() <= half);
let cells = [("prev", r.min.x + 4.0), ("key", r.min.x + 16.0), ("next", r.min.x + 28.0)];
for (what, cx) in cells {
let cr = Rect::from_center_size(pos2(cx, cy), vec2(12.0, 16.0));
let resp = ui.interact(cr, egui::Id::new(("props-nav", uid, what)), Sense::click());
let col = if resp.hovered() { t.text } else { t.text_dim };
match what {
"prev" => triangle(p, cr.center(), true, col),
"next" => triangle(p, cr.center(), false, col),
_ => {
let kr = Rect::from_center_size(cr.center(), vec2(10.0, 10.0));
if on_key {
icons::paint(p, kr, Icon::Keyframe, t.keyframe);
} else {
let c = kr.center();
let d = 4.5;
p.add(egui::Shape::closed_line(
vec![pos2(c.x, c.y - d), pos2(c.x + d, c.y), pos2(c.x, c.y + d), pos2(c.x - d, c.y)],
Stroke::new(1.0, col),
));
}
}
}
app.auto.add(&format!("{base}.{what}Key"), cr, &pr.name);
if resp.clicked() {
match what {
"prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))),
"next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))),
_ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid}))),
}
}
}
} else {
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);
if resp.clicked() {
actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid})));
}
}
p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text);
let vx = r.min.x + (r.width() * 0.5).max(130.0);
let merge = format!("props-{uid}");
let set = |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge})));
let value = ectx.value(layer, pr);
let is_scale = pr.name == "Scale";
let pct = is_scale || pr.name == "Opacity";
let suffix = if pct { "%" } else { "" };
match &value {
Value::Scalar(v) if pr.name == "Rotation" || pr.name.ends_with(" Rotation") => {
let (rev, deg) = split_rotation(*v);
let g = p.layout_no_wrap(format!("{rev}x{}", if deg < 0.0 { "" } else { "+" }), Tokens::ui(12.0), t.hot_text);
let gw = g.size().x;
p.galley(pos2(vx + 2.0, cy - g.size().y / 2.0), g, t.hot_text);
let (vr, nv, _) =
widgets::hot_number_at(ui, pos2(vx + gw, cy - 9.0), egui::Id::new(("props-v", uid)), deg, 0.5, (-1e9, 1e9), decimals(deg), "°", &t);
app.auto.add(&format!("{base}.value"), vr, &pr.name);
if let Some(nv) = nv {
set(actions, json!(rev as f64 * 360.0 + 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);
if let Some(nv) = nv {
set(actions, json!(nv));
}
}
Value::Vec2(_) | Value::Vec3(_) => {
let c = value.components();
let n = if pr.shown_dims > 0 && !(layer.is_3d() && c.len() == 3) { pr.shown_dims as usize } else { c.len() };
let mut x = vx;
let link_id = egui::Id::new(("props-scale-link", lid));
let linked = is_scale && ui.data(|d| d.get_temp::<bool>(link_id)).unwrap_or(true);
if is_scale {
// Constrain Proportions toggle, left of the values.
let lr = Rect::from_center_size(pos2(x - 12.0, cy), vec2(12.0, 12.0));
let resp = ui.interact(lr, link_id.with("btn"), Sense::click());
icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
app.auto.add(&format!("{base}.constrain"), lr, "Constrain Proportions");
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(link_id, !linked));
}
}
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);
if let Some(nv) = nv {
let mut nc = c.clone();
if linked && c[d].abs() > 1e-9 {
let k = nv / c[d];
for v in nc.iter_mut().take(n) {
*v *= k;
}
}
nc[d] = nv;
set(actions, json!(nc));
}
x = vr.max.x + 10.0;
}
}
_ => {}
}
}
/// Text section; returns the y below it.
#[allow(clippy::too_many_arguments)]
fn text_section(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
doc: &TextDoc,
x0: f32,
w: f32,
mut y: f32,
actions: &mut Actions,
) -> f32 {
let t = app.tokens;
let lid = layer.id.0;
let set = |actions: &mut Actions, v: serde_json::Value| {
let mut v = v;
v["layer"] = json!(lid);
actions.push(("layer.setText".into(), v));
};
section_header(app, ui, p, x0, w, y, "Text", "properties.text");
y += ROW_H + 4.0;
// Font family and style.
let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
let pop = egui::Id::new("props-font-pop");
if widgets::dropdown(ui, fr, &doc.font, &t, egui::Id::new("props-font")).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add("properties.text.font", fr, "Font family");
let fams: Vec<String> = effectcraft_engine::text_families();
if let Some(i) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)) {
set(actions, json!({"font": fams[i]}));
}
y += 28.0;
let sr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
let spop = egui::Id::new("props-style-pop");
if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::new("props-style")).clicked() {
widgets::open_popup(ui, spop);
}
app.auto.add("properties.text.style", sr, "Font style");
let styles: Vec<String> = ["Regular", "Medium", "SemiBold", "Bold", "Italic"].iter().map(|s| s.to_string()).collect();
if let Some(i) = widgets::popup_menu(ui, spop, sr.left_bottom(), &styles, styles.iter().position(|s| *s == doc.style)) {
set(actions, json!({"style": styles[i]}));
}
y += 32.0;
// Size / leading, tracking / stroke width.
let col2 = x0 + w / 2.0;
let field = |app: &mut EffectcraftApp, ui: &mut egui::Ui, x: f32, y: f32, glyph: &str, key: &str, v: f64, range: (f64, f64), suffix: &str| {
p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + 26.0, y), egui::Id::new(("props-text", key)), v, 0.5, range, decimals(v), suffix, &t);
app.auto.add(&format!("properties.text.{key}"), r, key);
nv
};
if let Some(v) = field(app, ui, x0, y, "T", "size", doc.size, (1.0, 2000.0), " px") {
set(actions, json!({"size": v, "merge": "props-text-size"}));
}
match doc.leading {
Some(l) => {
if let Some(v) = field(app, ui, col2, y, "A", "leading", l, (0.0, 5000.0), " px") {
set(actions, json!({"leading": v, "merge": "props-text-leading"}));
}
}
None => {
// Auto Leading: click to switch to an explicit value (120% of the size).
p.text(pos2(col2, y + 9.0), Align2::LEFT_CENTER, "A", Tokens::semibold(11.0), t.text_dim);
let r = Rect::from_min_size(pos2(col2 + 26.0, y), vec2(40.0, 18.0));
let resp = ui.interact(r, egui::Id::new("props-leading-auto"), Sense::click());
p.text(pos2(r.min.x + 2.0, r.center().y), Align2::LEFT_CENTER, "Auto", Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.hot_text });
app.auto.add("properties.text.leading", r, "leading");
if resp.clicked() {
set(actions, json!({"leading": (doc.size * 1.2).round()}));
}
}
}
y += 26.0;
if let Some(v) = field(app, ui, x0, y, "VA", "tracking", doc.tracking, (-1000.0, 10000.0), "") {
set(actions, json!({"tracking": v.round(), "merge": "props-text-tracking"}));
}
if let Some(v) = field(app, ui, col2, y, "W", "strokeWidth", doc.stroke_width, (0.0, 500.0), " px") {
set(actions, json!({"strokeWidth": v, "merge": "props-text-strokeWidth"}));
}
y += 28.0;
// Fill and Stroke, each with an enable checkbox, swatch and label.
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
let ry = y + i as f32 * 24.0;
let cr = Rect::from_min_size(pos2(x0, ry + 2.0), vec2(14.0, 14.0));
if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("props-apply", key))).clicked() {
let k = if key == "fill" { "applyFill" } else { "applyStroke" };
set(actions, json!({k: !on}));
}
app.auto.add(&format!("properties.text.{key}.enabled"), cr, label);
let sw = Rect::from_min_size(pos2(x0 + 24.0, ry + 1.0), vec2(16.0, 16.0));
let pop = egui::Id::new(("props-color-pop", key));
if widgets::swatch(ui, sw, c, egui::Id::new(("props-color", key)), &t).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("properties.text.{key}"), sw, label);
let mut rgb = [c[0], c[1], c[2]];
if crate::header::color_popup(ui, pop, sw.left_bottom(), &mut rgb) {
set(actions, json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("props-text-{key}")}));
}
p.text(pos2(sw.max.x + 8.0, sw.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text);
}
y += 52.0;
y = more_button(app, ui, p, x0, y, "props-text-more", "properties.text.more", PanelKind::Character);
y + 10.0
}
/// Paragraph section; returns the y below it.
#[allow(clippy::too_many_arguments)]
fn paragraph_section(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
doc: &TextDoc,
x0: f32,
w: f32,
mut y: f32,
actions: &mut Actions,
) -> f32 {
let t = app.tokens;
section_header(app, ui, p, x0, w, y, "Paragraph", "properties.paragraph");
y += ROW_H + 4.0;
for (i, (j, key)) in ALIGNS.into_iter().enumerate() {
let r = Rect::from_min_size(pos2(x0 + i as f32 * 26.0, y), vec2(22.0, 22.0));
let on = doc.justify == j;
let resp = ui.interact(r, egui::Id::new(("props-para", key)), Sense::click());
p.rect_filled(
r,
3.0,
if on {
t.pressed
} else if resp.hovered() {
t.hover
} else {
Color32::TRANSPARENT
},
);
paint_align_glyph(p, r, j, if on { t.text } else { t.text_dim });
app.auto.add(&format!("properties.paragraph.{key}"), r, key);
if resp.clicked() {
actions.push(("layer.setText".into(), json!({"layer": layer.id.0, "justify": key})));
}
}
y += 30.0;
y = more_button(app, ui, p, x0, y, "props-para-more", "properties.paragraph.more", PanelKind::Paragraph);
y + 10.0
}
/// Four lines showing an alignment: last line short for the "justify last …" modes.
fn paint_align_glyph(p: &egui::Painter, r: Rect, j: Justify, c: Color32) {
let full = r.width() - 8.0;
for k in 0..4 {
let ly = r.min.y + 6.0 + k as f32 * 3.5;
let last = k == 3;
let lw = match j {
Justify::Left | Justify::Center | Justify::Right => {
if k % 2 == 1 {
full * 0.6
} else {
full
}
}
Justify::JustifyAll => full,
_ if last => full * 0.55,
_ => full,
};
let lx = match j {
Justify::Center | Justify::JustifyLastCenter => r.center().x - lw / 2.0,
Justify::Right | Justify::JustifyLastRight => r.max.x - 4.0 - lw,
_ => r.min.x + 4.0,
};
p.line_segment([pos2(lx, ly), pos2(lx + lw, ly)], Stroke::new(1.2, c));
}
}
/// "··· More" button that opens the full panel; returns the y below it.
#[allow(clippy::too_many_arguments)]
fn more_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, y: f32, id: &str, auto: &str, panel: PanelKind) -> f32 {
let t = app.tokens;
let r = Rect::from_min_size(pos2(x0, y), vec2(64.0, 22.0));
let resp = ui.interact(r, egui::Id::new(id), Sense::click());
p.rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.field_bg });
p.text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, "···", Tokens::semibold(12.0), t.text);
p.text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, "More", Tokens::ui(12.0), t.text);
app.auto.add(auto, r, "More");
if resp.clicked() {
app.show_panel(panel);
}
y + 28.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rotation_splits_like_ae() {
assert_eq!(split_rotation(0.0), (0, 0.0));
assert_eq!(split_rotation(390.0), (1, 30.0));
assert_eq!(split_rotation(-45.0), (0, -45.0));
assert_eq!(split_rotation(-725.0), (-2, -5.0));
}
#[test]
fn decimals_hide_whole_numbers() {
assert_eq!(decimals(960.0), 0);
assert_eq!(decimals(960.5), 1);
}
}