use crate::{clamp01, ease_in_out_cubic, pulse, Rgba, Vec2};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct InteractionState {
pub hovered: bool,
pub pressed: bool,
pub focused: bool,
pub disabled: bool,
pub selected: bool,
}
impl InteractionState {
pub fn visual_weight(self) -> f32 {
if self.disabled {
return 0.0;
}
let mut weight = 0.0;
if self.hovered {
weight += 0.22;
}
if self.focused {
weight += 0.35;
}
if self.selected {
weight += 0.28;
}
if self.pressed {
weight += 0.45;
}
clamp01(weight)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum AnimationCurve {
Linear,
EaseInOut,
Emphasized,
Elastic,
}
impl AnimationCurve {
pub fn sample(self, value: f32) -> f32 {
let value = clamp01(value);
match self {
Self::Linear => value,
Self::EaseInOut => ease_in_out_cubic(value),
Self::Emphasized => {
if value < 0.2 {
0.5 * (value / 0.2).powi(2) * 0.2
} else {
0.1 + ease_in_out_cubic((value - 0.2) / 0.8) * 0.9
}
}
Self::Elastic => {
if value == 0.0 || value == 1.0 {
value
} else {
let c4 = (2.0 * std::f32::consts::PI) / 3.0;
2.0_f32.powf(-10.0 * value) * ((value * 10.0 - 0.75) * c4).sin() + 1.0
}
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MotionSpec {
pub duration_ms: u32,
pub delay_ms: u32,
pub curve: AnimationCurve,
}
impl MotionSpec {
pub const FAST: Self = Self::new(120, 0, AnimationCurve::EaseInOut);
pub const STANDARD: Self = Self::new(220, 0, AnimationCurve::Emphasized);
pub const SLOW: Self = Self::new(420, 0, AnimationCurve::Emphasized);
pub const fn new(duration_ms: u32, delay_ms: u32, curve: AnimationCurve) -> Self {
Self {
duration_ms,
delay_ms,
curve,
}
}
pub fn progress(self, elapsed_ms: u32) -> f32 {
if elapsed_ms <= self.delay_ms {
return 0.0;
}
if self.duration_ms == 0 {
return 1.0;
}
let raw = (elapsed_ms - self.delay_ms) as f32 / self.duration_ms as f32;
self.curve.sample(raw)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Ripple {
pub origin: Vec2,
pub started_ms: u64,
pub duration_ms: u32,
pub radius_px: f32,
pub color: Rgba,
}
impl Ripple {
pub fn new(origin: Vec2, started_ms: u64, radius_px: f32, color: Rgba) -> Self {
Self {
origin,
started_ms,
duration_ms: 520,
radius_px,
color,
}
}
pub fn sample(self, now_ms: u64) -> RippleSample {
let elapsed = now_ms.saturating_sub(self.started_ms) as u32;
let progress = MotionSpec::SLOW.progress(elapsed);
let fade = 1.0 - clamp01(elapsed as f32 / self.duration_ms.max(1) as f32);
RippleSample {
origin: self.origin,
radius_px: self.radius_px * progress,
alpha: fade * fade * self.color.a,
color: self.color.with_alpha(fade * fade * self.color.a),
alive: elapsed <= self.duration_ms,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RippleSample {
pub origin: Vec2,
pub radius_px: f32,
pub alpha: f32,
pub color: Rgba,
pub alive: bool,
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct RipplePool {
pub ripples: Vec<Ripple>,
pub max_ripples: usize,
}
impl RipplePool {
pub fn new(max_ripples: usize) -> Self {
Self {
ripples: Vec::new(),
max_ripples: max_ripples.max(1),
}
}
pub fn push(&mut self, ripple: Ripple) {
self.ripples.push(ripple);
let overflow = self.ripples.len().saturating_sub(self.max_ripples.max(1));
if overflow > 0 {
self.ripples.drain(0..overflow);
}
}
pub fn samples(&mut self, now_ms: u64) -> Vec<RippleSample> {
let samples = self
.ripples
.iter()
.copied()
.map(|ripple| ripple.sample(now_ms))
.collect::<Vec<_>>();
self.ripples.retain(|ripple| ripple.sample(now_ms).alive);
samples.into_iter().filter(|sample| sample.alive).collect()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Glow {
pub color: Rgba,
pub radius_px: f32,
pub spread_px: f32,
pub intensity: f32,
}
impl Glow {
pub fn for_state(color: Rgba, state: InteractionState, time: f32) -> Self {
let shimmer = pulse(time, 2.1) * 0.18;
let weight = state.visual_weight();
Self {
color: color.with_alpha(0.18 + weight * 0.44),
radius_px: 18.0 + weight * 42.0,
spread_px: 3.0 + weight * 11.0,
intensity: clamp01(weight + shimmer),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Elevation {
pub z: f32,
pub shadow_alpha: f32,
pub shadow_radius_px: f32,
pub y_offset_px: f32,
}
impl Elevation {
pub fn level(level: u8, state: InteractionState) -> Self {
let active = state.visual_weight();
let z = level as f32 + active * 3.0;
Self {
z,
shadow_alpha: clamp01(0.08 + z * 0.035),
shadow_radius_px: 8.0 + z * 5.0,
y_offset_px: 1.0 + z * 1.2,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SurfaceEffects {
pub interaction: InteractionState,
pub glow: Glow,
pub elevation: Elevation,
pub focus_ring_alpha: f32,
pub disabled_alpha: f32,
}
impl SurfaceEffects {
pub fn resolve(accent: Rgba, interaction: InteractionState, time: f32) -> Self {
Self {
interaction,
glow: Glow::for_state(accent, interaction, time),
elevation: Elevation::level(2, interaction),
focus_ring_alpha: if interaction.focused {
0.45 + pulse(time, 1.8) * 0.25
} else {
0.0
},
disabled_alpha: if interaction.disabled { 0.38 } else { 1.0 },
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ripple_pool_enforces_capacity_and_expires() {
let mut pool = RipplePool::new(2);
pool.push(Ripple::new(Vec2::new(0.0, 0.0), 0, 100.0, Rgba::WHITE));
pool.push(Ripple::new(Vec2::new(1.0, 1.0), 1, 100.0, Rgba::WHITE));
pool.push(Ripple::new(Vec2::new(2.0, 2.0), 2, 100.0, Rgba::WHITE));
assert_eq!(pool.ripples.len(), 2);
assert!(pool.samples(10_000).is_empty());
assert!(pool.ripples.is_empty());
}
#[test]
fn disabled_interaction_has_no_visual_weight() {
let state = InteractionState {
hovered: true,
pressed: true,
disabled: true,
..InteractionState::default()
};
assert_eq!(state.visual_weight(), 0.0);
}
}