use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
mod integration;
pub use integration::*;
pub const DEFAULT_HOVERFX_RUNTIME_BASE_PATH: &str = "/assets/dioxus-hoverfx.js";
pub const DEFAULT_HOVERFX_WORKER_BASE_PATH: &str = "/assets/dioxus-hoverfx-worker.js";
pub const DEFAULT_HOVERFX_RUNTIME_VERSION: &str = "1";
pub const DEFAULT_HOVERFX_WORKER_VERSION: &str = "1";
pub const DEFAULT_HOVERFX_RUNTIME_PATH: &str = "/assets/dioxus-hoverfx.js?v=1";
pub const DEFAULT_HOVERFX_WORKER_PATH: &str = "/assets/dioxus-hoverfx-worker.js?v=1";
pub const HOVERFX_PACKAGE_NAME: &str = "dioxus-hoverfx";
pub const HOVERFX_PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const DEFAULT_HOVERFX_RADIUS_PX: u16 = 180;
pub const DEFAULT_HOVERFX_RANGE_PX: u16 = DEFAULT_HOVERFX_RADIUS_PX;
pub const DEFAULT_HOVERFX_STRENGTH: f32 = 1.0;
pub const DEFAULT_HOVERFX_SMOOTHING: f32 = 0.18;
pub const DEFAULT_HOVERFX_MAX_ACTIVE_ELEMENTS: u16 = 8;
pub const DEFAULT_HOVERFX_PERF_LAZY_LOCAL_LAYERS: bool = true;
pub const DEFAULT_HOVERFX_PERF_WORKER_LOCAL_LAYERS: bool = true;
pub const DEFAULT_HOVERFX_PERF_DIRTY_RECT_RENDERING: bool = true;
pub const DEFAULT_HOVERFX_PERF_SHADER_TEXTURE_CACHE: bool = true;
pub const DEFAULT_HOVERFX_PERF_DPR_CAP: f32 = 2.0;
pub const DEFAULT_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS: u16 = 1_200;
pub const MIN_HOVERFX_RADIUS_PX: u16 = 1;
pub const MAX_HOVERFX_RADIUS_PX: u16 = 2_000;
pub const MIN_HOVERFX_RANGE_PX: u16 = 0;
pub const MAX_HOVERFX_RANGE_PX: u16 = 5_000;
pub const MAX_HOVERFX_STRENGTH: f32 = 10.0;
pub const MAX_HOVERFX_MAX_ACTIVE_ELEMENTS: u16 = 64;
pub const MIN_HOVERFX_PERF_DPR_CAP: f32 = 1.0;
pub const MAX_HOVERFX_PERF_DPR_CAP: f32 = 3.0;
pub const MAX_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS: u16 = 60_000;
pub const MAX_HOVERFX_PERF_CANDIDATE_OBSERVER_MARGIN_PX: u16 = 5_000;
pub const DEFAULT_HOVERFX_TEXT_REVEAL_CHARSET: &str = "01";
pub const DEFAULT_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS: u16 = 220;
pub const DEFAULT_HOVERFX_TEXT_REVEAL_DENSITY: f32 = 1.0;
pub const DEFAULT_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX: u16 = 14;
pub const DEFAULT_HOVERFX_TEXT_REVEAL_GAP_PX: u16 = 6;
pub const DEFAULT_HOVERFX_TEXT_REVEAL_FONT_FAMILY: &str =
"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
pub const DEFAULT_HOVERFX_TEXT_REVEAL_COLOR: &str =
"var(--dxh-binary-color, var(--dxt-accent, #22d3ee))";
pub const DEFAULT_HOVERFX_TEXT_REVEAL_TEXTFX_EFFECT: &str = "scramble";
pub const DEFAULT_HOVERFX_TOOLTIP_OFFSET_PX: u16 = 12;
pub const DEFAULT_HOVERFX_TOOLTIP_MAX_WIDTH_PX: u16 = 280;
pub const DEFAULT_HOVERFX_TOOLTIP_SHOW_DELAY_MS: u16 = 0;
pub const DEFAULT_HOVERFX_TOOLTIP_HIDE_DELAY_MS: u16 = 90;
pub const DEFAULT_HOVERFX_TOOLTIP_DURATION_MS: u16 = 260;
pub const DEFAULT_HOVERFX_TOOLTIP_SPEED_MS: u16 = 18;
pub const DEFAULT_HOVERFX_TOOLTIP_STAGGER_MS: u16 = 8;
pub const DEFAULT_HOVERFX_TOOLTIP_TEXTFX_EFFECT: &str = "scramble";
pub const DEFAULT_HOVERFX_TOOLTIP_SPLIT: &str = "chars";
pub const DEFAULT_HOVERFX_TOOLTIP_BOX_OPACITY: f32 = 0.88;
pub const MIN_HOVERFX_TOOLTIP_MAX_WIDTH_PX: u16 = 80;
pub const MAX_HOVERFX_TOOLTIP_MAX_WIDTH_PX: u16 = 720;
pub const MAX_HOVERFX_TOOLTIP_OFFSET_PX: u16 = 96;
pub const MAX_HOVERFX_TOOLTIP_DELAY_MS: u16 = 2_000;
pub const MAX_HOVERFX_TOOLTIP_DURATION_MS: u16 = 4_000;
pub const MAX_HOVERFX_TOOLTIP_TEXT_CHARS: usize = 2_048;
pub const MIN_HOVERFX_TOOLTIP_BOX_OPACITY: f32 = 0.0;
pub const MAX_HOVERFX_TOOLTIP_BOX_OPACITY: f32 = 1.0;
pub const DEFAULT_HOVERFX_SAND_GRAIN_SIZE_PX: f32 = 1.15;
pub const DEFAULT_HOVERFX_SAND_GRAIN_DENSITY: f32 = 1.0;
pub const DEFAULT_HOVERFX_SAND_SHIMMER_DENSITY: f32 = 0.16;
pub const DEFAULT_HOVERFX_SAND_SHIMMER_STRENGTH: f32 = 0.75;
pub const DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX: f32 = 250.0;
pub const DEFAULT_HOVERFX_SAND_SPECULAR_STRENGTH: f32 = 0.85;
pub const DEFAULT_HOVERFX_SAND_ROUGHNESS: f32 = 0.42;
pub const DEFAULT_HOVERFX_SAND_ANIMATION_SPEED_MS: u16 = 900;
pub const DEFAULT_HOVERFX_SAND_COLOR: &str = "var(--dxh-sand-color, #d7b878)";
pub const DEFAULT_HOVERFX_SAND_HIGHLIGHT_COLOR: &str = "var(--dxh-sand-highlight, #fff4c2)";
pub const MIN_HOVERFX_SAND_ANIMATION_SPEED_MS: u16 = 16;
pub const MAX_HOVERFX_SAND_ANIMATION_SPEED_MS: u16 = 4_000;
pub const MIN_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS: u16 = 16;
pub const MAX_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS: u16 = 2_000;
pub const MAX_HOVERFX_TEXT_REVEAL_CHARSET_CHARS: usize = 64;
pub const MAX_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX: u16 = 96;
pub const MAX_HOVERFX_TEXT_REVEAL_GAP_PX: u16 = 96;
pub const SUPPORTED_HOVERFX_TEXTFX_EFFECTS: [&str; 7] = [
"scramble",
"typewriter",
"wave",
"glitch",
"mask-reveal",
"highlight-sweep",
"gradient-shift",
];
pub type HoverCfg = HoverFxConfig;
pub type HoverDef = HoverFxDefinition;
pub type HoverReg = HoverFxRegistry;
pub type HoverPerf = HoverFxPerformanceConfig;
pub fn hoverfx() -> HoverFxConfig {
HoverFxConfig::new()
}
pub fn hover_fx() -> HoverFxConfig {
HoverFxConfig::new()
}
pub fn hover_def(id: impl AsRef<str>, label: impl Into<String>) -> HoverFxDefinition {
HoverFxDefinition::new(id, label)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum HoverFxPreset {
#[default]
Spotlight,
SoftGlow,
BorderTrace,
Sheen,
ColorWash,
BinaryReveal,
TextureReveal,
Sand,
Tooltip,
}
impl HoverFxPreset {
pub const ALL: [Self; 9] = [
Self::Spotlight,
Self::SoftGlow,
Self::BorderTrace,
Self::Sheen,
Self::ColorWash,
Self::BinaryReveal,
Self::TextureReveal,
Self::Sand,
Self::Tooltip,
];
pub const fn all() -> &'static [Self; 9] {
&Self::ALL
}
pub const fn as_attr(self) -> &'static str {
match self {
Self::Spotlight => "spotlight",
Self::SoftGlow => "soft-glow",
Self::BorderTrace => "border-trace",
Self::Sheen => "sheen",
Self::ColorWash => "color-wash",
Self::BinaryReveal => "binary-reveal",
Self::TextureReveal => "texture-reveal",
Self::Sand => "sand",
Self::Tooltip => "tooltip",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Spotlight => "Spotlight",
Self::SoftGlow => "Soft glow",
Self::BorderTrace => "Border trace",
Self::Sheen => "Sheen",
Self::ColorWash => "Color wash",
Self::BinaryReveal => "Binary reveal",
Self::TextureReveal => "Texture reveal",
Self::Sand => "Sand",
Self::Tooltip => "Tooltip",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum HoverFxTextAnimationSource {
HoverFx,
TextFx,
#[default]
Auto,
}
impl HoverFxTextAnimationSource {
pub const fn as_attr(self) -> &'static str {
match self {
Self::HoverFx => "hoverfx",
Self::TextFx => "textfx",
Self::Auto => "auto",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum HoverFxTextContrastMode {
#[default]
Off,
Auto,
Darken,
Invert,
}
impl HoverFxTextContrastMode {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Off => "off",
Self::Auto => "auto",
Self::Darken => "darken",
Self::Invert => "invert",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum HoverFxTextRevealRenderer {
#[default]
GlyphAtlas,
CanvasGrid,
}
impl HoverFxTextRevealRenderer {
pub const fn as_attr(self) -> &'static str {
match self {
Self::GlyphAtlas => "glyph-atlas",
Self::CanvasGrid => "canvas-grid",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum HoverFxTextureRevealMode {
#[default]
Auto,
Halftone,
StaticGrain,
}
impl HoverFxTextureRevealMode {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Halftone => "halftone",
Self::StaticGrain => "static-grain",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxTextureRevealConfig {
pub mode: HoverFxTextureRevealMode,
}
impl Default for HoverFxTextureRevealConfig {
fn default() -> Self {
Self {
mode: HoverFxTextureRevealMode::Auto,
}
}
}
impl HoverFxTextureRevealConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_mode(mut self, mode: HoverFxTextureRevealMode) -> Self {
self.mode = mode;
self
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum HoverFxSandColorSource {
#[default]
Custom,
Element,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxSandConfig {
pub grain_size_px: f32,
pub grain_density: f32,
pub shimmer_density: f32,
pub shimmer_strength: f32,
#[serde(default = "default_hoverfx_sand_shimmer_radius_px")]
pub shimmer_radius_px: f32,
pub specular_strength: f32,
pub roughness: f32,
pub animation_speed_ms: u16,
#[serde(default, skip_serializing_if = "is_default_hoverfx_sand_color_source")]
pub color_source: HoverFxSandColorSource,
pub color: String,
pub highlight_color: String,
}
impl Default for HoverFxSandConfig {
fn default() -> Self {
Self {
grain_size_px: DEFAULT_HOVERFX_SAND_GRAIN_SIZE_PX,
grain_density: DEFAULT_HOVERFX_SAND_GRAIN_DENSITY,
shimmer_density: DEFAULT_HOVERFX_SAND_SHIMMER_DENSITY,
shimmer_strength: DEFAULT_HOVERFX_SAND_SHIMMER_STRENGTH,
shimmer_radius_px: DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX,
specular_strength: DEFAULT_HOVERFX_SAND_SPECULAR_STRENGTH,
roughness: DEFAULT_HOVERFX_SAND_ROUGHNESS,
animation_speed_ms: DEFAULT_HOVERFX_SAND_ANIMATION_SPEED_MS,
color_source: HoverFxSandColorSource::Custom,
color: DEFAULT_HOVERFX_SAND_COLOR.to_string(),
highlight_color: DEFAULT_HOVERFX_SAND_HIGHLIGHT_COLOR.to_string(),
}
}
}
impl HoverFxSandConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_grain_size_px(mut self, grain_size_px: f32) -> Self {
self.grain_size_px = grain_size_px;
self
}
pub fn with_grain_density(mut self, grain_density: f32) -> Self {
self.grain_density = grain_density;
self
}
pub fn with_shimmer_density(mut self, shimmer_density: f32) -> Self {
self.shimmer_density = shimmer_density;
self
}
pub fn with_shimmer_strength(mut self, shimmer_strength: f32) -> Self {
self.shimmer_strength = shimmer_strength;
self
}
pub fn with_shimmer_radius_px(mut self, shimmer_radius_px: f32) -> Self {
self.shimmer_radius_px = shimmer_radius_px;
self
}
pub fn with_specular_strength(mut self, specular_strength: f32) -> Self {
self.specular_strength = specular_strength;
self
}
pub fn with_roughness(mut self, roughness: f32) -> Self {
self.roughness = roughness;
self
}
pub fn with_animation_speed_ms(mut self, animation_speed_ms: u16) -> Self {
self.animation_speed_ms = animation_speed_ms;
self
}
pub fn with_color_source(mut self, color_source: HoverFxSandColorSource) -> Self {
self.color_source = color_source;
self
}
pub fn with_color(mut self, color: impl Into<String>) -> Self {
self.color = color.into();
self
}
pub fn with_highlight_color(mut self, highlight_color: impl Into<String>) -> Self {
self.highlight_color = highlight_color.into();
self
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
}
fn default_hoverfx_sand_shimmer_radius_px() -> f32 {
DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX
}
fn default_hoverfx_range_px() -> u16 {
DEFAULT_HOVERFX_RANGE_PX
}
fn is_default_hoverfx_sand_color_source(color_source: &HoverFxSandColorSource) -> bool {
*color_source == HoverFxSandColorSource::Custom
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum HoverFxShape {
#[default]
Circle,
Square,
RoundedRect,
Polygon,
}
impl HoverFxShape {
pub const ALL: [Self; 4] = [Self::Circle, Self::Square, Self::RoundedRect, Self::Polygon];
pub const fn all() -> &'static [Self; 4] {
&Self::ALL
}
pub const fn as_attr(self) -> &'static str {
match self {
Self::Circle => "circle",
Self::Square => "square",
Self::RoundedRect => "rounded-rect",
Self::Polygon => "polygon",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Circle => "Circle",
Self::Square => "Square",
Self::RoundedRect => "Rounded rect",
Self::Polygon => "Polygon",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum HoverFxFalloff {
Hard,
Linear,
#[default]
Smooth,
Exponential,
}
impl HoverFxFalloff {
pub const ALL: [Self; 4] = [Self::Hard, Self::Linear, Self::Smooth, Self::Exponential];
pub const fn all() -> &'static [Self; 4] {
&Self::ALL
}
pub const fn as_attr(self) -> &'static str {
match self {
Self::Hard => "hard",
Self::Linear => "linear",
Self::Smooth => "smooth",
Self::Exponential => "exponential",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Hard => "Hard",
Self::Linear => "Linear",
Self::Smooth => "Smooth",
Self::Exponential => "Exponential",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum HoverFxRenderer {
#[default]
WorkerFirst,
CssOnly,
Disabled,
}
impl HoverFxRenderer {
pub const fn as_attr(self) -> &'static str {
match self {
Self::WorkerFirst => "worker-first",
Self::CssOnly => "css-only",
Self::Disabled => "disabled",
}
}
pub const fn uses_worker(self) -> bool {
matches!(self, Self::WorkerFirst)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxTextRevealConfig {
pub charset: String,
pub cycle: bool,
pub cycle_speed_ms: u16,
pub density: f32,
pub font_size_px: u16,
pub gap_px: u16,
pub font_family: String,
pub color: String,
pub animation_source: HoverFxTextAnimationSource,
#[serde(default)]
pub renderer: HoverFxTextRevealRenderer,
pub textfx_effect: String,
}
impl Default for HoverFxTextRevealConfig {
fn default() -> Self {
Self {
charset: DEFAULT_HOVERFX_TEXT_REVEAL_CHARSET.to_string(),
cycle: true,
cycle_speed_ms: DEFAULT_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS,
density: DEFAULT_HOVERFX_TEXT_REVEAL_DENSITY,
font_size_px: DEFAULT_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX,
gap_px: DEFAULT_HOVERFX_TEXT_REVEAL_GAP_PX,
font_family: DEFAULT_HOVERFX_TEXT_REVEAL_FONT_FAMILY.to_string(),
color: DEFAULT_HOVERFX_TEXT_REVEAL_COLOR.to_string(),
animation_source: HoverFxTextAnimationSource::Auto,
renderer: HoverFxTextRevealRenderer::GlyphAtlas,
textfx_effect: DEFAULT_HOVERFX_TEXT_REVEAL_TEXTFX_EFFECT.to_string(),
}
}
}
impl HoverFxTextRevealConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_charset(mut self, charset: impl Into<String>) -> Self {
self.charset = charset.into();
self
}
pub fn with_cycle(mut self, cycle: bool) -> Self {
self.cycle = cycle;
self
}
pub fn with_cycle_speed_ms(mut self, cycle_speed_ms: u16) -> Self {
self.cycle_speed_ms = cycle_speed_ms;
self
}
pub fn with_density(mut self, density: f32) -> Self {
self.density = density;
self
}
pub fn with_font_size_px(mut self, font_size_px: u16) -> Self {
self.font_size_px = font_size_px;
self
}
pub fn with_gap_px(mut self, gap_px: u16) -> Self {
self.gap_px = gap_px;
self
}
pub fn with_font_family(mut self, font_family: impl Into<String>) -> Self {
self.font_family = font_family.into();
self
}
pub fn with_color(mut self, color: impl Into<String>) -> Self {
self.color = color.into();
self
}
pub fn with_animation_source(mut self, animation_source: HoverFxTextAnimationSource) -> Self {
self.animation_source = animation_source;
self
}
pub fn with_renderer(mut self, renderer: HoverFxTextRevealRenderer) -> Self {
self.renderer = renderer;
self
}
pub fn with_textfx_effect(mut self, textfx_effect: impl AsRef<str>) -> Self {
self.textfx_effect = hoverfx_id(textfx_effect);
self
}
#[cfg(feature = "textfx-interop")]
pub fn with_textfx_effect_enum(mut self, effect: dioxus_textfx_core::TextFxEffect) -> Self {
self.textfx_effect = effect.as_attr().to_string();
self
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
pub fn to_textfx_config_json(
&self,
id: impl AsRef<str>,
text: impl AsRef<str>,
) -> serde_json::Result<String> {
serde_json::to_string(&serde_json::json!({
"id": hoverfx_id(id),
"text": text.as_ref(),
"effect": self.textfx_effect,
"timing": {
"durationMs": 640,
"speedMs": self.cycle_speed_ms,
"staggerMs": 16,
},
"split": "chars",
"trigger": "hover",
"charset": self.charset,
"reducedMotion": "instant",
"performanceProfile": "interactive",
}))
}
}
#[cfg(feature = "textfx-interop")]
impl From<dioxus_textfx_core::TextFxEffect> for HoverFxTextRevealConfig {
fn from(effect: dioxus_textfx_core::TextFxEffect) -> Self {
Self::default().with_textfx_effect_enum(effect)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxTooltipPlacement {
Top,
Right,
Bottom,
Left,
#[default]
Cursor,
}
impl HoverFxTooltipPlacement {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Top => "top",
Self::Right => "right",
Self::Bottom => "bottom",
Self::Left => "left",
Self::Cursor => "cursor",
}
}
}
fn default_hoverfx_tooltip_box_opacity() -> f32 {
DEFAULT_HOVERFX_TOOLTIP_BOX_OPACITY
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxTooltipConfig {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub i18n_key: Option<String>,
pub placement: HoverFxTooltipPlacement,
pub offset_px: u16,
pub max_width_px: u16,
#[serde(default = "default_hoverfx_tooltip_box_opacity")]
pub box_opacity: f32,
pub show_delay_ms: u16,
pub hide_delay_ms: u16,
pub duration_ms: u16,
pub speed_ms: u16,
pub stagger_ms: u16,
pub textfx_effect: String,
pub split: String,
}
impl Default for HoverFxTooltipConfig {
fn default() -> Self {
Self {
text: String::new(),
i18n_key: None,
placement: HoverFxTooltipPlacement::Cursor,
offset_px: DEFAULT_HOVERFX_TOOLTIP_OFFSET_PX,
max_width_px: DEFAULT_HOVERFX_TOOLTIP_MAX_WIDTH_PX,
box_opacity: DEFAULT_HOVERFX_TOOLTIP_BOX_OPACITY,
show_delay_ms: DEFAULT_HOVERFX_TOOLTIP_SHOW_DELAY_MS,
hide_delay_ms: DEFAULT_HOVERFX_TOOLTIP_HIDE_DELAY_MS,
duration_ms: DEFAULT_HOVERFX_TOOLTIP_DURATION_MS,
speed_ms: DEFAULT_HOVERFX_TOOLTIP_SPEED_MS,
stagger_ms: DEFAULT_HOVERFX_TOOLTIP_STAGGER_MS,
textfx_effect: DEFAULT_HOVERFX_TOOLTIP_TEXTFX_EFFECT.to_string(),
split: DEFAULT_HOVERFX_TOOLTIP_SPLIT.to_string(),
}
}
}
impl HoverFxTooltipConfig {
pub fn new(text: impl Into<String>) -> Self {
Self::default().with_text(text)
}
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.text = text.into();
self
}
pub fn with_i18n_key(mut self, key: impl AsRef<str>) -> Self {
let key = key.as_ref().trim();
self.i18n_key = if key.is_empty() {
None
} else {
Some(key.to_string())
};
self
}
pub fn i18n_key(self, key: impl AsRef<str>) -> Self {
self.with_i18n_key(key)
}
pub fn key(self, key: impl AsRef<str>) -> Self {
self.with_i18n_key(key)
}
pub fn locale_key(self, key: impl AsRef<str>) -> Self {
self.with_i18n_key(key)
}
pub fn with_placement(mut self, placement: HoverFxTooltipPlacement) -> Self {
self.placement = placement;
self
}
pub fn top(self) -> Self {
self.with_placement(HoverFxTooltipPlacement::Top)
}
pub fn right(self) -> Self {
self.with_placement(HoverFxTooltipPlacement::Right)
}
pub fn bottom(self) -> Self {
self.with_placement(HoverFxTooltipPlacement::Bottom)
}
pub fn left(self) -> Self {
self.with_placement(HoverFxTooltipPlacement::Left)
}
pub fn cursor(self) -> Self {
self.with_placement(HoverFxTooltipPlacement::Cursor)
}
pub fn with_offset_px(mut self, offset_px: u16) -> Self {
self.offset_px = offset_px;
self
}
pub fn offset(self, offset_px: u16) -> Self {
self.with_offset_px(offset_px)
}
pub fn with_max_width_px(mut self, max_width_px: u16) -> Self {
self.max_width_px = max_width_px;
self
}
pub fn max_width(self, max_width_px: u16) -> Self {
self.with_max_width_px(max_width_px)
}
pub fn with_box_opacity(mut self, box_opacity: f32) -> Self {
self.box_opacity = box_opacity;
self
}
pub fn box_opacity(self, box_opacity: f32) -> Self {
self.with_box_opacity(box_opacity)
}
pub fn opacity(self, box_opacity: f32) -> Self {
self.with_box_opacity(box_opacity)
}
pub fn with_show_delay_ms(mut self, show_delay_ms: u16) -> Self {
self.show_delay_ms = show_delay_ms;
self
}
pub fn show_delay_ms(self, show_delay_ms: u16) -> Self {
self.with_show_delay_ms(show_delay_ms)
}
pub fn with_hide_delay_ms(mut self, hide_delay_ms: u16) -> Self {
self.hide_delay_ms = hide_delay_ms;
self
}
pub fn hide_delay_ms(self, hide_delay_ms: u16) -> Self {
self.with_hide_delay_ms(hide_delay_ms)
}
pub fn with_duration_ms(mut self, duration_ms: u16) -> Self {
self.duration_ms = duration_ms;
self
}
pub fn dur_ms(self, duration_ms: u16) -> Self {
self.with_duration_ms(duration_ms)
}
pub fn with_speed_ms(mut self, speed_ms: u16) -> Self {
self.speed_ms = speed_ms;
self
}
pub fn speed_ms(self, speed_ms: u16) -> Self {
self.with_speed_ms(speed_ms)
}
pub fn with_stagger_ms(mut self, stagger_ms: u16) -> Self {
self.stagger_ms = stagger_ms;
self
}
pub fn stagger_ms(self, stagger_ms: u16) -> Self {
self.with_stagger_ms(stagger_ms)
}
pub fn with_textfx_effect(mut self, textfx_effect: impl AsRef<str>) -> Self {
self.textfx_effect = hoverfx_id(textfx_effect);
self
}
#[cfg(feature = "textfx-interop")]
pub fn with_textfx_effect_enum(mut self, effect: dioxus_textfx_core::TextFxEffect) -> Self {
self.textfx_effect = effect.as_attr().to_string();
self
}
pub fn scramble(self) -> Self {
self.with_textfx_effect("scramble")
}
pub fn typewriter(self) -> Self {
self.with_textfx_effect("typewriter")
}
pub fn wave(self) -> Self {
self.with_textfx_effect("wave")
}
pub fn glitch(self) -> Self {
self.with_textfx_effect("glitch")
}
pub fn with_split(mut self, split: impl Into<String>) -> Self {
self.split = split.into();
self
}
pub fn split_chars(self) -> Self {
self.with_split("chars")
}
pub fn split_words(self) -> Self {
self.with_split("words")
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
pub fn to_textfx_config_json(
&self,
id: impl AsRef<str>,
fallback_text: impl AsRef<str>,
) -> serde_json::Result<String> {
let text = if self.text.trim().is_empty() {
fallback_text.as_ref()
} else {
self.text.as_str()
};
serde_json::to_string(&serde_json::json!({
"id": hoverfx_id(id),
"text": text,
"effect": self.textfx_effect,
"timing": {
"durationMs": self.duration_ms,
"speedMs": self.speed_ms,
"staggerMs": self.stagger_ms,
},
"split": self.split,
"trigger": "manual",
"reducedMotion": "instant",
"performanceProfile": "interactive",
}))
}
}
#[cfg(feature = "textfx-interop")]
impl From<dioxus_textfx_core::TextFxEffect> for HoverFxTooltipConfig {
fn from(effect: dioxus_textfx_core::TextFxEffect) -> Self {
Self::default().with_textfx_effect_enum(effect)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxValidationSeverity {
Error,
Warning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxValidationCode {
MissingDefaultEffect,
InvalidDefaultEffectId,
InvalidEffectId,
EmptyRuntimePath,
EmptyWorkerPath,
InvalidRadius,
InvalidRange,
InvalidStrength,
InvalidSmoothing,
InvalidMaxActiveElements,
InvalidCssVariableName,
UnsafeCssValue,
UnsafeCustomShapeValue,
InvalidTextRevealCharset,
InvalidTextRevealCycleSpeed,
InvalidTextRevealMetric,
UnsafeTextRevealCssValue,
UnsupportedTextFxEffect,
InvalidTooltipMetric,
InvalidTooltipText,
InvalidTooltipI18nKey,
InvalidSandMetric,
InvalidSandAnimationSpeed,
UnsafeSandCssValue,
InvalidPerformanceMetric,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxValidationIssue {
pub severity: HoverFxValidationSeverity,
pub code: HoverFxValidationCode,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effect: Option<String>,
}
impl HoverFxValidationIssue {
pub fn error(
code: HoverFxValidationCode,
field: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
severity: HoverFxValidationSeverity::Error,
code,
message: message.into(),
field: Some(field.into()),
effect: None,
}
}
pub fn effect_error(
code: HoverFxValidationCode,
effect: impl Into<String>,
field: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
severity: HoverFxValidationSeverity::Error,
code,
message: message.into(),
field: Some(field.into()),
effect: Some(effect.into()),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxValidationReport {
pub issues: Vec<HoverFxValidationIssue>,
}
impl HoverFxValidationReport {
pub fn is_valid(&self) -> bool {
self.issues
.iter()
.all(|issue| issue.severity != HoverFxValidationSeverity::Error)
}
pub fn errors(&self) -> impl Iterator<Item = &HoverFxValidationIssue> {
self.issues
.iter()
.filter(|issue| issue.severity == HoverFxValidationSeverity::Error)
}
pub fn warnings(&self) -> impl Iterator<Item = &HoverFxValidationIssue> {
self.issues
.iter()
.filter(|issue| issue.severity == HoverFxValidationSeverity::Warning)
}
pub fn push(&mut self, issue: HoverFxValidationIssue) {
self.issues.push(issue);
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxDefinition {
pub id: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preset: Option<HoverFxPreset>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub radius_px: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub range_px: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape: Option<HoverFxShape>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub falloff: Option<HoverFxFalloff>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strength: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smoothing: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_shape: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_reveal: Option<HoverFxTextRevealConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub texture_reveal: Option<HoverFxTextureRevealConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sand: Option<HoverFxSandConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tooltip: Option<HoverFxTooltipConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_contrast: Option<HoverFxTextContrastMode>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub css_vars: BTreeMap<String, String>,
}
impl HoverFxDefinition {
pub fn new(id: impl AsRef<str>, label: impl Into<String>) -> Self {
Self {
id: hoverfx_id(id),
label: label.into(),
preset: None,
radius_px: None,
range_px: None,
shape: None,
falloff: None,
strength: None,
smoothing: None,
custom_shape: None,
text_reveal: None,
texture_reveal: None,
sand: None,
tooltip: None,
text_contrast: None,
css_vars: BTreeMap::new(),
}
}
pub fn from_preset(preset: HoverFxPreset) -> Self {
match preset {
HoverFxPreset::Spotlight => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_shape(HoverFxShape::Circle)
.with_falloff(HoverFxFalloff::Smooth)
.with_css_var("--dxh-color", "rgba(255,255,255,0.32)")
.with_css_var("--dxh-blend-mode", "screen"),
HoverFxPreset::SoftGlow => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_radius_px(220)
.with_shape(HoverFxShape::Circle)
.with_falloff(HoverFxFalloff::Smooth)
.with_strength(0.85)
.with_css_var("--dxh-color", "rgba(56,189,248,0.26)")
.with_css_var("--dxh-blur", "28px"),
HoverFxPreset::BorderTrace => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_shape(HoverFxShape::RoundedRect)
.with_falloff(HoverFxFalloff::Exponential)
.with_css_var("--dxh-border-color", "rgba(125,211,252,0.82)")
.with_css_var("--dxh-border-width", "1px"),
HoverFxPreset::Sheen => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_shape(HoverFxShape::Square)
.with_falloff(HoverFxFalloff::Linear)
.with_css_var("--dxh-angle", "115deg")
.with_css_var("--dxh-color", "rgba(255,255,255,0.36)"),
HoverFxPreset::ColorWash => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_radius_px(240)
.with_shape(HoverFxShape::Circle)
.with_falloff(HoverFxFalloff::Smooth)
.with_css_var("--dxh-color", "rgba(14,165,233,0.22)")
.with_css_var("--dxh-accent-color", "rgba(217,70,239,0.18)"),
HoverFxPreset::BinaryReveal => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_radius_px(300)
.with_shape(HoverFxShape::Circle)
.with_falloff(HoverFxFalloff::Smooth)
.with_strength(1.15)
.with_text_reveal(HoverFxTextRevealConfig::default())
.with_css_var("--dxh-binary-color", DEFAULT_HOVERFX_TEXT_REVEAL_COLOR),
HoverFxPreset::TextureReveal => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_radius_px(340)
.with_shape(HoverFxShape::Circle)
.with_falloff(HoverFxFalloff::Smooth)
.with_strength(1.1)
.with_texture_reveal(HoverFxTextureRevealConfig::default()),
HoverFxPreset::Sand => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_radius_px(320)
.with_shape(HoverFxShape::Circle)
.with_falloff(HoverFxFalloff::Smooth)
.with_strength(1.1)
.with_sand(HoverFxSandConfig::default())
.with_css_var("--dxh-sand-color", "#d7b878")
.with_css_var("--dxh-sand-highlight", "#fff4c2"),
HoverFxPreset::Tooltip => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_radius_px(96)
.with_range_px(0)
.with_shape(HoverFxShape::RoundedRect)
.with_falloff(HoverFxFalloff::Smooth)
.with_strength(0.0)
.with_tooltip(HoverFxTooltipConfig::default()),
}
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
pub fn with_preset(mut self, preset: HoverFxPreset) -> Self {
self.preset = Some(preset);
self
}
pub fn with_radius_px(mut self, radius_px: u16) -> Self {
self.radius_px = Some(radius_px);
self
}
pub fn radius(self, radius_px: u16) -> Self {
self.with_radius_px(radius_px)
}
pub fn radius_px(self, radius_px: u16) -> Self {
self.with_radius_px(radius_px)
}
pub fn with_range_px(mut self, range_px: u16) -> Self {
self.range_px = Some(range_px);
self
}
pub fn range(self, range_px: u16) -> Self {
self.with_range_px(range_px)
}
pub fn range_px(self, range_px: u16) -> Self {
self.with_range_px(range_px)
}
pub fn activation_range(self, range_px: u16) -> Self {
self.with_range_px(range_px)
}
pub fn with_shape(mut self, shape: HoverFxShape) -> Self {
self.shape = Some(shape);
self
}
pub fn with_falloff(mut self, falloff: HoverFxFalloff) -> Self {
self.falloff = Some(falloff);
self
}
pub fn with_strength(mut self, strength: f32) -> Self {
self.strength = Some(strength);
self
}
pub fn with_smoothing(mut self, smoothing: f32) -> Self {
self.smoothing = Some(smoothing);
self
}
pub fn with_custom_shape(mut self, custom_shape: impl Into<String>) -> Self {
self.custom_shape = Some(custom_shape.into());
self
}
pub fn with_text_reveal(mut self, text_reveal: HoverFxTextRevealConfig) -> Self {
self.text_reveal = Some(text_reveal);
self
}
pub fn with_texture_reveal(mut self, texture_reveal: HoverFxTextureRevealConfig) -> Self {
self.texture_reveal = Some(texture_reveal);
self
}
pub fn with_sand(mut self, sand: HoverFxSandConfig) -> Self {
self.sand = Some(sand);
self
}
pub fn with_tooltip(mut self, tooltip: HoverFxTooltipConfig) -> Self {
self.tooltip = Some(tooltip);
self
}
pub fn with_text_contrast(mut self, text_contrast: HoverFxTextContrastMode) -> Self {
self.text_contrast = Some(text_contrast);
self
}
pub fn with_css_var(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.css_vars.insert(name.into(), value.into());
self
}
pub fn with_css_vars<I, K, V>(mut self, css_vars: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
for (name, value) in css_vars {
self.css_vars.insert(name.into(), value.into());
}
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxRegistry {
pub effects: Vec<HoverFxDefinition>,
}
impl Default for HoverFxRegistry {
fn default() -> Self {
Self::defaults()
}
}
impl HoverFxRegistry {
pub fn new() -> Self {
Self {
effects: Vec::new(),
}
}
pub fn defaults() -> Self {
let mut registry = Self::new();
for preset in HoverFxPreset::all() {
registry.insert_effect(HoverFxDefinition::from_preset(*preset));
}
registry
}
pub fn with_effect(mut self, effect: HoverFxDefinition) -> Self {
self.insert_effect(effect);
self
}
pub fn with_effects<I>(mut self, effects: I) -> Self
where
I: IntoIterator<Item = HoverFxDefinition>,
{
for effect in effects {
self.insert_effect(effect);
}
self
}
pub fn insert_effect(&mut self, effect: HoverFxDefinition) -> Option<HoverFxDefinition> {
if let Some(existing) = self
.effects
.iter_mut()
.find(|candidate| candidate.id == effect.id)
{
return Some(std::mem::replace(existing, effect));
}
self.effects.push(effect);
None
}
pub fn contains_effect(&self, id: impl AsRef<str>) -> bool {
let id = hoverfx_id(id);
self.effects.iter().any(|effect| effect.id == id)
}
pub fn effect(&self, id: impl AsRef<str>) -> Option<&HoverFxDefinition> {
let id = hoverfx_id(id);
self.effects.iter().find(|effect| effect.id == id)
}
pub fn effect_ids(&self) -> Vec<&str> {
self.effects
.iter()
.map(|effect| effect.id.as_str())
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxPerformanceConfig {
pub lazy_local_layers: bool,
pub worker_local_layers: bool,
pub dirty_rect_rendering: bool,
pub shader_texture_cache: bool,
pub dpr_cap: f32,
pub idle_release_timeout_ms: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub candidate_observer_margin_px: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub motion_lane: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub motion_scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view_transition_name_isolation: Option<String>,
}
impl Default for HoverFxPerformanceConfig {
fn default() -> Self {
Self {
lazy_local_layers: DEFAULT_HOVERFX_PERF_LAZY_LOCAL_LAYERS,
worker_local_layers: DEFAULT_HOVERFX_PERF_WORKER_LOCAL_LAYERS,
dirty_rect_rendering: DEFAULT_HOVERFX_PERF_DIRTY_RECT_RENDERING,
shader_texture_cache: DEFAULT_HOVERFX_PERF_SHADER_TEXTURE_CACHE,
dpr_cap: DEFAULT_HOVERFX_PERF_DPR_CAP,
idle_release_timeout_ms: DEFAULT_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS,
candidate_observer_margin_px: None,
motion_lane: None,
motion_scope: None,
view_transition_name_isolation: None,
}
}
}
impl HoverFxPerformanceConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_lazy_local_layers(mut self, lazy_local_layers: bool) -> Self {
self.lazy_local_layers = lazy_local_layers;
self
}
pub fn with_worker_local_layers(mut self, worker_local_layers: bool) -> Self {
self.worker_local_layers = worker_local_layers;
self
}
pub fn with_dirty_rect_rendering(mut self, dirty_rect_rendering: bool) -> Self {
self.dirty_rect_rendering = dirty_rect_rendering;
self
}
pub fn with_shader_texture_cache(mut self, shader_texture_cache: bool) -> Self {
self.shader_texture_cache = shader_texture_cache;
self
}
pub fn with_dpr_cap(mut self, dpr_cap: f32) -> Self {
self.dpr_cap = dpr_cap;
self
}
pub fn with_idle_release_timeout_ms(mut self, idle_release_timeout_ms: u16) -> Self {
self.idle_release_timeout_ms = idle_release_timeout_ms;
self
}
pub fn with_candidate_observer_margin_px(mut self, candidate_observer_margin_px: u16) -> Self {
self.candidate_observer_margin_px = Some(candidate_observer_margin_px);
self
}
#[cfg(feature = "viewtx-interop")]
pub fn with_viewtx_motion_policy(
mut self,
policy: &dioxus_viewtx_core::ViewMotionPolicy,
) -> Self {
self.motion_lane = Some(policy.lane.as_attr().to_string());
self.motion_scope = Some(policy.scope.as_attr().to_string());
self.view_transition_name_isolation =
Some(policy.view_transition_name_isolation.as_attr().to_string());
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxConfig {
pub registry: HoverFxRegistry,
pub default_effect: String,
pub radius_px: u16,
#[serde(default = "default_hoverfx_range_px")]
pub range_px: u16,
pub shape: HoverFxShape,
pub falloff: HoverFxFalloff,
pub strength: f32,
pub smoothing: f32,
pub max_active_elements: u16,
pub renderer: HoverFxRenderer,
pub runtime_path: String,
pub worker_path: String,
#[serde(default)]
pub performance: HoverFxPerformanceConfig,
}
impl Default for HoverFxConfig {
fn default() -> Self {
Self::new()
}
}
impl HoverFxConfig {
pub fn new() -> Self {
Self {
registry: HoverFxRegistry::default(),
default_effect: HoverFxPreset::Spotlight.as_attr().to_string(),
radius_px: DEFAULT_HOVERFX_RADIUS_PX,
range_px: DEFAULT_HOVERFX_RANGE_PX,
shape: HoverFxShape::Circle,
falloff: HoverFxFalloff::Smooth,
strength: DEFAULT_HOVERFX_STRENGTH,
smoothing: DEFAULT_HOVERFX_SMOOTHING,
max_active_elements: DEFAULT_HOVERFX_MAX_ACTIVE_ELEMENTS,
renderer: HoverFxRenderer::WorkerFirst,
runtime_path: DEFAULT_HOVERFX_RUNTIME_PATH.to_string(),
worker_path: DEFAULT_HOVERFX_WORKER_PATH.to_string(),
performance: HoverFxPerformanceConfig::default(),
}
}
pub fn with_registry(mut self, registry: HoverFxRegistry) -> Self {
self.registry = registry;
self
}
pub fn registry(self, registry: HoverFxRegistry) -> Self {
self.with_registry(registry)
}
pub fn with_effect(mut self, effect: HoverFxDefinition) -> Self {
self.registry.insert_effect(effect);
self
}
pub fn effect(self, effect: HoverFxDefinition) -> Self {
self.with_effect(effect)
}
pub fn with_effects<I>(mut self, effects: I) -> Self
where
I: IntoIterator<Item = HoverFxDefinition>,
{
for effect in effects {
self.registry.insert_effect(effect);
}
self
}
pub fn effects<I>(self, effects: I) -> Self
where
I: IntoIterator<Item = HoverFxDefinition>,
{
self.with_effects(effects)
}
pub fn with_default_effect(mut self, default_effect: impl AsRef<str>) -> Self {
self.default_effect = hoverfx_id(default_effect);
self
}
pub fn default_effect(self, default_effect: impl AsRef<str>) -> Self {
self.with_default_effect(default_effect)
}
pub fn with_radius_px(mut self, radius_px: u16) -> Self {
let range_tracks_radius = self.range_px == self.radius_px;
self.radius_px = radius_px;
if range_tracks_radius {
self.range_px = radius_px;
}
self
}
pub fn radius(self, radius_px: u16) -> Self {
self.with_radius_px(radius_px)
}
pub fn radius_px(self, radius_px: u16) -> Self {
self.with_radius_px(radius_px)
}
pub fn with_range_px(mut self, range_px: u16) -> Self {
self.range_px = range_px;
self
}
pub fn range(self, range_px: u16) -> Self {
self.with_range_px(range_px)
}
pub fn range_px(self, range_px: u16) -> Self {
self.with_range_px(range_px)
}
pub fn activation_range(self, range_px: u16) -> Self {
self.with_range_px(range_px)
}
pub fn with_shape(mut self, shape: HoverFxShape) -> Self {
self.shape = shape;
self
}
pub fn shape(self, shape: HoverFxShape) -> Self {
self.with_shape(shape)
}
pub fn with_falloff(mut self, falloff: HoverFxFalloff) -> Self {
self.falloff = falloff;
self
}
pub fn falloff(self, falloff: HoverFxFalloff) -> Self {
self.with_falloff(falloff)
}
pub fn with_strength(mut self, strength: f32) -> Self {
self.strength = strength;
self
}
pub fn strength(self, strength: f32) -> Self {
self.with_strength(strength)
}
pub fn with_smoothing(mut self, smoothing: f32) -> Self {
self.smoothing = smoothing;
self
}
pub fn smooth(self, smoothing: f32) -> Self {
self.with_smoothing(smoothing)
}
pub fn with_max_active_elements(mut self, max_active_elements: u16) -> Self {
self.max_active_elements = max_active_elements;
self
}
pub fn max_active(self, max_active_elements: u16) -> Self {
self.with_max_active_elements(max_active_elements)
}
pub fn with_renderer(mut self, renderer: HoverFxRenderer) -> Self {
self.renderer = renderer;
self
}
pub fn renderer(self, renderer: HoverFxRenderer) -> Self {
self.with_renderer(renderer)
}
pub fn with_runtime_path(mut self, runtime_path: impl Into<String>) -> Self {
self.runtime_path = runtime_path.into();
self
}
pub fn runtime(self, runtime_path: impl Into<String>) -> Self {
self.with_runtime_path(runtime_path)
}
pub fn with_worker_path(mut self, worker_path: impl Into<String>) -> Self {
self.worker_path = worker_path.into();
self
}
pub fn worker_path(self, worker_path: impl Into<String>) -> Self {
self.with_worker_path(worker_path)
}
pub fn worker(mut self) -> Self {
self.renderer = HoverFxRenderer::WorkerFirst;
self
}
pub fn no_worker(mut self) -> Self {
self.renderer = HoverFxRenderer::CssOnly;
self
}
pub fn with_performance(mut self, performance: HoverFxPerformanceConfig) -> Self {
self.performance = performance;
self
}
pub fn perf(self, performance: HoverFxPerformanceConfig) -> Self {
self.with_performance(performance)
}
pub fn with_lazy_local_layers(mut self, lazy_local_layers: bool) -> Self {
self.performance.lazy_local_layers = lazy_local_layers;
self
}
pub fn lazy_layers_enabled(self, lazy_local_layers: bool) -> Self {
self.with_lazy_local_layers(lazy_local_layers)
}
pub fn lazy_layers(self) -> Self {
self.with_lazy_local_layers(true)
}
pub fn no_lazy_layers(self) -> Self {
self.with_lazy_local_layers(false)
}
pub fn with_worker_local_layers(mut self, worker_local_layers: bool) -> Self {
self.performance.worker_local_layers = worker_local_layers;
self
}
pub fn worker_layers(self, worker_local_layers: bool) -> Self {
self.with_worker_local_layers(worker_local_layers)
}
pub fn with_dirty_rect_rendering(mut self, dirty_rect_rendering: bool) -> Self {
self.performance.dirty_rect_rendering = dirty_rect_rendering;
self
}
pub fn dirty_rects(self, dirty_rect_rendering: bool) -> Self {
self.with_dirty_rect_rendering(dirty_rect_rendering)
}
pub fn with_shader_texture_cache(mut self, shader_texture_cache: bool) -> Self {
self.performance.shader_texture_cache = shader_texture_cache;
self
}
pub fn texture_cache(self, shader_texture_cache: bool) -> Self {
self.with_shader_texture_cache(shader_texture_cache)
}
pub fn with_dpr_cap(mut self, dpr_cap: f32) -> Self {
self.performance.dpr_cap = dpr_cap;
self
}
pub fn dpr(self, dpr_cap: f32) -> Self {
self.with_dpr_cap(dpr_cap)
}
pub fn with_idle_release_timeout_ms(mut self, idle_release_timeout_ms: u16) -> Self {
self.performance.idle_release_timeout_ms = idle_release_timeout_ms;
self
}
pub fn idle_release_ms(self, idle_release_timeout_ms: u16) -> Self {
self.with_idle_release_timeout_ms(idle_release_timeout_ms)
}
#[cfg(feature = "viewtx-interop")]
pub fn with_viewtx_motion_policy(
mut self,
policy: &dioxus_viewtx_core::ViewMotionPolicy,
) -> Self {
self.performance = self.performance.with_viewtx_motion_policy(policy);
self
}
pub fn with_candidate_observer_margin_px(mut self, candidate_observer_margin_px: u16) -> Self {
self.performance.candidate_observer_margin_px = Some(candidate_observer_margin_px);
self
}
pub fn observer_margin(self, candidate_observer_margin_px: u16) -> Self {
self.with_candidate_observer_margin_px(candidate_observer_margin_px)
}
pub fn validate(&self) -> HoverFxValidationReport {
let mut report = HoverFxValidationReport::default();
validate_radius(self.radius_px, "radius_px", None, &mut report);
validate_range(self.range_px, "range_px", None, &mut report);
validate_strength(self.strength, "strength", None, &mut report);
validate_smoothing(self.smoothing, "smoothing", None, &mut report);
validate_performance(&self.performance, &mut report);
if self.max_active_elements == 0
|| self.max_active_elements > MAX_HOVERFX_MAX_ACTIVE_ELEMENTS
{
report.push(HoverFxValidationIssue::error(
HoverFxValidationCode::InvalidMaxActiveElements,
"max_active_elements",
format!(
"max active elements must be between 1 and {}",
MAX_HOVERFX_MAX_ACTIVE_ELEMENTS
),
));
}
if !is_hoverfx_id(&self.default_effect) {
report.push(HoverFxValidationIssue::error(
HoverFxValidationCode::InvalidDefaultEffectId,
"default_effect",
"default effect id must be a kebab-case hoverfx id",
));
}
if !self.registry.contains_effect(&self.default_effect) {
report.push(HoverFxValidationIssue::error(
HoverFxValidationCode::MissingDefaultEffect,
"default_effect",
format!("default effect `{}` is not registered", self.default_effect),
));
}
if self.renderer.uses_worker() {
if self.runtime_path.trim().is_empty() {
report.push(HoverFxValidationIssue::error(
HoverFxValidationCode::EmptyRuntimePath,
"runtime_path",
"worker-first hoverfx requires a runtime path",
));
}
if self.worker_path.trim().is_empty() {
report.push(HoverFxValidationIssue::error(
HoverFxValidationCode::EmptyWorkerPath,
"worker_path",
"worker-first hoverfx requires a worker path",
));
}
}
for effect in &self.registry.effects {
validate_effect(effect, &mut report);
}
report
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
pub fn to_stable_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
pub fn to_preferred_json(
&self,
format: HoverFxSerializationFormat,
) -> serde_json::Result<String> {
match format {
HoverFxSerializationFormat::ReadableJson | HoverFxSerializationFormat::StableJson => {
self.to_stable_json()
}
HoverFxSerializationFormat::CompactJson => self.to_compact_json(),
}
}
pub fn to_compact_json(&self) -> serde_json::Result<String> {
let mut value = serde_json::to_value(self)?;
let default = serde_json::to_value(HoverFxConfig::default())?;
if let (Some(object), Some(defaults)) = (value.as_object_mut(), default.as_object()) {
for key in [
"registry",
"defaultEffect",
"radiusPx",
"rangePx",
"shape",
"falloff",
"strength",
"smoothing",
"maxActiveElements",
"renderer",
"runtimePath",
"workerPath",
"performance",
] {
if object.get(key) == defaults.get(key) {
object.remove(key);
}
}
}
serde_json::to_string(&value)
}
pub fn with_profile(mut self, profile: HoverFxPresetProfile) -> Self {
profile.apply_to_config(&mut self);
self
}
pub fn cache_key(&self, route: Option<&str>) -> String {
hoverfx_cache_key(self, route, None)
}
pub fn manifest_fragment(&self, policy: &HoverFxRoutePolicy) -> HoverFxManifestFragment {
hoverfx_manifest_fragment(self, policy)
}
pub fn output_report(&self, policy: &HoverFxRoutePolicy) -> HoverFxOutputReport {
hoverfx_output_report(self, policy)
}
pub fn explain(&self, policy: &HoverFxRoutePolicy) -> HoverFxExplainReport {
explain_hoverfx(self, policy)
}
pub fn try_validated(self) -> Result<Self, HoverFxConfigError> {
let report = self.validate();
if report.is_valid() {
Ok(self)
} else {
Err(HoverFxConfigError { report })
}
}
pub fn is_noop_for_route(&self, policy: &HoverFxRoutePolicy) -> bool {
!policy.enabled || policy.emission == HoverFxRuntimeEmission::Disabled
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HoverFxConfigError {
pub report: HoverFxValidationReport,
}
impl fmt::Display for HoverFxConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let count = self.report.errors().count();
write!(f, "invalid HoverFX config ({count} error(s))")
}
}
impl std::error::Error for HoverFxConfigError {}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxRuntimeEmission {
Always,
#[default]
WhenUsed,
CssOnly,
Disabled,
}
impl HoverFxRuntimeEmission {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Always => "always",
Self::WhenUsed => "when-used",
Self::CssOnly => "css-only",
Self::Disabled => "disabled",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxSerializationFormat {
#[default]
StableJson,
ReadableJson,
CompactJson,
}
impl HoverFxSerializationFormat {
pub const fn as_attr(self) -> &'static str {
match self {
Self::StableJson => "stable-json",
Self::ReadableJson => "readable-json",
Self::CompactJson => "compact-json",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxDiagnosticVerbosity {
Off,
Summary,
#[default]
Detailed,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxFallbackStrategy {
#[default]
StaticCss,
NativePort,
MainThread,
DisableEffect,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxPresetProfile {
Conservative,
#[default]
Balanced,
Aggressive,
}
impl HoverFxPresetProfile {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Conservative => "conservative",
Self::Balanced => "balanced",
Self::Aggressive => "aggressive",
}
}
pub fn apply_to_config(self, config: &mut HoverFxConfig) {
match self {
Self::Conservative => {
config.renderer = HoverFxRenderer::CssOnly;
config.range_px = config.range_px.min(240);
config.max_active_elements = config.max_active_elements.min(4);
config.performance.lazy_local_layers = true;
config.performance.worker_local_layers = false;
config.performance.dirty_rect_rendering = true;
config.performance.shader_texture_cache = true;
config.performance.dpr_cap = config.performance.dpr_cap.min(1.5);
}
Self::Balanced => {
config.renderer = HoverFxRenderer::WorkerFirst;
config.performance.lazy_local_layers = true;
config.performance.worker_local_layers = true;
config.performance.dirty_rect_rendering = true;
config.performance.shader_texture_cache = true;
}
Self::Aggressive => {
config.renderer = HoverFxRenderer::WorkerFirst;
config.range_px = config.range_px.max(config.radius_px);
config.max_active_elements = config.max_active_elements.max(12);
config.performance.lazy_local_layers = false;
config.performance.worker_local_layers = true;
config.performance.dirty_rect_rendering = true;
config.performance.shader_texture_cache = true;
config.performance.dpr_cap = MAX_HOVERFX_PERF_DPR_CAP.min(2.5);
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxInteropPolicy {
pub strata: bool,
pub resume: bool,
pub native_port: bool,
pub workertown: bool,
pub textfx: bool,
pub theme: bool,
pub viewtx: bool,
}
impl Default for HoverFxInteropPolicy {
fn default() -> Self {
Self {
strata: true,
resume: true,
native_port: true,
workertown: true,
textfx: true,
theme: true,
viewtx: true,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxOutputBudget {
#[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_style_bytes: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_effect_count: Option<usize>,
}
impl HoverFxOutputBudget {
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 style_bytes(mut self, max: usize) -> Self {
self.max_style_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 HoverFxRoutePolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
pub enabled: bool,
pub profile: HoverFxPresetProfile,
pub emission: HoverFxRuntimeEmission,
pub serialization: HoverFxSerializationFormat,
pub diagnostics: HoverFxDiagnosticVerbosity,
pub fallback: HoverFxFallbackStrategy,
#[serde(default)]
pub interop: HoverFxInteropPolicy,
#[serde(default)]
pub budget: HoverFxOutputBudget,
#[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 HoverFxRoutePolicy {
fn default() -> Self {
Self {
route: None,
enabled: true,
profile: HoverFxPresetProfile::Balanced,
emission: HoverFxRuntimeEmission::WhenUsed,
serialization: HoverFxSerializationFormat::StableJson,
diagnostics: HoverFxDiagnosticVerbosity::Detailed,
fallback: HoverFxFallbackStrategy::StaticCss,
interop: HoverFxInteropPolicy::default(),
budget: HoverFxOutputBudget::default(),
labels: BTreeMap::new(),
tags: Vec::new(),
}
}
}
impl HoverFxRoutePolicy {
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: HoverFxPresetProfile) -> Self {
self.profile = profile;
self
}
pub fn emission(mut self, emission: HoverFxRuntimeEmission) -> Self {
self.emission = emission;
self
}
pub fn serialization(mut self, serialization: HoverFxSerializationFormat) -> Self {
self.serialization = serialization;
self
}
pub fn diagnostics(mut self, diagnostics: HoverFxDiagnosticVerbosity) -> Self {
self.diagnostics = diagnostics;
self
}
pub fn fallback(mut self, fallback: HoverFxFallbackStrategy) -> Self {
self.fallback = fallback;
self
}
pub fn budget(mut self, budget: HoverFxOutputBudget) -> 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, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxManifestFragment {
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 default_effect: String,
pub runtime_path: String,
pub worker_path: String,
pub profile: HoverFxPresetProfile,
pub emission: HoverFxRuntimeEmission,
pub fallback: HoverFxFallbackStrategy,
#[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 HoverFxOutputViolation {
pub field: String,
pub actual: usize,
pub budget: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxOutputReport {
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 style_bytes: usize,
pub effect_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub violations: Vec<HoverFxOutputViolation>,
}
impl HoverFxOutputReport {
pub fn is_within_budget(&self) -> bool {
self.violations.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxExplainReport {
pub package: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
pub cache_key: String,
pub runtime_decision: String,
pub style_decision: String,
pub fallback_decision: String,
pub validation: HoverFxValidationReport,
pub manifest: HoverFxManifestFragment,
pub output: HoverFxOutputReport,
#[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 HoverFxCompatibilityRow {
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 HoverFxCompatibilityMatrix {
pub package: String,
pub rows: Vec<HoverFxCompatibilityRow>,
}
pub trait HoverFxManifestPolicyHook {
fn apply(&self, fragment: HoverFxManifestFragment) -> Option<HoverFxManifestFragment>;
}
pub fn apply_hoverfx_manifest_hook<H>(
config: &HoverFxConfig,
policy: &HoverFxRoutePolicy,
hook: &H,
) -> Option<HoverFxManifestFragment>
where
H: HoverFxManifestPolicyHook,
{
hook.apply(hoverfx_manifest_fragment(config, policy))
}
pub fn hoverfx_route_policy() -> HoverFxRoutePolicy {
HoverFxRoutePolicy::new()
}
pub fn hoverfx_output_budget() -> HoverFxOutputBudget {
HoverFxOutputBudget::new()
}
pub fn hoverfx_cache_key(
config: &HoverFxConfig,
route: Option<&str>,
extra: Option<&str>,
) -> String {
let json = config.to_stable_json().unwrap_or_default();
stable_hash_hex([
HOVERFX_PACKAGE_NAME,
HOVERFX_PACKAGE_VERSION,
route.unwrap_or("*"),
extra.unwrap_or(""),
json.as_str(),
])
}
pub fn hoverfx_manifest_fragment(
config: &HoverFxConfig,
policy: &HoverFxRoutePolicy,
) -> HoverFxManifestFragment {
let output = hoverfx_output_report(config, policy);
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("styleBytes".to_string(), output.style_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,
"fallback": policy.fallback,
}),
);
HoverFxManifestFragment {
package: HOVERFX_PACKAGE_NAME.to_string(),
version: HOVERFX_PACKAGE_VERSION.to_string(),
route: policy.route.clone(),
enabled: policy.enabled,
cache_key: output.cache_key,
default_effect: config.default_effect.clone(),
runtime_path: config.runtime_path.clone(),
worker_path: config.worker_path.clone(),
profile: policy.profile,
emission: policy.emission,
fallback: policy.fallback,
labels: policy.labels.clone(),
tags: policy.tags.clone(),
metrics,
policies,
}
}
pub fn hoverfx_output_report(
config: &HoverFxConfig,
policy: &HoverFxRoutePolicy,
) -> HoverFxOutputReport {
let config_json = config
.to_preferred_json(policy.serialization)
.unwrap_or_default();
let runtime_bytes = if policy.enabled && policy.emission != HoverFxRuntimeEmission::Disabled {
config.runtime_path.len() + config.worker_path.len()
} else {
0
};
let style_bytes = config
.registry
.effects
.iter()
.map(|effect| {
effect
.css_vars
.iter()
.map(|(k, v)| k.len() + v.len() + 2)
.sum::<usize>()
})
.sum::<usize>();
let effect_count = config.registry.effects.len();
let mut violations = Vec::new();
push_budget_violation(
&mut violations,
"configBytes",
config_json.len(),
policy.budget.max_config_bytes,
);
push_budget_violation(
&mut violations,
"runtimeBytes",
runtime_bytes,
policy.budget.max_runtime_bytes,
);
push_budget_violation(
&mut violations,
"styleBytes",
style_bytes,
policy.budget.max_style_bytes,
);
push_budget_violation(
&mut violations,
"effectCount",
effect_count,
policy.budget.max_effect_count,
);
HoverFxOutputReport {
package: HOVERFX_PACKAGE_NAME.to_string(),
route: policy.route.clone(),
cache_key: hoverfx_cache_key(
config,
policy.route.as_deref(),
Some(policy.profile.as_attr()),
),
config_bytes: config_json.len(),
runtime_bytes,
style_bytes,
effect_count,
violations,
}
}
pub fn explain_hoverfx(
config: &HoverFxConfig,
policy: &HoverFxRoutePolicy,
) -> HoverFxExplainReport {
let validation = config.validate();
let output = hoverfx_output_report(config, policy);
let manifest = hoverfx_manifest_fragment(config, policy);
let runtime_decision = if !policy.enabled {
"route disabled HoverFX emission".to_string()
} else if policy.emission == HoverFxRuntimeEmission::Disabled {
"runtime emission disabled by route policy".to_string()
} else if config.renderer.uses_worker() && policy.interop.workertown {
"worker-first runtime emitted with WorkerTown-compatible hints".to_string()
} else {
"CSS/static fallback is sufficient for this route".to_string()
};
let style_decision = if output.style_bytes == 0 {
"no custom effect CSS variables were emitted".to_string()
} else {
format!(
"{} bytes of custom effect CSS variables are tracked",
output.style_bytes
)
};
let fallback_decision = format!("fallback strategy: {:?}", policy.fallback);
let mut notes = Vec::new();
if !validation.is_valid() {
notes.push("validation errors must be resolved before SSR emission".to_string());
}
if !output.is_within_budget() {
notes.push("one or more HoverFX output budgets were exceeded".to_string());
}
if policy.interop.textfx {
notes.push(
"TextFX interop attributes are advertised for text reveal and tooltips".to_string(),
);
}
if policy.interop.theme {
notes.push("theme tokens are preserved as CSS custom properties".to_string());
}
HoverFxExplainReport {
package: HOVERFX_PACKAGE_NAME.to_string(),
route: policy.route.clone(),
cache_key: output.cache_key.clone(),
runtime_decision,
style_decision,
fallback_decision,
validation,
manifest,
output,
notes,
}
}
pub fn hoverfx_compatibility_matrix() -> HoverFxCompatibilityMatrix {
HoverFxCompatibilityMatrix {
package: HOVERFX_PACKAGE_NAME.to_string(),
rows: vec![
HoverFxCompatibilityRow {
target: "web".to_string(),
support: "full".to_string(),
runtime: "worker-first module plus optional CSS fallback".to_string(),
fallback: "static-css".to_string(),
notes: "cursor proximity, TextFX tooltip interop, and theme tokens are supported"
.to_string(),
},
HoverFxCompatibilityRow {
target: "server".to_string(),
support: "manifest".to_string(),
runtime: "SSR config/style emission with route gates".to_string(),
fallback: "disable-effect".to_string(),
notes: "Strata/resume consumers can use manifest fragments and cache keys"
.to_string(),
},
HoverFxCompatibilityRow {
target: "native".to_string(),
support: "adapter".to_string(),
runtime: "native-port action hints".to_string(),
fallback: "native-port".to_string(),
notes: "effects degrade to static CSS and explicit native actions".to_string(),
},
HoverFxCompatibilityRow {
target: "cli".to_string(),
support: "report".to_string(),
runtime: "no runtime required".to_string(),
fallback: "stable-json".to_string(),
notes: "output reports include deterministic cache keys and budget violations"
.to_string(),
},
],
}
}
pub fn hoverfx_native_port_hints(
config: &HoverFxConfig,
policy: &HoverFxRoutePolicy,
) -> BTreeMap<String, String> {
let mut hints = BTreeMap::new();
hints.insert("package".to_string(), HOVERFX_PACKAGE_NAME.to_string());
hints.insert("version".to_string(), HOVERFX_PACKAGE_VERSION.to_string());
hints.insert(
"cacheKey".to_string(),
hoverfx_cache_key(config, 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("fallback".to_string(), format!("{:?}", policy.fallback));
hints
}
fn push_budget_violation(
violations: &mut Vec<HoverFxOutputViolation>,
field: &str,
actual: usize,
budget: Option<usize>,
) {
if let Some(budget) = budget
&& actual > budget
{
violations.push(HoverFxOutputViolation {
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}")
}
fn validate_performance(
performance: &HoverFxPerformanceConfig,
report: &mut HoverFxValidationReport,
) {
if !performance.dpr_cap.is_finite()
|| !(MIN_HOVERFX_PERF_DPR_CAP..=MAX_HOVERFX_PERF_DPR_CAP).contains(&performance.dpr_cap)
{
push_numeric_issue(
HoverFxValidationCode::InvalidPerformanceMetric,
None,
"performance.dpr_cap",
format!(
"performance dpr cap must be finite and between {} and {}",
MIN_HOVERFX_PERF_DPR_CAP, MAX_HOVERFX_PERF_DPR_CAP
),
report,
);
}
if performance.idle_release_timeout_ms > MAX_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS {
push_numeric_issue(
HoverFxValidationCode::InvalidPerformanceMetric,
None,
"performance.idle_release_timeout_ms",
format!(
"idle release timeout must be no more than {}ms",
MAX_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS
),
report,
);
}
if let Some(margin) = performance.candidate_observer_margin_px
&& margin > MAX_HOVERFX_PERF_CANDIDATE_OBSERVER_MARGIN_PX
{
push_numeric_issue(
HoverFxValidationCode::InvalidPerformanceMetric,
None,
"performance.candidate_observer_margin_px",
format!(
"candidate observer margin must be no more than {}px",
MAX_HOVERFX_PERF_CANDIDATE_OBSERVER_MARGIN_PX
),
report,
);
}
}
fn validate_effect(effect: &HoverFxDefinition, report: &mut HoverFxValidationReport) {
if !is_hoverfx_id(&effect.id) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidEffectId,
effect.id.clone(),
"id",
"effect id must be a kebab-case hoverfx id",
));
}
if let Some(radius_px) = effect.radius_px {
validate_radius(radius_px, "radius_px", Some(&effect.id), report);
}
if let Some(range_px) = effect.range_px {
validate_range(range_px, "range_px", Some(&effect.id), report);
}
if let Some(strength) = effect.strength {
validate_strength(strength, "strength", Some(&effect.id), report);
}
if let Some(smoothing) = effect.smoothing {
validate_smoothing(smoothing, "smoothing", Some(&effect.id), report);
}
if let Some(custom_shape) = &effect.custom_shape
&& !is_safe_css_custom_value(custom_shape)
{
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::UnsafeCustomShapeValue,
effect.id.clone(),
"custom_shape",
"custom shape must be a safe CSS value",
));
}
if let Some(text_reveal) = &effect.text_reveal {
validate_text_reveal(text_reveal, &effect.id, report);
}
if let Some(sand) = &effect.sand {
validate_sand(sand, &effect.id, report);
}
if let Some(tooltip) = &effect.tooltip {
validate_tooltip(tooltip, &effect.id, report);
}
for (name, value) in &effect.css_vars {
if !is_custom_property_name(name) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidCssVariableName,
effect.id.clone(),
name.clone(),
"hoverfx CSS variables must be CSS custom properties",
));
}
if !is_safe_css_custom_value(value) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::UnsafeCssValue,
effect.id.clone(),
name.clone(),
"hoverfx CSS variable values must not contain declarations, URLs, or scriptable protocols",
));
}
}
}
fn validate_tooltip(
tooltip: &HoverFxTooltipConfig,
effect: &str,
report: &mut HoverFxValidationReport,
) {
if tooltip.text.chars().count() > MAX_HOVERFX_TOOLTIP_TEXT_CHARS
|| tooltip
.text
.chars()
.any(|ch| ch == '\0' || ch.is_control() && ch != '\n' && ch != '\t')
{
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidTooltipText,
effect,
"tooltip.text",
format!(
"tooltip text must contain at most {} printable characters",
MAX_HOVERFX_TOOLTIP_TEXT_CHARS
),
));
}
if let Some(key) = &tooltip.i18n_key
&& (key.trim().is_empty()
|| key.chars().count() > MAX_HOVERFX_TOOLTIP_TEXT_CHARS
|| key
.chars()
.any(|ch| ch == '\0' || ch.is_control() || ch == '<' || ch == '>'))
{
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidTooltipI18nKey,
effect,
"tooltip.i18n_key",
format!(
"tooltip i18n key must contain at most {} printable characters",
MAX_HOVERFX_TOOLTIP_TEXT_CHARS
),
));
}
if !(MIN_HOVERFX_TOOLTIP_MAX_WIDTH_PX..=MAX_HOVERFX_TOOLTIP_MAX_WIDTH_PX)
.contains(&tooltip.max_width_px)
|| tooltip.offset_px > MAX_HOVERFX_TOOLTIP_OFFSET_PX
|| tooltip.show_delay_ms > MAX_HOVERFX_TOOLTIP_DELAY_MS
|| tooltip.hide_delay_ms > MAX_HOVERFX_TOOLTIP_DELAY_MS
|| tooltip.duration_ms == 0
|| tooltip.duration_ms > MAX_HOVERFX_TOOLTIP_DURATION_MS
|| tooltip.speed_ms == 0
|| tooltip.speed_ms > MAX_HOVERFX_TOOLTIP_DURATION_MS
|| tooltip.stagger_ms > MAX_HOVERFX_TOOLTIP_DURATION_MS
|| !tooltip.box_opacity.is_finite()
|| !(MIN_HOVERFX_TOOLTIP_BOX_OPACITY..=MAX_HOVERFX_TOOLTIP_BOX_OPACITY)
.contains(&tooltip.box_opacity)
{
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidTooltipMetric,
effect,
"tooltip",
"tooltip offset, width, opacity, delay, and timing values must stay within supported ranges",
));
}
if !is_supported_textfx_effect(&tooltip.textfx_effect) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::UnsupportedTextFxEffect,
effect,
"tooltip.textfx_effect",
format!(
"tooltip textfx effect must be one of: {}",
SUPPORTED_HOVERFX_TEXTFX_EFFECTS.join(", ")
),
));
}
}
fn validate_sand(sand: &HoverFxSandConfig, effect: &str, report: &mut HoverFxValidationReport) {
for (field, value, min, max) in [
("sand.grain_size_px", sand.grain_size_px, 0.2, 12.0),
("sand.grain_density", sand.grain_density, 0.05, 8.0),
("sand.shimmer_density", sand.shimmer_density, 0.0, 2.0),
("sand.shimmer_strength", sand.shimmer_strength, 0.0, 4.0),
(
"sand.shimmer_radius_px",
sand.shimmer_radius_px,
1.0,
2_000.0,
),
("sand.specular_strength", sand.specular_strength, 0.0, 4.0),
("sand.roughness", sand.roughness, 0.0, 1.0),
] {
if !value.is_finite() || value < min || value > max {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidSandMetric,
effect,
field,
format!("sand metric must be finite and between {min} and {max}"),
));
}
}
if !(MIN_HOVERFX_SAND_ANIMATION_SPEED_MS..=MAX_HOVERFX_SAND_ANIMATION_SPEED_MS)
.contains(&sand.animation_speed_ms)
{
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidSandAnimationSpeed,
effect,
"sand.animation_speed_ms",
format!(
"sand animation speed must be between {}ms and {}ms",
MIN_HOVERFX_SAND_ANIMATION_SPEED_MS, MAX_HOVERFX_SAND_ANIMATION_SPEED_MS
),
));
}
for (field, value) in [
("sand.color", sand.color.as_str()),
("sand.highlight_color", sand.highlight_color.as_str()),
] {
if !is_safe_css_custom_value(value) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::UnsafeSandCssValue,
effect,
field,
"sand CSS values must not contain declarations, URLs, or scriptable protocols",
));
}
}
}
fn validate_text_reveal(
text_reveal: &HoverFxTextRevealConfig,
effect: &str,
report: &mut HoverFxValidationReport,
) {
let charset_len = text_reveal.charset.chars().count();
if charset_len == 0
|| charset_len > MAX_HOVERFX_TEXT_REVEAL_CHARSET_CHARS
|| text_reveal.charset.chars().any(char::is_control)
{
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidTextRevealCharset,
effect,
"text_reveal.charset",
format!(
"text reveal charset must contain 1 to {} printable characters",
MAX_HOVERFX_TEXT_REVEAL_CHARSET_CHARS
),
));
}
if !(MIN_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS..=MAX_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS)
.contains(&text_reveal.cycle_speed_ms)
{
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidTextRevealCycleSpeed,
effect,
"text_reveal.cycle_speed_ms",
format!(
"text reveal cycle speed must be between {}ms and {}ms",
MIN_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS, MAX_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS
),
));
}
if !text_reveal.density.is_finite() || !(0.05..=8.0).contains(&text_reveal.density) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidTextRevealMetric,
effect,
"text_reveal.density",
"text reveal density must be finite and between 0.05 and 8.0",
));
}
if text_reveal.font_size_px == 0
|| text_reveal.font_size_px > MAX_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX
|| text_reveal.gap_px > MAX_HOVERFX_TEXT_REVEAL_GAP_PX
{
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidTextRevealMetric,
effect,
"text_reveal.font_size_px",
"text reveal font size and gap must stay within supported pixel ranges",
));
}
for (field, value) in [
("text_reveal.font_family", text_reveal.font_family.as_str()),
("text_reveal.color", text_reveal.color.as_str()),
] {
if !is_safe_css_custom_value(value) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::UnsafeTextRevealCssValue,
effect,
field,
"text reveal CSS values must not contain declarations, URLs, or scriptable protocols",
));
}
}
if !is_supported_textfx_effect(&text_reveal.textfx_effect) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::UnsupportedTextFxEffect,
effect,
"text_reveal.textfx_effect",
format!(
"textfx effect must be one of: {}",
SUPPORTED_HOVERFX_TEXTFX_EFFECTS.join(", ")
),
));
}
}
fn validate_radius(
radius_px: u16,
field: &str,
effect: Option<&str>,
report: &mut HoverFxValidationReport,
) {
if !(MIN_HOVERFX_RADIUS_PX..=MAX_HOVERFX_RADIUS_PX).contains(&radius_px) {
push_numeric_issue(
HoverFxValidationCode::InvalidRadius,
effect,
field,
format!(
"radius must be between {}px and {}px",
MIN_HOVERFX_RADIUS_PX, MAX_HOVERFX_RADIUS_PX
),
report,
);
}
}
fn validate_range(
range_px: u16,
field: &str,
effect: Option<&str>,
report: &mut HoverFxValidationReport,
) {
if !(MIN_HOVERFX_RANGE_PX..=MAX_HOVERFX_RANGE_PX).contains(&range_px) {
push_numeric_issue(
HoverFxValidationCode::InvalidRange,
effect,
field,
format!(
"range must be between {}px and {}px",
MIN_HOVERFX_RANGE_PX, MAX_HOVERFX_RANGE_PX
),
report,
);
}
}
fn validate_strength(
strength: f32,
field: &str,
effect: Option<&str>,
report: &mut HoverFxValidationReport,
) {
if !strength.is_finite() || !(0.0..=MAX_HOVERFX_STRENGTH).contains(&strength) {
push_numeric_issue(
HoverFxValidationCode::InvalidStrength,
effect,
field,
format!("strength must be finite and between 0.0 and {MAX_HOVERFX_STRENGTH}"),
report,
);
}
}
fn validate_smoothing(
smoothing: f32,
field: &str,
effect: Option<&str>,
report: &mut HoverFxValidationReport,
) {
if !smoothing.is_finite() || !(0.0..=1.0).contains(&smoothing) {
push_numeric_issue(
HoverFxValidationCode::InvalidSmoothing,
effect,
field,
"smoothing must be finite and between 0.0 and 1.0",
report,
);
}
}
fn push_numeric_issue(
code: HoverFxValidationCode,
effect: Option<&str>,
field: &str,
message: impl Into<String>,
report: &mut HoverFxValidationReport,
) {
let message = message.into();
match effect {
Some(effect) => report.push(HoverFxValidationIssue::effect_error(
code,
effect.to_string(),
field,
message,
)),
None => report.push(HoverFxValidationIssue::error(code, field, message)),
}
}
pub fn hoverfx_id(id: impl AsRef<str>) -> String {
let mut normalized = String::with_capacity(id.as_ref().len());
let mut previous_dash = false;
for ch in id.as_ref().trim().chars() {
if ch.is_ascii_alphanumeric() {
normalized.push(ch.to_ascii_lowercase());
previous_dash = false;
} else if matches!(ch, '-' | '_' | ' ' | '.' | ':' | '/') && !previous_dash {
normalized.push('-');
previous_dash = true;
}
}
while normalized.ends_with('-') {
normalized.pop();
}
if normalized.is_empty() {
"effect".to_string()
} else {
normalized
}
}
pub fn is_hoverfx_id(id: &str) -> bool {
let bytes = id.as_bytes();
if bytes.is_empty() || bytes.first() == Some(&b'-') || bytes.last() == Some(&b'-') {
return false;
}
let mut previous_dash = false;
for byte in bytes {
let valid = byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-';
if !valid {
return false;
}
if *byte == b'-' {
if previous_dash {
return false;
}
previous_dash = true;
} else {
previous_dash = false;
}
}
true
}
pub fn is_custom_property_name(name: &str) -> bool {
let Some(rest) = name.strip_prefix("--") else {
return false;
};
!rest.is_empty()
&& rest
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
}
pub fn is_safe_css_custom_value(value: &str) -> bool {
let value = value.trim();
if value.is_empty() || value.len() > 512 {
return false;
}
if value
.chars()
.any(|ch| ch.is_control() || matches!(ch, '{' | '}' | ';' | '<' | '>'))
{
return false;
}
let lower = value.to_ascii_lowercase();
![
"url(",
"expression(",
"@import",
"javascript:",
"vbscript:",
"data:",
"</script",
]
.iter()
.any(|needle| lower.contains(needle))
}
pub fn is_supported_textfx_effect(effect: &str) -> bool {
let effect = hoverfx_id(effect);
SUPPORTED_HOVERFX_TEXTFX_EFFECTS
.iter()
.any(|supported| *supported == effect)
}
pub mod prelude {
pub use crate::integration::*;
pub use crate::{
HoverCfg, HoverDef, HoverFxCompatibilityMatrix, HoverFxCompatibilityRow, HoverFxConfig,
HoverFxDefinition, HoverFxDiagnosticVerbosity, HoverFxExplainReport,
HoverFxFallbackStrategy, HoverFxFalloff, HoverFxInteropPolicy, HoverFxManifestFragment,
HoverFxManifestPolicyHook, HoverFxOutputBudget, HoverFxOutputReport,
HoverFxOutputViolation, HoverFxPerformanceConfig, HoverFxPreset, HoverFxPresetProfile,
HoverFxRegistry, HoverFxRenderer, HoverFxRoutePolicy, HoverFxRuntimeEmission,
HoverFxSerializationFormat, HoverFxShape, HoverFxTooltipConfig, HoverFxTooltipPlacement,
HoverPerf, HoverReg, explain_hoverfx, hover_def, hover_fx, hoverfx, hoverfx_cache_key,
hoverfx_compatibility_matrix, hoverfx_manifest_fragment, hoverfx_native_port_hints,
hoverfx_output_budget, hoverfx_output_report, hoverfx_route_policy,
};
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn defaults_are_worker_first_and_include_builtin_presets() {
let config = HoverFxConfig::default();
assert_eq!(config.renderer, HoverFxRenderer::WorkerFirst);
assert_eq!(config.radius_px, DEFAULT_HOVERFX_RADIUS_PX);
assert_eq!(config.range_px, DEFAULT_HOVERFX_RANGE_PX);
assert_eq!(config.falloff, HoverFxFalloff::Smooth);
assert_eq!(config.strength, DEFAULT_HOVERFX_STRENGTH);
assert_eq!(config.smoothing, DEFAULT_HOVERFX_SMOOTHING);
assert!(config.performance.lazy_local_layers);
assert!(config.performance.worker_local_layers);
assert!(config.performance.dirty_rect_rendering);
assert!(config.performance.shader_texture_cache);
assert_eq!(config.performance.dpr_cap, DEFAULT_HOVERFX_PERF_DPR_CAP);
assert_eq!(
config.performance.idle_release_timeout_ms,
DEFAULT_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS
);
assert_eq!(config.performance.candidate_observer_margin_px, None);
assert_eq!(
config.max_active_elements,
DEFAULT_HOVERFX_MAX_ACTIVE_ELEMENTS
);
for preset in HoverFxPreset::all() {
assert!(config.registry.contains_effect(preset.as_attr()));
}
assert!(config.validate().is_valid());
}
#[test]
fn preset_helpers_and_kebab_case_serialization_match_attrs() {
assert_eq!(HoverFxPreset::SoftGlow.as_attr(), "soft-glow");
assert_eq!(HoverFxPreset::BorderTrace.label(), "Border trace");
assert_eq!(
serde_json::to_value(HoverFxPreset::ColorWash).unwrap(),
json!("color-wash")
);
assert_eq!(
serde_json::to_value(HoverFxShape::RoundedRect).unwrap(),
json!("rounded-rect")
);
assert_eq!(
serde_json::to_value(HoverFxRenderer::WorkerFirst).unwrap(),
json!("worker-first")
);
assert_eq!(HoverFxPreset::BinaryReveal.as_attr(), "binary-reveal");
assert_eq!(HoverFxPreset::BinaryReveal.label(), "Binary reveal");
assert_eq!(HoverFxPreset::TextureReveal.as_attr(), "texture-reveal");
assert_eq!(HoverFxPreset::TextureReveal.label(), "Texture reveal");
assert_eq!(HoverFxPreset::Sand.as_attr(), "sand");
assert_eq!(HoverFxPreset::Sand.label(), "Sand");
assert_eq!(
serde_json::to_value(HoverFxPreset::Sand).unwrap(),
json!("sand")
);
assert_eq!(HoverFxTextContrastMode::Auto.as_attr(), "auto");
assert_eq!(HoverFxTextContrastMode::Darken.as_attr(), "darken");
assert_eq!(HoverFxTextContrastMode::Invert.as_attr(), "invert");
assert_eq!(
serde_json::to_value(HoverFxTextContrastMode::Invert).unwrap(),
json!("invert")
);
assert_eq!(
serde_json::to_value(HoverFxTextureRevealMode::StaticGrain).unwrap(),
json!("static-grain")
);
assert_eq!(HoverFxPreset::Tooltip.as_attr(), "tooltip");
assert_eq!(HoverFxPreset::all().len(), 9);
}
#[test]
fn builder_overrides_serialize_camel_case() {
let config = HoverFxConfig::new()
.with_default_effect("brand-wash")
.with_radius_px(260)
.with_range_px(340)
.with_shape(HoverFxShape::Square)
.with_falloff(HoverFxFalloff::Exponential)
.with_strength(1.4)
.with_smoothing(0.25)
.with_max_active_elements(12)
.with_renderer(HoverFxRenderer::CssOnly)
.with_performance(
HoverFxPerformanceConfig::default()
.with_lazy_local_layers(false)
.with_worker_local_layers(false)
.with_dirty_rect_rendering(false)
.with_shader_texture_cache(false)
.with_dpr_cap(1.5)
.with_idle_release_timeout_ms(2400)
.with_candidate_observer_margin_px(720),
)
.with_runtime_path("/assets/custom-hoverfx.js")
.with_worker_path("/assets/custom-hoverfx-worker.js")
.with_effect(
HoverFxDefinition::new("brand-wash", "Brand wash")
.with_preset(HoverFxPreset::ColorWash)
.with_radius_px(300)
.with_range_px(420)
.with_text_contrast(HoverFxTextContrastMode::Auto)
.with_css_var("--dxh-color", "rgba(14,165,233,0.30)"),
);
assert!(config.validate().is_valid());
let json = serde_json::to_value(&config).unwrap();
assert_eq!(json["defaultEffect"], "brand-wash");
assert_eq!(json["radiusPx"], 260);
assert_eq!(json["rangePx"], 340);
assert_eq!(json["maxActiveElements"], 12);
assert_eq!(json["renderer"], "css-only");
assert_eq!(json["performance"]["lazyLocalLayers"], false);
assert_eq!(json["performance"]["workerLocalLayers"], false);
assert_eq!(json["performance"]["dirtyRectRendering"], false);
assert_eq!(json["performance"]["shaderTextureCache"], false);
assert_eq!(json["performance"]["dprCap"], 1.5);
assert_eq!(json["performance"]["idleReleaseTimeoutMs"], 2400);
assert_eq!(json["performance"]["candidateObserverMarginPx"], 720);
assert_eq!(json["registry"]["effects"][9]["preset"], "color-wash");
assert_eq!(json["registry"]["effects"][9]["rangePx"], 420);
assert_eq!(json["registry"]["effects"][9]["textContrast"], "auto");
let tracked = HoverFxConfig::new().with_radius_px(320);
assert_eq!(tracked.range_px, 320);
let independent = HoverFxConfig::new().with_range_px(160).with_radius_px(320);
assert_eq!(independent.radius_px, 320);
assert_eq!(independent.range_px, 160);
}
#[test]
fn validation_reports_invalid_config_and_effect_values() {
let mut config = HoverFxConfig::new()
.with_default_effect("missing")
.with_radius_px(0)
.with_strength(f32::INFINITY)
.with_smoothing(1.4)
.with_max_active_elements(0)
.with_dpr_cap(4.0)
.with_runtime_path("")
.with_worker_path("");
config.registry.effects.push(HoverFxDefinition {
id: "Bad Id".to_string(),
label: "Bad".to_string(),
preset: None,
radius_px: Some(0),
range_px: Some(MAX_HOVERFX_RANGE_PX + 1),
shape: Some(HoverFxShape::Polygon),
falloff: Some(HoverFxFalloff::Hard),
strength: Some(-1.0),
smoothing: Some(f32::NAN),
custom_shape: Some("polygon(0 0, url(javascript:bad))".to_string()),
text_reveal: Some(
HoverFxTextRevealConfig::default()
.with_charset("")
.with_cycle_speed_ms(1)
.with_density(f32::NAN)
.with_font_size_px(0)
.with_color("url(javascript:bad)")
.with_textfx_effect("fade"),
),
texture_reveal: None,
sand: Some(
HoverFxSandConfig::default()
.with_grain_size_px(0.0)
.with_shimmer_density(f32::NAN)
.with_shimmer_radius_px(0.0)
.with_animation_speed_ms(1)
.with_color("url(javascript:bad)"),
),
tooltip: Some(
HoverFxTooltipConfig::new("\0")
.i18n_key("\0")
.with_max_width_px(1)
.with_offset_px(5_000)
.with_box_opacity(f32::NAN)
.with_duration_ms(0)
.with_textfx_effect("fade"),
),
text_contrast: Some(HoverFxTextContrastMode::Invert),
css_vars: BTreeMap::from([
("color".to_string(), "red".to_string()),
(
"--dxh-bg".to_string(),
"url(javascript:alert(1))".to_string(),
),
]),
});
let report = config.validate();
let codes: Vec<_> = report.errors().map(|issue| issue.code).collect();
assert!(codes.contains(&HoverFxValidationCode::MissingDefaultEffect));
assert!(codes.contains(&HoverFxValidationCode::InvalidEffectId));
assert!(codes.contains(&HoverFxValidationCode::EmptyRuntimePath));
assert!(codes.contains(&HoverFxValidationCode::EmptyWorkerPath));
assert!(codes.contains(&HoverFxValidationCode::InvalidRadius));
assert!(codes.contains(&HoverFxValidationCode::InvalidStrength));
assert!(codes.contains(&HoverFxValidationCode::InvalidSmoothing));
assert!(codes.contains(&HoverFxValidationCode::InvalidMaxActiveElements));
assert!(codes.contains(&HoverFxValidationCode::InvalidCssVariableName));
assert!(codes.contains(&HoverFxValidationCode::UnsafeCssValue));
assert!(codes.contains(&HoverFxValidationCode::UnsafeCustomShapeValue));
assert!(codes.contains(&HoverFxValidationCode::InvalidTextRevealCharset));
assert!(codes.contains(&HoverFxValidationCode::InvalidTextRevealCycleSpeed));
assert!(codes.contains(&HoverFxValidationCode::InvalidTextRevealMetric));
assert!(codes.contains(&HoverFxValidationCode::UnsafeTextRevealCssValue));
assert!(codes.contains(&HoverFxValidationCode::UnsupportedTextFxEffect));
assert!(codes.contains(&HoverFxValidationCode::InvalidSandMetric));
assert!(codes.contains(&HoverFxValidationCode::InvalidSandAnimationSpeed));
assert!(codes.contains(&HoverFxValidationCode::UnsafeSandCssValue));
assert!(codes.contains(&HoverFxValidationCode::InvalidTooltipMetric));
assert!(codes.contains(&HoverFxValidationCode::InvalidTooltipText));
assert!(codes.contains(&HoverFxValidationCode::InvalidTooltipI18nKey));
assert!(codes.contains(&HoverFxValidationCode::InvalidPerformanceMetric));
}
#[test]
fn binary_reveal_defaults_and_textfx_bridge_serialize() {
let definition = HoverFxDefinition::from_preset(HoverFxPreset::BinaryReveal);
let text_reveal = definition.text_reveal.expect("binary reveal config");
assert_eq!(definition.radius_px, Some(300));
assert_eq!(definition.shape, Some(HoverFxShape::Circle));
assert_eq!(definition.falloff, Some(HoverFxFalloff::Smooth));
assert_eq!(definition.strength, Some(1.15));
assert_eq!(text_reveal.charset, "01");
assert_eq!(text_reveal.cycle_speed_ms, 220);
assert_eq!(
text_reveal.animation_source,
HoverFxTextAnimationSource::Auto
);
assert_eq!(text_reveal.renderer, HoverFxTextRevealRenderer::GlyphAtlas);
assert_eq!(text_reveal.textfx_effect, "scramble");
assert_eq!(
HoverFxTextRevealRenderer::GlyphAtlas.as_attr(),
"glyph-atlas"
);
assert_eq!(
HoverFxTextRevealRenderer::CanvasGrid.as_attr(),
"canvas-grid"
);
assert!(is_supported_textfx_effect("gradient-shift"));
assert!(!is_supported_textfx_effect("fade"));
let json = text_reveal.to_json().unwrap();
assert!(json.contains(r#""animationSource":"auto""#));
assert!(json.contains(r#""renderer":"glyph-atlas""#));
assert!(
HoverFxTextRevealConfig::default()
.with_renderer(HoverFxTextRevealRenderer::CanvasGrid)
.to_json()
.unwrap()
.contains(r#""renderer":"canvas-grid""#)
);
let textfx = text_reveal
.to_textfx_config_json("binary-demo", "0101")
.unwrap();
assert!(textfx.contains(r#""effect":"scramble""#));
assert!(textfx.contains(r#""charset":"01""#));
assert!(textfx.contains(r#""speedMs":220"#));
}
#[test]
fn texture_reveal_defaults_and_builder_serialize() {
let definition = HoverFxDefinition::from_preset(HoverFxPreset::TextureReveal);
let texture_reveal = definition.texture_reveal.expect("texture reveal config");
assert_eq!(definition.radius_px, Some(340));
assert_eq!(definition.shape, Some(HoverFxShape::Circle));
assert_eq!(definition.falloff, Some(HoverFxFalloff::Smooth));
assert_eq!(definition.strength, Some(1.1));
assert_eq!(texture_reveal.mode, HoverFxTextureRevealMode::Auto);
assert_eq!(HoverFxTextureRevealMode::Halftone.as_attr(), "halftone");
assert_eq!(texture_reveal.to_json().unwrap(), r#"{"mode":"auto"}"#);
assert!(
HoverFxTextureRevealConfig::default()
.with_mode(HoverFxTextureRevealMode::StaticGrain)
.to_json()
.unwrap()
.contains(r#""mode":"static-grain""#)
);
}
#[test]
fn sand_defaults_and_builder_serialize() {
let definition = HoverFxDefinition::from_preset(HoverFxPreset::Sand);
let sand = definition.sand.expect("sand config");
assert_eq!(definition.radius_px, Some(320));
assert_eq!(definition.shape, Some(HoverFxShape::Circle));
assert_eq!(definition.falloff, Some(HoverFxFalloff::Smooth));
assert_eq!(definition.strength, Some(1.1));
assert_eq!(sand.grain_size_px, DEFAULT_HOVERFX_SAND_GRAIN_SIZE_PX);
assert_eq!(
sand.animation_speed_ms,
DEFAULT_HOVERFX_SAND_ANIMATION_SPEED_MS
);
assert_eq!(
sand.shimmer_radius_px,
DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX
);
assert_eq!(sand.color_source, HoverFxSandColorSource::Custom);
assert!(definition.css_vars.contains_key("--dxh-sand-color"));
let custom = HoverFxSandConfig::default()
.with_grain_size_px(1.6)
.with_grain_density(1.4)
.with_shimmer_density(0.22)
.with_shimmer_strength(0.9)
.with_shimmer_radius_px(280.0)
.with_specular_strength(1.2)
.with_roughness(0.36)
.with_animation_speed_ms(720)
.with_color_source(HoverFxSandColorSource::Element)
.with_color("#c9a96a")
.with_highlight_color("#fff2b8");
let json = serde_json::to_value(&custom).unwrap();
assert!((json["grainSizePx"].as_f64().unwrap() - 1.6).abs() < 0.001);
assert_eq!(json["shimmerRadiusPx"], 280.0);
assert_eq!(json["animationSpeedMs"], 720);
assert_eq!(json["colorSource"], "element");
assert_eq!(json["highlightColor"], "#fff2b8");
assert!(
HoverFxDefinition::new("custom-sand", "Custom sand")
.with_preset(HoverFxPreset::Sand)
.with_sand(custom)
.with_radius_px(360)
.with_strength(1.2)
.with_shape(HoverFxShape::Circle)
.with_falloff(HoverFxFalloff::Smooth)
.preset
.is_some()
);
}
#[test]
fn tooltip_defaults_and_textfx_bridge_serialize() {
let definition = HoverFxDefinition::from_preset(HoverFxPreset::Tooltip);
let tooltip = definition.tooltip.expect("tooltip config");
assert_eq!(definition.radius_px, Some(96));
assert_eq!(definition.range_px, Some(0));
assert_eq!(definition.shape, Some(HoverFxShape::RoundedRect));
assert_eq!(definition.strength, Some(0.0));
assert_eq!(tooltip.placement, HoverFxTooltipPlacement::Cursor);
assert_eq!(tooltip.offset_px, DEFAULT_HOVERFX_TOOLTIP_OFFSET_PX);
assert_eq!(tooltip.box_opacity, DEFAULT_HOVERFX_TOOLTIP_BOX_OPACITY);
assert_eq!(tooltip.textfx_effect, "scramble");
assert_eq!(HoverFxTooltipPlacement::Cursor.as_attr(), "cursor");
let custom = HoverFxTooltipConfig::new("Ship only the hovered hint")
.key("hoverfx.tooltip.text")
.cursor()
.offset(18)
.max_width(320)
.opacity(0.72)
.typewriter()
.dur_ms(320)
.speed_ms(24)
.stagger_ms(10);
let json = custom.to_json().unwrap();
assert!(json.contains(r#""placement":"cursor""#));
assert!(json.contains(r#""i18nKey":"hoverfx.tooltip.text""#));
assert!(json.contains(r#""boxOpacity":0.72"#));
assert!(json.contains(r#""textfxEffect":"typewriter""#));
let old_json: HoverFxTooltipConfig =
serde_json::from_str(r#"{"placement":"top","offsetPx":12,"maxWidthPx":280,"showDelayMs":0,"hideDelayMs":90,"durationMs":260,"speedMs":18,"staggerMs":8,"textfxEffect":"scramble","split":"chars"}"#)
.unwrap();
assert_eq!(old_json.box_opacity, DEFAULT_HOVERFX_TOOLTIP_BOX_OPACITY);
let textfx = custom
.to_textfx_config_json("hoverfx-tip", "fallback")
.unwrap();
assert!(textfx.contains(r#""id":"hoverfx-tip""#));
assert!(textfx.contains(r#""text":"Ship only the hovered hint""#));
assert!(textfx.contains(r#""effect":"typewriter""#));
assert!(textfx.contains(r#""durationMs":320"#));
}
#[test]
fn route_policy_manifest_and_budget_report_are_stable() {
let config = HoverFxConfig::new()
.with_profile(HoverFxPresetProfile::Conservative)
.with_effect(HoverFxDefinition::from_preset(HoverFxPreset::Tooltip));
let policy = hoverfx_route_policy()
.route("/hoverfx")
.profile(HoverFxPresetProfile::Conservative)
.emission(HoverFxRuntimeEmission::WhenUsed)
.serialization(HoverFxSerializationFormat::CompactJson)
.budget(hoverfx_output_budget().config_bytes(8).effect_count(2))
.label("owner", "visual-effects")
.tag("hover")
.tag("tooltip");
let manifest = config.manifest_fragment(&policy);
let report = config.output_report(&policy);
let hints = hoverfx_native_port_hints(&config, &policy);
assert_eq!(manifest.package, HOVERFX_PACKAGE_NAME);
assert_eq!(manifest.route.as_deref(), Some("/hoverfx"));
assert_eq!(manifest.profile, HoverFxPresetProfile::Conservative);
assert_eq!(manifest.metrics["effectCount"], report.effect_count as u64);
assert_eq!(manifest.labels["owner"], "visual-effects");
assert_eq!(
manifest.tags,
vec!["hover".to_string(), "tooltip".to_string()]
);
assert_eq!(hints["route"], "/hoverfx");
assert_eq!(hints["profile"], "conservative");
assert!(
report
.violations
.iter()
.any(|violation| violation.field == "configBytes")
);
assert_eq!(
config.cache_key(Some("/hoverfx")),
config.cache_key(Some("/hoverfx"))
);
}
#[test]
fn explain_report_and_hook_cover_interop_decisions() {
struct DropDisabled;
impl HoverFxManifestPolicyHook for DropDisabled {
fn apply(&self, fragment: HoverFxManifestFragment) -> Option<HoverFxManifestFragment> {
fragment.enabled.then_some(fragment)
}
}
let config = HoverFxConfig::new()
.with_effect(HoverFxDefinition::from_preset(HoverFxPreset::BinaryReveal));
let enabled_policy = hoverfx_route_policy().route("/hoverfx").tag("textfx");
let disabled_policy = hoverfx_route_policy()
.route("/hoverfx/off")
.enabled(false)
.emission(HoverFxRuntimeEmission::Disabled);
let explain = explain_hoverfx(&config, &enabled_policy);
let matrix = hoverfx_compatibility_matrix();
assert!(explain.validation.is_valid());
assert!(
explain
.notes
.iter()
.any(|note| note.contains("TextFX interop"))
);
assert!(matrix.rows.iter().any(|row| row.target == "native"));
assert!(apply_hoverfx_manifest_hook(&config, &enabled_policy, &DropDisabled).is_some());
assert!(apply_hoverfx_manifest_hook(&config, &disabled_policy, &DropDisabled).is_none());
assert!(config.is_noop_for_route(&disabled_policy));
}
#[test]
fn hoverfx_id_sanitizes_to_kebab_case() {
assert_eq!(hoverfx_id(" Soft Glow "), "soft-glow");
assert_eq!(hoverfx_id("brand.fx/accent"), "brand-fx-accent");
assert_eq!(hoverfx_id(""), "effect");
assert!(is_hoverfx_id("border-trace"));
assert!(!is_hoverfx_id("BorderTrace"));
assert!(!is_hoverfx_id("-bad"));
assert!(!is_hoverfx_id("bad--id"));
}
#[test]
fn css_safety_allows_values_but_rejects_declarations_and_urls() {
assert!(is_custom_property_name("--dxh-color"));
assert!(!is_custom_property_name("dxh-color"));
assert!(is_safe_css_custom_value("rgba(14,165,233,0.30)"));
assert!(is_safe_css_custom_value(
"polygon(0 0, 100% 0, 80% 100%, 0 90%)"
));
assert!(!is_safe_css_custom_value("red; background: blue"));
assert!(!is_safe_css_custom_value("url(https://example.com/a.png)"));
assert!(!is_safe_css_custom_value("javascript:alert(1)"));
assert!(!is_safe_css_custom_value("<script>alert(1)</script>"));
}
}