Skip to main content

dioxus_textfx/
lib.rs

1use dioxus::prelude::*;
2pub use dioxus_textfx_core::{
3    ReducedMotion, TextCfg, TextEase, TextEffect, TextFxChoreography, TextFxConfig,
4    TextFxDirection, TextFxEasing, TextFxEffect, TextFxGpuBudget, TextFxLayoutReserve,
5    TextFxLiveContrast, TextFxLoop, TextFxPerformanceProfile, TextFxPlayback, TextFxProfile,
6    TextFxRenderPreference, TextFxTiming, TextFxTrigger, TextProfile, TextSplit, TokenAction,
7    TokenMark, TokenTarget, fx, text_fx, textfx, timing,
8};
9
10pub use BlurReveal as Blur;
11pub use CountUpText as Count;
12pub use LocaleTransition as LocaleText;
13pub use ScrambleText as Scramble;
14pub use SplitText as Split;
15pub use StaggerText as Stagger;
16pub use TextFx as Text;
17pub use Typewriter as Type;
18
19pub mod prelude {
20    pub use dioxus_textfx_core::prelude::*;
21
22    pub use crate::{
23        Blur, BlurReveal, Count, CountUpText, LocaleText, LocaleTransition, ReducedMotion,
24        Scramble, ScrambleText, Split, SplitText, Stagger, StaggerText, Text, TextCfg, TextEase,
25        TextEffect, TextFx, TextFxConfig, TextFxEffect, TextFxProfile, TextFxRenderPreference,
26        TextFxTiming, TextFxTrigger, TextProfile, TextSplit, Type, Typewriter, fx, text_fx, textfx,
27        textfx_component_explain, textfx_component_manifest, textfx_native_integration_hints,
28        timing,
29    };
30}
31
32pub mod dx {
33    pub use crate::prelude::*;
34    pub use dioxus_motion_core::dx::DurationDx;
35
36    pub fn text(
37        id: impl Into<String>,
38        value: impl Into<String>,
39    ) -> dioxus_textfx_core::TextFxConfig {
40        dioxus_textfx_core::TextFxConfig::new(id, value)
41    }
42
43    pub fn text_id(id: impl Into<String>) -> dioxus_textfx_core::TextFxConfig {
44        dioxus_textfx_core::TextFxConfig::new(id, "")
45    }
46
47    pub fn timing() -> dioxus_textfx_core::TextFxTiming {
48        dioxus_textfx_core::TextFxTiming::default()
49    }
50}
51
52pub const TEXTFX_THEME_CHANGE_EVENT: &str = dioxus_theme_core::THEME_CHANGE_EVENT;
53
54pub fn textfx_component_manifest<'a>(
55    configs: impl IntoIterator<Item = &'a TextFxConfig>,
56    policy: &dioxus_textfx_core::TextFxRoutePolicy,
57) -> dioxus_textfx_core::TextFxManifestFragment {
58    dioxus_textfx_core::textfx_manifest_fragment(configs, policy)
59}
60
61pub fn textfx_component_explain<'a>(
62    configs: impl IntoIterator<Item = &'a TextFxConfig>,
63    policy: &dioxus_textfx_core::TextFxRoutePolicy,
64) -> dioxus_textfx_core::TextFxExplainReport {
65    dioxus_textfx_core::explain_textfx(configs, policy)
66}
67
68pub fn textfx_native_integration_hints<'a>(
69    configs: impl IntoIterator<Item = &'a TextFxConfig>,
70    policy: &dioxus_textfx_core::TextFxRoutePolicy,
71) -> std::collections::BTreeMap<String, String> {
72    let configs = configs.into_iter().collect::<Vec<_>>();
73    let mut hints = dioxus_textfx_core::textfx_native_port_hints(configs.iter().copied(), policy);
74    hints.insert(
75        "nativeActions".to_string(),
76        textfx_native_package_actions(policy.route.as_deref())
77            .len()
78            .to_string(),
79    );
80    hints.insert(
81        "nativePackage".to_string(),
82        textfx_native_compatibility_manifest().package,
83    );
84    hints
85}
86
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub struct TextFxThemeTokenInterop {
89    pub change_event: &'static str,
90    pub gradient_keys: [&'static str; 3],
91    pub gradient_tokens: [&'static str; 3],
92    pub text_token: &'static str,
93}
94
95pub const fn textfx_theme_token_interop() -> TextFxThemeTokenInterop {
96    TextFxThemeTokenInterop {
97        change_event: dioxus_theme_core::THEME_CHANGE_EVENT,
98        gradient_keys: ["accent", "text", "muted"],
99        gradient_tokens: [
100            dioxus_theme_core::THEME_TOKEN_ACCENT,
101            dioxus_theme_core::THEME_TOKEN_TEXT,
102            dioxus_theme_core::THEME_TOKEN_MUTED,
103        ],
104        text_token: dioxus_theme_core::THEME_TOKEN_TEXT,
105    }
106}
107
108pub fn textfx_theme_gradient_css_vars() -> [&'static str; 3] {
109    textfx_theme_token_interop().gradient_tokens
110}
111
112#[derive(Clone, Copy, Debug, Eq, PartialEq)]
113pub enum TextFxRuntimeMode {
114    BrowserRuntime,
115    StaticFallback,
116}
117
118pub fn textfx_runtime_mode() -> TextFxRuntimeMode {
119    if cfg!(all(feature = "web", target_arch = "wasm32")) {
120        TextFxRuntimeMode::BrowserRuntime
121    } else {
122        TextFxRuntimeMode::StaticFallback
123    }
124}
125
126pub fn textfx_native_fallback_config(
127    id: impl Into<String>,
128    text: impl Into<String>,
129) -> TextFxConfig {
130    TextFxConfig::new(id, text).with_reduced_motion(ReducedMotion::Static)
131}
132
133pub fn textfx_native_compatibility_manifest() -> dioxus_native_port::VisualCompatibilityManifest {
134    dioxus_native_port::native_port_visual_compatibility_manifest("dioxus-textfx")
135        .expect("dioxus-textfx visual compatibility manifest is registered")
136}
137
138#[derive(Clone, Copy, Debug, Eq, PartialEq)]
139pub enum TextFxNativeAction {
140    SplitTokens,
141    RunTimeline,
142    CountUp,
143    LocaleTransition,
144}
145
146impl TextFxNativeAction {
147    pub const fn as_str(self) -> &'static str {
148        match self {
149            Self::SplitTokens => "split-tokens",
150            Self::RunTimeline => "run-timeline",
151            Self::CountUp => "count-up",
152            Self::LocaleTransition => "locale-transition",
153        }
154    }
155
156    pub const fn label(self) -> &'static str {
157        match self {
158            Self::SplitTokens => "Split tokens",
159            Self::RunTimeline => "Run native timeline",
160            Self::CountUp => "Count up",
161            Self::LocaleTransition => "Locale transition",
162        }
163    }
164}
165
166pub fn textfx_native_package_actions(
167    route: Option<&str>,
168) -> Vec<dioxus_native_port::NativePackageAction> {
169    let route = route.map(str::to_string);
170    [
171        TextFxNativeAction::SplitTokens,
172        TextFxNativeAction::RunTimeline,
173        TextFxNativeAction::CountUp,
174        TextFxNativeAction::LocaleTransition,
175    ]
176    .into_iter()
177    .map(|action| {
178        let mut package_action = dioxus_native_port::NativePackageAction::new(
179            "dioxus-textfx",
180            action.as_str(),
181            action.label(),
182            dioxus_native_port::NativeActionKind::NativeAction,
183        )
184        .description("Runs the text effect through native Dioxus state/timeline data.");
185        if let Some(route) = route.clone() {
186            package_action = package_action.route(route);
187        }
188        package_action
189    })
190    .collect()
191}
192
193pub fn textfx_native_action(
194    config: &TextFxConfig,
195    action: TextFxNativeAction,
196) -> dioxus_native_port::NativeActionResult {
197    let tokens = split_textfx_tokens(&config.text, config.split);
198    let timeline_steps = match action {
199        TextFxNativeAction::SplitTokens => tokens.len().max(1),
200        TextFxNativeAction::RunTimeline => tokens.len().max(1) + 2,
201        TextFxNativeAction::CountUp => 8,
202        TextFxNativeAction::LocaleTransition => tokens.len().max(1) + 1,
203    };
204    let final_text = match action {
205        TextFxNativeAction::CountUp => config
206            .to
207            .map(|value| value.to_string())
208            .unwrap_or_else(|| config.text.clone()),
209        _ => config.text.clone(),
210    };
211    let worker_mode = if config.requires_workertown_render() {
212        "workertown-render"
213    } else {
214        "native-state"
215    };
216
217    dioxus_native_port::NativeActionResult::succeeded(
218        "dioxus-textfx",
219        action.as_str(),
220        dioxus_native_port::NativeActionKind::NativeAction,
221        format!(
222            "{} prepared for native renderer state updates",
223            action.label()
224        ),
225    )
226    .with_backend(worker_mode)
227    .with_output("effect", config.effect.as_attr())
228    .with_output("split", text_split_attr(config.split))
229    .with_output("tokenCount", tokens.len().to_string())
230    .with_output("timelineSteps", timeline_steps.to_string())
231    .with_output("durationMs", config.timing.duration_ms.to_string())
232    .with_output("finalText", final_text)
233}
234
235fn split_textfx_tokens(text: &str, split: TextSplit) -> Vec<String> {
236    match split {
237        TextSplit::None => vec![text.to_string()],
238        TextSplit::Chars => text.chars().map(|ch| ch.to_string()).collect(),
239        TextSplit::Words => text.split_whitespace().map(str::to_string).collect(),
240        TextSplit::Lines => text.lines().map(str::to_string).collect(),
241    }
242}
243
244fn text_split_attr(split: TextSplit) -> &'static str {
245    match split {
246        TextSplit::None => "none",
247        TextSplit::Chars => "chars",
248        TextSplit::Words => "words",
249        TextSplit::Lines => "lines",
250    }
251}
252
253#[derive(Props, Clone, PartialEq)]
254pub struct TextFxProps {
255    pub text: String,
256    #[props(default)]
257    pub effect: TextFxEffect,
258    #[props(default)]
259    pub timing: TextFxTiming,
260    #[props(default)]
261    pub split: TextSplit,
262    #[props(default)]
263    pub performance_profile: TextFxPerformanceProfile,
264    #[props(default)]
265    pub gpu_budget: TextFxGpuBudget,
266    #[props(default)]
267    pub layout_reserve: TextFxLayoutReserve,
268    #[props(default)]
269    pub fx: String,
270    #[props(default = "span".to_string())]
271    pub as_tag: String,
272    #[props(default)]
273    pub class: String,
274    #[props(default)]
275    pub id: String,
276}
277
278#[component]
279pub fn TextFx(props: TextFxProps) -> Element {
280    let id = textfx_id(&props.id, &props.text);
281    let config = if props.fx.trim().is_empty() {
282        let config = TextFxConfig::new(id, props.text.clone())
283            .with_performance_profile(props.performance_profile)
284            .with_gpu_budget(props.gpu_budget)
285            .with_layout_reserve(props.layout_reserve)
286            .with_effect(props.effect)
287            .with_timing(props.timing);
288        if props.split == TextSplit::None {
289            config
290        } else {
291            config.with_split(props.split)
292        }
293    } else {
294        TextFxConfig::from_fx(id, props.text.clone(), props.fx.clone())
295            .unwrap_or_else(|_| TextFxConfig::new("", props.text.clone()))
296    };
297    render_textfx_node(&props.as_tag, &props.class, &config.text, &config)
298}
299
300#[derive(Props, Clone, PartialEq)]
301pub struct SplitTextProps {
302    pub text: String,
303    #[props(default = TextSplit::Chars)]
304    pub by: TextSplit,
305    #[props(default = TextFxEffect::Stagger)]
306    pub effect: TextFxEffect,
307    #[props(default = 28)]
308    pub stagger_ms: u32,
309    #[props(default)]
310    pub layout_reserve: TextFxLayoutReserve,
311    #[props(default)]
312    pub class: String,
313}
314
315#[component]
316pub fn SplitText(props: SplitTextProps) -> Element {
317    let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
318        .with_effect(props.effect)
319        .with_split(props.by)
320        .with_layout_reserve(props.layout_reserve)
321        .with_stagger_ms(props.stagger_ms);
322    render_textfx_node("span", &props.class, &config.text, &config)
323}
324
325#[derive(Props, Clone, PartialEq)]
326pub struct TypewriterProps {
327    pub text: String,
328    #[props(default = 32)]
329    pub speed_ms: u32,
330    #[props(default = true)]
331    pub cursor: bool,
332    #[props(default)]
333    pub layout_reserve: TextFxLayoutReserve,
334    #[props(default)]
335    pub class: String,
336}
337
338#[component]
339pub fn Typewriter(props: TypewriterProps) -> Element {
340    let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
341        .with_effect(TextFxEffect::Typewriter)
342        .with_layout_reserve(props.layout_reserve)
343        .with_speed_ms(props.speed_ms)
344        .with_cursor(props.cursor);
345    render_textfx_node("span", &props.class, &config.text, &config)
346}
347
348#[derive(Props, Clone, PartialEq)]
349pub struct ScrambleTextProps {
350    pub text: String,
351    #[props(default = dioxus_textfx_core::DEFAULT_TEXTFX_CHARSET.to_string())]
352    pub charset: String,
353    #[props(default = 32)]
354    pub speed_ms: u32,
355    #[props(default = 520)]
356    pub settle_ms: u32,
357    #[props(default)]
358    pub layout_reserve: TextFxLayoutReserve,
359    #[props(default)]
360    pub class: String,
361}
362
363#[component]
364pub fn ScrambleText(props: ScrambleTextProps) -> Element {
365    let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
366        .with_effect(TextFxEffect::Scramble)
367        .with_layout_reserve(props.layout_reserve)
368        .with_charset(props.charset)
369        .with_speed_ms(props.speed_ms)
370        .with_duration_ms(props.settle_ms);
371    render_textfx_node("span", &props.class, &config.text, &config)
372}
373
374#[derive(Props, Clone, PartialEq)]
375pub struct BlurRevealProps {
376    pub text: String,
377    #[props(default = 640)]
378    pub duration_ms: u32,
379    #[props(default)]
380    pub easing: TextFxEasing,
381    #[props(default)]
382    pub layout_reserve: TextFxLayoutReserve,
383    #[props(default)]
384    pub class: String,
385}
386
387#[component]
388pub fn BlurReveal(props: BlurRevealProps) -> Element {
389    let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
390        .with_effect(TextFxEffect::BlurReveal)
391        .with_layout_reserve(props.layout_reserve)
392        .with_duration_ms(props.duration_ms)
393        .with_easing(props.easing);
394    render_textfx_node("span", &props.class, &config.text, &config)
395}
396
397#[derive(Props, Clone, PartialEq)]
398pub struct StaggerTextProps {
399    pub text: String,
400    #[props(default = TextSplit::Words)]
401    pub by: TextSplit,
402    #[props(default = 28)]
403    pub delay_ms: u32,
404    #[props(default)]
405    pub layout_reserve: TextFxLayoutReserve,
406    #[props(default)]
407    pub class: String,
408}
409
410#[component]
411pub fn StaggerText(props: StaggerTextProps) -> Element {
412    let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
413        .with_effect(TextFxEffect::Stagger)
414        .with_split(props.by)
415        .with_layout_reserve(props.layout_reserve)
416        .with_stagger_ms(props.delay_ms);
417    render_textfx_node("span", &props.class, &config.text, &config)
418}
419
420#[derive(Props, Clone, PartialEq)]
421pub struct CountUpTextProps {
422    pub from: f64,
423    pub to: f64,
424    #[props(default = 900)]
425    pub duration_ms: u32,
426    #[props(default)]
427    pub layout_reserve: TextFxLayoutReserve,
428    #[props(default)]
429    pub class: String,
430}
431
432#[component]
433pub fn CountUpText(props: CountUpTextProps) -> Element {
434    let text = format!("{}", props.to);
435    let config = TextFxConfig::new(textfx_id("", &text), text.clone())
436        .with_effect(TextFxEffect::CountUp)
437        .with_layout_reserve(props.layout_reserve)
438        .with_duration_ms(props.duration_ms)
439        .with_numbers(props.from, props.to);
440    render_textfx_node("span", &props.class, &text, &config)
441}
442
443#[derive(Props, Clone, PartialEq)]
444pub struct LocaleTransitionProps {
445    pub text: String,
446    pub key_name: String,
447    #[props(default = TextFxEffect::BlurReveal)]
448    pub effect: TextFxEffect,
449    #[props(default)]
450    pub timing: TextFxTiming,
451    #[props(default)]
452    pub split: TextSplit,
453    #[props(default)]
454    pub performance_profile: TextFxPerformanceProfile,
455    #[props(default)]
456    pub gpu_budget: TextFxGpuBudget,
457    #[props(default)]
458    pub layout_reserve: TextFxLayoutReserve,
459    #[props(default)]
460    pub fx: String,
461    #[props(default)]
462    pub class: String,
463    #[props(default)]
464    pub id: String,
465}
466
467#[component]
468pub fn LocaleTransition(props: LocaleTransitionProps) -> Element {
469    let class = join_class("dxt-textfx", &props.class);
470    let id = textfx_id(&props.id, &props.text);
471    let config = if props.fx.trim().is_empty() {
472        let config = TextFxConfig::new(id, props.text.clone())
473            .with_performance_profile(props.performance_profile)
474            .with_gpu_budget(props.gpu_budget)
475            .with_layout_reserve(props.layout_reserve)
476            .with_effect(props.effect)
477            .with_timing(props.timing);
478        if props.split == TextSplit::None {
479            config
480        } else {
481            config.with_split(props.split)
482        }
483    } else {
484        TextFxConfig::from_fx(id, props.text.clone(), props.fx.clone())
485            .unwrap_or_else(|_| TextFxConfig::new("", props.text.clone()).with_effect(props.effect))
486    };
487    let effect = config.effect.as_attr();
488    let locale_fx = config
489        .to_compact_json()
490        .unwrap_or_else(|_| "{}".to_string());
491    if config.reserves_layout() {
492        let layout_target = config.layout_reserve.as_attr();
493        rsx! {
494            span {
495                class: "{class}",
496                "data-dxr-i18n-key": "{props.key_name}",
497                "data-dxt-locale-fx": "{locale_fx}",
498                "data-dxr-text-layout-target": "{layout_target}",
499                aria_label: "{props.text}",
500                title: "{effect}",
501                "{props.text}"
502            }
503        }
504    } else {
505        rsx! {
506            span {
507                class: "{class}",
508                "data-dxr-i18n-key": "{props.key_name}",
509                "data-dxt-locale-fx": "{locale_fx}",
510                aria_label: "{props.text}",
511                title: "{effect}",
512                "{props.text}"
513            }
514        }
515    }
516}
517
518fn render_textfx_node(tag: &str, class: &str, text: &str, config: &TextFxConfig) -> Element {
519    let class = join_class(
520        if config.effect == TextFxEffect::LiveContrast {
521            "dxt-textfx dxt-live-contrast"
522        } else {
523            "dxt-textfx"
524        },
525        class,
526    );
527    let effect = config.effect.as_attr();
528    let layout_target = config.layout_reserve.as_attr();
529    let reserve_layout = config.reserves_layout();
530    match tag {
531        "h1" if reserve_layout => {
532            rsx! { h1 { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
533        }
534        "h1" => {
535            rsx! { h1 { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
536        }
537        "h2" if reserve_layout => {
538            rsx! { h2 { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
539        }
540        "h2" => {
541            rsx! { h2 { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
542        }
543        "h3" if reserve_layout => {
544            rsx! { h3 { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
545        }
546        "h3" => {
547            rsx! { h3 { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
548        }
549        "p" if reserve_layout => {
550            rsx! { p { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
551        }
552        "p" => {
553            rsx! { p { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
554        }
555        "strong" if reserve_layout => {
556            rsx! { strong { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
557        }
558        "strong" => {
559            rsx! { strong { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
560        }
561        _ if reserve_layout => {
562            rsx! { span { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
563        }
564        _ => {
565            rsx! { span { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
566        }
567    }
568}
569
570fn textfx_id(id: &str, text: &str) -> String {
571    if !id.trim().is_empty() {
572        return id.to_string();
573    }
574    let slug: String = text
575        .chars()
576        .filter_map(|ch| {
577            if ch.is_ascii_alphanumeric() {
578                Some(ch.to_ascii_lowercase())
579            } else if ch.is_whitespace() || ch == '-' {
580                Some('-')
581            } else {
582                None
583            }
584        })
585        .take(48)
586        .collect();
587    format!("textfx-{}", slug.trim_matches('-'))
588}
589
590fn join_class(base: &str, extra: &str) -> String {
591    if extra.trim().is_empty() {
592        base.to_string()
593    } else {
594        format!("{base} {}", extra.trim())
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    #[test]
603    fn native_fallback_config_is_static_semantic_text() {
604        let config = textfx_native_fallback_config("title", "Readable");
605
606        assert_eq!(textfx_runtime_mode(), TextFxRuntimeMode::StaticFallback);
607        assert_eq!(config.reduced_motion, ReducedMotion::Static);
608        assert_eq!(config.text, "Readable");
609    }
610
611    #[test]
612    fn dx_text_id_supports_deferred_content() {
613        let config = crate::dx::text_id("headline").content("Launch ready");
614
615        assert_eq!(config.id, "headline");
616        assert_eq!(config.text, "Launch ready");
617    }
618
619    #[test]
620    fn native_textfx_action_reports_tokens_and_timeline() {
621        let config = TextFxConfig::new("title", "Native text actions")
622            .with_effect(TextFxEffect::Typewriter)
623            .with_split(TextSplit::Words);
624
625        let result = textfx_native_action(&config, TextFxNativeAction::RunTimeline);
626
627        assert_eq!(
628            result.status,
629            dioxus_native_port::NativeActionStatus::Succeeded
630        );
631        assert_eq!(
632            result.kind,
633            dioxus_native_port::NativeActionKind::NativeAction
634        );
635        assert_eq!(
636            result.outputs.get("tokenCount").map(String::as_str),
637            Some("3")
638        );
639        assert_eq!(
640            result.outputs.get("timelineSteps").map(String::as_str),
641            Some("5")
642        );
643    }
644
645    #[test]
646    fn native_textfx_package_actions_are_route_scoped() {
647        let actions = textfx_native_package_actions(Some("/textfx"));
648        let manifest = textfx_native_compatibility_manifest();
649
650        assert_eq!(actions.len(), 4);
651        assert_eq!(manifest.package, "dioxus-textfx");
652        assert!(
653            actions
654                .iter()
655                .all(|action| action.route.as_deref() == Some("/textfx"))
656        );
657    }
658
659    #[test]
660    fn component_manifest_wraps_core_policy_and_native_hints() {
661        let config = TextFxConfig::new("headline", "Launch ready").scramble();
662        let policy = dioxus_textfx_core::textfx_route_policy()
663            .route("/textfx")
664            .tag("native");
665        let manifest = textfx_component_manifest([&config], &policy);
666        let explain = textfx_component_explain([&config], &policy);
667        let hints = textfx_native_integration_hints([&config], &policy);
668
669        assert_eq!(manifest.route.as_deref(), Some("/textfx"));
670        assert_eq!(explain.manifest.cache_key, manifest.cache_key);
671        assert_eq!(hints["nativePackage"], "dioxus-textfx");
672        assert!(hints["nativeActions"].parse::<usize>().unwrap() >= 1);
673    }
674
675    #[test]
676    fn theme_token_interop_metadata_uses_shared_contract() {
677        let interop = textfx_theme_token_interop();
678        assert_eq!(interop.change_event, "dioxus-theme:change");
679        assert_eq!(interop.gradient_keys, ["accent", "text", "muted"]);
680        assert_eq!(
681            interop.gradient_tokens,
682            [
683                dioxus_theme_core::THEME_TOKEN_ACCENT,
684                dioxus_theme_core::THEME_TOKEN_TEXT,
685                dioxus_theme_core::THEME_TOKEN_MUTED,
686            ]
687        );
688        assert_eq!(
689            textfx_theme_gradient_css_vars()[1],
690            dioxus_theme_core::THEME_TOKEN_TEXT
691        );
692    }
693
694    #[test]
695    fn dx_textfx_syntax_builds_short_effect_config() {
696        use crate::dx::DurationDx;
697
698        let config = crate::dx::text("headline", "Launch ready")
699            .scramble()
700            .dur(420.ms())
701            .stagger(18.ms())
702            .split_chars();
703
704        assert_eq!(config.id, "headline");
705        assert_eq!(config.text, "Launch ready");
706        assert_eq!(config.effect, TextFxEffect::Scramble);
707        assert_eq!(config.timing.duration_ms, 420);
708        assert_eq!(config.timing.stagger_ms, 18);
709        assert_eq!(config.split, TextSplit::Chars);
710    }
711}