Skip to main content

dioxus_textfx_core/
lib.rs

1//! native-port: supports css-filter
2
3use 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('&', "&amp;")
1596        .replace('<', "&lt;")
1597        .replace('>', "&gt;")
1598}
1599
1600pub fn escape_attr(value: &str) -> String {
1601    escape_html(value)
1602        .replace('"', "&quot;")
1603        .replace('\'', "&#39;")
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("&quot;sp&quot;:&quot;chars&quot;")
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("&quot;v&quot;:1"));
2528        assert!(attr.contains("&quot;e&quot;:&quot;br&quot;"));
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}