use serde::{Deserialize, Serialize};
use std::fmt;
use std::ops::RangeInclusive;
pub const DEFAULT_TEXTFX_DURATION_MS: u32 = 640;
pub const DEFAULT_TEXTFX_STAGGER_MS: u32 = 28;
pub const DEFAULT_TEXTFX_SPEED_MS: u32 = 32;
pub const DEFAULT_TEXTFX_CHARSET: &str =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const BLUR_REVEAL_ATTR: &str = concat!("blur", "-reveal");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxEffect {
Fade,
Slide,
BlurReveal,
Scale,
Typewriter,
Scramble,
Stagger,
CountUp,
Wave,
Flip,
MaskReveal,
Glitch,
HighlightSweep,
GradientShift,
KerningExpand,
NumberTicker,
LiveContrast,
}
impl Default for TextFxEffect {
fn default() -> Self {
Self::BlurReveal
}
}
impl TextFxEffect {
pub const ALL: [Self; 17] = [
Self::Fade,
Self::Slide,
Self::BlurReveal,
Self::Scale,
Self::Typewriter,
Self::Scramble,
Self::Stagger,
Self::CountUp,
Self::Wave,
Self::Flip,
Self::MaskReveal,
Self::Glitch,
Self::HighlightSweep,
Self::GradientShift,
Self::KerningExpand,
Self::NumberTicker,
Self::LiveContrast,
];
pub fn as_attr(self) -> &'static str {
match self {
Self::Fade => "fade",
Self::Slide => "slide",
Self::BlurReveal => BLUR_REVEAL_ATTR,
Self::Scale => "scale",
Self::Typewriter => "typewriter",
Self::Scramble => "scramble",
Self::Stagger => "stagger",
Self::CountUp => "count-up",
Self::Wave => "wave",
Self::Flip => "flip",
Self::MaskReveal => "mask-reveal",
Self::Glitch => "glitch",
Self::HighlightSweep => "highlight-sweep",
Self::GradientShift => "gradient-shift",
Self::KerningExpand => "kerning-expand",
Self::NumberTicker => "number-ticker",
Self::LiveContrast => "live-contrast",
}
}
pub fn compact_id(self) -> &'static str {
match self {
Self::Fade => "f",
Self::Slide => "sl",
Self::BlurReveal => "br",
Self::Scale => "sc",
Self::Typewriter => "tw",
Self::Scramble => "sr",
Self::Stagger => "st",
Self::CountUp => "cu",
Self::Wave => "wv",
Self::Flip => "fl",
Self::MaskReveal => "mr",
Self::Glitch => "gl",
Self::HighlightSweep => "hs",
Self::GradientShift => "gs",
Self::KerningExpand => "ke",
Self::NumberTicker => "nt",
Self::LiveContrast => "lc",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Fade => "Fade",
Self::Slide => "Slide",
Self::BlurReveal => "Blur Reveal",
Self::Scale => "Scale",
Self::Typewriter => "Typewriter",
Self::Scramble => "Scramble",
Self::Stagger => "Stagger",
Self::CountUp => "Count Up",
Self::Wave => "Wave",
Self::Flip => "Flip",
Self::MaskReveal => "Mask Reveal",
Self::Glitch => "Glitch",
Self::HighlightSweep => "Highlight Sweep",
Self::GradientShift => "Gradient Shift",
Self::KerningExpand => "Kerning Expand",
Self::NumberTicker => "Number Ticker",
Self::LiveContrast => "Live Contrast",
}
}
pub fn needs_split(self) -> bool {
matches!(
self,
Self::Stagger | Self::Wave | Self::Flip | Self::Glitch | Self::KerningExpand
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxLiveContrast {
Difference,
Exclusion,
Plus,
}
impl Default for TextFxLiveContrast {
fn default() -> Self {
Self::Difference
}
}
impl TextFxLiveContrast {
pub fn as_attr(self) -> &'static str {
match self {
Self::Difference => "difference",
Self::Exclusion => "exclusion",
Self::Plus => "plus",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxEasing {
Linear,
EaseIn,
EaseOut,
EaseInOut,
Spring,
CubicBezier(f32, f32, f32, f32),
}
impl Default for TextFxEasing {
fn default() -> Self {
Self::EaseOut
}
}
impl TextFxEasing {
pub fn css_value(self) -> String {
match self {
Self::Linear => "linear".to_string(),
Self::EaseIn => "cubic-bezier(.42,0,1,1)".to_string(),
Self::EaseOut => "cubic-bezier(0,0,.2,1)".to_string(),
Self::EaseInOut => "cubic-bezier(.42,0,.58,1)".to_string(),
Self::Spring => "cubic-bezier(.18,.89,.32,1.28)".to_string(),
Self::CubicBezier(a, b, c, d) => format!("cubic-bezier({a},{b},{c},{d})"),
}
}
#[cfg(feature = "viewtx-interop")]
pub fn from_viewtx_easing(easing: &str) -> Self {
match easing.trim().to_ascii_lowercase().as_str() {
"linear" => Self::Linear,
"ease-in" => Self::EaseIn,
"ease-out" => Self::EaseOut,
"ease" | "ease-in-out" => Self::EaseInOut,
"spring" => Self::Spring,
_ => dioxus_viewtx_core::parse_viewtx_cubic_bezier(easing)
.map(|(a, b, c, d)| Self::CubicBezier(a, b, c, d))
.unwrap_or(Self::EaseOut),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum TextFxTrigger {
Load,
Visible,
Interaction,
Manual,
Hover,
Click,
Focus,
Blur,
WordHover,
WordClick,
SelectorClick { selector: String },
SelectorHover { selector: String },
Event { name: String },
Cascade { name: String },
}
impl Default for TextFxTrigger {
fn default() -> Self {
Self::Visible
}
}
impl TextFxTrigger {
pub fn resume_attr(&self) -> Option<String> {
match self {
Self::Load => Some(r#"data-dxr-on-load="textfx.run""#.to_string()),
Self::Hover | Self::WordHover => {
Some(r#"data-dxr-on-pointerover="textfx.run""#.to_string())
}
Self::Click | Self::Interaction | Self::WordClick => {
Some(r#"data-dxr-on-click="textfx.run""#.to_string())
}
Self::Manual => None,
_ => Some(r#"data-dxr-on-visible="textfx.run""#.to_string()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxLoop {
Once,
Infinite,
Count(u16),
}
impl Default for TextFxLoop {
fn default() -> Self {
Self::Once
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxPlayback {
pub loop_mode: TextFxLoop,
pub reverse: bool,
pub alternate: bool,
pub yoyo: bool,
pub repeat_delay_ms: u32,
}
impl Default for TextFxPlayback {
fn default() -> Self {
Self {
loop_mode: TextFxLoop::Once,
reverse: false,
alternate: false,
yoyo: false,
repeat_delay_ms: 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextSplit {
None,
Chars,
Words,
Lines,
}
impl Default for TextSplit {
fn default() -> Self {
Self::None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReducedMotion {
Static,
FadeOnly,
Ignore,
}
impl Default for ReducedMotion {
fn default() -> Self {
Self::FadeOnly
}
}
impl ReducedMotion {
#[cfg(feature = "viewtx-interop")]
pub fn from_viewtx_reduced_motion(
reduced_motion: dioxus_viewtx_core::ViewTransitionReducedMotion,
) -> Self {
match reduced_motion {
dioxus_viewtx_core::ViewTransitionReducedMotion::Disable => Self::Static,
dioxus_viewtx_core::ViewTransitionReducedMotion::FadeOnly => Self::FadeOnly,
dioxus_viewtx_core::ViewTransitionReducedMotion::Ignore => Self::Ignore,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxDirection {
Up,
Right,
Down,
Left,
}
impl Default for TextFxDirection {
fn default() -> Self {
Self::Up
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenMark {
pub name: String,
pub text: String,
pub char_start: usize,
pub char_end: usize,
pub word_start: usize,
pub word_end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum TokenTarget {
All,
Others,
Mark { name: String },
Word { index: usize },
WordRange { start: usize, end: usize },
CharRange { start: usize, end: usize },
WordText { value: String },
Contains { value: String },
}
impl TokenTarget {
pub fn all() -> Self {
Self::All
}
pub fn others() -> Self {
Self::Others
}
pub fn mark(name: impl Into<String>) -> Self {
Self::Mark { name: name.into() }
}
pub fn word(index: usize) -> Self {
Self::Word { index }
}
pub fn word_range(range: RangeInclusive<usize>) -> Self {
Self::WordRange {
start: *range.start(),
end: *range.end(),
}
}
pub fn char_range(range: RangeInclusive<usize>) -> Self {
Self::CharRange {
start: *range.start(),
end: *range.end(),
}
}
pub fn word_text(value: impl Into<String>) -> Self {
Self::WordText {
value: value.into(),
}
}
pub fn contains(value: impl Into<String>) -> Self {
Self::Contains {
value: value.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenAction {
pub stay: bool,
pub scale: Option<f32>,
pub slide_away: Option<TextFxDirection>,
pub opacity: Option<f32>,
pub highlight: bool,
pub underline_sweep: bool,
pub swap: Option<String>,
pub scramble_to: Option<String>,
pub blur: bool,
pub color: Option<String>,
pub delay_ms: Option<u32>,
pub stagger_ms: Option<u32>,
pub live_contrast: Option<TextFxLiveContrast>,
}
impl Default for TokenAction {
fn default() -> Self {
Self {
stay: false,
scale: None,
slide_away: None,
opacity: None,
highlight: false,
underline_sweep: false,
swap: None,
scramble_to: None,
blur: false,
color: None,
delay_ms: None,
stagger_ms: None,
live_contrast: None,
}
}
}
impl TokenAction {
pub fn stay(mut self) -> Self {
self.stay = true;
self
}
pub fn scale(value: f32) -> Self {
Self {
scale: Some(value),
..Self::default()
}
}
pub fn slide_away(direction: TextFxDirection) -> Self {
Self {
slide_away: Some(direction),
..Self::default()
}
}
pub fn highlight() -> Self {
Self {
highlight: true,
..Self::default()
}
}
pub fn swap(value: impl Into<String>) -> Self {
Self {
swap: Some(value.into()),
..Self::default()
}
}
pub fn live_contrast() -> Self {
Self::live_contrast_mode(TextFxLiveContrast::Difference)
}
pub fn live_contrast_mode(mode: TextFxLiveContrast) -> Self {
Self {
live_contrast: Some(mode),
..Self::default()
}
}
pub fn merge(mut self, other: Self) -> Self {
self.stay |= other.stay;
self.highlight |= other.highlight;
self.underline_sweep |= other.underline_sweep;
self.blur |= other.blur;
self.scale = other.scale.or(self.scale);
self.slide_away = other.slide_away.or(self.slide_away);
self.opacity = other.opacity.or(self.opacity);
self.swap = other.swap.or(self.swap);
self.scramble_to = other.scramble_to.or(self.scramble_to);
self.color = other.color.or(self.color);
self.delay_ms = other.delay_ms.or(self.delay_ms);
self.stagger_ms = other.stagger_ms.or(self.stagger_ms);
self.live_contrast = other.live_contrast.or(self.live_contrast);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxChoreography {
pub target: TokenTarget,
pub action: TokenAction,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextFxParseError {
message: String,
}
impl TextFxParseError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for TextFxParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.message.fmt(f)
}
}
impl std::error::Error for TextFxParseError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkedText {
pub clean_text: String,
pub marks: Vec<TokenMark>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxTiming {
pub duration_ms: u32,
pub delay_ms: u32,
pub speed_ms: u32,
pub stagger_ms: u32,
pub easing: TextFxEasing,
}
impl Default for TextFxTiming {
fn default() -> Self {
Self {
duration_ms: DEFAULT_TEXTFX_DURATION_MS,
delay_ms: 0,
speed_ms: DEFAULT_TEXTFX_SPEED_MS,
stagger_ms: DEFAULT_TEXTFX_STAGGER_MS,
easing: TextFxEasing::EaseOut,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TextFxPhaseTiming {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delay_ms: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed_ms: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stagger_ms: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub easing: Option<TextFxEasing>,
}
impl TextFxPhaseTiming {
pub fn is_empty(&self) -> bool {
self.duration_ms.is_none()
&& self.delay_ms.is_none()
&& self.speed_ms.is_none()
&& self.stagger_ms.is_none()
&& self.easing.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TextFxPhase {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effect: Option<TextFxEffect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timing: Option<TextFxPhaseTiming>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub split: Option<TextSplit>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direction: Option<TextFxDirection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub playback: Option<TextFxPlayback>,
}
impl TextFxPhase {
pub fn new() -> Self {
Self::default()
}
pub fn reverse_of_enter() -> Self {
let playback = TextFxPlayback {
reverse: true,
..TextFxPlayback::default()
};
Self {
playback: Some(playback),
..Self::default()
}
}
pub fn is_empty(&self) -> bool {
self.effect.is_none()
&& self
.timing
.as_ref()
.map_or(true, TextFxPhaseTiming::is_empty)
&& self.split.is_none()
&& self.direction.is_none()
&& self.playback.is_none()
}
fn timing_mut(&mut self) -> &mut TextFxPhaseTiming {
self.timing.get_or_insert_with(TextFxPhaseTiming::default)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TextFxLifecycle {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enter: Option<TextFxPhase>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit: Option<TextFxPhase>,
}
impl TextFxLifecycle {
pub fn is_empty(&self) -> bool {
self.enter.as_ref().map_or(true, TextFxPhase::is_empty)
&& self.exit.as_ref().map_or(true, TextFxPhase::is_empty)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TextFxPhaseKind {
Enter,
Exit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxProfile {
Lighthouse,
Showcase,
Interactive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxPerformanceProfile {
CssFirst,
Balanced,
VisualExact,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxGpuBudget {
Auto,
LowPower,
Normal,
Exact,
}
impl Default for TextFxGpuBudget {
fn default() -> Self {
Self::Auto
}
}
impl TextFxGpuBudget {
pub fn as_attr(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::LowPower => "low-power",
Self::Normal => "normal",
Self::Exact => "exact",
}
}
pub fn compact_id(self) -> &'static str {
match self {
Self::Auto => "a",
Self::LowPower => "l",
Self::Normal => "n",
Self::Exact => "x",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxRenderPreference {
#[default]
Auto,
CssFirst,
#[serde(rename = "workertown-render")]
WorkerTownRender,
MainThreadFallback,
}
impl TextFxRenderPreference {
pub fn as_attr(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::CssFirst => "css-first",
Self::WorkerTownRender => "workertown-render",
Self::MainThreadFallback => "main-thread-fallback",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxLayoutReserve {
Off,
Auto,
Exact,
}
impl Default for TextFxLayoutReserve {
fn default() -> Self {
Self::Auto
}
}
impl TextFxLayoutReserve {
pub fn as_attr(self) -> &'static str {
match self {
Self::Off => "off",
Self::Auto => "auto",
Self::Exact => "exact",
}
}
}
impl Default for TextFxPerformanceProfile {
fn default() -> Self {
Self::CssFirst
}
}
impl TextFxPerformanceProfile {
pub fn as_attr(self) -> &'static str {
match self {
Self::CssFirst => "css-first",
Self::Balanced => "balanced",
Self::VisualExact => "visual-exact",
}
}
pub fn compact_id(self) -> &'static str {
match self {
Self::CssFirst => "css",
Self::Balanced => "bal",
Self::VisualExact => "exact",
}
}
}
impl TextFxProfile {
pub fn as_attr(self) -> &'static str {
match self {
Self::Lighthouse => "lighthouse",
Self::Showcase => "showcase",
Self::Interactive => "interactive",
}
}
pub fn timing(self) -> TextFxTiming {
match self {
Self::Lighthouse => TextFxTiming {
duration_ms: 360,
delay_ms: 0,
speed_ms: 24,
stagger_ms: 10,
easing: TextFxEasing::EaseOut,
},
Self::Showcase => TextFxTiming {
duration_ms: 760,
delay_ms: 0,
speed_ms: 32,
stagger_ms: 32,
easing: TextFxEasing::Spring,
},
Self::Interactive => TextFxTiming {
duration_ms: 520,
delay_ms: 0,
speed_ms: 24,
stagger_ms: 18,
easing: TextFxEasing::EaseOut,
},
}
}
pub fn reduced_motion(self) -> ReducedMotion {
match self {
Self::Lighthouse => ReducedMotion::Static,
Self::Showcase | Self::Interactive => ReducedMotion::FadeOnly,
}
}
pub fn trigger(self) -> TextFxTrigger {
match self {
Self::Lighthouse => TextFxTrigger::Load,
Self::Showcase => TextFxTrigger::Visible,
Self::Interactive => TextFxTrigger::Interaction,
}
}
pub fn prefers_css_first(self) -> bool {
matches!(self, Self::Lighthouse | Self::Showcase)
}
pub fn performance_profile(self) -> TextFxPerformanceProfile {
match self {
Self::Lighthouse => TextFxPerformanceProfile::CssFirst,
Self::Showcase => TextFxPerformanceProfile::VisualExact,
Self::Interactive => TextFxPerformanceProfile::Balanced,
}
}
pub fn gpu_budget(self) -> TextFxGpuBudget {
match self {
Self::Lighthouse | Self::Interactive => TextFxGpuBudget::Auto,
Self::Showcase => TextFxGpuBudget::Exact,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxConfig {
pub id: String,
pub text: String,
pub effect: TextFxEffect,
pub timing: TextFxTiming,
pub split: TextSplit,
pub reduced_motion: ReducedMotion,
pub performance_profile: TextFxPerformanceProfile,
pub gpu_budget: TextFxGpuBudget,
#[serde(default)]
pub render_preference: TextFxRenderPreference,
#[serde(default)]
pub layout_reserve: TextFxLayoutReserve,
pub trigger: TextFxTrigger,
pub direction: TextFxDirection,
pub playback: TextFxPlayback,
pub intensity: f32,
pub palette: Vec<String>,
pub charset: String,
pub cursor: bool,
pub from: Option<f64>,
pub to: Option<f64>,
pub fx: Option<String>,
#[serde(default, skip_serializing_if = "TextFxLifecycle::is_empty")]
pub lifecycle: TextFxLifecycle,
pub marks: Vec<TokenMark>,
pub choreography: Vec<TextFxChoreography>,
}
impl TextFxConfig {
pub fn new(id: impl Into<String>, text: impl Into<String>) -> Self {
let marked = parse_inline_marks(&text.into());
Self {
id: id.into(),
text: marked.clean_text,
effect: TextFxEffect::default(),
timing: TextFxTiming::default(),
split: TextSplit::None,
reduced_motion: ReducedMotion::default(),
performance_profile: TextFxPerformanceProfile::default(),
gpu_budget: TextFxGpuBudget::default(),
render_preference: TextFxRenderPreference::default(),
layout_reserve: TextFxLayoutReserve::default(),
trigger: TextFxTrigger::default(),
direction: TextFxDirection::default(),
playback: TextFxPlayback::default(),
intensity: 1.0,
palette: vec![
"#ff7a1a".to_string(),
"#ffffff".to_string(),
"#9fb7ff".to_string(),
],
charset: DEFAULT_TEXTFX_CHARSET.to_string(),
cursor: true,
from: None,
to: None,
fx: None,
lifecycle: TextFxLifecycle::default(),
marks: marked.marks,
choreography: Vec::new(),
}
}
pub fn from_fx(
id: impl Into<String>,
text: impl Into<String>,
fx: impl Into<String>,
) -> Result<Self, TextFxParseError> {
let fx = fx.into();
let mut config = Self::new(id, text);
config.fx = Some(fx.clone());
parse_fx_tokens(&mut config, &fx)?;
Ok(config)
}
pub fn profile(id: impl Into<String>, text: impl Into<String>, profile: TextFxProfile) -> Self {
Self::new(id, text).with_profile(profile)
}
pub fn with_profile(mut self, profile: TextFxProfile) -> Self {
self.timing = profile.timing();
self.reduced_motion = profile.reduced_motion();
self.performance_profile = profile.performance_profile();
self.gpu_budget = profile.gpu_budget();
self.trigger = profile.trigger();
if profile.prefers_css_first() && !self.effect.needs_split() {
self.split = TextSplit::None;
}
self
}
pub fn with_effect(mut self, effect: TextFxEffect) -> Self {
self.effect = effect;
if effect.needs_split() && self.split == TextSplit::None {
self.split = TextSplit::Chars;
self.promote_for_runtime_text_motion();
}
if matches!(effect, TextFxEffect::CountUp | TextFxEffect::NumberTicker) {
self.split = TextSplit::None;
}
self
}
pub fn with_timing(mut self, timing: TextFxTiming) -> Self {
self.timing = timing;
self
}
pub fn with_duration_ms(mut self, duration_ms: u32) -> Self {
self.timing.duration_ms = duration_ms;
self
}
pub fn with_delay_ms(mut self, delay_ms: u32) -> Self {
self.timing.delay_ms = delay_ms;
self
}
pub fn with_speed_ms(mut self, speed_ms: u32) -> Self {
self.timing.speed_ms = speed_ms.max(1);
self
}
pub fn with_stagger_ms(mut self, stagger_ms: u32) -> Self {
self.timing.stagger_ms = stagger_ms;
self
}
pub fn with_enter_effect(mut self, effect: TextFxEffect) -> Self {
self.apply_phase_effect(TextFxPhaseKind::Enter, effect);
self
}
pub fn with_enter_delay_ms(mut self, delay_ms: u32) -> Self {
self.enter_phase_mut().timing_mut().delay_ms = Some(delay_ms);
self
}
pub fn with_enter_duration_ms(mut self, duration_ms: u32) -> Self {
self.enter_phase_mut().timing_mut().duration_ms = Some(duration_ms);
self
}
pub fn with_enter_stagger_ms(mut self, stagger_ms: u32) -> Self {
self.enter_phase_mut().timing_mut().stagger_ms = Some(stagger_ms);
self
}
pub fn with_exit_effect(mut self, effect: TextFxEffect) -> Self {
self.apply_phase_effect(TextFxPhaseKind::Exit, effect);
self
}
pub fn with_exit_delay_ms(mut self, delay_ms: u32) -> Self {
self.exit_phase_mut().timing_mut().delay_ms = Some(delay_ms);
self
}
pub fn with_exit_duration_ms(mut self, duration_ms: u32) -> Self {
self.exit_phase_mut().timing_mut().duration_ms = Some(duration_ms);
self
}
pub fn with_exit_stagger_ms(mut self, stagger_ms: u32) -> Self {
self.exit_phase_mut().timing_mut().stagger_ms = Some(stagger_ms);
self
}
pub fn with_exit_reverse_of_enter(mut self) -> Self {
let phase = self.exit_phase_mut();
let playback = TextFxPlayback {
reverse: true,
..phase.playback.clone().unwrap_or_default()
};
phase.playback = Some(playback);
self
}
pub fn with_easing(mut self, easing: TextFxEasing) -> Self {
self.timing.easing = easing;
self
}
#[cfg(feature = "viewtx-interop")]
pub fn with_viewtx_motion_policy(
mut self,
policy: &dioxus_viewtx_core::ViewMotionPolicy,
) -> Self {
self.timing.duration_ms = policy.duration_ms;
self.timing.easing = TextFxEasing::from_viewtx_easing(&policy.easing);
self.reduced_motion = ReducedMotion::from_viewtx_reduced_motion(policy.reduced_motion);
self
}
pub fn with_split(mut self, split: TextSplit) -> Self {
self.split = split;
if split != TextSplit::None {
self.promote_for_runtime_text_motion();
}
self
}
pub fn with_performance_profile(mut self, profile: TextFxPerformanceProfile) -> Self {
self.performance_profile = profile;
self
}
pub fn with_gpu_budget(mut self, budget: TextFxGpuBudget) -> Self {
self.gpu_budget = budget;
self
}
pub fn with_render_preference(mut self, preference: TextFxRenderPreference) -> Self {
self.render_preference = preference;
if matches!(preference, TextFxRenderPreference::WorkerTownRender) {
self.performance_profile = TextFxPerformanceProfile::VisualExact;
self.gpu_budget = TextFxGpuBudget::Exact;
}
self
}
pub fn with_layout_reserve(mut self, reserve: TextFxLayoutReserve) -> Self {
self.layout_reserve = reserve;
self
}
pub fn css_first(self) -> Self {
self.with_performance_profile(TextFxPerformanceProfile::CssFirst)
}
pub fn balanced(self) -> Self {
self.with_performance_profile(TextFxPerformanceProfile::Balanced)
}
pub fn visual_exact(self) -> Self {
self.with_performance_profile(TextFxPerformanceProfile::VisualExact)
}
pub fn gpu_auto(self) -> Self {
self.with_gpu_budget(TextFxGpuBudget::Auto)
}
pub fn gpu_low_power(self) -> Self {
self.with_gpu_budget(TextFxGpuBudget::LowPower)
}
pub fn gpu_normal(self) -> Self {
self.with_gpu_budget(TextFxGpuBudget::Normal)
}
pub fn gpu_exact(self) -> Self {
self.with_gpu_budget(TextFxGpuBudget::Exact)
}
pub fn workertown_render(self) -> Self {
self.with_render_preference(TextFxRenderPreference::WorkerTownRender)
}
pub fn layout_reserve_off(self) -> Self {
self.with_layout_reserve(TextFxLayoutReserve::Off)
}
pub fn layout_reserve_auto(self) -> Self {
self.with_layout_reserve(TextFxLayoutReserve::Auto)
}
pub fn layout_reserve_exact(self) -> Self {
self.with_layout_reserve(TextFxLayoutReserve::Exact)
}
fn promote_for_runtime_text_motion(&mut self) {
if self.performance_profile == TextFxPerformanceProfile::CssFirst {
self.performance_profile = TextFxPerformanceProfile::Balanced;
}
}
fn enter_phase_mut(&mut self) -> &mut TextFxPhase {
self.lifecycle
.enter
.get_or_insert_with(TextFxPhase::default)
}
fn exit_phase_mut(&mut self) -> &mut TextFxPhase {
self.lifecycle.exit.get_or_insert_with(TextFxPhase::default)
}
fn phase_mut(&mut self, phase: TextFxPhaseKind) -> &mut TextFxPhase {
match phase {
TextFxPhaseKind::Enter => self.enter_phase_mut(),
TextFxPhaseKind::Exit => self.exit_phase_mut(),
}
}
fn apply_phase_effect(&mut self, phase: TextFxPhaseKind, effect: TextFxEffect) {
let should_promote = {
let phase = self.phase_mut(phase);
phase.effect = Some(effect);
let mut should_promote = false;
if effect.needs_split() && phase.split.is_none() {
phase.split = Some(TextSplit::Chars);
should_promote = true;
}
if matches!(effect, TextFxEffect::CountUp | TextFxEffect::NumberTicker) {
phase.split = Some(TextSplit::None);
}
should_promote
};
if should_promote {
self.promote_for_runtime_text_motion();
}
}
pub fn with_trigger(mut self, trigger: TextFxTrigger) -> Self {
self.trigger = trigger;
self
}
pub fn on_hover(self) -> Self {
self.with_trigger(TextFxTrigger::Hover)
}
pub fn on_click(self) -> Self {
self.with_trigger(TextFxTrigger::Click)
}
pub fn split_words(self) -> Self {
self.with_split(TextSplit::Words)
}
pub fn split_chars(self) -> Self {
self.with_split(TextSplit::Chars)
}
pub fn loop_count(mut self, count: u16) -> Self {
self.playback.loop_mode = TextFxLoop::Count(count.max(1));
self
}
pub fn loop_infinite(mut self) -> Self {
self.playback.loop_mode = TextFxLoop::Infinite;
self
}
pub fn reverse(mut self) -> Self {
self.playback.reverse = true;
self
}
pub fn alternate(mut self) -> Self {
self.playback.alternate = true;
self
}
pub fn yoyo(mut self) -> Self {
self.playback.yoyo = true;
self
}
pub fn target(mut self, target: TokenTarget, action: TokenAction) -> Self {
self.add_target(target, action);
self
}
pub fn add_target(&mut self, target: TokenTarget, action: TokenAction) {
if matches!(
target,
TokenTarget::Word { .. }
| TokenTarget::WordRange { .. }
| TokenTarget::WordText { .. }
| TokenTarget::Contains { .. }
| TokenTarget::Mark { .. }
| TokenTarget::Others
) && self.split == TextSplit::None
{
self.split = TextSplit::Words;
}
if matches!(target, TokenTarget::CharRange { .. }) {
self.split = TextSplit::Chars;
}
self.promote_for_runtime_text_motion();
self.choreography
.push(TextFxChoreography { target, action });
}
pub fn with_reduced_motion(mut self, reduced_motion: ReducedMotion) -> Self {
self.reduced_motion = reduced_motion;
self
}
pub fn with_direction(mut self, direction: TextFxDirection) -> Self {
self.direction = direction;
self
}
pub fn with_palette(mut self, palette: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.palette = palette.into_iter().map(Into::into).collect();
self
}
pub fn with_numbers(mut self, from: f64, to: f64) -> Self {
self.from = Some(from);
self.to = Some(to);
self
}
pub fn with_cursor(mut self, cursor: bool) -> Self {
self.cursor = cursor;
self
}
pub fn with_charset(mut self, charset: impl Into<String>) -> Self {
self.charset = charset.into();
self
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn to_compact_json(&self) -> Result<String, serde_json::Error> {
let value = serde_json::to_value(self)?;
let Some(full) = value.as_object() else {
return serde_json::to_string(self);
};
let defaults = Self::default();
let default_value = serde_json::to_value(defaults)?;
let default = default_value.as_object();
let mut compact = serde_json::Map::new();
compact.insert("v".to_string(), serde_json::json!(1));
compact.insert("i".to_string(), serde_json::json!(self.id));
compact.insert("t".to_string(), serde_json::json!(self.text));
compact.insert("e".to_string(), serde_json::json!(self.effect.compact_id()));
if let Some(enter) = self
.lifecycle
.enter
.as_ref()
.filter(|phase| !phase.is_empty())
{
compact.insert("en".to_string(), serde_json::to_value(enter)?);
}
if let Some(exit) = self
.lifecycle
.exit
.as_ref()
.filter(|phase| !phase.is_empty())
{
compact.insert("ex".to_string(), serde_json::to_value(exit)?);
}
for (long, short) in [
("timing", "tm"),
("split", "sp"),
("reducedMotion", "rm"),
("performanceProfile", "pf"),
("gpuBudget", "gb"),
("renderPreference", "rp"),
("layoutReserve", "tlr"),
("trigger", "tr"),
("direction", "dir"),
("playback", "pb"),
("intensity", "in"),
("palette", "pa"),
("charset", "ch"),
("cursor", "cu"),
("from", "fr"),
("to", "to"),
("fx", "fx"),
("marks", "mk"),
("choreography", "cg"),
] {
let Some(value) = full.get(long) else {
continue;
};
let is_default = default
.and_then(|default| default.get(long))
.is_some_and(|default| default == value);
if !is_default {
compact.insert(short.to_string(), value.clone());
}
}
serde_json::to_string(&compact)
}
pub fn data_attr(&self) -> Result<String, serde_json::Error> {
let full = self.to_json()?;
let compact = self.to_compact_json()?;
let json = if compact.len() < full.len() {
compact
} else {
full
};
Ok(format!(r#"data-dxt-textfx="{}""#, escape_attr(&json)))
}
pub fn locale_data_attr(&self) -> Result<String, serde_json::Error> {
let full = self.to_json()?;
let compact = self.to_compact_json()?;
let json = if compact.len() < full.len() {
compact
} else {
full
};
let attr = format!(r#"data-dxt-locale-fx="{}""#, escape_attr(&json));
Ok(match self.layout_reserve_attr() {
Some(layout) => format!("{attr} {layout}"),
None => attr,
})
}
pub fn is_css_first(&self) -> bool {
if matches!(
self.render_preference,
TextFxRenderPreference::WorkerTownRender
) {
return false;
}
matches!(
self.effect,
TextFxEffect::Fade
| TextFxEffect::Slide
| TextFxEffect::BlurReveal
| TextFxEffect::Scale
| TextFxEffect::MaskReveal
| TextFxEffect::HighlightSweep
| TextFxEffect::GradientShift
) && self.split == TextSplit::None
&& self.choreography.is_empty()
&& matches!(self.trigger, TextFxTrigger::Load | TextFxTrigger::Visible)
&& self.playback.repeat_delay_ms == 0
}
pub fn is_css_first_split(&self) -> bool {
if matches!(
self.render_preference,
TextFxRenderPreference::WorkerTownRender
) {
return false;
}
matches!(
self.effect,
TextFxEffect::Stagger
| TextFxEffect::Wave
| TextFxEffect::Flip
| TextFxEffect::Glitch
| TextFxEffect::KerningExpand
) && matches!(
self.split,
TextSplit::Chars | TextSplit::Words | TextSplit::Lines
) && self.choreography.is_empty()
&& matches!(self.trigger, TextFxTrigger::Load | TextFxTrigger::Visible)
&& self.playback.repeat_delay_ms == 0
}
pub fn is_css_first_renderable(&self) -> bool {
self.is_css_first() || self.is_css_first_split()
}
pub fn css_first_class(&self) -> Option<String> {
self.is_css_first_renderable()
.then(|| format!("dxt-effect-{}", self.effect.as_attr()))
}
pub fn css_first_state_attrs(&self) -> Option<String> {
if !self.is_css_first_renderable() {
return None;
}
let iterations = match self.playback.loop_mode {
TextFxLoop::Once => "1".to_string(),
TextFxLoop::Infinite => "infinite".to_string(),
TextFxLoop::Count(count) => count.max(1).to_string(),
};
let direction = if self.playback.reverse {
"reverse"
} else if self.playback.alternate || self.playback.yoyo {
"alternate"
} else {
"normal"
};
let gradient_a = self
.palette
.first()
.map(String::as_str)
.unwrap_or("#ff7a1a");
let gradient_b = self.palette.get(1).map(String::as_str).unwrap_or("#ffffff");
let gradient_c = self.palette.get(2).map(String::as_str).unwrap_or("#9fb7ff");
let mut style = format!(
"--dxt-duration:{}ms;--dxt-delay:{}ms;--dxt-stagger:{}ms;--dxt-ease:{};--dxt-iterations:{};--dxt-direction:{};--dxt-gradient-a:{};--dxt-gradient-b:{};--dxt-gradient-c:{};",
self.timing.duration_ms,
self.timing.delay_ms,
self.timing.stagger_ms,
escape_attr(&self.timing.easing.css_value()),
escape_attr(&iterations),
direction,
escape_attr(gradient_a),
escape_attr(gradient_b),
escape_attr(gradient_c),
);
if self.is_css_first_split() && self.reserves_layout() {
style.push_str(&format!(
"--dxt-layout-fallback-lines:{};min-block-size:calc(var(--dxt-layout-fallback-lines) * 1.2em);",
self.layout_fallback_lines()
));
}
let attrs = vec![
r#"data-dxt-css-first="true""#.to_string(),
r#"data-dxt-state="running""#.to_string(),
format!(r#"style="{style}""#),
];
Some(attrs.join(" "))
}
pub fn reserves_layout(&self) -> bool {
self.layout_reserve != TextFxLayoutReserve::Off
}
pub fn layout_reserve_attr(&self) -> Option<String> {
self.reserves_layout().then(|| {
format!(
r#"data-dxr-text-layout-target="{}""#,
escape_attr(self.layout_reserve.as_attr())
)
})
}
pub fn layout_fallback_lines(&self) -> usize {
self.text.split('\n').count().max(1)
}
pub fn live_contrast_mode(&self) -> Option<TextFxLiveContrast> {
if self.effect == TextFxEffect::LiveContrast {
Some(TextFxLiveContrast::Difference)
} else {
None
}
}
pub fn live_contrast_attr(&self) -> Option<String> {
self.live_contrast_mode().map(|mode| {
format!(
r#"data-dxt-live-contrast="{}""#,
escape_attr(mode.as_attr())
)
})
}
pub fn requires_workertown_render(&self) -> bool {
matches!(
self.render_preference,
TextFxRenderPreference::WorkerTownRender
)
}
pub fn trigger_attr(&self) -> Option<&'static str> {
None
}
pub fn resume_trigger_attr(&self) -> Option<String> {
if matches!(
self.render_preference,
TextFxRenderPreference::WorkerTownRender
) {
return self.trigger.resume_attr();
}
if self.is_css_first_renderable()
|| (self.effect == TextFxEffect::LiveContrast && self.choreography.is_empty())
{
None
} else {
self.trigger.resume_attr()
}
}
pub fn html_attrs(&self) -> Result<String, serde_json::Error> {
let class = self
.css_first_class()
.map(|effect_class| {
if self.is_css_first_split() {
format!("dxt-textfx dxt-split {effect_class}")
} else {
format!("dxt-textfx {effect_class}")
}
})
.unwrap_or_else(|| "dxt-textfx".to_string());
let mut attrs = vec![
format!(r#"id="{}""#, escape_attr(&self.id)),
format!(r#"class="{}""#, escape_attr(&class)),
self.data_attr()?,
format!(
r#"data-dxt-performance="{}""#,
escape_attr(self.performance_profile.as_attr())
),
format!(
r#"data-dxt-gpu-budget="{}""#,
escape_attr(self.gpu_budget.as_attr())
),
];
if self.render_preference != TextFxRenderPreference::Auto {
attrs.push(format!(
r#"data-dxt-renderer="{}""#,
escape_attr(self.render_preference.as_attr())
));
}
if let Some(attr) = self.css_first_state_attrs() {
attrs.push(attr);
}
if let Some(attr) = self.live_contrast_attr() {
attrs.push(attr);
}
if let Some(attr) = self.layout_reserve_attr() {
attrs.push(attr);
}
if let Some(trigger) = self.resume_trigger_attr() {
attrs.push(trigger);
}
Ok(attrs.join(" "))
}
pub fn static_html(
&self,
tag: impl AsRef<str>,
extra_attrs: impl AsRef<str>,
) -> Result<String, serde_json::Error> {
let tag = sanitize_tag(tag.as_ref());
let attrs = self.html_attrs()?;
let extra_attrs = extra_attrs.as_ref().trim();
let attrs = if extra_attrs.is_empty() {
attrs
} else {
format!("{attrs} {extra_attrs}")
};
let inner = escape_html(&self.text);
Ok(format!("<{tag} {attrs}>{inner}</{tag}>"))
}
}
impl Default for TextFxConfig {
fn default() -> Self {
Self::new("textfx", "")
}
}
pub fn escape_html(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
pub fn escape_attr(value: &str) -> String {
escape_html(value)
.replace('"', """)
.replace('\'', "'")
}
fn sanitize_tag(tag: &str) -> &str {
match tag {
"h1" | "h2" | "h3" | "h4" | "p" | "span" | "strong" | "em" | "small" | "div" => tag,
_ => "span",
}
}
pub fn parse_inline_marks(source: &str) -> MarkedText {
let mut clean_text = String::new();
let mut marks = Vec::new();
let mut rest = source;
let mut word_count = 0usize;
while let Some(start) = rest.find("[[") {
let before = &rest[..start];
clean_text.push_str(before);
word_count += count_words(before);
let after_start = &rest[start + 2..];
let Some(end) = after_start.find("]]") else {
clean_text.push_str(&rest[start..]);
return MarkedText { clean_text, marks };
};
let marker = &after_start[..end];
if let Some((visible, name)) = marker.rsplit_once('|') {
let char_start = clean_text.chars().count();
let word_start = word_count;
clean_text.push_str(visible);
let word_len = count_words(visible).max(1);
word_count += word_len;
let char_end = clean_text.chars().count();
marks.push(TokenMark {
name: name.trim().to_string(),
text: visible.to_string(),
char_start,
char_end,
word_start,
word_end: word_start + word_len.saturating_sub(1),
});
} else {
clean_text.push_str(marker);
word_count += count_words(marker);
}
rest = &after_start[end + 2..];
}
clean_text.push_str(rest);
MarkedText { clean_text, marks }
}
fn count_words(value: &str) -> usize {
value
.split_whitespace()
.filter(|part| !part.is_empty())
.count()
}
fn parse_fx_tokens(config: &mut TextFxConfig, fx: &str) -> Result<(), TextFxParseError> {
for token in split_fx_tokens(fx) {
parse_fx_token(config, &token)?;
}
Ok(())
}
fn split_fx_tokens(fx: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut paren_depth = 0usize;
let mut quote: Option<char> = None;
for ch in fx.chars() {
match ch {
'\'' | '"' if quote == Some(ch) => {
quote = None;
current.push(ch);
}
'\'' | '"' if quote.is_none() => {
quote = Some(ch);
current.push(ch);
}
'(' if quote.is_none() => {
paren_depth += 1;
current.push(ch);
}
')' if quote.is_none() => {
paren_depth = paren_depth.saturating_sub(1);
current.push(ch);
}
ch if ch.is_whitespace() && quote.is_none() && paren_depth == 0 => {
if !current.trim().is_empty() {
tokens.push(current.trim().to_string());
current.clear();
}
}
_ => current.push(ch),
}
}
if !current.trim().is_empty() {
tokens.push(current.trim().to_string());
}
tokens
}
fn parse_fx_token(config: &mut TextFxConfig, token: &str) -> Result<(), TextFxParseError> {
if let Some(value) = token.strip_prefix("enter:") {
return parse_fx_phase_token(config, TextFxPhaseKind::Enter, value);
}
if let Some(value) = token.strip_prefix("exit:") {
return parse_fx_phase_token(config, TextFxPhaseKind::Exit, value);
}
match token {
"split-words" => {
config.split = TextSplit::Words;
config.promote_for_runtime_text_motion();
return Ok(());
}
"split-chars" | "split-letters" => {
config.split = TextSplit::Chars;
config.promote_for_runtime_text_motion();
return Ok(());
}
"split-lines" => {
config.split = TextSplit::Lines;
config.promote_for_runtime_text_motion();
return Ok(());
}
"on-hover" => {
config.trigger = TextFxTrigger::Hover;
return Ok(());
}
"on-click" => {
config.trigger = TextFxTrigger::Click;
return Ok(());
}
"on-visible" => {
config.trigger = TextFxTrigger::Visible;
return Ok(());
}
"on-load" => {
config.trigger = TextFxTrigger::Load;
return Ok(());
}
"on-word-hover" => {
config.trigger = TextFxTrigger::WordHover;
config.split = TextSplit::Words;
config.promote_for_runtime_text_motion();
return Ok(());
}
"on-word-click" => {
config.trigger = TextFxTrigger::WordClick;
config.split = TextSplit::Words;
config.promote_for_runtime_text_motion();
return Ok(());
}
"loop" => {
config.playback.loop_mode = TextFxLoop::Infinite;
return Ok(());
}
"reverse" => {
config.playback.reverse = true;
return Ok(());
}
"alternate" => {
config.playback.alternate = true;
return Ok(());
}
"yoyo" => {
config.playback.yoyo = true;
return Ok(());
}
"perf:css-first" | "perf:css" | "css-first" => {
config.performance_profile = TextFxPerformanceProfile::CssFirst;
return Ok(());
}
"perf:balanced" | "perf:balance" => {
config.performance_profile = TextFxPerformanceProfile::Balanced;
return Ok(());
}
"perf:exact" | "perf:visual-exact" | "visual-exact" => {
config.performance_profile = TextFxPerformanceProfile::VisualExact;
return Ok(());
}
"gpu:auto" | "gpu-auto" => {
config.gpu_budget = TextFxGpuBudget::Auto;
return Ok(());
}
"gpu:low-power" | "gpu-low-power" | "gpu:low" | "gpu-low" => {
config.gpu_budget = TextFxGpuBudget::LowPower;
return Ok(());
}
"gpu:normal" | "gpu-normal" => {
config.gpu_budget = TextFxGpuBudget::Normal;
return Ok(());
}
"gpu:exact" | "gpu-exact" => {
config.gpu_budget = TextFxGpuBudget::Exact;
return Ok(());
}
"render:auto" | "renderer:auto" | "render-auto" => {
config.render_preference = TextFxRenderPreference::Auto;
return Ok(());
}
"render:css-first" | "renderer:css-first" | "render-css-first" => {
config.render_preference = TextFxRenderPreference::CssFirst;
return Ok(());
}
"render:workertown"
| "renderer:workertown"
| "render:workertown-render"
| "renderer:workertown-render"
| "render-workertown" => {
*config = config
.clone()
.with_render_preference(TextFxRenderPreference::WorkerTownRender);
return Ok(());
}
"render:main-thread-fallback"
| "renderer:main-thread-fallback"
| "render-main-thread-fallback" => {
config.render_preference = TextFxRenderPreference::MainThreadFallback;
return Ok(());
}
"layout-reserve:off" | "layout-reserve-off" | "tlr:off" | "tlr-off" => {
config.layout_reserve = TextFxLayoutReserve::Off;
return Ok(());
}
"layout-reserve:auto" | "layout-reserve-auto" | "tlr:auto" | "tlr-auto" => {
config.layout_reserve = TextFxLayoutReserve::Auto;
return Ok(());
}
"layout-reserve:exact" | "layout-reserve-exact" | "tlr:exact" | "tlr-exact" => {
config.layout_reserve = TextFxLayoutReserve::Exact;
return Ok(());
}
"ease-in" => {
config.timing.easing = TextFxEasing::EaseIn;
return Ok(());
}
"ease-out" => {
config.timing.easing = TextFxEasing::EaseOut;
return Ok(());
}
"ease-in-out" => {
config.timing.easing = TextFxEasing::EaseInOut;
return Ok(());
}
"ease-linear" | "linear" => {
config.timing.easing = TextFxEasing::Linear;
return Ok(());
}
_ => {}
}
if let Some(value) = token.strip_prefix("duration-") {
config.timing.duration_ms = parse_u32(value, token)?;
return Ok(());
}
if let Some(value) = token.strip_prefix("delay-") {
config.timing.delay_ms = parse_u32(value, token)?;
return Ok(());
}
if let Some(value) = token.strip_prefix("stagger-") {
config.timing.stagger_ms = parse_u32(value, token)?;
return Ok(());
}
if let Some(value) = token.strip_prefix("speed-") {
config.timing.speed_ms = parse_u32(value, token)?.max(1);
return Ok(());
}
if let Some(value) = token.strip_prefix("loop-") {
config.playback.loop_mode = TextFxLoop::Count(parse_u16(value, token)?.max(1));
return Ok(());
}
if let Some(selector) = token.strip_prefix("on-click:") {
config.trigger = TextFxTrigger::SelectorClick {
selector: selector.to_string(),
};
return Ok(());
}
if let Some(selector) = token.strip_prefix("on-hover:") {
config.trigger = TextFxTrigger::SelectorHover {
selector: selector.to_string(),
};
return Ok(());
}
if let Some(event) = token.strip_prefix("on-event:") {
config.trigger = TextFxTrigger::Event {
name: event.to_string(),
};
return Ok(());
}
if token.starts_with("target:") || token.starts_with("mark:") {
let rule = parse_rule_token(token)?;
config.add_target(rule.target, rule.action);
return Ok(());
}
if let Some(effect) = parse_effect_token(token) {
config.effect = effect;
if effect.needs_split() && config.split == TextSplit::None {
config.split = TextSplit::Chars;
config.promote_for_runtime_text_motion();
}
return Ok(());
}
Err(TextFxParseError::new(format!(
"unknown textfx token `{token}`"
)))
}
fn parse_fx_phase_token(
config: &mut TextFxConfig,
phase: TextFxPhaseKind,
token: &str,
) -> Result<(), TextFxParseError> {
match token {
"split-words" => {
config.phase_mut(phase).split = Some(TextSplit::Words);
config.promote_for_runtime_text_motion();
return Ok(());
}
"split-chars" | "split-letters" => {
config.phase_mut(phase).split = Some(TextSplit::Chars);
config.promote_for_runtime_text_motion();
return Ok(());
}
"split-lines" => {
config.phase_mut(phase).split = Some(TextSplit::Lines);
config.promote_for_runtime_text_motion();
return Ok(());
}
"split-none" => {
config.phase_mut(phase).split = Some(TextSplit::None);
return Ok(());
}
"reverse" => {
let playback = TextFxPlayback {
reverse: true,
..TextFxPlayback::default()
};
config.phase_mut(phase).playback = Some(playback);
return Ok(());
}
"ease-in" => {
config.phase_mut(phase).timing_mut().easing = Some(TextFxEasing::EaseIn);
return Ok(());
}
"ease-out" => {
config.phase_mut(phase).timing_mut().easing = Some(TextFxEasing::EaseOut);
return Ok(());
}
"ease-in-out" => {
config.phase_mut(phase).timing_mut().easing = Some(TextFxEasing::EaseInOut);
return Ok(());
}
"ease-linear" | "linear" => {
config.phase_mut(phase).timing_mut().easing = Some(TextFxEasing::Linear);
return Ok(());
}
_ => {}
}
if let Some(value) = token.strip_prefix("duration-") {
config.phase_mut(phase).timing_mut().duration_ms = Some(parse_u32(value, token)?);
return Ok(());
}
if let Some(value) = token.strip_prefix("delay-") {
config.phase_mut(phase).timing_mut().delay_ms = Some(parse_u32(value, token)?);
return Ok(());
}
if let Some(value) = token.strip_prefix("stagger-") {
config.phase_mut(phase).timing_mut().stagger_ms = Some(parse_u32(value, token)?);
return Ok(());
}
if let Some(value) = token.strip_prefix("speed-") {
config.phase_mut(phase).timing_mut().speed_ms = Some(parse_u32(value, token)?.max(1));
return Ok(());
}
if let Some(value) = token
.strip_prefix("direction-")
.or_else(|| token.strip_prefix("dir-"))
{
config.phase_mut(phase).direction = Some(parse_direction(value)?);
return Ok(());
}
if let Some(effect) = parse_effect_token(token) {
config.apply_phase_effect(phase, effect);
return Ok(());
}
Err(TextFxParseError::new(format!(
"unknown textfx phase token `{token}`"
)))
}
fn parse_rule_token(token: &str) -> Result<TextFxChoreography, TextFxParseError> {
let parts = split_colon_parts(token);
if parts.len() < 3 {
return Err(TextFxParseError::new(format!(
"token `{token}` must include a target and at least one action"
)));
}
let target = if parts[0] == "target" {
parse_target(parts[1])?
} else if parts[0] == "mark" {
if parts[1] == "others" {
TokenTarget::Others
} else {
TokenTarget::Mark {
name: parts[1].to_string(),
}
}
} else {
return Err(TextFxParseError::new(format!(
"token `{token}` must start with target: or mark:"
)));
};
let mut action = TokenAction::default();
for part in parts.iter().skip(2) {
action = action.merge(parse_action(part)?);
}
Ok(TextFxChoreography { target, action })
}
fn split_colon_parts(value: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0usize;
let mut paren_depth = 0usize;
let mut quote: Option<char> = None;
for (idx, ch) in value.char_indices() {
match ch {
'\'' | '"' if quote == Some(ch) => quote = None,
'\'' | '"' if quote.is_none() => quote = Some(ch),
'(' if quote.is_none() => paren_depth += 1,
')' if quote.is_none() => paren_depth = paren_depth.saturating_sub(1),
':' if quote.is_none() && paren_depth == 0 => {
parts.push(&value[start..idx]);
start = idx + 1;
}
_ => {}
}
}
parts.push(&value[start..]);
parts
}
fn parse_target(value: &str) -> Result<TokenTarget, TextFxParseError> {
if value == "all" {
return Ok(TokenTarget::All);
}
if value == "others" {
return Ok(TokenTarget::Others);
}
if let Some(inner) = value
.strip_prefix("words(")
.and_then(|v| v.strip_suffix(')'))
{
return parse_range_or_index(inner).map(|(start, end)| {
if start == end {
TokenTarget::Word { index: start }
} else {
TokenTarget::WordRange { start, end }
}
});
}
if let Some(inner) = value
.strip_prefix("word(")
.and_then(|v| v.strip_suffix(')'))
{
return Ok(TokenTarget::WordText {
value: unquote(inner).to_string(),
});
}
if let Some(inner) = value
.strip_prefix("chars(")
.and_then(|v| v.strip_suffix(')'))
{
return parse_range_or_index(inner)
.map(|(start, end)| TokenTarget::CharRange { start, end });
}
if let Some(inner) = value
.strip_prefix("contains(")
.and_then(|v| v.strip_suffix(')'))
{
return Ok(TokenTarget::Contains {
value: unquote(inner).to_string(),
});
}
Err(TextFxParseError::new(format!("invalid target `{value}`")))
}
fn parse_action(value: &str) -> Result<TokenAction, TextFxParseError> {
if value == "stay" {
return Ok(TokenAction::default().stay());
}
if value == "highlight" {
return Ok(TokenAction::highlight());
}
if value == "underline-sweep" {
return Ok(TokenAction {
underline_sweep: true,
..TokenAction::default()
});
}
if value == "live-contrast" {
return Ok(TokenAction::live_contrast());
}
if value == "live-contrast-exclusion" {
return Ok(TokenAction::live_contrast_mode(
TextFxLiveContrast::Exclusion,
));
}
if value == "live-contrast-plus" {
return Ok(TokenAction::live_contrast_mode(TextFxLiveContrast::Plus));
}
if value == "blur" {
return Ok(TokenAction {
blur: true,
..TokenAction::default()
});
}
if value == "slide-away" {
return Ok(TokenAction::slide_away(TextFxDirection::Left));
}
if let Some(direction) = value.strip_prefix("slide-away-") {
return Ok(TokenAction::slide_away(parse_direction(direction)?));
}
if let Some(value) = value.strip_prefix("scale-") {
return Ok(TokenAction::scale(parse_scale(value)?));
}
if let Some(value) = value.strip_prefix("fade-") {
return Ok(TokenAction {
opacity: Some(parse_fade(value)?),
..TokenAction::default()
});
}
if let Some(value) = value.strip_prefix("delay-") {
return Ok(TokenAction {
delay_ms: Some(parse_u32(value, value)?),
..TokenAction::default()
});
}
if let Some(value) = value.strip_prefix("stagger-") {
return Ok(TokenAction {
stagger_ms: Some(parse_u32(value, value)?),
..TokenAction::default()
});
}
if let Some(value) = value.strip_prefix("color-[") {
let value = value
.strip_suffix(']')
.ok_or_else(|| TextFxParseError::new("color token must end with ]"))?;
return Ok(TokenAction {
color: Some(value.to_string()),
..TokenAction::default()
});
}
if let Some(inner) = value
.strip_prefix("swap(")
.and_then(|v| v.strip_suffix(')'))
{
return Ok(TokenAction::swap(unquote(inner)));
}
if let Some(inner) = value
.strip_prefix("scramble-to(")
.and_then(|v| v.strip_suffix(')'))
{
return Ok(TokenAction {
scramble_to: Some(unquote(inner).to_string()),
..TokenAction::default()
});
}
Err(TextFxParseError::new(format!("invalid action `{value}`")))
}
fn parse_effect_token(token: &str) -> Option<TextFxEffect> {
TextFxEffect::ALL
.into_iter()
.find(|effect| effect.as_attr() == token)
}
fn parse_range_or_index(value: &str) -> Result<(usize, usize), TextFxParseError> {
if let Some((start, end)) = value.split_once("..") {
let start = parse_usize(start, value)?;
let end = parse_usize(end.trim_start_matches('='), value)?;
if start > end {
return Err(TextFxParseError::new(format!(
"invalid descending range `{value}`"
)));
}
return Ok((start, end));
}
let index = parse_usize(value, value)?;
Ok((index, index))
}
fn parse_direction(value: &str) -> Result<TextFxDirection, TextFxParseError> {
match value {
"up" => Ok(TextFxDirection::Up),
"right" => Ok(TextFxDirection::Right),
"down" => Ok(TextFxDirection::Down),
"left" => Ok(TextFxDirection::Left),
_ => Err(TextFxParseError::new(format!(
"invalid direction `{value}`"
))),
}
}
fn parse_scale(value: &str) -> Result<f32, TextFxParseError> {
let number = parse_u32(value, value)?;
Ok(number as f32 / 100.0)
}
fn parse_fade(value: &str) -> Result<f32, TextFxParseError> {
let number = parse_u32(value, value)?;
Ok((number.min(100) as f32) / 100.0)
}
fn parse_usize(value: &str, token: &str) -> Result<usize, TextFxParseError> {
value
.parse::<usize>()
.map_err(|_| TextFxParseError::new(format!("invalid number in `{token}`")))
}
fn parse_u32(value: &str, token: &str) -> Result<u32, TextFxParseError> {
value
.parse::<u32>()
.map_err(|_| TextFxParseError::new(format!("invalid number in `{token}`")))
}
fn parse_u16(value: &str, token: &str) -> Result<u16, TextFxParseError> {
value
.parse::<u16>()
.map_err(|_| TextFxParseError::new(format!("invalid number in `{token}`")))
}
fn unquote(value: &str) -> &str {
value.trim().trim_matches('"').trim_matches('\'')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serializes_all_effects() {
for effect in TextFxEffect::ALL {
let json = TextFxConfig::new(effect.as_attr(), effect.label())
.with_effect(effect)
.to_json()
.unwrap();
assert!(json.contains(effect.as_attr()), "{json}");
}
}
#[test]
fn css_first_gradient_shift_can_loop_and_yoyo() {
let config = TextFxConfig::new("gradient", "Gradient shift")
.with_effect(TextFxEffect::GradientShift)
.with_palette(["#111111", "#ffffff", "#ff7a1a"])
.loop_infinite()
.yoyo();
assert!(config.is_css_first());
let attrs = config.css_first_state_attrs().unwrap();
assert!(attrs.contains("--dxt-iterations:infinite"));
assert!(attrs.contains("--dxt-direction:alternate"));
assert!(attrs.contains("--dxt-gradient-c:#ff7a1a"));
}
#[test]
fn showcase_profile_preserves_split_effects_for_runtime_loops() {
let config = TextFxConfig::new("wave", "Wave motion")
.with_effect(TextFxEffect::Wave)
.with_profile(TextFxProfile::Showcase)
.loop_infinite()
.yoyo();
assert_eq!(config.split, TextSplit::Chars);
assert!(!config.is_css_first());
assert!(config.is_css_first_split());
assert!(config.is_css_first_renderable());
assert_eq!(config.resume_trigger_attr(), None);
assert!(
config
.data_attr()
.unwrap()
.contains(""sp":"chars"")
);
}
#[test]
fn split_showcase_effects_are_css_first_renderable() {
for effect in [
TextFxEffect::Stagger,
TextFxEffect::Wave,
TextFxEffect::Flip,
TextFxEffect::Glitch,
TextFxEffect::KerningExpand,
] {
let config = TextFxConfig::new(effect.as_attr(), effect.label())
.with_effect(effect)
.with_profile(TextFxProfile::Showcase)
.loop_infinite()
.yoyo();
assert!(config.is_css_first_split(), "{effect:?}");
assert!(config.css_first_class().is_some(), "{effect:?}");
assert!(
config
.css_first_state_attrs()
.unwrap()
.contains("--dxt-iterations:infinite")
);
assert_eq!(config.resume_trigger_attr(), None);
}
}
#[test]
fn whole_node_live_contrast_needs_no_runtime_trigger() {
let config = TextFxConfig::new("contrast", "Readable")
.with_effect(TextFxEffect::LiveContrast)
.with_trigger(TextFxTrigger::Load);
assert_eq!(config.resume_trigger_attr(), None);
assert_eq!(
config.live_contrast_attr().as_deref(),
Some(r#"data-dxt-live-contrast="difference""#)
);
}
#[test]
fn css_first_classification_includes_safe_single_node_effects() {
for effect in [
TextFxEffect::Fade,
TextFxEffect::Slide,
TextFxEffect::BlurReveal,
TextFxEffect::Scale,
TextFxEffect::MaskReveal,
TextFxEffect::HighlightSweep,
TextFxEffect::GradientShift,
] {
let config = TextFxConfig::new(effect.as_attr(), effect.label()).with_effect(effect);
assert!(config.is_css_first(), "{effect:?}");
}
assert!(
!TextFxConfig::new("typewriter", "Typewriter")
.with_effect(TextFxEffect::Typewriter)
.is_css_first()
);
assert!(
!TextFxConfig::new("hover", "Hover")
.with_effect(TextFxEffect::Fade)
.on_hover()
.is_css_first()
);
}
#[test]
fn profiles_apply_runtime_defaults() {
let lighthouse =
TextFxConfig::profile("hero", "Fast first paint", TextFxProfile::Lighthouse);
assert_eq!(lighthouse.trigger, TextFxTrigger::Load);
assert_eq!(lighthouse.reduced_motion, ReducedMotion::Static);
assert_eq!(
lighthouse.performance_profile,
TextFxPerformanceProfile::CssFirst
);
assert_eq!(lighthouse.gpu_budget, TextFxGpuBudget::Auto);
assert_eq!(lighthouse.timing.duration_ms, 360);
assert!(lighthouse.is_css_first());
let interactive = TextFxConfig::profile("cta", "Click me", TextFxProfile::Interactive);
assert_eq!(interactive.trigger, TextFxTrigger::Interaction);
assert_eq!(
interactive.performance_profile,
TextFxPerformanceProfile::Balanced
);
assert_eq!(interactive.gpu_budget, TextFxGpuBudget::Auto);
assert_eq!(interactive.timing.stagger_ms, 18);
assert!(!interactive.is_css_first());
let showcase = TextFxConfig::profile("demo", "Loop", TextFxProfile::Showcase);
assert_eq!(showcase.gpu_budget, TextFxGpuBudget::Exact);
}
#[test]
fn performance_profile_serializes_and_parses() {
let defaulted = TextFxConfig::new("hero", "Fast text");
assert_eq!(
defaulted.performance_profile,
TextFxPerformanceProfile::CssFirst
);
let balanced = TextFxConfig::from_fx(
"hero",
"Build [[fast|focus]]",
"perf:balanced mark:focus:scale-150",
)
.unwrap();
assert_eq!(
balanced.performance_profile,
TextFxPerformanceProfile::Balanced
);
assert_eq!(balanced.split, TextSplit::Words);
let exact = TextFxConfig::from_fx("hero", "Exact", "perf:exact fade").unwrap();
assert_eq!(
exact.performance_profile,
TextFxPerformanceProfile::VisualExact
);
let attr = exact.html_attrs().unwrap();
assert!(attr.contains(r#"data-dxt-performance="visual-exact""#));
assert!(
exact
.to_compact_json()
.unwrap()
.contains(r#""pf":"visual-exact""#)
);
}
#[test]
fn gpu_budget_serializes_and_parses() {
let config =
TextFxConfig::from_fx("hero", "GPU budget", "fade gpu:low-power perf:balanced")
.unwrap();
assert_eq!(config.gpu_budget, TextFxGpuBudget::LowPower);
assert_eq!(
config.performance_profile,
TextFxPerformanceProfile::Balanced
);
let json = config.to_compact_json().unwrap();
assert!(json.contains(r#""gb":"low-power""#));
let attr = config.html_attrs().unwrap();
assert!(attr.contains(r#"data-dxt-gpu-budget="low-power""#));
let exact = TextFxConfig::from_fx("hero", "Exact", "gpu-exact").unwrap();
assert_eq!(exact.gpu_budget, TextFxGpuBudget::Exact);
}
#[test]
fn workertown_render_preference_is_explicit_and_route_scoped() {
let defaulted = TextFxConfig::new("hero", "Static text");
assert_eq!(defaulted.render_preference, TextFxRenderPreference::Auto);
assert!(!defaulted.requires_workertown_render());
let worker = TextFxConfig::from_fx(
"hero",
"Worker rendered text",
"highlight-sweep render:workertown",
)
.unwrap();
assert_eq!(
worker.render_preference,
TextFxRenderPreference::WorkerTownRender
);
assert_eq!(
worker.performance_profile,
TextFxPerformanceProfile::VisualExact
);
assert_eq!(worker.gpu_budget, TextFxGpuBudget::Exact);
assert!(worker.requires_workertown_render());
assert!(
worker
.html_attrs()
.unwrap()
.contains(r#"data-dxt-renderer="workertown-render""#)
);
assert!(
worker
.to_compact_json()
.unwrap()
.contains(r#""rp":"workertown-render""#)
);
}
#[test]
fn layout_reserve_serializes_compact_target_metadata_and_fx_tokens() {
let defaulted = TextFxConfig::new("hero", "Stable text");
assert_eq!(defaulted.layout_reserve, TextFxLayoutReserve::Auto);
assert!(defaulted.reserves_layout());
assert!(!defaulted.to_compact_json().unwrap().contains(r#""tlr""#));
assert!(
defaulted
.html_attrs()
.unwrap()
.contains(r#"data-dxr-text-layout-target="auto""#)
);
let exact = TextFxConfig::from_fx("hero", "Exact reserve", "fade tlr:exact").unwrap();
assert_eq!(exact.layout_reserve, TextFxLayoutReserve::Exact);
assert!(
exact
.to_compact_json()
.unwrap()
.contains(r#""tlr":"exact""#)
);
assert!(
exact
.html_attrs()
.unwrap()
.contains(r#"data-dxr-text-layout-target="exact""#)
);
let off = TextFxConfig::new("hero", "No reserve").layout_reserve_off();
assert_eq!(off.layout_reserve, TextFxLayoutReserve::Off);
assert!(!off.reserves_layout());
assert!(off.to_compact_json().unwrap().contains(r#""tlr":"off""#));
assert!(
!off.html_attrs()
.unwrap()
.contains("data-dxr-text-layout-target")
);
}
#[test]
fn compact_data_attr_keeps_textfx_contract() {
let config = TextFxConfig::new("hero", "Readable first paint")
.with_effect(TextFxEffect::BlurReveal)
.with_profile(TextFxProfile::Lighthouse);
let attr = config.data_attr().unwrap();
assert!(attr.starts_with("data-dxt-textfx="));
assert!(attr.contains(""v":1"));
assert!(attr.contains(""e":"br""));
assert!(attr.len() < config.to_json().unwrap().len() + "data-dxt-textfx=\"\"".len());
}
#[test]
fn locale_data_attr_uses_locale_specific_contract() {
for effect in TextFxEffect::ALL {
let attr = TextFxConfig::new(effect.as_attr(), effect.label())
.with_effect(effect)
.locale_data_attr()
.unwrap();
assert!(attr.starts_with("data-dxt-locale-fx="));
assert!(!attr.contains("data-dxt-textfx"));
assert!(attr.contains(effect.compact_id()) || attr.contains(effect.as_attr()));
}
}
#[test]
fn lifecycle_builders_serialize_compact_enter_and_exit_phases() {
let config = TextFxConfig::new("route-title", "Route")
.with_effect(TextFxEffect::Flip)
.with_duration_ms(520)
.with_stagger_ms(18)
.with_enter_delay_ms(30)
.with_exit_duration_ms(260)
.with_exit_stagger_ms(8)
.with_exit_reverse_of_enter();
assert_eq!(
config
.lifecycle
.exit
.as_ref()
.and_then(|phase| phase.playback.as_ref())
.map(|playback| playback.reverse),
Some(true)
);
let compact = config.to_compact_json().unwrap();
assert!(compact.contains(r#""en":"#), "{compact}");
assert!(compact.contains(r#""ex":"#), "{compact}");
assert!(compact.contains(r#""delayMs":30"#), "{compact}");
assert!(compact.contains(r#""durationMs":260"#), "{compact}");
}
#[test]
fn lifecycle_fx_tokens_parse_phase_overrides_without_changing_base_timing() {
let config = TextFxConfig::from_fx(
"hero",
"Tabbed title",
concat!(
"flip duration-520 stagger-18 enter:delay-80 exit:",
"blur",
"-reveal exit:duration-240 exit:reverse"
),
)
.unwrap();
assert_eq!(config.effect, TextFxEffect::Flip);
assert_eq!(config.timing.duration_ms, 520);
assert_eq!(config.timing.stagger_ms, 18);
assert_eq!(
config
.lifecycle
.enter
.as_ref()
.and_then(|phase| phase.timing.as_ref())
.and_then(|timing| timing.delay_ms),
Some(80)
);
assert_eq!(
config
.lifecycle
.exit
.as_ref()
.and_then(|phase| phase.effect),
Some(TextFxEffect::BlurReveal)
);
assert_eq!(
config
.lifecycle
.exit
.as_ref()
.and_then(|phase| phase.timing.as_ref())
.and_then(|timing| timing.duration_ms),
Some(240)
);
assert_eq!(
config
.lifecycle
.exit
.as_ref()
.and_then(|phase| phase.playback.as_ref())
.map(|playback| playback.reverse),
Some(true)
);
}
#[test]
fn configs_without_lifecycle_keep_existing_compact_shape() {
let compact = TextFxConfig::new("hero", "Stable")
.with_effect(TextFxEffect::BlurReveal)
.to_compact_json()
.unwrap();
assert!(!compact.contains(r#""en""#), "{compact}");
assert!(!compact.contains(r#""ex""#), "{compact}");
assert!(!compact.contains("lifecycle"), "{compact}");
}
#[test]
fn default_timing_matches_package_contract() {
let timing = TextFxTiming::default();
assert_eq!(timing.duration_ms, 640);
assert_eq!(timing.stagger_ms, 28);
assert_eq!(timing.easing, TextFxEasing::EaseOut);
assert_eq!(ReducedMotion::default(), ReducedMotion::FadeOnly);
}
#[test]
fn static_html_keeps_semantic_text_and_no_script() {
let html = TextFxConfig::new("hero", "Readable first paint")
.with_effect(TextFxEffect::Typewriter)
.static_html("h1", "")
.unwrap();
assert!(html.contains("Readable first paint"));
assert!(html.contains("data-dxt-textfx"));
assert!(html.contains("data-dxr-on-visible=\"textfx.run\""));
assert!(!html.contains("aria-label="));
assert!(!html.contains("<script"));
assert!(!html.contains("modulepreload"));
}
#[test]
fn html_attrs_do_not_name_generic_textfx_elements() {
let attrs = TextFxConfig::new("hero", "Readable first paint")
.with_effect(TextFxEffect::Wave)
.html_attrs()
.unwrap();
assert!(attrs.contains("data-dxt-textfx"));
assert!(!attrs.contains("aria-label="));
}
#[test]
fn trigger_attrs_match_resume_events() {
assert_eq!(
TextFxTrigger::Visible.resume_attr().as_deref(),
Some(r#"data-dxr-on-visible="textfx.run""#)
);
assert_eq!(
TextFxTrigger::Hover.resume_attr().as_deref(),
Some(r#"data-dxr-on-pointerover="textfx.run""#)
);
assert_eq!(
TextFxTrigger::WordClick.resume_attr().as_deref(),
Some(r#"data-dxr-on-click="textfx.run""#)
);
assert_eq!(TextFxTrigger::Manual.resume_attr(), None);
}
#[test]
fn parses_inline_marks_into_clean_text() {
let marked = parse_inline_marks("Build [[fast websites|focus]] with [[zero reloads|swap]]");
assert_eq!(marked.clean_text, "Build fast websites with zero reloads");
assert_eq!(marked.marks.len(), 2);
assert_eq!(marked.marks[0].name, "focus");
assert_eq!(marked.marks[0].word_start, 1);
assert_eq!(marked.marks[0].word_end, 2);
}
#[test]
fn parses_tailwind_like_target_tokens() {
let config = TextFxConfig::from_fx(
"hero",
"Build fast websites with zero reloads",
"split-words on-hover target:words(1..2):scale-150:stay target:others:slide-away-left duration-700 ease-in-out loop-3",
)
.unwrap();
assert_eq!(config.split, TextSplit::Words);
assert_eq!(config.trigger, TextFxTrigger::Hover);
assert_eq!(config.timing.duration_ms, 700);
assert_eq!(config.playback.loop_mode, TextFxLoop::Count(3));
assert_eq!(config.choreography.len(), 2);
assert!(matches!(
config.choreography[0].target,
TokenTarget::WordRange { start: 1, end: 2 }
));
assert_eq!(config.choreography[0].action.scale, Some(1.5));
assert!(config.choreography[0].action.stay);
}
#[test]
fn parses_mark_swap_tokens() {
let config = TextFxConfig::from_fx(
"hero",
"Build [[fast websites|focus]] with [[zero reloads|swap]]",
"on-word-click mark:focus:highlight mark:swap:swap('instant resumes')",
)
.unwrap();
assert_eq!(config.text, "Build fast websites with zero reloads");
assert_eq!(config.marks.len(), 2);
assert_eq!(config.trigger, TextFxTrigger::WordClick);
assert_eq!(
config.choreography[1].action.swap.as_deref(),
Some("instant resumes")
);
}
#[test]
fn parses_live_contrast_effect_and_token_actions() {
let config = TextFxConfig::from_fx(
"contrast",
"Only [[these words|focus]] adapt live",
"live-contrast mark:focus:live-contrast-exclusion",
)
.unwrap();
assert_eq!(config.effect, TextFxEffect::LiveContrast);
assert_eq!(
config.choreography[0].action.live_contrast,
Some(TextFxLiveContrast::Exclusion)
);
assert!(config.to_json().unwrap().contains("liveContrast"));
assert_eq!(
config.live_contrast_attr().as_deref(),
Some(r#"data-dxt-live-contrast="difference""#)
);
}
}