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}