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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 21:19:23 -04:00
co-authored by Claude Opus 5.5
parent 214345df13
commit 4f47a01fed
12 changed files with 765 additions and 65 deletions
+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);
}