1use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::ops::RangeInclusive;
6
7pub const DEFAULT_TEXTFX_DURATION_MS: u32 = 640;
8pub const DEFAULT_TEXTFX_STAGGER_MS: u32 = 28;
9pub const DEFAULT_TEXTFX_SPEED_MS: u32 = 32;
10pub const DEFAULT_TEXTFX_CHARSET: &str =
11 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
12const BLUR_REVEAL_ATTR: &str = concat!("blur", "-reveal");
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum TextFxEffect {
17 Fade,
18 Slide,
19 BlurReveal,
20 Scale,
21 Typewriter,
22 Scramble,
23 Stagger,
24 CountUp,
25 Wave,
26 Flip,
27 MaskReveal,
28 Glitch,
29 HighlightSweep,
30 GradientShift,
31 KerningExpand,
32 NumberTicker,
33 LiveContrast,
34}
35
36impl Default for TextFxEffect {
37 fn default() -> Self {
38 Self::BlurReveal
39 }
40}
41
42impl TextFxEffect {
43 pub const ALL: [Self; 17] = [
44 Self::Fade,
45 Self::Slide,
46 Self::BlurReveal,
47 Self::Scale,
48 Self::Typewriter,
49 Self::Scramble,
50 Self::Stagger,
51 Self::CountUp,
52 Self::Wave,
53 Self::Flip,
54 Self::MaskReveal,
55 Self::Glitch,
56 Self::HighlightSweep,
57 Self::GradientShift,
58 Self::KerningExpand,
59 Self::NumberTicker,
60 Self::LiveContrast,
61 ];
62
63 pub fn as_attr(self) -> &'static str {
64 match self {
65 Self::Fade => "fade",
66 Self::Slide => "slide",
67 Self::BlurReveal => BLUR_REVEAL_ATTR,
68 Self::Scale => "scale",
69 Self::Typewriter => "typewriter",
70 Self::Scramble => "scramble",
71 Self::Stagger => "stagger",
72 Self::CountUp => "count-up",
73 Self::Wave => "wave",
74 Self::Flip => "flip",
75 Self::MaskReveal => "mask-reveal",
76 Self::Glitch => "glitch",
77 Self::HighlightSweep => "highlight-sweep",
78 Self::GradientShift => "gradient-shift",
79 Self::KerningExpand => "kerning-expand",
80 Self::NumberTicker => "number-ticker",
81 Self::LiveContrast => "live-contrast",
82 }
83 }
84
85 pub fn compact_id(self) -> &'static str {
86 match self {
87 Self::Fade => "f",
88 Self::Slide => "sl",
89 Self::BlurReveal => "br",
90 Self::Scale => "sc",
91 Self::Typewriter => "tw",
92 Self::Scramble => "sr",
93 Self::Stagger => "st",
94 Self::CountUp => "cu",
95 Self::Wave => "wv",
96 Self::Flip => "fl",
97 Self::MaskReveal => "mr",
98 Self::Glitch => "gl",
99 Self::HighlightSweep => "hs",
100 Self::GradientShift => "gs",
101 Self::KerningExpand => "ke",
102 Self::NumberTicker => "nt",
103 Self::LiveContrast => "lc",
104 }
105 }
106
107 pub fn label(self) -> &'static str {
108 match self {
109 Self::Fade => "Fade",
110 Self::Slide => "Slide",
111 Self::BlurReveal => "Blur Reveal",
112 Self::Scale => "Scale",
113 Self::Typewriter => "Typewriter",
114 Self::Scramble => "Scramble",
115 Self::Stagger => "Stagger",
116 Self::CountUp => "Count Up",
117 Self::Wave => "Wave",
118 Self::Flip => "Flip",
119 Self::MaskReveal => "Mask Reveal",
120 Self::Glitch => "Glitch",
121 Self::HighlightSweep => "Highlight Sweep",
122 Self::GradientShift => "Gradient Shift",
123 Self::KerningExpand => "Kerning Expand",
124 Self::NumberTicker => "Number Ticker",
125 Self::LiveContrast => "Live Contrast",
126 }
127 }
128
129 pub fn needs_split(self) -> bool {
130 matches!(
131 self,
132 Self::Stagger | Self::Wave | Self::Flip | Self::Glitch | Self::KerningExpand
133 )
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "kebab-case")]
139pub enum TextFxLiveContrast {
140 Difference,
141 Exclusion,
142 Plus,
143}
144
145impl Default for TextFxLiveContrast {
146 fn default() -> Self {
147 Self::Difference
148 }
149}
150
151impl TextFxLiveContrast {
152 pub fn as_attr(self) -> &'static str {
153 match self {
154 Self::Difference => "difference",
155 Self::Exclusion => "exclusion",
156 Self::Plus => "plus",
157 }
158 }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
162#[serde(rename_all = "kebab-case")]
163pub enum TextFxEasing {
164 Linear,
165 EaseIn,
166 EaseOut,
167 EaseInOut,
168 Spring,
169 CubicBezier(f32, f32, f32, f32),
170}
171
172impl Default for TextFxEasing {
173 fn default() -> Self {
174 Self::EaseOut
175 }
176}
177
178impl TextFxEasing {
179 pub fn css_value(self) -> String {
180 match self {
181 Self::Linear => "linear".to_string(),
182 Self::EaseIn => "cubic-bezier(.42,0,1,1)".to_string(),
183 Self::EaseOut => "cubic-bezier(0,0,.2,1)".to_string(),
184 Self::EaseInOut => "cubic-bezier(.42,0,.58,1)".to_string(),
185 Self::Spring => "cubic-bezier(.18,.89,.32,1.28)".to_string(),
186 Self::CubicBezier(a, b, c, d) => format!("cubic-bezier({a},{b},{c},{d})"),
187 }
188 }
189
190 #[cfg(feature = "viewtx-interop")]
191 pub fn from_viewtx_easing(easing: &str) -> Self {
192 match easing.trim().to_ascii_lowercase().as_str() {
193 "linear" => Self::Linear,
194 "ease-in" => Self::EaseIn,
195 "ease-out" => Self::EaseOut,
196 "ease" | "ease-in-out" => Self::EaseInOut,
197 "spring" => Self::Spring,
198 _ => dioxus_viewtx_core::parse_viewtx_cubic_bezier(easing)
199 .map(|(a, b, c, d)| Self::CubicBezier(a, b, c, d))
200 .unwrap_or(Self::EaseOut),
201 }
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206#[serde(tag = "kind", rename_all = "kebab-case")]
207pub enum TextFxTrigger {
208 Load,
209 Visible,
210 Interaction,
211 Manual,
212 Hover,
213 Click,
214 Focus,
215 Blur,
216 WordHover,
217 WordClick,
218 SelectorClick { selector: String },
219 SelectorHover { selector: String },
220 Event { name: String },
221 Cascade { name: String },
222}
223
224impl Default for TextFxTrigger {
225 fn default() -> Self {
226 Self::Visible
227 }
228}
229
230impl TextFxTrigger {
231 pub fn resume_attr(&self) -> Option<String> {
232 match self {
233 Self::Load => Some(r#"data-dxr-on-load="textfx.run""#.to_string()),
234 Self::Hover | Self::WordHover => {
235 Some(r#"data-dxr-on-pointerover="textfx.run""#.to_string())
236 }
237 Self::Click | Self::Interaction | Self::WordClick => {
238 Some(r#"data-dxr-on-click="textfx.run""#.to_string())
239 }
240 Self::Manual => None,
241 _ => Some(r#"data-dxr-on-visible="textfx.run""#.to_string()),
242 }
243 }
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "kebab-case")]
248pub enum TextFxLoop {
249 Once,
250 Infinite,
251 Count(u16),
252}
253
254impl Default for TextFxLoop {
255 fn default() -> Self {
256 Self::Once
257 }
258}
259
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub struct TextFxPlayback {
263 pub loop_mode: TextFxLoop,
264 pub reverse: bool,
265 pub alternate: bool,
266 pub yoyo: bool,
267 pub repeat_delay_ms: u32,
268}
269
270impl Default for TextFxPlayback {
271 fn default() -> Self {
272 Self {
273 loop_mode: TextFxLoop::Once,
274 reverse: false,
275 alternate: false,
276 yoyo: false,
277 repeat_delay_ms: 0,
278 }
279 }
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "kebab-case")]
284pub enum TextSplit {
285 None,
286 Chars,
287 Words,
288 Lines,
289}
290
291impl Default for TextSplit {
292 fn default() -> Self {
293 Self::None
294 }
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
298#[serde(rename_all = "kebab-case")]
299pub enum ReducedMotion {
300 Static,
301 FadeOnly,
302 Ignore,
303}
304
305impl Default for ReducedMotion {
306 fn default() -> Self {
307 Self::FadeOnly
308 }
309}
310
311impl ReducedMotion {
312 #[cfg(feature = "viewtx-interop")]
313 pub fn from_viewtx_reduced_motion(
314 reduced_motion: dioxus_viewtx_core::ViewTransitionReducedMotion,
315 ) -> Self {
316 match reduced_motion {
317 dioxus_viewtx_core::ViewTransitionReducedMotion::Disable => Self::Static,
318 dioxus_viewtx_core::ViewTransitionReducedMotion::FadeOnly => Self::FadeOnly,
319 dioxus_viewtx_core::ViewTransitionReducedMotion::Ignore => Self::Ignore,
320 }
321 }
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "kebab-case")]
326pub enum TextFxDirection {
327 Up,
328 Right,
329 Down,
330 Left,
331}
332
333impl Default for TextFxDirection {
334 fn default() -> Self {
335 Self::Up
336 }
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(rename_all = "camelCase")]
341pub struct TokenMark {
342 pub name: String,
343 pub text: String,
344 pub char_start: usize,
345 pub char_end: usize,
346 pub word_start: usize,
347 pub word_end: usize,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
351#[serde(tag = "kind", rename_all = "kebab-case")]
352pub enum TokenTarget {
353 All,
354 Others,
355 Mark { name: String },
356 Word { index: usize },
357 WordRange { start: usize, end: usize },
358 CharRange { start: usize, end: usize },
359 WordText { value: String },
360 Contains { value: String },
361}
362
363impl TokenTarget {
364 pub fn all() -> Self {
365 Self::All
366 }
367
368 pub fn others() -> Self {
369 Self::Others
370 }
371
372 pub fn mark(name: impl Into<String>) -> Self {
373 Self::Mark { name: name.into() }
374 }
375
376 pub fn word(index: usize) -> Self {
377 Self::Word { index }
378 }
379
380 pub fn word_range(range: RangeInclusive<usize>) -> Self {
381 Self::WordRange {
382 start: *range.start(),
383 end: *range.end(),
384 }
385 }
386
387 pub fn char_range(range: RangeInclusive<usize>) -> Self {
388 Self::CharRange {
389 start: *range.start(),
390 end: *range.end(),
391 }
392 }
393
394 pub fn word_text(value: impl Into<String>) -> Self {
395 Self::WordText {
396 value: value.into(),
397 }
398 }
399
400 pub fn contains(value: impl Into<String>) -> Self {
401 Self::Contains {
402 value: value.into(),
403 }
404 }
405}
406
407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
408#[serde(rename_all = "camelCase")]
409pub struct TokenAction {
410 pub stay: bool,
411 pub scale: Option<f32>,
412 pub slide_away: Option<TextFxDirection>,
413 pub opacity: Option<f32>,
414 pub highlight: bool,
415 pub underline_sweep: bool,
416 pub swap: Option<String>,
417 pub scramble_to: Option<String>,
418 pub blur: bool,
419 pub color: Option<String>,
420 pub delay_ms: Option<u32>,
421 pub stagger_ms: Option<u32>,
422 pub live_contrast: Option<TextFxLiveContrast>,
423}
424
425impl Default for TokenAction {
426 fn default() -> Self {
427 Self {
428 stay: false,
429 scale: None,
430 slide_away: None,
431 opacity: None,
432 highlight: false,
433 underline_sweep: false,
434 swap: None,
435 scramble_to: None,
436 blur: false,
437 color: None,
438 delay_ms: None,
439 stagger_ms: None,
440 live_contrast: None,
441 }
442 }
443}
444
445impl TokenAction {
446 pub fn stay(mut self) -> Self {
447 self.stay = true;
448 self
449 }
450
451 pub fn scale(value: f32) -> Self {
452 Self {
453 scale: Some(value),
454 ..Self::default()
455 }
456 }
457
458 pub fn slide_away(direction: TextFxDirection) -> Self {
459 Self {
460 slide_away: Some(direction),
461 ..Self::default()
462 }
463 }
464
465 pub fn highlight() -> Self {
466 Self {
467 highlight: true,
468 ..Self::default()
469 }
470 }
471
472 pub fn swap(value: impl Into<String>) -> Self {
473 Self {
474 swap: Some(value.into()),
475 ..Self::default()
476 }
477 }
478
479 pub fn live_contrast() -> Self {
480 Self::live_contrast_mode(TextFxLiveContrast::Difference)
481 }
482
483 pub fn live_contrast_mode(mode: TextFxLiveContrast) -> Self {
484 Self {
485 live_contrast: Some(mode),
486 ..Self::default()
487 }
488 }
489
490 pub fn merge(mut self, other: Self) -> Self {
491 self.stay |= other.stay;
492 self.highlight |= other.highlight;
493 self.underline_sweep |= other.underline_sweep;
494 self.blur |= other.blur;
495 self.scale = other.scale.or(self.scale);
496 self.slide_away = other.slide_away.or(self.slide_away);
497 self.opacity = other.opacity.or(self.opacity);
498 self.swap = other.swap.or(self.swap);
499 self.scramble_to = other.scramble_to.or(self.scramble_to);
500 self.color = other.color.or(self.color);
501 self.delay_ms = other.delay_ms.or(self.delay_ms);
502 self.stagger_ms = other.stagger_ms.or(self.stagger_ms);
503 self.live_contrast = other.live_contrast.or(self.live_contrast);
504 self
505 }
506}
507
508#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
509#[serde(rename_all = "camelCase")]
510pub struct TextFxChoreography {
511 pub target: TokenTarget,
512 pub action: TokenAction,
513}
514
515#[derive(Debug, Clone, PartialEq, Eq)]
516pub struct TextFxParseError {
517 message: String,
518}
519
520impl TextFxParseError {
521 fn new(message: impl Into<String>) -> Self {
522 Self {
523 message: message.into(),
524 }
525 }
526}
527
528impl fmt::Display for TextFxParseError {
529 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
530 self.message.fmt(f)
531 }
532}
533
534impl std::error::Error for TextFxParseError {}
535
536#[derive(Debug, Clone, PartialEq, Eq)]
537pub struct MarkedText {
538 pub clean_text: String,
539 pub marks: Vec<TokenMark>,
540}
541
542#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
543#[serde(rename_all = "camelCase")]
544pub struct TextFxTiming {
545 pub duration_ms: u32,
546 pub delay_ms: u32,
547 pub speed_ms: u32,
548 pub stagger_ms: u32,
549 pub easing: TextFxEasing,
550}
551
552impl Default for TextFxTiming {
553 fn default() -> Self {
554 Self {
555 duration_ms: DEFAULT_TEXTFX_DURATION_MS,
556 delay_ms: 0,
557 speed_ms: DEFAULT_TEXTFX_SPEED_MS,
558 stagger_ms: DEFAULT_TEXTFX_STAGGER_MS,
559 easing: TextFxEasing::EaseOut,
560 }
561 }
562}
563
564#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
565#[serde(rename_all = "camelCase")]
566pub struct TextFxPhaseTiming {
567 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub duration_ms: Option<u32>,
569 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub delay_ms: Option<u32>,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
572 pub speed_ms: Option<u32>,
573 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub stagger_ms: Option<u32>,
575 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub easing: Option<TextFxEasing>,
577}
578
579impl TextFxPhaseTiming {
580 pub fn is_empty(&self) -> bool {
581 self.duration_ms.is_none()
582 && self.delay_ms.is_none()
583 && self.speed_ms.is_none()
584 && self.stagger_ms.is_none()
585 && self.easing.is_none()
586 }
587}
588
589#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
590#[serde(rename_all = "camelCase")]
591pub struct TextFxPhase {
592 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub effect: Option<TextFxEffect>,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub timing: Option<TextFxPhaseTiming>,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
597 pub split: Option<TextSplit>,
598 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub direction: Option<TextFxDirection>,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
601 pub playback: Option<TextFxPlayback>,
602}
603
604impl TextFxPhase {
605 pub fn new() -> Self {
606 Self::default()
607 }
608
609 pub fn reverse_of_enter() -> Self {
610 let playback = TextFxPlayback {
611 reverse: true,
612 ..TextFxPlayback::default()
613 };
614 Self {
615 playback: Some(playback),
616 ..Self::default()
617 }
618 }
619
620 pub fn is_empty(&self) -> bool {
621 self.effect.is_none()
622 && self
623 .timing
624 .as_ref()
625 .map_or(true, TextFxPhaseTiming::is_empty)
626 && self.split.is_none()
627 && self.direction.is_none()
628 && self.playback.is_none()
629 }
630
631 fn timing_mut(&mut self) -> &mut TextFxPhaseTiming {
632 self.timing.get_or_insert_with(TextFxPhaseTiming::default)
633 }
634}
635
636#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
637#[serde(rename_all = "camelCase")]
638pub struct TextFxLifecycle {
639 #[serde(default, skip_serializing_if = "Option::is_none")]
640 pub enter: Option<TextFxPhase>,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub exit: Option<TextFxPhase>,
643}
644
645impl TextFxLifecycle {
646 pub fn is_empty(&self) -> bool {
647 self.enter.as_ref().map_or(true, TextFxPhase::is_empty)
648 && self.exit.as_ref().map_or(true, TextFxPhase::is_empty)
649 }
650}
651
652#[derive(Debug, Clone, Copy, PartialEq, Eq)]
653enum TextFxPhaseKind {
654 Enter,
655 Exit,
656}
657
658#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
659#[serde(rename_all = "kebab-case")]
660pub enum TextFxProfile {
661 Lighthouse,
662 Showcase,
663 Interactive,
664}
665
666#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
667#[serde(rename_all = "kebab-case")]
668pub enum TextFxPerformanceProfile {
669 CssFirst,
670 Balanced,
671 VisualExact,
672}
673
674#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
675#[serde(rename_all = "kebab-case")]
676pub enum TextFxGpuBudget {
677 Auto,
678 LowPower,
679 Normal,
680 Exact,
681}
682
683impl Default for TextFxGpuBudget {
684 fn default() -> Self {
685 Self::Auto
686 }
687}
688
689impl TextFxGpuBudget {
690 pub fn as_attr(self) -> &'static str {
691 match self {
692 Self::Auto => "auto",
693 Self::LowPower => "low-power",
694 Self::Normal => "normal",
695 Self::Exact => "exact",
696 }
697 }
698
699 pub fn compact_id(self) -> &'static str {
700 match self {
701 Self::Auto => "a",
702 Self::LowPower => "l",
703 Self::Normal => "n",
704 Self::Exact => "x",
705 }
706 }
707}
708
709#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
710#[serde(rename_all = "kebab-case")]
711pub enum TextFxRenderPreference {
712 #[default]
713 Auto,
714 CssFirst,
715 #[serde(rename = "workertown-render")]
716 WorkerTownRender,
717 MainThreadFallback,
718}
719
720impl TextFxRenderPreference {
721 pub fn as_attr(self) -> &'static str {
722 match self {
723 Self::Auto => "auto",
724 Self::CssFirst => "css-first",
725 Self::WorkerTownRender => "workertown-render",
726 Self::MainThreadFallback => "main-thread-fallback",
727 }
728 }
729}
730
731#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
732#[serde(rename_all = "kebab-case")]
733pub enum TextFxLayoutReserve {
734 Off,
735 Auto,
736 Exact,
737}
738
739impl Default for TextFxLayoutReserve {
740 fn default() -> Self {
741 Self::Auto
742 }
743}
744
745impl TextFxLayoutReserve {
746 pub fn as_attr(self) -> &'static str {
747 match self {
748 Self::Off => "off",
749 Self::Auto => "auto",
750 Self::Exact => "exact",
751 }
752 }
753}
754
755impl Default for TextFxPerformanceProfile {
756 fn default() -> Self {
757 Self::CssFirst
758 }
759}
760
761impl TextFxPerformanceProfile {
762 pub fn as_attr(self) -> &'static str {
763 match self {
764 Self::CssFirst => "css-first",
765 Self::Balanced => "balanced",
766 Self::VisualExact => "visual-exact",
767 }
768 }
769
770 pub fn compact_id(self) -> &'static str {
771 match self {
772 Self::CssFirst => "css",
773 Self::Balanced => "bal",
774 Self::VisualExact => "exact",
775 }
776 }
777}
778
779impl TextFxProfile {
780 pub fn as_attr(self) -> &'static str {
781 match self {
782 Self::Lighthouse => "lighthouse",
783 Self::Showcase => "showcase",
784 Self::Interactive => "interactive",
785 }
786 }
787
788 pub fn timing(self) -> TextFxTiming {
789 match self {
790 Self::Lighthouse => TextFxTiming {
791 duration_ms: 360,
792 delay_ms: 0,
793 speed_ms: 24,
794 stagger_ms: 10,
795 easing: TextFxEasing::EaseOut,
796 },
797 Self::Showcase => TextFxTiming {
798 duration_ms: 760,
799 delay_ms: 0,
800 speed_ms: 32,
801 stagger_ms: 32,
802 easing: TextFxEasing::Spring,
803 },
804 Self::Interactive => TextFxTiming {
805 duration_ms: 520,
806 delay_ms: 0,
807 speed_ms: 24,
808 stagger_ms: 18,
809 easing: TextFxEasing::EaseOut,
810 },
811 }
812 }
813
814 pub fn reduced_motion(self) -> ReducedMotion {
815 match self {
816 Self::Lighthouse => ReducedMotion::Static,
817 Self::Showcase | Self::Interactive => ReducedMotion::FadeOnly,
818 }
819 }
820
821 pub fn trigger(self) -> TextFxTrigger {
822 match self {
823 Self::Lighthouse => TextFxTrigger::Load,
824 Self::Showcase => TextFxTrigger::Visible,
825 Self::Interactive => TextFxTrigger::Interaction,
826 }
827 }
828
829 pub fn prefers_css_first(self) -> bool {
830 matches!(self, Self::Lighthouse | Self::Showcase)
831 }
832
833 pub fn performance_profile(self) -> TextFxPerformanceProfile {
834 match self {
835 Self::Lighthouse => TextFxPerformanceProfile::CssFirst,
836 Self::Showcase => TextFxPerformanceProfile::VisualExact,
837 Self::Interactive => TextFxPerformanceProfile::Balanced,
838 }
839 }
840
841 pub fn gpu_budget(self) -> TextFxGpuBudget {
842 match self {
843 Self::Lighthouse | Self::Interactive => TextFxGpuBudget::Auto,
844 Self::Showcase => TextFxGpuBudget::Exact,
845 }
846 }
847}
848
849#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
850#[serde(rename_all = "camelCase")]
851pub struct TextFxConfig {
852 pub id: String,
853 pub text: String,
854 pub effect: TextFxEffect,
855 pub timing: TextFxTiming,
856 pub split: TextSplit,
857 pub reduced_motion: ReducedMotion,
858 pub performance_profile: TextFxPerformanceProfile,
859 pub gpu_budget: TextFxGpuBudget,
860 #[serde(default)]
861 pub render_preference: TextFxRenderPreference,
862 #[serde(default)]
863 pub layout_reserve: TextFxLayoutReserve,
864 pub trigger: TextFxTrigger,
865 pub direction: TextFxDirection,
866 pub playback: TextFxPlayback,
867 pub intensity: f32,
868 pub palette: Vec<String>,
869 pub charset: String,
870 pub cursor: bool,
871 pub from: Option<f64>,
872 pub to: Option<f64>,
873 pub fx: Option<String>,
874 #[serde(default, skip_serializing_if = "TextFxLifecycle::is_empty")]
875 pub lifecycle: TextFxLifecycle,
876 pub marks: Vec<TokenMark>,
877 pub choreography: Vec<TextFxChoreography>,
878}
879
880impl TextFxConfig {
881 pub fn new(id: impl Into<String>, text: impl Into<String>) -> Self {
882 let marked = parse_inline_marks(&text.into());
883 Self {
884 id: id.into(),
885 text: marked.clean_text,
886 effect: TextFxEffect::default(),
887 timing: TextFxTiming::default(),
888 split: TextSplit::None,
889 reduced_motion: ReducedMotion::default(),
890 performance_profile: TextFxPerformanceProfile::default(),
891 gpu_budget: TextFxGpuBudget::default(),
892 render_preference: TextFxRenderPreference::default(),
893 layout_reserve: TextFxLayoutReserve::default(),
894 trigger: TextFxTrigger::default(),
895 direction: TextFxDirection::default(),
896 playback: TextFxPlayback::default(),
897 intensity: 1.0,
898 palette: vec![
899 "#ff7a1a".to_string(),
900 "#ffffff".to_string(),
901 "#9fb7ff".to_string(),
902 ],
903 charset: DEFAULT_TEXTFX_CHARSET.to_string(),
904 cursor: true,
905 from: None,
906 to: None,
907 fx: None,
908 lifecycle: TextFxLifecycle::default(),
909 marks: marked.marks,
910 choreography: Vec::new(),
911 }
912 }
913
914 pub fn from_fx(
915 id: impl Into<String>,
916 text: impl Into<String>,
917 fx: impl Into<String>,
918 ) -> Result<Self, TextFxParseError> {
919 let fx = fx.into();
920 let mut config = Self::new(id, text);
921 config.fx = Some(fx.clone());
922 parse_fx_tokens(&mut config, &fx)?;
923 Ok(config)
924 }
925
926 pub fn profile(id: impl Into<String>, text: impl Into<String>, profile: TextFxProfile) -> Self {
927 Self::new(id, text).with_profile(profile)
928 }
929
930 pub fn with_profile(mut self, profile: TextFxProfile) -> Self {
931 self.timing = profile.timing();
932 self.reduced_motion = profile.reduced_motion();
933 self.performance_profile = profile.performance_profile();
934 self.gpu_budget = profile.gpu_budget();
935 self.trigger = profile.trigger();
936 if profile.prefers_css_first() && !self.effect.needs_split() {
937 self.split = TextSplit::None;
938 }
939 self
940 }
941
942 pub fn with_effect(mut self, effect: TextFxEffect) -> Self {
943 self.effect = effect;
944 if effect.needs_split() && self.split == TextSplit::None {
945 self.split = TextSplit::Chars;
946 self.promote_for_runtime_text_motion();
947 }
948 if matches!(effect, TextFxEffect::CountUp | TextFxEffect::NumberTicker) {
949 self.split = TextSplit::None;
950 }
951 self
952 }
953
954 pub fn with_timing(mut self, timing: TextFxTiming) -> Self {
955 self.timing = timing;
956 self
957 }
958
959 pub fn with_duration_ms(mut self, duration_ms: u32) -> Self {
960 self.timing.duration_ms = duration_ms;
961 self
962 }
963
964 pub fn with_delay_ms(mut self, delay_ms: u32) -> Self {
965 self.timing.delay_ms = delay_ms;
966 self
967 }
968
969 pub fn with_speed_ms(mut self, speed_ms: u32) -> Self {
970 self.timing.speed_ms = speed_ms.max(1);
971 self
972 }
973
974 pub fn with_stagger_ms(mut self, stagger_ms: u32) -> Self {
975 self.timing.stagger_ms = stagger_ms;
976 self
977 }
978
979 pub fn with_enter_effect(mut self, effect: TextFxEffect) -> Self {
980 self.apply_phase_effect(TextFxPhaseKind::Enter, effect);
981 self
982 }
983
984 pub fn with_enter_delay_ms(mut self, delay_ms: u32) -> Self {
985 self.enter_phase_mut().timing_mut().delay_ms = Some(delay_ms);
986 self
987 }
988
989 pub fn with_enter_duration_ms(mut self, duration_ms: u32) -> Self {
990 self.enter_phase_mut().timing_mut().duration_ms = Some(duration_ms);
991 self
992 }
993
994 pub fn with_enter_stagger_ms(mut self, stagger_ms: u32) -> Self {
995 self.enter_phase_mut().timing_mut().stagger_ms = Some(stagger_ms);
996 self
997 }
998
999 pub fn with_exit_effect(mut self, effect: TextFxEffect) -> Self {
1000 self.apply_phase_effect(TextFxPhaseKind::Exit, effect);
1001 self
1002 }
1003
1004 pub fn with_exit_delay_ms(mut self, delay_ms: u32) -> Self {
1005 self.exit_phase_mut().timing_mut().delay_ms = Some(delay_ms);
1006 self
1007 }
1008
1009 pub fn with_exit_duration_ms(mut self, duration_ms: u32) -> Self {
1010 self.exit_phase_mut().timing_mut().duration_ms = Some(duration_ms);
1011 self
1012 }
1013
1014 pub fn with_exit_stagger_ms(mut self, stagger_ms: u32) -> Self {
1015 self.exit_phase_mut().timing_mut().stagger_ms = Some(stagger_ms);
1016 self
1017 }
1018
1019 pub fn with_exit_reverse_of_enter(mut self) -> Self {
1020 let phase = self.exit_phase_mut();
1021 let playback = TextFxPlayback {
1022 reverse: true,
1023 ..phase.playback.clone().unwrap_or_default()
1024 };
1025 phase.playback = Some(playback);
1026 self
1027 }
1028
1029 pub fn with_easing(mut self, easing: TextFxEasing) -> Self {
1030 self.timing.easing = easing;
1031 self
1032 }
1033
1034 #[cfg(feature = "viewtx-interop")]
1035 pub fn with_viewtx_motion_policy(
1036 mut self,
1037 policy: &dioxus_viewtx_core::ViewMotionPolicy,
1038 ) -> Self {
1039 self.timing.duration_ms = policy.duration_ms;
1040 self.timing.easing = TextFxEasing::from_viewtx_easing(&policy.easing);
1041 self.reduced_motion = ReducedMotion::from_viewtx_reduced_motion(policy.reduced_motion);
1042 self
1043 }
1044
1045 pub fn with_split(mut self, split: TextSplit) -> Self {
1046 self.split = split;
1047 if split != TextSplit::None {
1048 self.promote_for_runtime_text_motion();
1049 }
1050 self
1051 }
1052
1053 pub fn with_performance_profile(mut self, profile: TextFxPerformanceProfile) -> Self {
1054 self.performance_profile = profile;
1055 self
1056 }
1057
1058 pub fn with_gpu_budget(mut self, budget: TextFxGpuBudget) -> Self {
1059 self.gpu_budget = budget;
1060 self
1061 }
1062
1063 pub fn with_render_preference(mut self, preference: TextFxRenderPreference) -> Self {
1064 self.render_preference = preference;
1065 if matches!(preference, TextFxRenderPreference::WorkerTownRender) {
1066 self.performance_profile = TextFxPerformanceProfile::VisualExact;
1067 self.gpu_budget = TextFxGpuBudget::Exact;
1068 }
1069 self
1070 }
1071
1072 pub fn with_layout_reserve(mut self, reserve: TextFxLayoutReserve) -> Self {
1073 self.layout_reserve = reserve;
1074 self
1075 }
1076
1077 pub fn css_first(self) -> Self {
1078 self.with_performance_profile(TextFxPerformanceProfile::CssFirst)
1079 }
1080
1081 pub fn balanced(self) -> Self {
1082 self.with_performance_profile(TextFxPerformanceProfile::Balanced)
1083 }
1084
1085 pub fn visual_exact(self) -> Self {
1086 self.with_performance_profile(TextFxPerformanceProfile::VisualExact)
1087 }
1088
1089 pub fn gpu_auto(self) -> Self {
1090 self.with_gpu_budget(TextFxGpuBudget::Auto)
1091 }
1092
1093 pub fn gpu_low_power(self) -> Self {
1094 self.with_gpu_budget(TextFxGpuBudget::LowPower)
1095 }
1096
1097 pub fn gpu_normal(self) -> Self {
1098 self.with_gpu_budget(TextFxGpuBudget::Normal)
1099 }
1100
1101 pub fn gpu_exact(self) -> Self {
1102 self.with_gpu_budget(TextFxGpuBudget::Exact)
1103 }
1104
1105 pub fn workertown_render(self) -> Self {
1106 self.with_render_preference(TextFxRenderPreference::WorkerTownRender)
1107 }
1108
1109 pub fn layout_reserve_off(self) -> Self {
1110 self.with_layout_reserve(TextFxLayoutReserve::Off)
1111 }
1112
1113 pub fn layout_reserve_auto(self) -> Self {
1114 self.with_layout_reserve(TextFxLayoutReserve::Auto)
1115 }
1116
1117 pub fn layout_reserve_exact(self) -> Self {
1118 self.with_layout_reserve(TextFxLayoutReserve::Exact)
1119 }
1120
1121 fn promote_for_runtime_text_motion(&mut self) {
1122 if self.performance_profile == TextFxPerformanceProfile::CssFirst {
1123 self.performance_profile = TextFxPerformanceProfile::Balanced;
1124 }
1125 }
1126
1127 fn enter_phase_mut(&mut self) -> &mut TextFxPhase {
1128 self.lifecycle
1129 .enter
1130 .get_or_insert_with(TextFxPhase::default)
1131 }
1132
1133 fn exit_phase_mut(&mut self) -> &mut TextFxPhase {
1134 self.lifecycle.exit.get_or_insert_with(TextFxPhase::default)
1135 }
1136
1137 fn phase_mut(&mut self, phase: TextFxPhaseKind) -> &mut TextFxPhase {
1138 match phase {
1139 TextFxPhaseKind::Enter => self.enter_phase_mut(),
1140 TextFxPhaseKind::Exit => self.exit_phase_mut(),
1141 }
1142 }
1143
1144 fn apply_phase_effect(&mut self, phase: TextFxPhaseKind, effect: TextFxEffect) {
1145 let should_promote = {
1146 let phase = self.phase_mut(phase);
1147 phase.effect = Some(effect);
1148 let mut should_promote = false;
1149 if effect.needs_split() && phase.split.is_none() {
1150 phase.split = Some(TextSplit::Chars);
1151 should_promote = true;
1152 }
1153 if matches!(effect, TextFxEffect::CountUp | TextFxEffect::NumberTicker) {
1154 phase.split = Some(TextSplit::None);
1155 }
1156 should_promote
1157 };
1158 if should_promote {
1159 self.promote_for_runtime_text_motion();
1160 }
1161 }
1162
1163 pub fn with_trigger(mut self, trigger: TextFxTrigger) -> Self {
1164 self.trigger = trigger;
1165 self
1166 }
1167
1168 pub fn on_hover(self) -> Self {
1169 self.with_trigger(TextFxTrigger::Hover)
1170 }
1171
1172 pub fn on_click(self) -> Self {
1173 self.with_trigger(TextFxTrigger::Click)
1174 }
1175
1176 pub fn split_words(self) -> Self {
1177 self.with_split(TextSplit::Words)
1178 }
1179
1180 pub fn split_chars(self) -> Self {
1181 self.with_split(TextSplit::Chars)
1182 }
1183
1184 pub fn loop_count(mut self, count: u16) -> Self {
1185 self.playback.loop_mode = TextFxLoop::Count(count.max(1));
1186 self
1187 }
1188
1189 pub fn loop_infinite(mut self) -> Self {
1190 self.playback.loop_mode = TextFxLoop::Infinite;
1191 self
1192 }
1193
1194 pub fn reverse(mut self) -> Self {
1195 self.playback.reverse = true;
1196 self
1197 }
1198
1199 pub fn alternate(mut self) -> Self {
1200 self.playback.alternate = true;
1201 self
1202 }
1203
1204 pub fn yoyo(mut self) -> Self {
1205 self.playback.yoyo = true;
1206 self
1207 }
1208
1209 pub fn target(mut self, target: TokenTarget, action: TokenAction) -> Self {
1210 self.add_target(target, action);
1211 self
1212 }
1213
1214 pub fn add_target(&mut self, target: TokenTarget, action: TokenAction) {
1215 if matches!(
1216 target,
1217 TokenTarget::Word { .. }
1218 | TokenTarget::WordRange { .. }
1219 | TokenTarget::WordText { .. }
1220 | TokenTarget::Contains { .. }
1221 | TokenTarget::Mark { .. }
1222 | TokenTarget::Others
1223 ) && self.split == TextSplit::None
1224 {
1225 self.split = TextSplit::Words;
1226 }
1227 if matches!(target, TokenTarget::CharRange { .. }) {
1228 self.split = TextSplit::Chars;
1229 }
1230 self.promote_for_runtime_text_motion();
1231 self.choreography
1232 .push(TextFxChoreography { target, action });
1233 }
1234
1235 pub fn with_reduced_motion(mut self, reduced_motion: ReducedMotion) -> Self {
1236 self.reduced_motion = reduced_motion;
1237 self
1238 }
1239
1240 pub fn with_direction(mut self, direction: TextFxDirection) -> Self {
1241 self.direction = direction;
1242 self
1243 }
1244
1245 pub fn with_palette(mut self, palette: impl IntoIterator<Item = impl Into<String>>) -> Self {
1246 self.palette = palette.into_iter().map(Into::into).collect();
1247 self
1248 }
1249
1250 pub fn with_numbers(mut self, from: f64, to: f64) -> Self {
1251 self.from = Some(from);
1252 self.to = Some(to);
1253 self
1254 }
1255
1256 pub fn with_cursor(mut self, cursor: bool) -> Self {
1257 self.cursor = cursor;
1258 self
1259 }
1260
1261 pub fn with_charset(mut self, charset: impl Into<String>) -> Self {
1262 self.charset = charset.into();
1263 self
1264 }
1265
1266 pub fn to_json(&self) -> Result<String, serde_json::Error> {
1267 serde_json::to_string(self)
1268 }
1269
1270 pub fn to_compact_json(&self) -> Result<String, serde_json::Error> {
1271 let value = serde_json::to_value(self)?;
1272 let Some(full) = value.as_object() else {
1273 return serde_json::to_string(self);
1274 };
1275 let defaults = Self::default();
1276 let default_value = serde_json::to_value(defaults)?;
1277 let default = default_value.as_object();
1278 let mut compact = serde_json::Map::new();
1279 compact.insert("v".to_string(), serde_json::json!(1));
1280 compact.insert("i".to_string(), serde_json::json!(self.id));
1281 compact.insert("t".to_string(), serde_json::json!(self.text));
1282 compact.insert("e".to_string(), serde_json::json!(self.effect.compact_id()));
1283 if let Some(enter) = self
1284 .lifecycle
1285 .enter
1286 .as_ref()
1287 .filter(|phase| !phase.is_empty())
1288 {
1289 compact.insert("en".to_string(), serde_json::to_value(enter)?);
1290 }
1291 if let Some(exit) = self
1292 .lifecycle
1293 .exit
1294 .as_ref()
1295 .filter(|phase| !phase.is_empty())
1296 {
1297 compact.insert("ex".to_string(), serde_json::to_value(exit)?);
1298 }
1299
1300 for (long, short) in [
1301 ("timing", "tm"),
1302 ("split", "sp"),
1303 ("reducedMotion", "rm"),
1304 ("performanceProfile", "pf"),
1305 ("gpuBudget", "gb"),
1306 ("renderPreference", "rp"),
1307 ("layoutReserve", "tlr"),
1308 ("trigger", "tr"),
1309 ("direction", "dir"),
1310 ("playback", "pb"),
1311 ("intensity", "in"),
1312 ("palette", "pa"),
1313 ("charset", "ch"),
1314 ("cursor", "cu"),
1315 ("from", "fr"),
1316 ("to", "to"),
1317 ("fx", "fx"),
1318 ("marks", "mk"),
1319 ("choreography", "cg"),
1320 ] {
1321 let Some(value) = full.get(long) else {
1322 continue;
1323 };
1324 let is_default = default
1325 .and_then(|default| default.get(long))
1326 .is_some_and(|default| default == value);
1327 if !is_default {
1328 compact.insert(short.to_string(), value.clone());
1329 }
1330 }
1331
1332 serde_json::to_string(&compact)
1333 }
1334
1335 pub fn data_attr(&self) -> Result<String, serde_json::Error> {
1336 let full = self.to_json()?;
1337 let compact = self.to_compact_json()?;
1338 let json = if compact.len() < full.len() {
1339 compact
1340 } else {
1341 full
1342 };
1343 Ok(format!(r#"data-dxt-textfx="{}""#, escape_attr(&json)))
1344 }
1345
1346 pub fn locale_data_attr(&self) -> Result<String, serde_json::Error> {
1347 let full = self.to_json()?;
1348 let compact = self.to_compact_json()?;
1349 let json = if compact.len() < full.len() {
1350 compact
1351 } else {
1352 full
1353 };
1354 let attr = format!(r#"data-dxt-locale-fx="{}""#, escape_attr(&json));
1355 Ok(match self.layout_reserve_attr() {
1356 Some(layout) => format!("{attr} {layout}"),
1357 None => attr,
1358 })
1359 }
1360
1361 pub fn is_css_first(&self) -> bool {
1362 if matches!(
1363 self.render_preference,
1364 TextFxRenderPreference::WorkerTownRender
1365 ) {
1366 return false;
1367 }
1368 matches!(
1369 self.effect,
1370 TextFxEffect::Fade
1371 | TextFxEffect::Slide
1372 | TextFxEffect::BlurReveal
1373 | TextFxEffect::Scale
1374 | TextFxEffect::MaskReveal
1375 | TextFxEffect::HighlightSweep
1376 | TextFxEffect::GradientShift
1377 ) && self.split == TextSplit::None
1378 && self.choreography.is_empty()
1379 && matches!(self.trigger, TextFxTrigger::Load | TextFxTrigger::Visible)
1380 && self.playback.repeat_delay_ms == 0
1381 }
1382
1383 pub fn is_css_first_split(&self) -> bool {
1384 if matches!(
1385 self.render_preference,
1386 TextFxRenderPreference::WorkerTownRender
1387 ) {
1388 return false;
1389 }
1390 matches!(
1391 self.effect,
1392 TextFxEffect::Stagger
1393 | TextFxEffect::Wave
1394 | TextFxEffect::Flip
1395 | TextFxEffect::Glitch
1396 | TextFxEffect::KerningExpand
1397 ) && matches!(
1398 self.split,
1399 TextSplit::Chars | TextSplit::Words | TextSplit::Lines
1400 ) && self.choreography.is_empty()
1401 && matches!(self.trigger, TextFxTrigger::Load | TextFxTrigger::Visible)
1402 && self.playback.repeat_delay_ms == 0
1403 }
1404
1405 pub fn is_css_first_renderable(&self) -> bool {
1406 self.is_css_first() || self.is_css_first_split()
1407 }
1408
1409 pub fn css_first_class(&self) -> Option<String> {
1410 self.is_css_first_renderable()
1411 .then(|| format!("dxt-effect-{}", self.effect.as_attr()))
1412 }
1413
1414 pub fn css_first_state_attrs(&self) -> Option<String> {
1415 if !self.is_css_first_renderable() {
1416 return None;
1417 }
1418 let iterations = match self.playback.loop_mode {
1419 TextFxLoop::Once => "1".to_string(),
1420 TextFxLoop::Infinite => "infinite".to_string(),
1421 TextFxLoop::Count(count) => count.max(1).to_string(),
1422 };
1423 let direction = if self.playback.reverse {
1424 "reverse"
1425 } else if self.playback.alternate || self.playback.yoyo {
1426 "alternate"
1427 } else {
1428 "normal"
1429 };
1430 let gradient_a = self
1431 .palette
1432 .first()
1433 .map(String::as_str)
1434 .unwrap_or("#ff7a1a");
1435 let gradient_b = self.palette.get(1).map(String::as_str).unwrap_or("#ffffff");
1436 let gradient_c = self.palette.get(2).map(String::as_str).unwrap_or("#9fb7ff");
1437 let mut style = format!(
1438 "--dxt-duration:{}ms;--dxt-delay:{}ms;--dxt-stagger:{}ms;--dxt-ease:{};--dxt-iterations:{};--dxt-direction:{};--dxt-gradient-a:{};--dxt-gradient-b:{};--dxt-gradient-c:{};",
1439 self.timing.duration_ms,
1440 self.timing.delay_ms,
1441 self.timing.stagger_ms,
1442 escape_attr(&self.timing.easing.css_value()),
1443 escape_attr(&iterations),
1444 direction,
1445 escape_attr(gradient_a),
1446 escape_attr(gradient_b),
1447 escape_attr(gradient_c),
1448 );
1449 if self.is_css_first_split() && self.reserves_layout() {
1450 style.push_str(&format!(
1451 "--dxt-layout-fallback-lines:{};min-block-size:calc(var(--dxt-layout-fallback-lines) * 1.2em);",
1452 self.layout_fallback_lines()
1453 ));
1454 }
1455 let attrs = vec![
1456 r#"data-dxt-css-first="true""#.to_string(),
1457 r#"data-dxt-state="running""#.to_string(),
1458 format!(r#"style="{style}""#),
1459 ];
1460 Some(attrs.join(" "))
1461 }
1462
1463 pub fn reserves_layout(&self) -> bool {
1464 self.layout_reserve != TextFxLayoutReserve::Off
1465 }
1466
1467 pub fn layout_reserve_attr(&self) -> Option<String> {
1468 self.reserves_layout().then(|| {
1469 format!(
1470 r#"data-dxr-text-layout-target="{}""#,
1471 escape_attr(self.layout_reserve.as_attr())
1472 )
1473 })
1474 }
1475
1476 pub fn layout_fallback_lines(&self) -> usize {
1477 self.text.split('\n').count().max(1)
1478 }
1479
1480 pub fn live_contrast_mode(&self) -> Option<TextFxLiveContrast> {
1481 if self.effect == TextFxEffect::LiveContrast {
1482 Some(TextFxLiveContrast::Difference)
1483 } else {
1484 None
1485 }
1486 }
1487
1488 pub fn live_contrast_attr(&self) -> Option<String> {
1489 self.live_contrast_mode().map(|mode| {
1490 format!(
1491 r#"data-dxt-live-contrast="{}""#,
1492 escape_attr(mode.as_attr())
1493 )
1494 })
1495 }
1496
1497 pub fn requires_workertown_render(&self) -> bool {
1498 matches!(
1499 self.render_preference,
1500 TextFxRenderPreference::WorkerTownRender
1501 )
1502 }
1503
1504 pub fn trigger_attr(&self) -> Option<&'static str> {
1505 None
1506 }
1507
1508 pub fn resume_trigger_attr(&self) -> Option<String> {
1509 if matches!(
1510 self.render_preference,
1511 TextFxRenderPreference::WorkerTownRender
1512 ) {
1513 return self.trigger.resume_attr();
1514 }
1515 if self.is_css_first_renderable()
1516 || (self.effect == TextFxEffect::LiveContrast && self.choreography.is_empty())
1517 {
1518 None
1519 } else {
1520 self.trigger.resume_attr()
1521 }
1522 }
1523
1524 pub fn html_attrs(&self) -> Result<String, serde_json::Error> {
1525 let class = self
1526 .css_first_class()
1527 .map(|effect_class| {
1528 if self.is_css_first_split() {
1529 format!("dxt-textfx dxt-split {effect_class}")
1530 } else {
1531 format!("dxt-textfx {effect_class}")
1532 }
1533 })
1534 .unwrap_or_else(|| "dxt-textfx".to_string());
1535 let mut attrs = vec![
1536 format!(r#"id="{}""#, escape_attr(&self.id)),
1537 format!(r#"class="{}""#, escape_attr(&class)),
1538 self.data_attr()?,
1539 format!(
1540 r#"data-dxt-performance="{}""#,
1541 escape_attr(self.performance_profile.as_attr())
1542 ),
1543 format!(
1544 r#"data-dxt-gpu-budget="{}""#,
1545 escape_attr(self.gpu_budget.as_attr())
1546 ),
1547 ];
1548 if self.render_preference != TextFxRenderPreference::Auto {
1549 attrs.push(format!(
1550 r#"data-dxt-renderer="{}""#,
1551 escape_attr(self.render_preference.as_attr())
1552 ));
1553 }
1554 if let Some(attr) = self.css_first_state_attrs() {
1555 attrs.push(attr);
1556 }
1557 if let Some(attr) = self.live_contrast_attr() {
1558 attrs.push(attr);
1559 }
1560 if let Some(attr) = self.layout_reserve_attr() {
1561 attrs.push(attr);
1562 }
1563 if let Some(trigger) = self.resume_trigger_attr() {
1564 attrs.push(trigger);
1565 }
1566 Ok(attrs.join(" "))
1567 }
1568
1569 pub fn static_html(
1570 &self,
1571 tag: impl AsRef<str>,
1572 extra_attrs: impl AsRef<str>,
1573 ) -> Result<String, serde_json::Error> {
1574 let tag = sanitize_tag(tag.as_ref());
1575 let attrs = self.html_attrs()?;
1576 let extra_attrs = extra_attrs.as_ref().trim();
1577 let attrs = if extra_attrs.is_empty() {
1578 attrs
1579 } else {
1580 format!("{attrs} {extra_attrs}")
1581 };
1582 let inner = escape_html(&self.text);
1583 Ok(format!("<{tag} {attrs}>{inner}</{tag}>"))
1584 }
1585}
1586
1587impl Default for TextFxConfig {
1588 fn default() -> Self {
1589 Self::new("textfx", "")
1590 }
1591}
1592
1593pub fn escape_html(value: &str) -> String {
1594 value
1595 .replace('&', "&")
1596 .replace('<', "<")
1597 .replace('>', ">")
1598}
1599
1600pub fn escape_attr(value: &str) -> String {
1601 escape_html(value)
1602 .replace('"', """)
1603 .replace('\'', "'")
1604}
1605
1606fn sanitize_tag(tag: &str) -> &str {
1607 match tag {
1608 "h1" | "h2" | "h3" | "h4" | "p" | "span" | "strong" | "em" | "small" | "div" => tag,
1609 _ => "span",
1610 }
1611}
1612
1613pub fn parse_inline_marks(source: &str) -> MarkedText {
1614 let mut clean_text = String::new();
1615 let mut marks = Vec::new();
1616 let mut rest = source;
1617 let mut word_count = 0usize;
1618
1619 while let Some(start) = rest.find("[[") {
1620 let before = &rest[..start];
1621 clean_text.push_str(before);
1622 word_count += count_words(before);
1623 let after_start = &rest[start + 2..];
1624 let Some(end) = after_start.find("]]") else {
1625 clean_text.push_str(&rest[start..]);
1626 return MarkedText { clean_text, marks };
1627 };
1628 let marker = &after_start[..end];
1629 if let Some((visible, name)) = marker.rsplit_once('|') {
1630 let char_start = clean_text.chars().count();
1631 let word_start = word_count;
1632 clean_text.push_str(visible);
1633 let word_len = count_words(visible).max(1);
1634 word_count += word_len;
1635 let char_end = clean_text.chars().count();
1636 marks.push(TokenMark {
1637 name: name.trim().to_string(),
1638 text: visible.to_string(),
1639 char_start,
1640 char_end,
1641 word_start,
1642 word_end: word_start + word_len.saturating_sub(1),
1643 });
1644 } else {
1645 clean_text.push_str(marker);
1646 word_count += count_words(marker);
1647 }
1648 rest = &after_start[end + 2..];
1649 }
1650 clean_text.push_str(rest);
1651
1652 MarkedText { clean_text, marks }
1653}
1654
1655fn count_words(value: &str) -> usize {
1656 value
1657 .split_whitespace()
1658 .filter(|part| !part.is_empty())
1659 .count()
1660}
1661
1662fn parse_fx_tokens(config: &mut TextFxConfig, fx: &str) -> Result<(), TextFxParseError> {
1663 for token in split_fx_tokens(fx) {
1664 parse_fx_token(config, &token)?;
1665 }
1666 Ok(())
1667}
1668
1669fn split_fx_tokens(fx: &str) -> Vec<String> {
1670 let mut tokens = Vec::new();
1671 let mut current = String::new();
1672 let mut paren_depth = 0usize;
1673 let mut quote: Option<char> = None;
1674
1675 for ch in fx.chars() {
1676 match ch {
1677 '\'' | '"' if quote == Some(ch) => {
1678 quote = None;
1679 current.push(ch);
1680 }
1681 '\'' | '"' if quote.is_none() => {
1682 quote = Some(ch);
1683 current.push(ch);
1684 }
1685 '(' if quote.is_none() => {
1686 paren_depth += 1;
1687 current.push(ch);
1688 }
1689 ')' if quote.is_none() => {
1690 paren_depth = paren_depth.saturating_sub(1);
1691 current.push(ch);
1692 }
1693 ch if ch.is_whitespace() && quote.is_none() && paren_depth == 0 => {
1694 if !current.trim().is_empty() {
1695 tokens.push(current.trim().to_string());
1696 current.clear();
1697 }
1698 }
1699 _ => current.push(ch),
1700 }
1701 }
1702 if !current.trim().is_empty() {
1703 tokens.push(current.trim().to_string());
1704 }
1705 tokens
1706}
1707
1708fn parse_fx_token(config: &mut TextFxConfig, token: &str) -> Result<(), TextFxParseError> {
1709 if let Some(value) = token.strip_prefix("enter:") {
1710 return parse_fx_phase_token(config, TextFxPhaseKind::Enter, value);
1711 }
1712 if let Some(value) = token.strip_prefix("exit:") {
1713 return parse_fx_phase_token(config, TextFxPhaseKind::Exit, value);
1714 }
1715
1716 match token {
1717 "split-words" => {
1718 config.split = TextSplit::Words;
1719 config.promote_for_runtime_text_motion();
1720 return Ok(());
1721 }
1722 "split-chars" | "split-letters" => {
1723 config.split = TextSplit::Chars;
1724 config.promote_for_runtime_text_motion();
1725 return Ok(());
1726 }
1727 "split-lines" => {
1728 config.split = TextSplit::Lines;
1729 config.promote_for_runtime_text_motion();
1730 return Ok(());
1731 }
1732 "on-hover" => {
1733 config.trigger = TextFxTrigger::Hover;
1734 return Ok(());
1735 }
1736 "on-click" => {
1737 config.trigger = TextFxTrigger::Click;
1738 return Ok(());
1739 }
1740 "on-visible" => {
1741 config.trigger = TextFxTrigger::Visible;
1742 return Ok(());
1743 }
1744 "on-load" => {
1745 config.trigger = TextFxTrigger::Load;
1746 return Ok(());
1747 }
1748 "on-word-hover" => {
1749 config.trigger = TextFxTrigger::WordHover;
1750 config.split = TextSplit::Words;
1751 config.promote_for_runtime_text_motion();
1752 return Ok(());
1753 }
1754 "on-word-click" => {
1755 config.trigger = TextFxTrigger::WordClick;
1756 config.split = TextSplit::Words;
1757 config.promote_for_runtime_text_motion();
1758 return Ok(());
1759 }
1760 "loop" => {
1761 config.playback.loop_mode = TextFxLoop::Infinite;
1762 return Ok(());
1763 }
1764 "reverse" => {
1765 config.playback.reverse = true;
1766 return Ok(());
1767 }
1768 "alternate" => {
1769 config.playback.alternate = true;
1770 return Ok(());
1771 }
1772 "yoyo" => {
1773 config.playback.yoyo = true;
1774 return Ok(());
1775 }
1776 "perf:css-first" | "perf:css" | "css-first" => {
1777 config.performance_profile = TextFxPerformanceProfile::CssFirst;
1778 return Ok(());
1779 }
1780 "perf:balanced" | "perf:balance" => {
1781 config.performance_profile = TextFxPerformanceProfile::Balanced;
1782 return Ok(());
1783 }
1784 "perf:exact" | "perf:visual-exact" | "visual-exact" => {
1785 config.performance_profile = TextFxPerformanceProfile::VisualExact;
1786 return Ok(());
1787 }
1788 "gpu:auto" | "gpu-auto" => {
1789 config.gpu_budget = TextFxGpuBudget::Auto;
1790 return Ok(());
1791 }
1792 "gpu:low-power" | "gpu-low-power" | "gpu:low" | "gpu-low" => {
1793 config.gpu_budget = TextFxGpuBudget::LowPower;
1794 return Ok(());
1795 }
1796 "gpu:normal" | "gpu-normal" => {
1797 config.gpu_budget = TextFxGpuBudget::Normal;
1798 return Ok(());
1799 }
1800 "gpu:exact" | "gpu-exact" => {
1801 config.gpu_budget = TextFxGpuBudget::Exact;
1802 return Ok(());
1803 }
1804 "render:auto" | "renderer:auto" | "render-auto" => {
1805 config.render_preference = TextFxRenderPreference::Auto;
1806 return Ok(());
1807 }
1808 "render:css-first" | "renderer:css-first" | "render-css-first" => {
1809 config.render_preference = TextFxRenderPreference::CssFirst;
1810 return Ok(());
1811 }
1812 "render:workertown"
1813 | "renderer:workertown"
1814 | "render:workertown-render"
1815 | "renderer:workertown-render"
1816 | "render-workertown" => {
1817 *config = config
1818 .clone()
1819 .with_render_preference(TextFxRenderPreference::WorkerTownRender);
1820 return Ok(());
1821 }
1822 "render:main-thread-fallback"
1823 | "renderer:main-thread-fallback"
1824 | "render-main-thread-fallback" => {
1825 config.render_preference = TextFxRenderPreference::MainThreadFallback;
1826 return Ok(());
1827 }
1828 "layout-reserve:off" | "layout-reserve-off" | "tlr:off" | "tlr-off" => {
1829 config.layout_reserve = TextFxLayoutReserve::Off;
1830 return Ok(());
1831 }
1832 "layout-reserve:auto" | "layout-reserve-auto" | "tlr:auto" | "tlr-auto" => {
1833 config.layout_reserve = TextFxLayoutReserve::Auto;
1834 return Ok(());
1835 }
1836 "layout-reserve:exact" | "layout-reserve-exact" | "tlr:exact" | "tlr-exact" => {
1837 config.layout_reserve = TextFxLayoutReserve::Exact;
1838 return Ok(());
1839 }
1840 "ease-in" => {
1841 config.timing.easing = TextFxEasing::EaseIn;
1842 return Ok(());
1843 }
1844 "ease-out" => {
1845 config.timing.easing = TextFxEasing::EaseOut;
1846 return Ok(());
1847 }
1848 "ease-in-out" => {
1849 config.timing.easing = TextFxEasing::EaseInOut;
1850 return Ok(());
1851 }
1852 "ease-linear" | "linear" => {
1853 config.timing.easing = TextFxEasing::Linear;
1854 return Ok(());
1855 }
1856 _ => {}
1857 }
1858
1859 if let Some(value) = token.strip_prefix("duration-") {
1860 config.timing.duration_ms = parse_u32(value, token)?;
1861 return Ok(());
1862 }
1863 if let Some(value) = token.strip_prefix("delay-") {
1864 config.timing.delay_ms = parse_u32(value, token)?;
1865 return Ok(());
1866 }
1867 if let Some(value) = token.strip_prefix("stagger-") {
1868 config.timing.stagger_ms = parse_u32(value, token)?;
1869 return Ok(());
1870 }
1871 if let Some(value) = token.strip_prefix("speed-") {
1872 config.timing.speed_ms = parse_u32(value, token)?.max(1);
1873 return Ok(());
1874 }
1875 if let Some(value) = token.strip_prefix("loop-") {
1876 config.playback.loop_mode = TextFxLoop::Count(parse_u16(value, token)?.max(1));
1877 return Ok(());
1878 }
1879 if let Some(selector) = token.strip_prefix("on-click:") {
1880 config.trigger = TextFxTrigger::SelectorClick {
1881 selector: selector.to_string(),
1882 };
1883 return Ok(());
1884 }
1885 if let Some(selector) = token.strip_prefix("on-hover:") {
1886 config.trigger = TextFxTrigger::SelectorHover {
1887 selector: selector.to_string(),
1888 };
1889 return Ok(());
1890 }
1891 if let Some(event) = token.strip_prefix("on-event:") {
1892 config.trigger = TextFxTrigger::Event {
1893 name: event.to_string(),
1894 };
1895 return Ok(());
1896 }
1897 if token.starts_with("target:") || token.starts_with("mark:") {
1898 let rule = parse_rule_token(token)?;
1899 config.add_target(rule.target, rule.action);
1900 return Ok(());
1901 }
1902
1903 if let Some(effect) = parse_effect_token(token) {
1904 config.effect = effect;
1905 if effect.needs_split() && config.split == TextSplit::None {
1906 config.split = TextSplit::Chars;
1907 config.promote_for_runtime_text_motion();
1908 }
1909 return Ok(());
1910 }
1911
1912 Err(TextFxParseError::new(format!(
1913 "unknown textfx token `{token}`"
1914 )))
1915}
1916
1917fn parse_fx_phase_token(
1918 config: &mut TextFxConfig,
1919 phase: TextFxPhaseKind,
1920 token: &str,
1921) -> Result<(), TextFxParseError> {
1922 match token {
1923 "split-words" => {
1924 config.phase_mut(phase).split = Some(TextSplit::Words);
1925 config.promote_for_runtime_text_motion();
1926 return Ok(());
1927 }
1928 "split-chars" | "split-letters" => {
1929 config.phase_mut(phase).split = Some(TextSplit::Chars);
1930 config.promote_for_runtime_text_motion();
1931 return Ok(());
1932 }
1933 "split-lines" => {
1934 config.phase_mut(phase).split = Some(TextSplit::Lines);
1935 config.promote_for_runtime_text_motion();
1936 return Ok(());
1937 }
1938 "split-none" => {
1939 config.phase_mut(phase).split = Some(TextSplit::None);
1940 return Ok(());
1941 }
1942 "reverse" => {
1943 let playback = TextFxPlayback {
1944 reverse: true,
1945 ..TextFxPlayback::default()
1946 };
1947 config.phase_mut(phase).playback = Some(playback);
1948 return Ok(());
1949 }
1950 "ease-in" => {
1951 config.phase_mut(phase).timing_mut().easing = Some(TextFxEasing::EaseIn);
1952 return Ok(());
1953 }
1954 "ease-out" => {
1955 config.phase_mut(phase).timing_mut().easing = Some(TextFxEasing::EaseOut);
1956 return Ok(());
1957 }
1958 "ease-in-out" => {
1959 config.phase_mut(phase).timing_mut().easing = Some(TextFxEasing::EaseInOut);
1960 return Ok(());
1961 }
1962 "ease-linear" | "linear" => {
1963 config.phase_mut(phase).timing_mut().easing = Some(TextFxEasing::Linear);
1964 return Ok(());
1965 }
1966 _ => {}
1967 }
1968
1969 if let Some(value) = token.strip_prefix("duration-") {
1970 config.phase_mut(phase).timing_mut().duration_ms = Some(parse_u32(value, token)?);
1971 return Ok(());
1972 }
1973 if let Some(value) = token.strip_prefix("delay-") {
1974 config.phase_mut(phase).timing_mut().delay_ms = Some(parse_u32(value, token)?);
1975 return Ok(());
1976 }
1977 if let Some(value) = token.strip_prefix("stagger-") {
1978 config.phase_mut(phase).timing_mut().stagger_ms = Some(parse_u32(value, token)?);
1979 return Ok(());
1980 }
1981 if let Some(value) = token.strip_prefix("speed-") {
1982 config.phase_mut(phase).timing_mut().speed_ms = Some(parse_u32(value, token)?.max(1));
1983 return Ok(());
1984 }
1985 if let Some(value) = token
1986 .strip_prefix("direction-")
1987 .or_else(|| token.strip_prefix("dir-"))
1988 {
1989 config.phase_mut(phase).direction = Some(parse_direction(value)?);
1990 return Ok(());
1991 }
1992 if let Some(effect) = parse_effect_token(token) {
1993 config.apply_phase_effect(phase, effect);
1994 return Ok(());
1995 }
1996
1997 Err(TextFxParseError::new(format!(
1998 "unknown textfx phase token `{token}`"
1999 )))
2000}
2001
2002fn parse_rule_token(token: &str) -> Result<TextFxChoreography, TextFxParseError> {
2003 let parts = split_colon_parts(token);
2004 if parts.len() < 3 {
2005 return Err(TextFxParseError::new(format!(
2006 "token `{token}` must include a target and at least one action"
2007 )));
2008 }
2009
2010 let target = if parts[0] == "target" {
2011 parse_target(parts[1])?
2012 } else if parts[0] == "mark" {
2013 if parts[1] == "others" {
2014 TokenTarget::Others
2015 } else {
2016 TokenTarget::Mark {
2017 name: parts[1].to_string(),
2018 }
2019 }
2020 } else {
2021 return Err(TextFxParseError::new(format!(
2022 "token `{token}` must start with target: or mark:"
2023 )));
2024 };
2025
2026 let mut action = TokenAction::default();
2027 for part in parts.iter().skip(2) {
2028 action = action.merge(parse_action(part)?);
2029 }
2030 Ok(TextFxChoreography { target, action })
2031}
2032
2033fn split_colon_parts(value: &str) -> Vec<&str> {
2034 let mut parts = Vec::new();
2035 let mut start = 0usize;
2036 let mut paren_depth = 0usize;
2037 let mut quote: Option<char> = None;
2038
2039 for (idx, ch) in value.char_indices() {
2040 match ch {
2041 '\'' | '"' if quote == Some(ch) => quote = None,
2042 '\'' | '"' if quote.is_none() => quote = Some(ch),
2043 '(' if quote.is_none() => paren_depth += 1,
2044 ')' if quote.is_none() => paren_depth = paren_depth.saturating_sub(1),
2045 ':' if quote.is_none() && paren_depth == 0 => {
2046 parts.push(&value[start..idx]);
2047 start = idx + 1;
2048 }
2049 _ => {}
2050 }
2051 }
2052 parts.push(&value[start..]);
2053 parts
2054}
2055
2056fn parse_target(value: &str) -> Result<TokenTarget, TextFxParseError> {
2057 if value == "all" {
2058 return Ok(TokenTarget::All);
2059 }
2060 if value == "others" {
2061 return Ok(TokenTarget::Others);
2062 }
2063 if let Some(inner) = value
2064 .strip_prefix("words(")
2065 .and_then(|v| v.strip_suffix(')'))
2066 {
2067 return parse_range_or_index(inner).map(|(start, end)| {
2068 if start == end {
2069 TokenTarget::Word { index: start }
2070 } else {
2071 TokenTarget::WordRange { start, end }
2072 }
2073 });
2074 }
2075 if let Some(inner) = value
2076 .strip_prefix("word(")
2077 .and_then(|v| v.strip_suffix(')'))
2078 {
2079 return Ok(TokenTarget::WordText {
2080 value: unquote(inner).to_string(),
2081 });
2082 }
2083 if let Some(inner) = value
2084 .strip_prefix("chars(")
2085 .and_then(|v| v.strip_suffix(')'))
2086 {
2087 return parse_range_or_index(inner)
2088 .map(|(start, end)| TokenTarget::CharRange { start, end });
2089 }
2090 if let Some(inner) = value
2091 .strip_prefix("contains(")
2092 .and_then(|v| v.strip_suffix(')'))
2093 {
2094 return Ok(TokenTarget::Contains {
2095 value: unquote(inner).to_string(),
2096 });
2097 }
2098 Err(TextFxParseError::new(format!("invalid target `{value}`")))
2099}
2100
2101fn parse_action(value: &str) -> Result<TokenAction, TextFxParseError> {
2102 if value == "stay" {
2103 return Ok(TokenAction::default().stay());
2104 }
2105 if value == "highlight" {
2106 return Ok(TokenAction::highlight());
2107 }
2108 if value == "underline-sweep" {
2109 return Ok(TokenAction {
2110 underline_sweep: true,
2111 ..TokenAction::default()
2112 });
2113 }
2114 if value == "live-contrast" {
2115 return Ok(TokenAction::live_contrast());
2116 }
2117 if value == "live-contrast-exclusion" {
2118 return Ok(TokenAction::live_contrast_mode(
2119 TextFxLiveContrast::Exclusion,
2120 ));
2121 }
2122 if value == "live-contrast-plus" {
2123 return Ok(TokenAction::live_contrast_mode(TextFxLiveContrast::Plus));
2124 }
2125 if value == "blur" {
2126 return Ok(TokenAction {
2127 blur: true,
2128 ..TokenAction::default()
2129 });
2130 }
2131 if value == "slide-away" {
2132 return Ok(TokenAction::slide_away(TextFxDirection::Left));
2133 }
2134 if let Some(direction) = value.strip_prefix("slide-away-") {
2135 return Ok(TokenAction::slide_away(parse_direction(direction)?));
2136 }
2137 if let Some(value) = value.strip_prefix("scale-") {
2138 return Ok(TokenAction::scale(parse_scale(value)?));
2139 }
2140 if let Some(value) = value.strip_prefix("fade-") {
2141 return Ok(TokenAction {
2142 opacity: Some(parse_fade(value)?),
2143 ..TokenAction::default()
2144 });
2145 }
2146 if let Some(value) = value.strip_prefix("delay-") {
2147 return Ok(TokenAction {
2148 delay_ms: Some(parse_u32(value, value)?),
2149 ..TokenAction::default()
2150 });
2151 }
2152 if let Some(value) = value.strip_prefix("stagger-") {
2153 return Ok(TokenAction {
2154 stagger_ms: Some(parse_u32(value, value)?),
2155 ..TokenAction::default()
2156 });
2157 }
2158 if let Some(value) = value.strip_prefix("color-[") {
2159 let value = value
2160 .strip_suffix(']')
2161 .ok_or_else(|| TextFxParseError::new("color token must end with ]"))?;
2162 return Ok(TokenAction {
2163 color: Some(value.to_string()),
2164 ..TokenAction::default()
2165 });
2166 }
2167 if let Some(inner) = value
2168 .strip_prefix("swap(")
2169 .and_then(|v| v.strip_suffix(')'))
2170 {
2171 return Ok(TokenAction::swap(unquote(inner)));
2172 }
2173 if let Some(inner) = value
2174 .strip_prefix("scramble-to(")
2175 .and_then(|v| v.strip_suffix(')'))
2176 {
2177 return Ok(TokenAction {
2178 scramble_to: Some(unquote(inner).to_string()),
2179 ..TokenAction::default()
2180 });
2181 }
2182 Err(TextFxParseError::new(format!("invalid action `{value}`")))
2183}
2184
2185fn parse_effect_token(token: &str) -> Option<TextFxEffect> {
2186 TextFxEffect::ALL
2187 .into_iter()
2188 .find(|effect| effect.as_attr() == token)
2189}
2190
2191fn parse_range_or_index(value: &str) -> Result<(usize, usize), TextFxParseError> {
2192 if let Some((start, end)) = value.split_once("..") {
2193 let start = parse_usize(start, value)?;
2194 let end = parse_usize(end.trim_start_matches('='), value)?;
2195 if start > end {
2196 return Err(TextFxParseError::new(format!(
2197 "invalid descending range `{value}`"
2198 )));
2199 }
2200 return Ok((start, end));
2201 }
2202 let index = parse_usize(value, value)?;
2203 Ok((index, index))
2204}
2205
2206fn parse_direction(value: &str) -> Result<TextFxDirection, TextFxParseError> {
2207 match value {
2208 "up" => Ok(TextFxDirection::Up),
2209 "right" => Ok(TextFxDirection::Right),
2210 "down" => Ok(TextFxDirection::Down),
2211 "left" => Ok(TextFxDirection::Left),
2212 _ => Err(TextFxParseError::new(format!(
2213 "invalid direction `{value}`"
2214 ))),
2215 }
2216}
2217
2218fn parse_scale(value: &str) -> Result<f32, TextFxParseError> {
2219 let number = parse_u32(value, value)?;
2220 Ok(number as f32 / 100.0)
2221}
2222
2223fn parse_fade(value: &str) -> Result<f32, TextFxParseError> {
2224 let number = parse_u32(value, value)?;
2225 Ok((number.min(100) as f32) / 100.0)
2226}
2227
2228fn parse_usize(value: &str, token: &str) -> Result<usize, TextFxParseError> {
2229 value
2230 .parse::<usize>()
2231 .map_err(|_| TextFxParseError::new(format!("invalid number in `{token}`")))
2232}
2233
2234fn parse_u32(value: &str, token: &str) -> Result<u32, TextFxParseError> {
2235 value
2236 .parse::<u32>()
2237 .map_err(|_| TextFxParseError::new(format!("invalid number in `{token}`")))
2238}
2239
2240fn parse_u16(value: &str, token: &str) -> Result<u16, TextFxParseError> {
2241 value
2242 .parse::<u16>()
2243 .map_err(|_| TextFxParseError::new(format!("invalid number in `{token}`")))
2244}
2245
2246fn unquote(value: &str) -> &str {
2247 value.trim().trim_matches('"').trim_matches('\'')
2248}
2249
2250#[cfg(test)]
2251mod tests {
2252 use super::*;
2253
2254 #[test]
2255 fn serializes_all_effects() {
2256 for effect in TextFxEffect::ALL {
2257 let json = TextFxConfig::new(effect.as_attr(), effect.label())
2258 .with_effect(effect)
2259 .to_json()
2260 .unwrap();
2261 assert!(json.contains(effect.as_attr()), "{json}");
2262 }
2263 }
2264
2265 #[test]
2266 fn css_first_gradient_shift_can_loop_and_yoyo() {
2267 let config = TextFxConfig::new("gradient", "Gradient shift")
2268 .with_effect(TextFxEffect::GradientShift)
2269 .with_palette(["#111111", "#ffffff", "#ff7a1a"])
2270 .loop_infinite()
2271 .yoyo();
2272 assert!(config.is_css_first());
2273 let attrs = config.css_first_state_attrs().unwrap();
2274 assert!(attrs.contains("--dxt-iterations:infinite"));
2275 assert!(attrs.contains("--dxt-direction:alternate"));
2276 assert!(attrs.contains("--dxt-gradient-c:#ff7a1a"));
2277 }
2278
2279 #[test]
2280 fn showcase_profile_preserves_split_effects_for_runtime_loops() {
2281 let config = TextFxConfig::new("wave", "Wave motion")
2282 .with_effect(TextFxEffect::Wave)
2283 .with_profile(TextFxProfile::Showcase)
2284 .loop_infinite()
2285 .yoyo();
2286 assert_eq!(config.split, TextSplit::Chars);
2287 assert!(!config.is_css_first());
2288 assert!(config.is_css_first_split());
2289 assert!(config.is_css_first_renderable());
2290 assert_eq!(config.resume_trigger_attr(), None);
2291 assert!(
2292 config
2293 .data_attr()
2294 .unwrap()
2295 .contains(""sp":"chars"")
2296 );
2297 }
2298
2299 #[test]
2300 fn split_showcase_effects_are_css_first_renderable() {
2301 for effect in [
2302 TextFxEffect::Stagger,
2303 TextFxEffect::Wave,
2304 TextFxEffect::Flip,
2305 TextFxEffect::Glitch,
2306 TextFxEffect::KerningExpand,
2307 ] {
2308 let config = TextFxConfig::new(effect.as_attr(), effect.label())
2309 .with_effect(effect)
2310 .with_profile(TextFxProfile::Showcase)
2311 .loop_infinite()
2312 .yoyo();
2313 assert!(config.is_css_first_split(), "{effect:?}");
2314 assert!(config.css_first_class().is_some(), "{effect:?}");
2315 assert!(
2316 config
2317 .css_first_state_attrs()
2318 .unwrap()
2319 .contains("--dxt-iterations:infinite")
2320 );
2321 assert_eq!(config.resume_trigger_attr(), None);
2322 }
2323 }
2324
2325 #[test]
2326 fn whole_node_live_contrast_needs_no_runtime_trigger() {
2327 let config = TextFxConfig::new("contrast", "Readable")
2328 .with_effect(TextFxEffect::LiveContrast)
2329 .with_trigger(TextFxTrigger::Load);
2330 assert_eq!(config.resume_trigger_attr(), None);
2331 assert_eq!(
2332 config.live_contrast_attr().as_deref(),
2333 Some(r#"data-dxt-live-contrast="difference""#)
2334 );
2335 }
2336
2337 #[test]
2338 fn css_first_classification_includes_safe_single_node_effects() {
2339 for effect in [
2340 TextFxEffect::Fade,
2341 TextFxEffect::Slide,
2342 TextFxEffect::BlurReveal,
2343 TextFxEffect::Scale,
2344 TextFxEffect::MaskReveal,
2345 TextFxEffect::HighlightSweep,
2346 TextFxEffect::GradientShift,
2347 ] {
2348 let config = TextFxConfig::new(effect.as_attr(), effect.label()).with_effect(effect);
2349 assert!(config.is_css_first(), "{effect:?}");
2350 }
2351
2352 assert!(
2353 !TextFxConfig::new("typewriter", "Typewriter")
2354 .with_effect(TextFxEffect::Typewriter)
2355 .is_css_first()
2356 );
2357 assert!(
2358 !TextFxConfig::new("hover", "Hover")
2359 .with_effect(TextFxEffect::Fade)
2360 .on_hover()
2361 .is_css_first()
2362 );
2363 }
2364
2365 #[test]
2366 fn profiles_apply_runtime_defaults() {
2367 let lighthouse =
2368 TextFxConfig::profile("hero", "Fast first paint", TextFxProfile::Lighthouse);
2369 assert_eq!(lighthouse.trigger, TextFxTrigger::Load);
2370 assert_eq!(lighthouse.reduced_motion, ReducedMotion::Static);
2371 assert_eq!(
2372 lighthouse.performance_profile,
2373 TextFxPerformanceProfile::CssFirst
2374 );
2375 assert_eq!(lighthouse.gpu_budget, TextFxGpuBudget::Auto);
2376 assert_eq!(lighthouse.timing.duration_ms, 360);
2377 assert!(lighthouse.is_css_first());
2378
2379 let interactive = TextFxConfig::profile("cta", "Click me", TextFxProfile::Interactive);
2380 assert_eq!(interactive.trigger, TextFxTrigger::Interaction);
2381 assert_eq!(
2382 interactive.performance_profile,
2383 TextFxPerformanceProfile::Balanced
2384 );
2385 assert_eq!(interactive.gpu_budget, TextFxGpuBudget::Auto);
2386 assert_eq!(interactive.timing.stagger_ms, 18);
2387 assert!(!interactive.is_css_first());
2388
2389 let showcase = TextFxConfig::profile("demo", "Loop", TextFxProfile::Showcase);
2390 assert_eq!(showcase.gpu_budget, TextFxGpuBudget::Exact);
2391 }
2392
2393 #[test]
2394 fn performance_profile_serializes_and_parses() {
2395 let defaulted = TextFxConfig::new("hero", "Fast text");
2396 assert_eq!(
2397 defaulted.performance_profile,
2398 TextFxPerformanceProfile::CssFirst
2399 );
2400 let balanced = TextFxConfig::from_fx(
2401 "hero",
2402 "Build [[fast|focus]]",
2403 "perf:balanced mark:focus:scale-150",
2404 )
2405 .unwrap();
2406 assert_eq!(
2407 balanced.performance_profile,
2408 TextFxPerformanceProfile::Balanced
2409 );
2410 assert_eq!(balanced.split, TextSplit::Words);
2411 let exact = TextFxConfig::from_fx("hero", "Exact", "perf:exact fade").unwrap();
2412 assert_eq!(
2413 exact.performance_profile,
2414 TextFxPerformanceProfile::VisualExact
2415 );
2416 let attr = exact.html_attrs().unwrap();
2417 assert!(attr.contains(r#"data-dxt-performance="visual-exact""#));
2418 assert!(
2419 exact
2420 .to_compact_json()
2421 .unwrap()
2422 .contains(r#""pf":"visual-exact""#)
2423 );
2424 }
2425
2426 #[test]
2427 fn gpu_budget_serializes_and_parses() {
2428 let config =
2429 TextFxConfig::from_fx("hero", "GPU budget", "fade gpu:low-power perf:balanced")
2430 .unwrap();
2431 assert_eq!(config.gpu_budget, TextFxGpuBudget::LowPower);
2432 assert_eq!(
2433 config.performance_profile,
2434 TextFxPerformanceProfile::Balanced
2435 );
2436 let json = config.to_compact_json().unwrap();
2437 assert!(json.contains(r#""gb":"low-power""#));
2438 let attr = config.html_attrs().unwrap();
2439 assert!(attr.contains(r#"data-dxt-gpu-budget="low-power""#));
2440
2441 let exact = TextFxConfig::from_fx("hero", "Exact", "gpu-exact").unwrap();
2442 assert_eq!(exact.gpu_budget, TextFxGpuBudget::Exact);
2443 }
2444
2445 #[test]
2446 fn workertown_render_preference_is_explicit_and_route_scoped() {
2447 let defaulted = TextFxConfig::new("hero", "Static text");
2448 assert_eq!(defaulted.render_preference, TextFxRenderPreference::Auto);
2449 assert!(!defaulted.requires_workertown_render());
2450
2451 let worker = TextFxConfig::from_fx(
2452 "hero",
2453 "Worker rendered text",
2454 "highlight-sweep render:workertown",
2455 )
2456 .unwrap();
2457 assert_eq!(
2458 worker.render_preference,
2459 TextFxRenderPreference::WorkerTownRender
2460 );
2461 assert_eq!(
2462 worker.performance_profile,
2463 TextFxPerformanceProfile::VisualExact
2464 );
2465 assert_eq!(worker.gpu_budget, TextFxGpuBudget::Exact);
2466 assert!(worker.requires_workertown_render());
2467 assert!(
2468 worker
2469 .html_attrs()
2470 .unwrap()
2471 .contains(r#"data-dxt-renderer="workertown-render""#)
2472 );
2473 assert!(
2474 worker
2475 .to_compact_json()
2476 .unwrap()
2477 .contains(r#""rp":"workertown-render""#)
2478 );
2479 }
2480
2481 #[test]
2482 fn layout_reserve_serializes_compact_target_metadata_and_fx_tokens() {
2483 let defaulted = TextFxConfig::new("hero", "Stable text");
2484 assert_eq!(defaulted.layout_reserve, TextFxLayoutReserve::Auto);
2485 assert!(defaulted.reserves_layout());
2486 assert!(!defaulted.to_compact_json().unwrap().contains(r#""tlr""#));
2487 assert!(
2488 defaulted
2489 .html_attrs()
2490 .unwrap()
2491 .contains(r#"data-dxr-text-layout-target="auto""#)
2492 );
2493
2494 let exact = TextFxConfig::from_fx("hero", "Exact reserve", "fade tlr:exact").unwrap();
2495 assert_eq!(exact.layout_reserve, TextFxLayoutReserve::Exact);
2496 assert!(
2497 exact
2498 .to_compact_json()
2499 .unwrap()
2500 .contains(r#""tlr":"exact""#)
2501 );
2502 assert!(
2503 exact
2504 .html_attrs()
2505 .unwrap()
2506 .contains(r#"data-dxr-text-layout-target="exact""#)
2507 );
2508
2509 let off = TextFxConfig::new("hero", "No reserve").layout_reserve_off();
2510 assert_eq!(off.layout_reserve, TextFxLayoutReserve::Off);
2511 assert!(!off.reserves_layout());
2512 assert!(off.to_compact_json().unwrap().contains(r#""tlr":"off""#));
2513 assert!(
2514 !off.html_attrs()
2515 .unwrap()
2516 .contains("data-dxr-text-layout-target")
2517 );
2518 }
2519
2520 #[test]
2521 fn compact_data_attr_keeps_textfx_contract() {
2522 let config = TextFxConfig::new("hero", "Readable first paint")
2523 .with_effect(TextFxEffect::BlurReveal)
2524 .with_profile(TextFxProfile::Lighthouse);
2525 let attr = config.data_attr().unwrap();
2526 assert!(attr.starts_with("data-dxt-textfx="));
2527 assert!(attr.contains(""v":1"));
2528 assert!(attr.contains(""e":"br""));
2529 assert!(attr.len() < config.to_json().unwrap().len() + "data-dxt-textfx=\"\"".len());
2530 }
2531
2532 #[test]
2533 fn locale_data_attr_uses_locale_specific_contract() {
2534 for effect in TextFxEffect::ALL {
2535 let attr = TextFxConfig::new(effect.as_attr(), effect.label())
2536 .with_effect(effect)
2537 .locale_data_attr()
2538 .unwrap();
2539 assert!(attr.starts_with("data-dxt-locale-fx="));
2540 assert!(!attr.contains("data-dxt-textfx"));
2541 assert!(attr.contains(effect.compact_id()) || attr.contains(effect.as_attr()));
2542 }
2543 }
2544
2545 #[test]
2546 fn lifecycle_builders_serialize_compact_enter_and_exit_phases() {
2547 let config = TextFxConfig::new("route-title", "Route")
2548 .with_effect(TextFxEffect::Flip)
2549 .with_duration_ms(520)
2550 .with_stagger_ms(18)
2551 .with_enter_delay_ms(30)
2552 .with_exit_duration_ms(260)
2553 .with_exit_stagger_ms(8)
2554 .with_exit_reverse_of_enter();
2555
2556 assert_eq!(
2557 config
2558 .lifecycle
2559 .exit
2560 .as_ref()
2561 .and_then(|phase| phase.playback.as_ref())
2562 .map(|playback| playback.reverse),
2563 Some(true)
2564 );
2565 let compact = config.to_compact_json().unwrap();
2566 assert!(compact.contains(r#""en":"#), "{compact}");
2567 assert!(compact.contains(r#""ex":"#), "{compact}");
2568 assert!(compact.contains(r#""delayMs":30"#), "{compact}");
2569 assert!(compact.contains(r#""durationMs":260"#), "{compact}");
2570 }
2571
2572 #[test]
2573 fn lifecycle_fx_tokens_parse_phase_overrides_without_changing_base_timing() {
2574 let config = TextFxConfig::from_fx(
2575 "hero",
2576 "Tabbed title",
2577 concat!(
2578 "flip duration-520 stagger-18 enter:delay-80 exit:",
2579 "blur",
2580 "-reveal exit:duration-240 exit:reverse"
2581 ),
2582 )
2583 .unwrap();
2584
2585 assert_eq!(config.effect, TextFxEffect::Flip);
2586 assert_eq!(config.timing.duration_ms, 520);
2587 assert_eq!(config.timing.stagger_ms, 18);
2588 assert_eq!(
2589 config
2590 .lifecycle
2591 .enter
2592 .as_ref()
2593 .and_then(|phase| phase.timing.as_ref())
2594 .and_then(|timing| timing.delay_ms),
2595 Some(80)
2596 );
2597 assert_eq!(
2598 config
2599 .lifecycle
2600 .exit
2601 .as_ref()
2602 .and_then(|phase| phase.effect),
2603 Some(TextFxEffect::BlurReveal)
2604 );
2605 assert_eq!(
2606 config
2607 .lifecycle
2608 .exit
2609 .as_ref()
2610 .and_then(|phase| phase.timing.as_ref())
2611 .and_then(|timing| timing.duration_ms),
2612 Some(240)
2613 );
2614 assert_eq!(
2615 config
2616 .lifecycle
2617 .exit
2618 .as_ref()
2619 .and_then(|phase| phase.playback.as_ref())
2620 .map(|playback| playback.reverse),
2621 Some(true)
2622 );
2623 }
2624
2625 #[test]
2626 fn configs_without_lifecycle_keep_existing_compact_shape() {
2627 let compact = TextFxConfig::new("hero", "Stable")
2628 .with_effect(TextFxEffect::BlurReveal)
2629 .to_compact_json()
2630 .unwrap();
2631 assert!(!compact.contains(r#""en""#), "{compact}");
2632 assert!(!compact.contains(r#""ex""#), "{compact}");
2633 assert!(!compact.contains("lifecycle"), "{compact}");
2634 }
2635
2636 #[test]
2637 fn default_timing_matches_package_contract() {
2638 let timing = TextFxTiming::default();
2639 assert_eq!(timing.duration_ms, 640);
2640 assert_eq!(timing.stagger_ms, 28);
2641 assert_eq!(timing.easing, TextFxEasing::EaseOut);
2642 assert_eq!(ReducedMotion::default(), ReducedMotion::FadeOnly);
2643 }
2644
2645 #[test]
2646 fn static_html_keeps_semantic_text_and_no_script() {
2647 let html = TextFxConfig::new("hero", "Readable first paint")
2648 .with_effect(TextFxEffect::Typewriter)
2649 .static_html("h1", "")
2650 .unwrap();
2651 assert!(html.contains("Readable first paint"));
2652 assert!(html.contains("data-dxt-textfx"));
2653 assert!(html.contains("data-dxr-on-visible=\"textfx.run\""));
2654 assert!(!html.contains("aria-label="));
2655 assert!(!html.contains("<script"));
2656 assert!(!html.contains("modulepreload"));
2657 }
2658
2659 #[test]
2660 fn html_attrs_do_not_name_generic_textfx_elements() {
2661 let attrs = TextFxConfig::new("hero", "Readable first paint")
2662 .with_effect(TextFxEffect::Wave)
2663 .html_attrs()
2664 .unwrap();
2665 assert!(attrs.contains("data-dxt-textfx"));
2666 assert!(!attrs.contains("aria-label="));
2667 }
2668
2669 #[test]
2670 fn trigger_attrs_match_resume_events() {
2671 assert_eq!(
2672 TextFxTrigger::Visible.resume_attr().as_deref(),
2673 Some(r#"data-dxr-on-visible="textfx.run""#)
2674 );
2675 assert_eq!(
2676 TextFxTrigger::Hover.resume_attr().as_deref(),
2677 Some(r#"data-dxr-on-pointerover="textfx.run""#)
2678 );
2679 assert_eq!(
2680 TextFxTrigger::WordClick.resume_attr().as_deref(),
2681 Some(r#"data-dxr-on-click="textfx.run""#)
2682 );
2683 assert_eq!(TextFxTrigger::Manual.resume_attr(), None);
2684 }
2685
2686 #[test]
2687 fn parses_inline_marks_into_clean_text() {
2688 let marked = parse_inline_marks("Build [[fast websites|focus]] with [[zero reloads|swap]]");
2689 assert_eq!(marked.clean_text, "Build fast websites with zero reloads");
2690 assert_eq!(marked.marks.len(), 2);
2691 assert_eq!(marked.marks[0].name, "focus");
2692 assert_eq!(marked.marks[0].word_start, 1);
2693 assert_eq!(marked.marks[0].word_end, 2);
2694 }
2695
2696 #[test]
2697 fn parses_tailwind_like_target_tokens() {
2698 let config = TextFxConfig::from_fx(
2699 "hero",
2700 "Build fast websites with zero reloads",
2701 "split-words on-hover target:words(1..2):scale-150:stay target:others:slide-away-left duration-700 ease-in-out loop-3",
2702 )
2703 .unwrap();
2704 assert_eq!(config.split, TextSplit::Words);
2705 assert_eq!(config.trigger, TextFxTrigger::Hover);
2706 assert_eq!(config.timing.duration_ms, 700);
2707 assert_eq!(config.playback.loop_mode, TextFxLoop::Count(3));
2708 assert_eq!(config.choreography.len(), 2);
2709 assert!(matches!(
2710 config.choreography[0].target,
2711 TokenTarget::WordRange { start: 1, end: 2 }
2712 ));
2713 assert_eq!(config.choreography[0].action.scale, Some(1.5));
2714 assert!(config.choreography[0].action.stay);
2715 }
2716
2717 #[test]
2718 fn parses_mark_swap_tokens() {
2719 let config = TextFxConfig::from_fx(
2720 "hero",
2721 "Build [[fast websites|focus]] with [[zero reloads|swap]]",
2722 "on-word-click mark:focus:highlight mark:swap:swap('instant resumes')",
2723 )
2724 .unwrap();
2725 assert_eq!(config.text, "Build fast websites with zero reloads");
2726 assert_eq!(config.marks.len(), 2);
2727 assert_eq!(config.trigger, TextFxTrigger::WordClick);
2728 assert_eq!(
2729 config.choreography[1].action.swap.as_deref(),
2730 Some("instant resumes")
2731 );
2732 }
2733
2734 #[test]
2735 fn parses_live_contrast_effect_and_token_actions() {
2736 let config = TextFxConfig::from_fx(
2737 "contrast",
2738 "Only [[these words|focus]] adapt live",
2739 "live-contrast mark:focus:live-contrast-exclusion",
2740 )
2741 .unwrap();
2742 assert_eq!(config.effect, TextFxEffect::LiveContrast);
2743 assert_eq!(
2744 config.choreography[0].action.live_contrast,
2745 Some(TextFxLiveContrast::Exclusion)
2746 );
2747 assert!(config.to_json().unwrap().contains("liveContrast"));
2748 assert_eq!(
2749 config.live_contrast_attr().as_deref(),
2750 Some(r#"data-dxt-live-contrast="difference""#)
2751 );
2752 }
2753}