rae 0.1.10

Renderer-neutral widget, layout, sanitization, and GLSL shader primitives for Rust desktop tools.
Documentation
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);
    }
}