use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
use std::ops::RangeInclusive;
use std::sync::OnceLock;
use std::time::Duration;
mod integration;
pub use integration::*;
pub const TEXTFX_PACKAGE_NAME: &str = "dioxus-textfx";
pub const TEXTFX_PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const DEFAULT_TEXTFX_RUNTIME_PATH: &str = "/assets/dioxus-textfx.js?v=35";
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");
pub type TextCfg = TextFxConfig;
pub type TextEffect = TextFxEffect;
pub type TextEase = TextFxEasing;
pub type TextProfile = TextFxProfile;
pub fn textfx(id: impl Into<String>, text: impl Into<String>) -> TextFxConfig {
TextFxConfig::new(id, text)
}
pub fn text_fx(id: impl Into<String>, text: impl Into<String>) -> TextFxConfig {
TextFxConfig::new(id, text)
}
pub fn fx(
id: impl Into<String>,
text: impl Into<String>,
script: impl Into<String>,
) -> Result<TextFxConfig, TextFxParseError> {
TextFxConfig::from_fx(id, text, script)
}
pub fn timing() -> TextFxTiming {
TextFxTiming::default()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum TextFxEffect {
Fade,
Slide,
#[default]
BlurReveal,
Scale,
Typewriter,
Scramble,
Stagger,
CountUp,
Wave,
Flip,
MaskReveal,
Glitch,
HighlightSweep,
GradientShift,
KerningExpand,
NumberTicker,
LiveContrast,
}
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 from_attr(value: &str) -> Option<Self> {
Self::ALL
.into_iter()
.find(|effect| effect.as_attr() == value)
}
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")]
#[derive(Default)]
pub enum TextFxLiveContrast {
#[default]
Difference,
Exclusion,
Plus,
}
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")]
#[derive(Default)]
pub enum TextFxEasing {
Linear,
EaseIn,
#[default]
EaseOut,
EaseInOut,
Spring,
CubicBezier(f32, f32, f32, f32),
}
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")]
#[derive(Default)]
pub enum TextFxTrigger {
Load,
#[default]
Visible,
Interaction,
Manual,
Hover,
Click,
Focus,
Blur,
WordHover,
WordClick,
SelectorClick {
selector: String,
},
SelectorHover {
selector: String,
},
Event {
name: String,
},
Cascade {
name: String,
},
}
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")]
#[derive(Default)]
pub enum TextFxLoop {
#[default]
Once,
Infinite,
Count(u16),
}
#[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")]
#[derive(Default)]
pub enum TextSplit {
#[default]
None,
Chars,
Words,
Lines,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum ReducedMotion {
Static,
#[default]
FadeOnly,
Ignore,
}
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")]
#[derive(Default)]
pub enum TextFxDirection {
#[default]
Up,
Right,
Down,
Left,
}
#[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")]
#[derive(Default)]
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 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,
}
}
}
impl TextFxTiming {
pub fn dur(mut self, duration: Duration) -> Self {
self.duration_ms = duration.as_millis().min(u128::from(u32::MAX)) as u32;
self
}
pub fn dur_ms(mut self, duration_ms: u32) -> Self {
self.duration_ms = duration_ms;
self
}
pub fn delay(mut self, delay: Duration) -> Self {
self.delay_ms = delay.as_millis().min(u128::from(u32::MAX)) as u32;
self
}
pub fn delay_ms(mut self, delay_ms: u32) -> Self {
self.delay_ms = delay_ms;
self
}
pub fn speed(mut self, speed: Duration) -> Self {
self.speed_ms = (speed.as_millis().min(u128::from(u32::MAX)) as u32).max(1);
self
}
pub fn speed_ms(mut self, speed_ms: u32) -> Self {
self.speed_ms = speed_ms.max(1);
self
}
pub fn stagger(mut self, stagger: Duration) -> Self {
self.stagger_ms = stagger.as_millis().min(u128::from(u32::MAX)) as u32;
self
}
pub fn stagger_ms(mut self, stagger_ms: u32) -> Self {
self.stagger_ms = stagger_ms;
self
}
pub fn ease(mut self, easing: TextFxEasing) -> Self {
self.easing = easing;
self
}
}
#[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().is_none_or(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().is_none_or(TextFxPhase::is_empty)
&& self.exit.as_ref().is_none_or(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")]
#[derive(Default)]
pub enum TextFxPerformanceProfile {
#[default]
CssFirst,
Balanced,
VisualExact,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum TextFxGpuBudget {
#[default]
Auto,
LowPower,
Normal,
Exact,
}
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")]
#[derive(Default)]
pub enum TextFxLayoutReserve {
Off,
#[default]
Auto,
Exact,
}
impl TextFxLayoutReserve {
pub fn as_attr(self) -> &'static str {
match self {
Self::Off => "off",
Self::Auto => "auto",
Self::Exact => "exact",
}
}
}
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_owned(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 with_text(mut self, text: impl Into<String>) -> Self {
let marked = parse_inline_marks_owned(text.into());
self.text = marked.clean_text;
self.marks = marked.marks;
self
}
pub fn content(self, text: impl Into<String>) -> Self {
self.with_text(text)
}
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 profile_preset(self, profile: TextFxProfile) -> Self {
self.with_profile(profile)
}
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 effect(self, effect: TextFxEffect) -> Self {
self.with_effect(effect)
}
pub fn fade(self) -> Self {
self.with_effect(TextFxEffect::Fade)
}
pub fn slide(self) -> Self {
self.with_effect(TextFxEffect::Slide)
}
pub fn blur(self) -> Self {
self.with_effect(TextFxEffect::BlurReveal)
}
pub fn scale(self) -> Self {
self.with_effect(TextFxEffect::Scale)
}
pub fn typewriter(self) -> Self {
self.with_effect(TextFxEffect::Typewriter)
}
pub fn scramble(self) -> Self {
self.with_effect(TextFxEffect::Scramble)
}
pub fn staggered(self) -> Self {
self.with_effect(TextFxEffect::Stagger)
}
pub fn with_timing(mut self, timing: TextFxTiming) -> Self {
self.timing = timing;
self
}
pub fn timing(self, timing: TextFxTiming) -> Self {
self.with_timing(timing)
}
pub fn with_duration_ms(mut self, duration_ms: u32) -> Self {
self.timing.duration_ms = duration_ms;
self
}
pub fn dur_ms(self, duration_ms: u32) -> Self {
self.with_duration_ms(duration_ms)
}
pub fn dur(self, duration: Duration) -> Self {
self.with_duration_ms(duration.as_millis().min(u128::from(u32::MAX)) as u32)
}
pub fn with_delay_ms(mut self, delay_ms: u32) -> Self {
self.timing.delay_ms = delay_ms;
self
}
pub fn delay_ms(self, delay_ms: u32) -> Self {
self.with_delay_ms(delay_ms)
}
pub fn with_speed_ms(mut self, speed_ms: u32) -> Self {
self.timing.speed_ms = speed_ms.max(1);
self
}
pub fn speed_ms(self, speed_ms: u32) -> Self {
self.with_speed_ms(speed_ms)
}
pub fn with_stagger_ms(mut self, stagger_ms: u32) -> Self {
self.timing.stagger_ms = stagger_ms;
self
}
pub fn stagger_ms(self, stagger_ms: u32) -> Self {
self.with_stagger_ms(stagger_ms)
}
pub fn stagger(self, stagger: Duration) -> Self {
self.with_stagger_ms(stagger.as_millis().min(u128::from(u32::MAX)) as u32)
}
pub fn with_enter_effect(mut self, effect: TextFxEffect) -> Self {
self.apply_phase_effect(TextFxPhaseKind::Enter, effect);
self
}
pub fn enter(self, effect: TextFxEffect) -> Self {
self.with_enter_effect(effect)
}
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 enter_delay_ms(self, delay_ms: u32) -> Self {
self.with_enter_delay_ms(delay_ms)
}
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 enter_dur_ms(self, duration_ms: u32) -> Self {
self.with_enter_duration_ms(duration_ms)
}
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 enter_stagger_ms(self, stagger_ms: u32) -> Self {
self.with_enter_stagger_ms(stagger_ms)
}
pub fn with_exit_effect(mut self, effect: TextFxEffect) -> Self {
self.apply_phase_effect(TextFxPhaseKind::Exit, effect);
self
}
pub fn exit(self, effect: TextFxEffect) -> Self {
self.with_exit_effect(effect)
}
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 exit_delay_ms(self, delay_ms: u32) -> Self {
self.with_exit_delay_ms(delay_ms)
}
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 exit_dur_ms(self, duration_ms: u32) -> Self {
self.with_exit_duration_ms(duration_ms)
}
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 exit_stagger_ms(self, stagger_ms: u32) -> Self {
self.with_exit_stagger_ms(stagger_ms)
}
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 exit_reverse(self) -> Self {
self.with_exit_reverse_of_enter()
}
pub fn with_easing(mut self, easing: TextFxEasing) -> Self {
self.timing.easing = easing;
self
}
pub fn ease(self, easing: TextFxEasing) -> Self {
self.with_easing(easing)
}
#[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 split(self, split: TextSplit) -> Self {
self.with_split(split)
}
pub fn split_lines(self) -> Self {
self.with_split(TextSplit::Lines)
}
pub fn with_performance_profile(mut self, profile: TextFxPerformanceProfile) -> Self {
self.performance_profile = profile;
self
}
pub fn perf(self, profile: TextFxPerformanceProfile) -> Self {
self.with_performance_profile(profile)
}
pub fn with_gpu_budget(mut self, budget: TextFxGpuBudget) -> Self {
self.gpu_budget = budget;
self
}
pub fn gpu(self, budget: TextFxGpuBudget) -> Self {
self.with_gpu_budget(budget)
}
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 render(self, preference: TextFxRenderPreference) -> Self {
self.with_render_preference(preference)
}
pub fn with_layout_reserve(mut self, reserve: TextFxLayoutReserve) -> Self {
self.layout_reserve = reserve;
self
}
pub fn reserve(self, reserve: TextFxLayoutReserve) -> Self {
self.with_layout_reserve(reserve)
}
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 trigger(self, trigger: TextFxTrigger) -> Self {
self.with_trigger(trigger)
}
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 reduced(self, reduced_motion: ReducedMotion) -> Self {
self.with_reduced_motion(reduced_motion)
}
pub fn with_direction(mut self, direction: TextFxDirection) -> Self {
self.direction = direction;
self
}
pub fn direction(self, direction: TextFxDirection) -> Self {
self.with_direction(direction)
}
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 palette(self, palette: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.with_palette(palette)
}
pub fn with_numbers(mut self, from: f64, to: f64) -> Self {
self.from = Some(from);
self.to = Some(to);
self
}
pub fn nums(self, from: f64, to: f64) -> Self {
self.with_numbers(from, to)
}
pub fn with_cursor(mut self, cursor: bool) -> Self {
self.cursor = cursor;
self
}
pub fn cursor(self, cursor: bool) -> Self {
self.with_cursor(cursor)
}
pub fn with_charset(mut self, charset: impl Into<String>) -> Self {
self.charset = charset.into();
self
}
pub fn charset(self, charset: impl Into<String>) -> Self {
self.with_charset(charset)
}
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 default = textfx_default_value_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.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 json = self.preferred_payload_json()?;
Ok(format!(r#"data-dxt-textfx="{}""#, escape_attr(&json)))
}
pub fn locale_data_attr(&self) -> Result<String, serde_json::Error> {
let json = self.preferred_payload_json()?;
let attr = format!(r#"data-dxt-locale-fx="{}""#, escape_attr(&json));
Ok(match self.layout_reserve_attr() {
Some(layout) => format!("{attr} {layout}"),
None => attr,
})
}
fn preferred_payload_json(&self) -> Result<String, serde_json::Error> {
let full = self.to_json()?;
let compact = self.to_compact_json()?;
if compact.len() < full.len() {
Ok(compact)
} else {
Ok(full)
}
}
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 = [
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}>"))
}
pub fn with_route_profile(mut self, profile: TextFxPresetProfile) -> Self {
profile.apply_to_config(&mut self);
self
}
pub fn route_profile(self, profile: TextFxPresetProfile) -> Self {
self.with_route_profile(profile)
}
pub fn cache_key(&self, route: Option<&str>) -> String {
textfx_cache_key([self], route, None)
}
pub fn diagnostics(&self, verbosity: TextFxDiagnosticVerbosity) -> TextFxDiagnosticReport {
textfx_diagnostics([self], verbosity)
}
pub fn explain(&self, policy: &TextFxRoutePolicy) -> TextFxExplainReport {
explain_textfx([self], policy)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxRuntimeEmission {
Always,
#[default]
WhenNeeded,
CssFirst,
Disabled,
}
impl TextFxRuntimeEmission {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Always => "always",
Self::WhenNeeded => "when-needed",
Self::CssFirst => "css-first",
Self::Disabled => "disabled",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxSerializationFormat {
ReadableJson,
#[default]
CompactWhenSmaller,
StableJson,
}
impl TextFxSerializationFormat {
pub const fn as_attr(self) -> &'static str {
match self {
Self::ReadableJson => "readable-json",
Self::CompactWhenSmaller => "compact-when-smaller",
Self::StableJson => "stable-json",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxDiagnosticVerbosity {
Off,
Summary,
#[default]
Detailed,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxPresetProfile {
Conservative,
#[default]
Balanced,
Expressive,
}
impl TextFxPresetProfile {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Conservative => "conservative",
Self::Balanced => "balanced",
Self::Expressive => "expressive",
}
}
pub fn apply_to_config(self, config: &mut TextFxConfig) {
match self {
Self::Conservative => {
config.performance_profile = TextFxPerformanceProfile::CssFirst;
config.gpu_budget = TextFxGpuBudget::LowPower;
config.render_preference = TextFxRenderPreference::CssFirst;
config.reduced_motion = ReducedMotion::Static;
config.layout_reserve = TextFxLayoutReserve::Auto;
if config.effect.needs_split() {
config.split = TextSplit::Words;
}
}
Self::Balanced => {
config.performance_profile = TextFxPerformanceProfile::Balanced;
config.gpu_budget = TextFxGpuBudget::Auto;
config.render_preference = TextFxRenderPreference::Auto;
config.layout_reserve = TextFxLayoutReserve::Auto;
}
Self::Expressive => {
config.performance_profile = TextFxPerformanceProfile::VisualExact;
config.gpu_budget = TextFxGpuBudget::Exact;
config.render_preference = TextFxRenderPreference::WorkerTownRender;
config.layout_reserve = TextFxLayoutReserve::Exact;
config.reduced_motion = ReducedMotion::FadeOnly;
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxInteropPolicy {
pub strata: bool,
pub resume: bool,
pub native_port: bool,
pub workertown: bool,
pub hoverfx: bool,
pub theme: bool,
pub viewtx: bool,
}
impl Default for TextFxInteropPolicy {
fn default() -> Self {
Self {
strata: true,
resume: true,
native_port: true,
workertown: true,
hoverfx: true,
theme: true,
viewtx: true,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxOutputBudget {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_config_bytes: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_runtime_bytes: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_static_html_bytes: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_effect_count: Option<usize>,
}
impl TextFxOutputBudget {
pub fn new() -> Self {
Self::default()
}
pub fn config_bytes(mut self, max: usize) -> Self {
self.max_config_bytes = Some(max);
self
}
pub fn runtime_bytes(mut self, max: usize) -> Self {
self.max_runtime_bytes = Some(max);
self
}
pub fn static_html_bytes(mut self, max: usize) -> Self {
self.max_static_html_bytes = Some(max);
self
}
pub fn effect_count(mut self, max: usize) -> Self {
self.max_effect_count = Some(max);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxRoutePolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
pub enabled: bool,
pub profile: TextFxPresetProfile,
pub emission: TextFxRuntimeEmission,
pub serialization: TextFxSerializationFormat,
pub diagnostics: TextFxDiagnosticVerbosity,
#[serde(default)]
pub interop: TextFxInteropPolicy,
#[serde(default)]
pub budget: TextFxOutputBudget,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
impl Default for TextFxRoutePolicy {
fn default() -> Self {
Self {
route: None,
enabled: true,
profile: TextFxPresetProfile::Balanced,
emission: TextFxRuntimeEmission::WhenNeeded,
serialization: TextFxSerializationFormat::CompactWhenSmaller,
diagnostics: TextFxDiagnosticVerbosity::Detailed,
interop: TextFxInteropPolicy::default(),
budget: TextFxOutputBudget::default(),
labels: BTreeMap::new(),
tags: Vec::new(),
}
}
}
impl TextFxRoutePolicy {
pub fn new() -> Self {
Self::default()
}
pub fn route(mut self, route: impl Into<String>) -> Self {
self.route = Some(route.into());
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn profile(mut self, profile: TextFxPresetProfile) -> Self {
self.profile = profile;
self
}
pub fn emission(mut self, emission: TextFxRuntimeEmission) -> Self {
self.emission = emission;
self
}
pub fn serialization(mut self, serialization: TextFxSerializationFormat) -> Self {
self.serialization = serialization;
self
}
pub fn diagnostics(mut self, diagnostics: TextFxDiagnosticVerbosity) -> Self {
self.diagnostics = diagnostics;
self
}
pub fn budget(mut self, budget: TextFxOutputBudget) -> Self {
self.budget = budget;
self
}
pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.labels.insert(key.into(), value.into());
self
}
pub fn tag(mut self, tag: impl Into<String>) -> Self {
let tag = tag.into();
if !tag.is_empty() && !self.tags.contains(&tag) {
self.tags.push(tag);
self.tags.sort();
}
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxDiagnosticSeverity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxDiagnostic {
pub severity: TextFxDiagnosticSeverity,
pub code: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxDiagnosticReport {
pub diagnostics: Vec<TextFxDiagnostic>,
}
impl TextFxDiagnosticReport {
pub fn is_valid(&self) -> bool {
self.diagnostics
.iter()
.all(|diagnostic| diagnostic.severity != TextFxDiagnosticSeverity::Error)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxManifestFragment {
pub package: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
pub enabled: bool,
pub cache_key: String,
pub profile: TextFxPresetProfile,
pub emission: TextFxRuntimeEmission,
pub config_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ids: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metrics: BTreeMap<String, u64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub policies: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxOutputViolation {
pub field: String,
pub actual: usize,
pub budget: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxOutputReport {
pub package: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
pub cache_key: String,
pub config_bytes: usize,
pub runtime_bytes: usize,
pub static_html_bytes: usize,
pub effect_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub violations: Vec<TextFxOutputViolation>,
}
impl TextFxOutputReport {
pub fn is_within_budget(&self) -> bool {
self.violations.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxExplainReport {
pub package: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
pub cache_key: String,
pub runtime_decision: String,
pub layout_decision: String,
pub diagnostics: TextFxDiagnosticReport,
pub manifest: TextFxManifestFragment,
pub output: TextFxOutputReport,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxCompatibilityRow {
pub target: String,
pub support: String,
pub runtime: String,
pub fallback: String,
pub notes: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxCompatibilityMatrix {
pub package: String,
pub rows: Vec<TextFxCompatibilityRow>,
}
pub trait TextFxManifestPolicyHook {
fn apply(&self, fragment: TextFxManifestFragment) -> Option<TextFxManifestFragment>;
}
pub fn apply_textfx_manifest_hook<'a, H>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
policy: &TextFxRoutePolicy,
hook: &H,
) -> Option<TextFxManifestFragment>
where
H: TextFxManifestPolicyHook,
{
hook.apply(textfx_manifest_fragment(configs, policy))
}
pub fn textfx_route_policy() -> TextFxRoutePolicy {
TextFxRoutePolicy::new()
}
pub fn textfx_output_budget() -> TextFxOutputBudget {
TextFxOutputBudget::new()
}
pub fn textfx_cache_key<'a>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
route: Option<&str>,
extra: Option<&str>,
) -> String {
let configs = configs.into_iter().collect::<Vec<_>>();
let mut parts = vec![
TEXTFX_PACKAGE_NAME.to_string(),
TEXTFX_PACKAGE_VERSION.to_string(),
route.unwrap_or("*").to_string(),
extra.unwrap_or("").to_string(),
];
for config in configs {
parts.push(config.to_compact_json().unwrap_or_default());
}
stable_hash_hex(parts.iter().map(String::as_str))
}
pub fn textfx_diagnostics<'a>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
verbosity: TextFxDiagnosticVerbosity,
) -> TextFxDiagnosticReport {
let mut report = TextFxDiagnosticReport::default();
if verbosity == TextFxDiagnosticVerbosity::Off {
return report;
}
for config in configs {
if config.id.trim().is_empty() {
report.diagnostics.push(TextFxDiagnostic {
severity: TextFxDiagnosticSeverity::Error,
code: "empty-id".to_string(),
message: "TextFX config id must not be empty".to_string(),
id: None,
});
}
if config.text.is_empty() && verbosity == TextFxDiagnosticVerbosity::Detailed {
report.diagnostics.push(TextFxDiagnostic {
severity: TextFxDiagnosticSeverity::Warning,
code: "empty-text".to_string(),
message: "TextFX config has no static text fallback".to_string(),
id: Some(config.id.clone()),
});
}
if config.requires_workertown_render() && verbosity == TextFxDiagnosticVerbosity::Detailed {
report.diagnostics.push(TextFxDiagnostic {
severity: TextFxDiagnosticSeverity::Info,
code: "workertown-render".to_string(),
message: "WorkerTown render preference will require a worker-capable runtime lane"
.to_string(),
id: Some(config.id.clone()),
});
}
}
report
}
pub fn textfx_manifest_fragment<'a>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
policy: &TextFxRoutePolicy,
) -> TextFxManifestFragment {
let configs = configs.into_iter().collect::<Vec<_>>();
let output = textfx_output_report(configs.iter().copied(), policy);
let mut ids = configs
.iter()
.map(|config| config.id.clone())
.collect::<Vec<_>>();
ids.sort();
let mut metrics = BTreeMap::new();
metrics.insert("configBytes".to_string(), output.config_bytes as u64);
metrics.insert("effectCount".to_string(), output.effect_count as u64);
metrics.insert("runtimeBytes".to_string(), output.runtime_bytes as u64);
metrics.insert(
"staticHtmlBytes".to_string(),
output.static_html_bytes as u64,
);
let mut policies = BTreeMap::new();
policies.insert(
"interop".to_string(),
serde_json::to_value(&policy.interop).unwrap_or(serde_json::Value::Null),
);
policies.insert(
"route".to_string(),
serde_json::json!({
"enabled": policy.enabled,
"profile": policy.profile,
"emission": policy.emission,
"serialization": policy.serialization,
}),
);
TextFxManifestFragment {
package: TEXTFX_PACKAGE_NAME.to_string(),
version: TEXTFX_PACKAGE_VERSION.to_string(),
route: policy.route.clone(),
enabled: policy.enabled,
cache_key: output.cache_key,
profile: policy.profile,
emission: policy.emission,
config_count: configs.len(),
ids,
labels: policy.labels.clone(),
tags: policy.tags.clone(),
metrics,
policies,
}
}
pub fn textfx_output_report<'a>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
policy: &TextFxRoutePolicy,
) -> TextFxOutputReport {
let configs = configs.into_iter().collect::<Vec<_>>();
let mut config_bytes = 0;
let mut static_html_bytes = 0;
let mut needs_runtime = policy.emission == TextFxRuntimeEmission::Always;
for config in &configs {
let json = match policy.serialization {
TextFxSerializationFormat::ReadableJson | TextFxSerializationFormat::StableJson => {
config.to_json()
}
TextFxSerializationFormat::CompactWhenSmaller => {
match (config.to_json(), config.to_compact_json()) {
(Ok(full), Ok(compact)) => Ok(if compact.len() < full.len() {
compact
} else {
full
}),
(Ok(full), Err(_)) => Ok(full),
(Err(err), _) => Err(err),
}
}
}
.unwrap_or_default();
config_bytes += json.len();
static_html_bytes += config
.static_html("span", "")
.map(|html| html.len())
.unwrap_or_default();
needs_runtime |= !config.is_css_first_renderable() || config.requires_workertown_render();
}
let runtime_bytes =
if policy.enabled && policy.emission != TextFxRuntimeEmission::Disabled && needs_runtime {
DEFAULT_TEXTFX_RUNTIME_PATH.len()
} else {
0
};
let effect_count = configs.len();
let mut violations = Vec::new();
push_textfx_budget_violation(
&mut violations,
"configBytes",
config_bytes,
policy.budget.max_config_bytes,
);
push_textfx_budget_violation(
&mut violations,
"runtimeBytes",
runtime_bytes,
policy.budget.max_runtime_bytes,
);
push_textfx_budget_violation(
&mut violations,
"staticHtmlBytes",
static_html_bytes,
policy.budget.max_static_html_bytes,
);
push_textfx_budget_violation(
&mut violations,
"effectCount",
effect_count,
policy.budget.max_effect_count,
);
TextFxOutputReport {
package: TEXTFX_PACKAGE_NAME.to_string(),
route: policy.route.clone(),
cache_key: textfx_cache_key(
configs.iter().copied(),
policy.route.as_deref(),
Some(policy.profile.as_attr()),
),
config_bytes,
runtime_bytes,
static_html_bytes,
effect_count,
violations,
}
}
pub fn explain_textfx<'a>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
policy: &TextFxRoutePolicy,
) -> TextFxExplainReport {
let configs = configs.into_iter().collect::<Vec<_>>();
let diagnostics = textfx_diagnostics(configs.iter().copied(), policy.diagnostics);
let output = textfx_output_report(configs.iter().copied(), policy);
let manifest = textfx_manifest_fragment(configs.iter().copied(), policy);
let runtime_decision = if !policy.enabled {
"route disabled TextFX emission".to_string()
} else if policy.emission == TextFxRuntimeEmission::Disabled {
"runtime emission disabled by route policy".to_string()
} else if output.runtime_bytes == 0 {
"all TextFX configs can render static/CSS-first for this route".to_string()
} else {
"TextFX runtime is required by at least one config or policy".to_string()
};
let layout_decision = if configs.iter().any(|config| config.reserves_layout()) {
"layout reserve attributes will be emitted for stable text boxes".to_string()
} else {
"no layout reserve attributes are required".to_string()
};
let mut notes = Vec::new();
if policy.interop.hoverfx {
notes.push("HoverFX can trigger TextFX through shared data attributes".to_string());
}
if policy.interop.theme {
notes.push("theme token gradients remain CSS custom properties".to_string());
}
if !output.is_within_budget() {
notes.push("one or more TextFX output budgets were exceeded".to_string());
}
TextFxExplainReport {
package: TEXTFX_PACKAGE_NAME.to_string(),
route: policy.route.clone(),
cache_key: output.cache_key.clone(),
runtime_decision,
layout_decision,
diagnostics,
manifest,
output,
notes,
}
}
pub fn textfx_compatibility_matrix() -> TextFxCompatibilityMatrix {
TextFxCompatibilityMatrix {
package: TEXTFX_PACKAGE_NAME.to_string(),
rows: vec![
TextFxCompatibilityRow {
target: "web".to_string(),
support: "full".to_string(),
runtime: "CSS-first or module runtime".to_string(),
fallback: "static text".to_string(),
notes:
"HoverFX triggers, theme gradients, and WorkerTown render hints are supported"
.to_string(),
},
TextFxCompatibilityRow {
target: "server".to_string(),
support: "manifest".to_string(),
runtime: "route-gated SSR requirements".to_string(),
fallback: "escaped static HTML".to_string(),
notes: "resume/Strata consumers can use cache keys and output reports".to_string(),
},
TextFxCompatibilityRow {
target: "native".to_string(),
support: "adapter".to_string(),
runtime: "native-port action hints".to_string(),
fallback: "semantic text".to_string(),
notes: "native renderers can consume split/token/timeline summaries".to_string(),
},
TextFxCompatibilityRow {
target: "cli".to_string(),
support: "report".to_string(),
runtime: "none".to_string(),
fallback: "compact-json".to_string(),
notes: "diagnostics and budget reports are machine-readable".to_string(),
},
],
}
}
pub fn textfx_native_port_hints<'a>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
policy: &TextFxRoutePolicy,
) -> BTreeMap<String, String> {
let configs = configs.into_iter().collect::<Vec<_>>();
let mut hints = BTreeMap::new();
hints.insert("package".to_string(), TEXTFX_PACKAGE_NAME.to_string());
hints.insert("version".to_string(), TEXTFX_PACKAGE_VERSION.to_string());
hints.insert(
"cacheKey".to_string(),
textfx_cache_key(configs.iter().copied(), policy.route.as_deref(), None),
);
hints.insert(
"route".to_string(),
policy.route.clone().unwrap_or_else(|| "*".to_string()),
);
hints.insert("runtime".to_string(), policy.emission.as_attr().to_string());
hints.insert("profile".to_string(), policy.profile.as_attr().to_string());
hints.insert("configCount".to_string(), configs.len().to_string());
hints
}
fn push_textfx_budget_violation(
violations: &mut Vec<TextFxOutputViolation>,
field: &str,
actual: usize,
budget: Option<usize>,
) {
if let Some(budget) = budget
&& actual > budget
{
violations.push(TextFxOutputViolation {
field: field.to_string(),
actual,
budget,
});
}
}
fn stable_hash_hex<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
let mut hash = 0xcbf29ce484222325u64;
for part in parts {
for byte in part.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash ^= 0xff;
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
pub mod prelude {
pub use crate::integration::*;
pub use crate::{
ReducedMotion, TextCfg, TextEase, TextEffect, TextFxCompatibilityMatrix,
TextFxCompatibilityRow, TextFxConfig, TextFxDiagnostic, TextFxDiagnosticReport,
TextFxDiagnosticSeverity, TextFxDiagnosticVerbosity, TextFxDirection, TextFxEasing,
TextFxEffect, TextFxExplainReport, TextFxGpuBudget, TextFxInteropPolicy,
TextFxLayoutReserve, TextFxManifestFragment, TextFxManifestPolicyHook, TextFxOutputBudget,
TextFxOutputReport, TextFxOutputViolation, TextFxPlayback, TextFxPresetProfile,
TextFxProfile, TextFxRenderPreference, TextFxRoutePolicy, TextFxRuntimeEmission,
TextFxSerializationFormat, TextFxTiming, TextFxTrigger, TextProfile, TextSplit,
TokenAction, TokenTarget, explain_textfx, fx, text_fx, textfx, textfx_cache_key,
textfx_compatibility_matrix, textfx_diagnostics, textfx_manifest_fragment,
textfx_native_port_hints, textfx_output_budget, textfx_output_report, textfx_route_policy,
};
}
impl Default for TextFxConfig {
fn default() -> Self {
Self::new("textfx", "")
}
}
fn textfx_default_value_object() -> &'static serde_json::Map<String, serde_json::Value> {
static DEFAULT: OnceLock<serde_json::Map<String, serde_json::Value>> = OnceLock::new();
DEFAULT.get_or_init(|| {
serde_json::to_value(TextFxConfig::default())
.expect("default TextFxConfig serializes")
.as_object()
.expect("default TextFxConfig serializes to an object")
.clone()
})
}
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",
}
}
fn parse_inline_marks_owned(source: String) -> MarkedText {
if !source.contains("[[") {
return MarkedText {
clean_text: source,
marks: Vec::new(),
};
}
parse_inline_marks_impl(&source)
}
pub fn parse_inline_marks(source: &str) -> MarkedText {
if !source.contains("[[") {
return MarkedText {
clean_text: source.to_string(),
marks: Vec::new(),
};
}
parse_inline_marks_impl(source)
}
fn parse_inline_marks_impl(source: &str) -> MarkedText {
let mut clean_text = String::with_capacity(source.len());
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::from_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 effect_attr_lookup_matches_serialized_effect_ids() {
for effect in TextFxEffect::ALL {
assert_eq!(TextFxEffect::from_attr(effect.as_attr()), Some(effect));
assert_eq!(parse_effect_token(effect.as_attr()), Some(effect));
}
assert_eq!(TextFxEffect::from_attr("blur_reveal"), None);
assert_eq!(parse_effect_token("unknown"), None);
}
#[test]
fn fx_token_splitter_keeps_quoted_and_parenthesized_actions_together() {
let tokens = split_fx_tokens(
"split-words mark:hero:swap('instant resumes') target:contains(\"fast sites\"):stay",
);
assert_eq!(
tokens,
vec![
"split-words",
"mark:hero:swap('instant resumes')",
"target:contains(\"fast sites\"):stay",
]
);
}
#[test]
fn plain_text_construction_preserves_large_unmarked_text() {
let text = "Plain text without inline marks. ".repeat(128);
let config = TextFxConfig::new("plain", text.clone());
assert_eq!(config.text, text);
assert!(config.marks.is_empty());
let parsed = parse_inline_marks(&config.text);
assert_eq!(parsed.clean_text, config.text);
assert!(parsed.marks.is_empty());
}
#[test]
fn compact_payload_is_stable_and_stays_under_growth_budget() {
let config = TextFxConfig::from_fx(
"hero",
"Build [[fast sites|focus]] with [[instant resumes|swap]]",
concat!(
"split-words on-word-click duration-700 stagger-18 ",
"mark:focus:highlight mark:swap:swap('zero reloads') gpu:low-power"
),
)
.unwrap();
let compact = config.to_compact_json().unwrap();
let repeated = config.to_compact_json().unwrap();
let full = config.to_json().unwrap();
assert_eq!(compact, repeated);
assert!(compact.len() < full.len(), "{compact} >= full payload");
assert!(
compact.len() < 1200,
"compact payload grew to {} bytes",
compact.len()
);
assert_eq!(config.preferred_payload_json().unwrap(), compact);
let compact_attr = config.data_attr().unwrap();
let full_attr = format!(r#"data-dxt-textfx="{}""#, escape_attr(&full));
assert!(compact_attr.len() < full_attr.len());
assert!(compact_attr.len() < 3500);
}
#[test]
fn long_text_tokenization_and_compact_payload_stay_stable() {
let mut text = String::with_capacity(16_384);
for index in 0..512 {
if index % 16 == 0 {
text.push_str("[[important segment|focus]] ");
} else {
text.push_str("ordinary segment ");
}
}
let parsed = parse_inline_marks(&text);
let reparsed = parse_inline_marks(&text);
assert_eq!(parsed.clean_text, reparsed.clean_text);
assert_eq!(parsed.marks, reparsed.marks);
assert_eq!(parsed.marks.len(), 32);
let config = TextFxConfig::new("stress", text)
.with_effect(TextFxEffect::Typewriter)
.with_split(TextSplit::Words);
let compact = config.to_compact_json().unwrap();
let repeated = config.to_compact_json().unwrap();
assert_eq!(compact, repeated);
assert!(
compact.len() < config.to_json().unwrap().len(),
"compact payload should stay smaller than full 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 content_setter_reparses_inline_marks() {
let config = TextFxConfig::new("headline", "Draft").content("Launch [[ready|focus]]");
assert_eq!(config.text, "Launch ready");
assert_eq!(config.marks.len(), 1);
assert_eq!(config.marks[0].name, "focus");
}
#[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""#)
);
}
#[test]
fn route_policy_manifest_and_budget_report_batch_configs() {
let headline = TextFxConfig::new("headline", "Launch ready")
.scramble()
.route_profile(TextFxPresetProfile::Expressive);
let kicker = TextFxConfig::new("kicker", "Fast")
.fade()
.route_profile(TextFxPresetProfile::Conservative);
let policy = textfx_route_policy()
.route("/textfx")
.profile(TextFxPresetProfile::Expressive)
.emission(TextFxRuntimeEmission::WhenNeeded)
.budget(textfx_output_budget().config_bytes(4).effect_count(3))
.label("owner", "copy-motion")
.tag("hero");
let manifest = textfx_manifest_fragment([&headline, &kicker], &policy);
let report = textfx_output_report([&headline, &kicker], &policy);
let hints = textfx_native_port_hints([&headline, &kicker], &policy);
assert_eq!(manifest.package, TEXTFX_PACKAGE_NAME);
assert_eq!(manifest.route.as_deref(), Some("/textfx"));
assert_eq!(manifest.config_count, 2);
assert_eq!(
manifest.ids,
vec!["headline".to_string(), "kicker".to_string()]
);
assert_eq!(manifest.metrics["effectCount"], 2);
assert_eq!(hints["configCount"], "2");
assert!(
report
.violations
.iter()
.any(|violation| violation.field == "configBytes")
);
assert_eq!(
textfx_cache_key([&headline, &kicker], Some("/textfx"), None),
textfx_cache_key([&headline, &kicker], Some("/textfx"), None)
);
}
#[test]
fn explain_report_diagnostics_and_hook_cover_interop() {
struct DropDisabled;
impl TextFxManifestPolicyHook for DropDisabled {
fn apply(&self, fragment: TextFxManifestFragment) -> Option<TextFxManifestFragment> {
fragment.enabled.then_some(fragment)
}
}
let config = TextFxConfig::new("tooltip", "Helpful copy").typewriter();
let enabled_policy = textfx_route_policy().route("/textfx").tag("hoverfx");
let disabled_policy = textfx_route_policy()
.route("/textfx/off")
.enabled(false)
.emission(TextFxRuntimeEmission::Disabled);
let explain = explain_textfx([&config], &enabled_policy);
let matrix = textfx_compatibility_matrix();
assert!(explain.diagnostics.is_valid());
assert!(explain.notes.iter().any(|note| note.contains("HoverFX")));
assert!(matrix.rows.iter().any(|row| row.target == "native"));
assert!(apply_textfx_manifest_hook([&config], &enabled_policy, &DropDisabled).is_some());
assert!(apply_textfx_manifest_hook([&config], &disabled_policy, &DropDisabled).is_none());
}
}