mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
1 parent
c5cf189e1a
commit
fedb5cb715
10 files changed
+216
-48
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user