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}