use dioxus::prelude::*;
pub use dioxus_textfx_core::{
ReducedMotion, TextFxChoreography, TextFxConfig, TextFxDirection, TextFxEasing, TextFxEffect,
TextFxGpuBudget, TextFxLayoutReserve, TextFxLiveContrast, TextFxLoop, TextFxPerformanceProfile,
TextFxPlayback, TextFxProfile, TextFxTiming, TextFxTrigger, TextSplit, TokenAction, TokenMark,
TokenTarget,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextFxRuntimeMode {
BrowserRuntime,
StaticFallback,
}
pub fn textfx_runtime_mode() -> TextFxRuntimeMode {
if cfg!(all(feature = "web", target_arch = "wasm32")) {
TextFxRuntimeMode::BrowserRuntime
} else {
TextFxRuntimeMode::StaticFallback
}
}
pub fn textfx_native_fallback_config(
id: impl Into<String>,
text: impl Into<String>,
) -> TextFxConfig {
TextFxConfig::new(id, text).with_reduced_motion(ReducedMotion::Static)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextFxNativeAction {
SplitTokens,
RunTimeline,
CountUp,
LocaleTransition,
}
impl TextFxNativeAction {
pub const fn as_str(self) -> &'static str {
match self {
Self::SplitTokens => "split-tokens",
Self::RunTimeline => "run-timeline",
Self::CountUp => "count-up",
Self::LocaleTransition => "locale-transition",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::SplitTokens => "Split tokens",
Self::RunTimeline => "Run native timeline",
Self::CountUp => "Count up",
Self::LocaleTransition => "Locale transition",
}
}
}
pub fn textfx_native_package_actions(
route: Option<&str>,
) -> Vec<dioxus_native_port::NativePackageAction> {
let route = route.map(str::to_string);
[
TextFxNativeAction::SplitTokens,
TextFxNativeAction::RunTimeline,
TextFxNativeAction::CountUp,
TextFxNativeAction::LocaleTransition,
]
.into_iter()
.map(|action| {
let mut package_action = dioxus_native_port::NativePackageAction::new(
"dioxus-textfx",
action.as_str(),
action.label(),
dioxus_native_port::NativeActionKind::NativeAction,
)
.description("Runs the text effect through native Dioxus state/timeline data.");
if let Some(route) = route.clone() {
package_action = package_action.route(route);
}
package_action
})
.collect()
}
pub fn textfx_native_action(
config: &TextFxConfig,
action: TextFxNativeAction,
) -> dioxus_native_port::NativeActionResult {
let tokens = split_textfx_tokens(&config.text, config.split);
let timeline_steps = match action {
TextFxNativeAction::SplitTokens => tokens.len().max(1),
TextFxNativeAction::RunTimeline => tokens.len().max(1) + 2,
TextFxNativeAction::CountUp => 8,
TextFxNativeAction::LocaleTransition => tokens.len().max(1) + 1,
};
let final_text = match action {
TextFxNativeAction::CountUp => config
.to
.map(|value| value.to_string())
.unwrap_or_else(|| config.text.clone()),
_ => config.text.clone(),
};
let worker_mode = if config.requires_workertown_render() {
"workertown-render"
} else {
"native-state"
};
dioxus_native_port::NativeActionResult::succeeded(
"dioxus-textfx",
action.as_str(),
dioxus_native_port::NativeActionKind::NativeAction,
format!(
"{} prepared for native renderer state updates",
action.label()
),
)
.with_backend(worker_mode)
.with_output("effect", config.effect.as_attr())
.with_output("split", text_split_attr(config.split))
.with_output("tokenCount", tokens.len().to_string())
.with_output("timelineSteps", timeline_steps.to_string())
.with_output("durationMs", config.timing.duration_ms.to_string())
.with_output("finalText", final_text)
}
fn split_textfx_tokens(text: &str, split: TextSplit) -> Vec<String> {
match split {
TextSplit::None => vec![text.to_string()],
TextSplit::Chars => text.chars().map(|ch| ch.to_string()).collect(),
TextSplit::Words => text.split_whitespace().map(str::to_string).collect(),
TextSplit::Lines => text.lines().map(str::to_string).collect(),
}
}
fn text_split_attr(split: TextSplit) -> &'static str {
match split {
TextSplit::None => "none",
TextSplit::Chars => "chars",
TextSplit::Words => "words",
TextSplit::Lines => "lines",
}
}
#[derive(Props, Clone, PartialEq)]
pub struct TextFxProps {
pub text: String,
#[props(default)]
pub effect: TextFxEffect,
#[props(default)]
pub timing: TextFxTiming,
#[props(default)]
pub split: TextSplit,
#[props(default)]
pub performance_profile: TextFxPerformanceProfile,
#[props(default)]
pub gpu_budget: TextFxGpuBudget,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub fx: String,
#[props(default = "span".to_string())]
pub as_tag: String,
#[props(default)]
pub class: String,
#[props(default)]
pub id: String,
}
#[component]
pub fn TextFx(props: TextFxProps) -> Element {
let id = textfx_id(&props.id, &props.text);
let config = if props.fx.trim().is_empty() {
let config = TextFxConfig::new(id, props.text.clone())
.with_performance_profile(props.performance_profile)
.with_gpu_budget(props.gpu_budget)
.with_layout_reserve(props.layout_reserve)
.with_effect(props.effect)
.with_timing(props.timing);
if props.split == TextSplit::None {
config
} else {
config.with_split(props.split)
}
} else {
TextFxConfig::from_fx(id, props.text.clone(), props.fx.clone())
.unwrap_or_else(|_| TextFxConfig::new("", props.text.clone()))
};
render_textfx_node(&props.as_tag, &props.class, &config.text, &config)
}
#[derive(Props, Clone, PartialEq)]
pub struct SplitTextProps {
pub text: String,
#[props(default = TextSplit::Chars)]
pub by: TextSplit,
#[props(default = TextFxEffect::Stagger)]
pub effect: TextFxEffect,
#[props(default = 28)]
pub stagger_ms: u32,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub class: String,
}
#[component]
pub fn SplitText(props: SplitTextProps) -> Element {
let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
.with_effect(props.effect)
.with_split(props.by)
.with_layout_reserve(props.layout_reserve)
.with_stagger_ms(props.stagger_ms);
render_textfx_node("span", &props.class, &config.text, &config)
}
#[derive(Props, Clone, PartialEq)]
pub struct TypewriterProps {
pub text: String,
#[props(default = 32)]
pub speed_ms: u32,
#[props(default = true)]
pub cursor: bool,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub class: String,
}
#[component]
pub fn Typewriter(props: TypewriterProps) -> Element {
let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
.with_effect(TextFxEffect::Typewriter)
.with_layout_reserve(props.layout_reserve)
.with_speed_ms(props.speed_ms)
.with_cursor(props.cursor);
render_textfx_node("span", &props.class, &config.text, &config)
}
#[derive(Props, Clone, PartialEq)]
pub struct ScrambleTextProps {
pub text: String,
#[props(default = dioxus_textfx_core::DEFAULT_TEXTFX_CHARSET.to_string())]
pub charset: String,
#[props(default = 32)]
pub speed_ms: u32,
#[props(default = 520)]
pub settle_ms: u32,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub class: String,
}
#[component]
pub fn ScrambleText(props: ScrambleTextProps) -> Element {
let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
.with_effect(TextFxEffect::Scramble)
.with_layout_reserve(props.layout_reserve)
.with_charset(props.charset)
.with_speed_ms(props.speed_ms)
.with_duration_ms(props.settle_ms);
render_textfx_node("span", &props.class, &config.text, &config)
}
#[derive(Props, Clone, PartialEq)]
pub struct BlurRevealProps {
pub text: String,
#[props(default = 640)]
pub duration_ms: u32,
#[props(default)]
pub easing: TextFxEasing,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub class: String,
}
#[component]
pub fn BlurReveal(props: BlurRevealProps) -> Element {
let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
.with_effect(TextFxEffect::BlurReveal)
.with_layout_reserve(props.layout_reserve)
.with_duration_ms(props.duration_ms)
.with_easing(props.easing);
render_textfx_node("span", &props.class, &config.text, &config)
}
#[derive(Props, Clone, PartialEq)]
pub struct StaggerTextProps {
pub text: String,
#[props(default = TextSplit::Words)]
pub by: TextSplit,
#[props(default = 28)]
pub delay_ms: u32,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub class: String,
}
#[component]
pub fn StaggerText(props: StaggerTextProps) -> Element {
let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
.with_effect(TextFxEffect::Stagger)
.with_split(props.by)
.with_layout_reserve(props.layout_reserve)
.with_stagger_ms(props.delay_ms);
render_textfx_node("span", &props.class, &config.text, &config)
}
#[derive(Props, Clone, PartialEq)]
pub struct CountUpTextProps {
pub from: f64,
pub to: f64,
#[props(default = 900)]
pub duration_ms: u32,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub class: String,
}
#[component]
pub fn CountUpText(props: CountUpTextProps) -> Element {
let text = format!("{}", props.to);
let config = TextFxConfig::new(textfx_id("", &text), text.clone())
.with_effect(TextFxEffect::CountUp)
.with_layout_reserve(props.layout_reserve)
.with_duration_ms(props.duration_ms)
.with_numbers(props.from, props.to);
render_textfx_node("span", &props.class, &text, &config)
}
#[derive(Props, Clone, PartialEq)]
pub struct LocaleTransitionProps {
pub text: String,
pub key_name: String,
#[props(default = TextFxEffect::BlurReveal)]
pub effect: TextFxEffect,
#[props(default)]
pub timing: TextFxTiming,
#[props(default)]
pub split: TextSplit,
#[props(default)]
pub performance_profile: TextFxPerformanceProfile,
#[props(default)]
pub gpu_budget: TextFxGpuBudget,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub fx: String,
#[props(default)]
pub class: String,
#[props(default)]
pub id: String,
}
#[component]
pub fn LocaleTransition(props: LocaleTransitionProps) -> Element {
let class = join_class("dxt-textfx", &props.class);
let id = textfx_id(&props.id, &props.text);
let config = if props.fx.trim().is_empty() {
let config = TextFxConfig::new(id, props.text.clone())
.with_performance_profile(props.performance_profile)
.with_gpu_budget(props.gpu_budget)
.with_layout_reserve(props.layout_reserve)
.with_effect(props.effect)
.with_timing(props.timing);
if props.split == TextSplit::None {
config
} else {
config.with_split(props.split)
}
} else {
TextFxConfig::from_fx(id, props.text.clone(), props.fx.clone())
.unwrap_or_else(|_| TextFxConfig::new("", props.text.clone()).with_effect(props.effect))
};
let effect = config.effect.as_attr();
let locale_fx = config
.to_compact_json()
.unwrap_or_else(|_| "{}".to_string());
if config.reserves_layout() {
let layout_target = config.layout_reserve.as_attr();
rsx! {
span {
class: "{class}",
"data-dxr-i18n-key": "{props.key_name}",
"data-dxt-locale-fx": "{locale_fx}",
"data-dxr-text-layout-target": "{layout_target}",
aria_label: "{props.text}",
title: "{effect}",
"{props.text}"
}
}
} else {
rsx! {
span {
class: "{class}",
"data-dxr-i18n-key": "{props.key_name}",
"data-dxt-locale-fx": "{locale_fx}",
aria_label: "{props.text}",
title: "{effect}",
"{props.text}"
}
}
}
}
fn render_textfx_node(tag: &str, class: &str, text: &str, config: &TextFxConfig) -> Element {
let class = join_class(
if config.effect == TextFxEffect::LiveContrast {
"dxt-textfx dxt-live-contrast"
} else {
"dxt-textfx"
},
class,
);
let effect = config.effect.as_attr();
let layout_target = config.layout_reserve.as_attr();
let reserve_layout = config.reserves_layout();
match tag {
"h1" if reserve_layout => {
rsx! { h1 { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"h1" => {
rsx! { h1 { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"h2" if reserve_layout => {
rsx! { h2 { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"h2" => {
rsx! { h2 { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"h3" if reserve_layout => {
rsx! { h3 { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"h3" => {
rsx! { h3 { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"p" if reserve_layout => {
rsx! { p { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"p" => {
rsx! { p { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"strong" if reserve_layout => {
rsx! { strong { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"strong" => {
rsx! { strong { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
_ if reserve_layout => {
rsx! { span { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
_ => {
rsx! { span { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
}
}
fn textfx_id(id: &str, text: &str) -> String {
if !id.trim().is_empty() {
return id.to_string();
}
let slug: String = text
.chars()
.filter_map(|ch| {
if ch.is_ascii_alphanumeric() {
Some(ch.to_ascii_lowercase())
} else if ch.is_whitespace() || ch == '-' {
Some('-')
} else {
None
}
})
.take(48)
.collect();
format!("textfx-{}", slug.trim_matches('-'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn native_fallback_config_is_static_semantic_text() {
let config = textfx_native_fallback_config("title", "Readable");
assert_eq!(textfx_runtime_mode(), TextFxRuntimeMode::StaticFallback);
assert_eq!(config.reduced_motion, ReducedMotion::Static);
assert_eq!(config.text, "Readable");
}
#[test]
fn native_textfx_action_reports_tokens_and_timeline() {
let config = TextFxConfig::new("title", "Native text actions")
.with_effect(TextFxEffect::Typewriter)
.with_split(TextSplit::Words);
let result = textfx_native_action(&config, TextFxNativeAction::RunTimeline);
assert_eq!(
result.status,
dioxus_native_port::NativeActionStatus::Succeeded
);
assert_eq!(
result.kind,
dioxus_native_port::NativeActionKind::NativeAction
);
assert_eq!(
result.outputs.get("tokenCount").map(String::as_str),
Some("3")
);
assert_eq!(
result.outputs.get("timelineSteps").map(String::as_str),
Some("5")
);
}
#[test]
fn native_textfx_package_actions_are_route_scoped() {
let actions = textfx_native_package_actions(Some("/textfx"));
assert_eq!(actions.len(), 4);
assert!(
actions
.iter()
.all(|action| action.route.as_deref() == Some("/textfx"))
);
}
}
fn join_class(base: &str, extra: &str) -> String {
if extra.trim().is_empty() {
base.to_string()
} else {
format!("{base} {}", extra.trim())
}
}