diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 484a9a4f..d136cbcf 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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) } diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index ef2babf1..5ebf4fdd 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -17,6 +17,11 @@ fn presets() -> Vec { serde_json::from_str::(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)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index df684dd9..73d6faf6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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 { effectcraft_text::families().into_iter().map(|(f, _)| f).collect() } diff --git a/crates/engine/src/tests_text.rs b/crates/engine/src/tests_text.rs index d615ef2c..6af93eb2 100644 --- a/crates/engine/src/tests_text.rs +++ b/crates/engine/src/tests_text.rs @@ -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()); diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 5a2ee4c2..8ff451bd 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -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::>() }; - // 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("); diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 0505c32b..0d266d72 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -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 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 { - 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 diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 9be47e03..165ea298 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -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 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index efc9efbf..1490b3ad 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -379,6 +379,40 @@ pub fn menu_items(app: &EffectcraftApp) -> Vec { 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 { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 7e41c82d..cb963580 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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 = 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 = 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 = 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; } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a7ff2338..7c0b0f1f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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, f: &dyn Fn(&Property) -> boo fn push_group(rows: &mut Vec, l: &Layer, g: &PropGroup, depth: usize, open: &std::collections::BTreeSet) { 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) { } } +/// 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 = 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> = 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));