Skip to main content

dioxus_textfx/
lib.rs

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