M9.6: text animation UI (Animate:/Add: pop-ups, flattened animator rows, path popup, presets, menus)

Timeline: Text group 'Animate:' and animator 'Add:' pop-ups (properties and
Range/Wiggly/Expression selectors), selectors/properties listed directly under
each animator, Path Options ▸ Path lists the layer's masks and hides the
other path options until one is chosen. Properties panel Add Animator lists
every animator property plus Enable Per-character 3D, and a Presets menu.
Animation menu: Animate Text, Add Text Selector, Text Animation Presets.
Expression Selector result replaces the selection (no Mode); selector popups
are non-keyframable like AE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 00:12:41 -04:00
1 parent c5cf189e1a
commit fedb5cb715
10 files changed
+216 -48

No files matched your search

+5
View File
@@ -91,6 +91,11 @@ pub fn command_specs() -> &'static [CommandSpec] {
})
}
/// (id, name) of the text animation presets.
pub fn text_preset_list() -> Vec<(String, String)> {
text_anim::preset_names()
}
pub fn find(id: &str) -> Option<&'static CommandSpec> {
command_specs().iter().find(|c| c.id == id)
}
+5
View File
@@ -17,6 +17,11 @@ fn presets() -> Vec<Value> {
serde_json::from_str::<Value>(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
}
/// (id, name) of every preset.
pub fn preset_names() -> Vec<(String, String)> {
presets().iter().filter_map(|p| Some((p.get("id")?.as_str()?.to_string(), p.get("name")?.as_str()?.to_string()))).collect()
}
/// Enabled when a text layer is selected.
fn has_text_layer(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
+5
View File
@@ -424,6 +424,11 @@ mod tests_text;
mod tests_timeline;
/// Font families available to text layers (bundled + scanned system fonts).
/// Text animation presets: (id, name), for menus.
pub fn text_presets() -> Vec<(String, String)> {
commands::text_preset_list()
}
pub fn text_families() -> Vec<String> {
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
}
+3 -1
View File
@@ -66,9 +66,11 @@ fn add_property_and_selectors_with_undo_redo() {
assert_eq!(matches(sels), ["rangeSelector", "wigglySelector", "expressionSelector", "rangeSelector"]);
let names: Vec<&str> = sels.children.iter().map(Node::name).collect();
assert_eq!(names, ["Range Selector 1", "Wiggly Selector 1", "Expression Selector 1", "Range Selector 2"]);
// Expression selector carries the default amount expression; wiggly defaults to Intersect.
// Expression selector carries the default amount expression (and no Mode); wiggly defaults to
// Intersect.
let ex = sels.group("expressionSelector").unwrap();
assert!(ex.get("amount").unwrap().has_expression());
assert!(ex.get("mode").is_none());
assert_eq!(sels.group("wigglySelector").unwrap().get("mode").unwrap().value, KV::Enum(2));
let cid = s.active_comp_id().unwrap();
let a1 = s.render(cid, s.time(), Default::default());
+3 -5
View File
@@ -971,7 +971,7 @@ fn text_expression_selector_values() {
let anim = l.props.group("text/animators/#1").unwrap();
effectcraft_render::text::animator_selection(&ctx, l, anim, &lay).into_iter().map(|v| (v[0] * 1000.0).round() / 1000.0).collect::<Vec<_>>()
};
// Default: selectorValue * textIndex / textTotal, intersected with the full range selector.
// Default: selectorValue * textIndex / textTotal (selectorValue = the full range selector).
assert_eq!(sel_of(&p), vec![0.25, 0.5, 0.75, 1.0]);
let set_expr = |p: &mut Project, text: &str| {
let l = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap();
@@ -986,10 +986,8 @@ fn text_expression_selector_values() {
assert_eq!(sel_of(&p), vec![0.0, 1.0, 0.0, 1.0]);
set_expr(&mut p, "[50, 0, 0]");
assert_eq!(sel_of(&p), vec![0.5; 4]);
// Mode Add after the range: 1 + v.
let l = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap();
l.props.prop_mut("text/animators/#1/selectors/#2/mode").unwrap().value = Value::Enum(0);
set_expr(&mut p, "-textIndex / textTotal * 100");
// The result replaces the selection so far (which it reads as selectorValue).
set_expr(&mut p, "selectorValue - textIndex / textTotal * 100");
assert_eq!(sel_of(&p), vec![0.75, 0.5, 0.25, 0.0]);
// Errors fall back to the static amount (100%).
set_expr(&mut p, "nope(");
+23 -18
View File
@@ -108,6 +108,12 @@ pub const INTER_CHAR_BLEND_MODES: &[&str] = &[
"Luminosity",
];
/// A non-keyframable header-like property (no stopwatch, as in AE's selector popups).
fn fixed(mut p: Property) -> Property {
p.static_only = true;
p
}
/// Path Options group (Path popup: 0 = None, k = mask k).
pub fn path_options(ids: &mut Ids) -> PropGroup {
let mut path = ids.prop("path", "Path", Value::Enum(0)).with_ui(popup(&["None"]));
@@ -124,14 +130,14 @@ pub fn path_options(ids: &mut Ids) -> PropGroup {
/// More Options group.
pub fn more_options(ids: &mut Ids) -> PropGroup {
ids.group("moreOptions", "More Options")
.with(ids.prop("anchorGrouping", "Anchor Point Grouping", Value::Enum(0)).with_ui(popup(&["Character", "Word", "Line", "All"])))
.with(fixed(ids.prop("anchorGrouping", "Anchor Point Grouping", Value::Enum(0)).with_ui(popup(&["Character", "Word", "Line", "All"]))))
.with(ids.prop("groupingAlignment", "Grouping Alignment", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Percent))
.with(ids.prop("fillStroke", "Fill & Stroke", Value::Enum(0)).with_ui(popup(&[
.with(fixed(ids.prop("fillStroke", "Fill & Stroke", Value::Enum(0)).with_ui(popup(&[
"Per Character Palette",
"All Fills Over All Strokes",
"All Strokes Over All Fills",
])))
.with(ids.prop("interCharBlend", "Inter-Character Blending", Value::Enum(0)).with_ui(popup(INTER_CHAR_BLEND_MODES)))
]))))
.with(fixed(ids.prop("interCharBlend", "Inter-Character Blending", Value::Enum(0)).with_ui(popup(INTER_CHAR_BLEND_MODES))))
}
pub fn text(ids: &mut Ids, doc: TextDoc) -> PropGroup {
@@ -166,15 +172,15 @@ pub fn range_selector(ids: &mut Ids, name: &str) -> PropGroup {
g.kind = GroupKind::Indexed;
let adv = ids
.group("advanced", "Advanced")
.with(ids.prop("units", "Units", Value::Enum(0)).with_ui(popup(&["Percentage", "Index"])))
.with(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(BASED_ON)))
.with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(SEL_MODES)))
.with(fixed(ids.prop("units", "Units", Value::Enum(0)).with_ui(popup(&["Percentage", "Index"]))))
.with(fixed(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(BASED_ON))))
.with(fixed(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(SEL_MODES))))
.with(ids.prop("amount", "Amount", Value::Scalar(100.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0)))
.with(ids.prop("shape", "Shape", Value::Enum(0)).with_ui(popup(&["Square", "Ramp Up", "Ramp Down", "Triangle", "Round", "Smooth"])))
.with(fixed(ids.prop("shape", "Shape", Value::Enum(0)).with_ui(popup(&["Square", "Ramp Up", "Ramp Down", "Triangle", "Round", "Smooth"]))))
.with(ids.prop("smoothness", "Smoothness", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0)))
.with(ids.prop("easeHigh", "Ease High", Value::Scalar(0.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0)))
.with(ids.prop("easeLow", "Ease Low", Value::Scalar(0.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0)))
.with(ids.prop("randomize", "Randomize Order", Value::Bool(false)).with_ui(ParamUi::Checkbox))
.with(fixed(ids.prop("randomize", "Randomize Order", Value::Bool(false)).with_ui(ParamUi::Checkbox)))
.with(ids.prop("randomSeed", "Random Seed", Value::Scalar(0.0)));
g.with(ids.prop("start", "Start", Value::Scalar(0.0)).with_ui(ParamUi::Percent))
.with(ids.prop("end", "End", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
@@ -186,10 +192,10 @@ pub fn range_selector(ids: &mut Ids, name: &str) -> PropGroup {
pub fn wiggly_selector(ids: &mut Ids, name: &str) -> PropGroup {
let mut g = ids.group("wigglySelector", name);
g.kind = GroupKind::Indexed;
g.with(ids.prop("mode", "Mode", Value::Enum(2)).with_ui(popup(SEL_MODES)))
g.with(fixed(ids.prop("mode", "Mode", Value::Enum(2)).with_ui(popup(SEL_MODES))))
.with(ids.prop("maxAmount", "Max Amount", Value::Scalar(100.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0)))
.with(ids.prop("minAmount", "Min Amount", Value::Scalar(-100.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0)))
.with(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(BASED_ON)))
.with(fixed(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(BASED_ON))))
.with(ids.prop("wigglesPerSecond", "Wiggles/Second", Value::Scalar(2.0)).with_ui(slider(0.0, 1000.0, 0.0, 10.0, 1)))
.with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0)))
.with(ids.prop("temporalPhase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
@@ -202,15 +208,14 @@ pub fn wiggly_selector(ids: &mut Ids, name: &str) -> PropGroup {
pub const EXPRESSION_SELECTOR_DEFAULT: &str = "selectorValue * textIndex / textTotal";
/// Expression Selector: Amount is computed per unit by its expression (`textIndex`,
/// `textTotal`, `selectorValue`).
/// `textTotal`, `selectorValue` = the selection of the selectors above it) and replaces that
/// selection (there is no Mode: the expression combines with `selectorValue` itself).
pub fn expression_selector(ids: &mut Ids, name: &str) -> PropGroup {
let mut g = ids.group("expressionSelector", name);
g.kind = GroupKind::Indexed;
let mut amount = ids.prop("amount", "Amount", Value::Vec3([100.0, 100.0, 100.0])).with_ui(ParamUi::Percent);
amount.expr = Some(crate::props::Expression { text: EXPRESSION_SELECTOR_DEFAULT.into(), enabled: true });
g.with(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(BASED_ON)))
.with(ids.prop("mode", "Mode", Value::Enum(2)).with_ui(popup(SEL_MODES)))
.with(amount)
g.with(fixed(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(BASED_ON)))).with(amount)
}
/// A selector by kind (`range`, `wiggly`, `expression`).
@@ -306,7 +311,7 @@ pub fn text_anim_props(ids: &mut Ids, kind: &str, three_d: bool) -> Vec<Property
"strokeOpacity" => vec![pct_slider(ids, "strokeOpacity", "Stroke Opacity", 100.0, 0.0)],
"strokeWidth" => vec![ids.prop("strokeWidth", "Stroke Width", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)],
"tracking" => {
let tt = ids.prop("trackingType", "Tracking Type", Value::Enum(0)).with_ui(popup(&["Before & After", "Before", "After"]));
let tt = fixed(ids.prop("trackingType", "Tracking Type", Value::Enum(0)).with_ui(popup(&["Before & After", "Before", "After"])));
vec![tt, ids.prop("tracking", "Tracking Amount", Value::Scalar(0.0))]
}
_ => vec![],
@@ -314,11 +319,11 @@ pub fn text_anim_props(ids: &mut Ids, kind: &str, three_d: bool) -> Vec<Property
}
fn char_alignment(ids: &mut Ids) -> Property {
ids.prop("characterAlignment", "Character Alignment", Value::Enum(1)).with_ui(popup(&["Left or Top", "Center", "Right or Bottom", "Adjust Kerning"]))
fixed(ids.prop("characterAlignment", "Character Alignment", Value::Enum(1)).with_ui(popup(&["Left or Top", "Center", "Right or Bottom", "Adjust Kerning"])))
}
fn char_range(ids: &mut Ids) -> Property {
ids.prop("characterRange", "Character Range", Value::Enum(0)).with_ui(popup(&["Preserve Case & Digits", "Full Unicode"]))
fixed(ids.prop("characterRange", "Character Range", Value::Enum(0)).with_ui(popup(&["Preserve Case & Digits", "Full Unicode"])))
}
/// The main property of an animator kind (the last of [`text_anim_props`]; kept for callers
+14 -5
View File
@@ -146,7 +146,8 @@ pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &
let adv = sel.sub("advanced");
(adv.map(|a| ctx.e(layer, a, "mode")).unwrap_or(0), adv.map(|a| ctx.e(layer, a, "basedOn")).unwrap_or(0))
}
_ => (ctx.e(layer, sel, "mode"), ctx.e(layer, sel, "basedOn")),
"wigglySelector" => (ctx.e(layer, sel, "mode"), ctx.e(layer, sel, "basedOn")),
_ => (0, ctx.e(layer, sel, "basedOn")),
};
let mode = Mode::from_index(mode);
let based = BasedOn::from_index(based);
@@ -180,22 +181,30 @@ pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &
let stat = ctx.v3(layer, sel, "amount", [100.0; 3]);
Box::new(move |i, n, prev| {
let pv = prev.map(|x| x * 100.0);
// Without an expression engine: the static amount scales the selection.
let fallback = [0, 1, 2].map(|d| stat[d] * prev[d]);
let r = match ctx.expr.filter(|_| prop.has_expression()) {
Some(h) => h.eval_text_selector(ctx, layer, prop, i, n, pv).unwrap_or(stat),
None => stat,
Some(h) => h.eval_text_selector(ctx, layer, prop, i, n, pv).unwrap_or(fallback),
None => fallback,
};
r.map(|x| (x / 100.0).clamp(-1.0, 1.0))
})
}
_ => continue,
};
// The Expression Selector's result replaces the selection (it reads it as selectorValue).
let replace = sel.match_id == "expressionSelector";
let mut memo: std::collections::HashMap<(usize, [u64; 3]), [f64; 3]> = std::collections::HashMap::new();
for (gi, g) in lay.glyphs.iter().enumerate() {
let (i, n) = unit_of(based, g, lay);
let prev = if si == 0 { [mode.initial(); 3] } else { acc[gi] };
let prev = match si {
0 if replace => [1.0; 3],
0 => [mode.initial(); 3],
_ => acc[gi],
};
let key = (i, prev.map(f64::to_bits));
let v = *memo.entry(key).or_insert_with(|| values(i, n, prev));
acc[gi] = [0, 1, 2].map(|d| mode.combine(prev[d], v[d]));
acc[gi] = if replace { v } else { [0, 1, 2].map(|d| mode.combine(prev[d], v[d])) };
}
}
acc
+34
View File
@@ -379,6 +379,40 @@ pub fn menu_items(app: &EffectcraftApp) -> Vec<MenuItem> {
params: Value::Null,
});
}
// Animation ▸ Animate Text / Add Text Selector / Text Animation Presets.
let anim_on = app.session.is_enabled("layer.addTextAnimator");
for (k, l) in effectcraft_engine::project::build::TEXT_ANIMATOR_KINDS.iter().filter(|(k, _)| *k != "-") {
out.push(MenuItem {
id: "layer.addTextAnimator".into(),
label: (*l).into(),
path: vec!["Animation".into(), "Animate Text".into()],
shortcut: None,
enabled: anim_on,
params: json!({"property": k}),
});
}
let sel_on = app.session.is_enabled("layer.addTextSelector");
for (k, l) in [("range", "Range"), ("wiggly", "Wiggly"), ("expression", "Expression")] {
out.push(MenuItem {
id: "layer.addTextSelector".into(),
label: l.into(),
path: vec!["Animation".into(), "Add Text Selector".into()],
shortcut: None,
enabled: sel_on,
params: json!({"kind": k}),
});
}
let preset_on = app.session.is_enabled("layer.applyTextPreset");
for (id, name) in effectcraft_engine::text_presets() {
out.push(MenuItem {
id: "layer.applyTextPreset".into(),
label: name,
path: vec!["Animation".into(), "Text Animation Presets".into()],
shortcut: None,
enabled: preset_on,
params: json!({"preset": id}),
});
}
let has_layer = !app.session.state.selected_layers.is_empty();
for e in effectcraft_engine::effects::registry() {
out.push(MenuItem {
+32 -18
View File
@@ -24,22 +24,15 @@ 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"),
];
/// Text Animation › Add Animator entries: (menu label, `layer.addTextAnimator` property; `""`
/// for separators). The engine's list (`build::TEXT_ANIMATOR_KINDS`) after Enable Per-character 3D.
fn animator_entries(per_char: bool) -> Vec<(String, &'static str)> {
let mut v = vec![((if per_char { "Disable Per-character 3D" } else { "Enable Per-character 3D" }).to_string(), "perChar3d"), ("-".to_string(), "")];
for (k, l) in effectcraft_engine::project::build::TEXT_ANIMATOR_KINDS {
v.push((l.to_string(), if *k == "-" { "" } else { k }));
}
v
}
/// AE's seven Paragraph alignment buttons: (justify, `layer.setText` key).
const ALIGNS: [(Justify, &str); 7] = [
@@ -134,9 +127,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if resp.clicked() {
widgets::open_popup(ui, pop);
}
let labels: Vec<String> = ANIMATORS.iter().map(|(l, _)| l.to_string()).collect();
let per_char = layer.props.prop("text/perChar3d").is_some_and(|q| q.value.as_bool());
let entries = animator_entries(per_char);
let labels: Vec<String> = entries.iter().map(|(l, _)| l.clone()).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})));
match entries[i].1 {
"" => {}
"perChar3d" => actions.push(("layer.enablePerChar3D".into(), json!({"layer": layer.id.0, "enabled": !per_char}))),
k => actions.push(("layer.addTextAnimator".into(), json!({"layer": layer.id.0, "property": k}))),
}
}
// Our own presets (original work, see presets/text_animators.json).
let pr = Rect::from_min_size(pos2(br.max.x + 8.0, y), vec2(84.0, 24.0));
let presp = ui.interact(pr, egui::Id::new("props-text-presets"), Sense::click());
p.rect_stroke(pr, 4.0, Stroke::new(1.0, if presp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside);
p.text(pr.center(), Align2::CENTER_CENTER, "Presets", Tokens::ui(12.0), t.text);
app.auto.add("properties.textPresets", pr, "Text Animation Presets");
let ppop = egui::Id::new("props-text-presets-pop");
if presp.clicked() {
widgets::open_popup(ui, ppop);
}
let presets = effectcraft_engine::text_presets();
let names: Vec<String> = presets.iter().map(|(_, n)| n.clone()).collect();
if let Some(i) = widgets::popup_menu(ui, ppop, pr.left_bottom(), &names, None) {
actions.push(("layer.applyTextPreset".into(), json!({"layer": layer.id.0, "preset": presets[i].0})));
}
y += 24.0;
}
+92 -1
View File
@@ -180,6 +180,14 @@ fn prop_visible(p: &Property, layer: &Layer) -> bool {
if matches!(p.ui, ParamUi::Hidden) {
return false;
}
// Path Options show only the Path popup until a mask path is chosen.
if let Some(po) = layer.props.group("text/pathOptions")
&& p.match_id != "path"
&& po.get(&p.match_id).is_some_and(|q| q.uid == p.uid)
&& po.get("path").is_some_and(|q| q.value.as_enum() == 0 && q.keys.is_empty())
{
return false;
}
if p.three_d_only && !layer.is_3d() {
return false;
}
@@ -317,6 +325,10 @@ fn collect_props(g: &PropGroup, out: &mut Vec<u64>, f: &dyn Fn(&Property) -> boo
fn push_group(rows: &mut Vec<Row>, l: &Layer, g: &PropGroup, depth: usize, open: &std::collections::BTreeSet<u64>) {
for c in &g.children {
match c {
// Text animators list their selectors and properties directly (as in AE).
Node::Group(sg) if g.match_id == "animator" && matches!(sg.match_id.as_str(), "selectors" | "properties") => {
push_group(rows, l, sg, depth, open);
}
Node::Group(sg) => {
let o = open.contains(&sg.uid);
let fx = matches!(sg.kind, GroupKind::Effect { .. }).then_some(sg.enabled);
@@ -932,6 +944,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
// Mask mode + inverted inline.
if let Some(g) = layer.props.find_group(*uid)
&& let GroupKind::Mask { mode, inverted, .. } = g.kind
@@ -1408,6 +1421,77 @@ fn walk_groups(g: &PropGroup, out: &mut Vec<u64>) {
}
}
/// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus).
#[allow(clippy::too_many_arguments)]
fn text_anim_popups(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
uid: u64,
x: f32,
cy: f32,
actions: &mut Vec<(String, serde_json::Value)>,
) {
use effectcraft_engine::project::build::TEXT_ANIMATOR_KINDS;
if !matches!(layer.source, LayerSource::Text) {
return;
}
let Some(g) = layer.props.find_group(uid) else { return };
let t = app.tokens;
let (label, key) = match g.match_id.as_str() {
"text" if layer.props.sub("text").is_some_and(|tg| tg.uid == uid) => ("Animate:", "animate"),
"animator" => ("Add:", "add"),
_ => return,
};
p.text(pos2(x + 6.0, cy), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
let br = Rect::from_center_size(pos2(x + 6.0 + if key == "add" { 34.0 } else { 62.0 }, cy), vec2(16.0, 16.0));
let resp = ui.interact(br, egui::Id::new(("tl-textanim", uid)), Sense::click()).on_hover_text(if key == "add" {
"Add property or selector"
} else {
"Animate text"
});
icons::paint(p, br.shrink(3.0), Icon::ChevronRight, if resp.hovered() { t.text } else { t.text_dim });
app.auto.add(&format!("timeline.group.{uid}.{key}"), br, label);
let pop = egui::Id::new(("tl-textanim-pop", uid));
if resp.clicked() {
widgets::open_popup(ui, pop);
}
let per_char = layer.props.prop("text/perChar3d").is_some_and(|q| q.value.as_bool());
let mut opts: Vec<String> = Vec::new();
let mut acts: Vec<(String, serde_json::Value)> = Vec::new();
if key == "animate" {
opts.push(if per_char { "Disable Per-character 3D" } else { "Enable Per-character 3D" }.into());
acts.push(("layer.enablePerChar3D".into(), json!({"layer": layer.id.0, "enabled": !per_char})));
opts.push("-".into());
acts.push((String::new(), json!(null)));
for (k, l) in TEXT_ANIMATOR_KINDS {
opts.push(l.to_string());
acts.push(if *k == "-" { (String::new(), json!(null)) } else { ("layer.addTextAnimator".into(), json!({"layer": layer.id.0, "property": k})) });
}
} else {
for (kind, l) in [("range", "Selector: Range"), ("wiggly", "Selector: Wiggly"), ("expression", "Selector: Expression")] {
opts.push(l.into());
acts.push(("layer.addTextSelector".into(), json!({"layer": layer.id.0, "animator": uid, "kind": kind})));
}
opts.push("-".into());
acts.push((String::new(), json!(null)));
for (k, l) in TEXT_ANIMATOR_KINDS {
opts.push(if *k == "-" { "-".into() } else { format!("Property: {l}") });
acts.push(if *k == "-" {
(String::new(), json!(null))
} else {
("layer.addTextAnimatorProperty".into(), json!({"layer": layer.id.0, "animator": uid, "property": k}))
});
}
}
if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &opts, None)
&& let Some((id, params)) = acts.get(i).filter(|a| !a.0.is_empty())
{
actions.push((id.clone(), params.clone()));
}
}
fn kind_name(k: MatteKind) -> &'static str {
match k {
MatteKind::Alpha => "alpha",
@@ -1586,7 +1670,14 @@ fn value_editor(
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
}
Value::Enum(i) => {
if let ParamUi::Popup { options } = &prop.ui {
// Text Path Options ▸ Path lists the layer's masks.
let mask_opts: Option<Vec<String>> = layer
.props
.prop("text/pathOptions/path")
.filter(|q| q.uid == uid)
.map(|_| std::iter::once("None".to_string()).chain(layer.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone()))).collect());
let ui_opts = mask_opts.map(|options| ParamUi::Popup { options });
if let ParamUi::Popup { options } = ui_opts.as_ref().unwrap_or(&prop.ui) {
let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
let label = options.get(*i as usize).cloned().unwrap_or_default();
let pop = egui::Id::new(("epop", uid));