Skip to main content

tessera_components/
ripple_state.rs

1//! Ripple state — manage ripple animation and interaction state layers.
2//!
3//! ## Usage
4//!
5//! Provide ripple and state-layer feedback for interactive controls such as
6//! buttons and surfaces.
7
8use std::time::Duration;
9
10use tessera_ui::{Dp, PxSize, current_frame_nanos, time::Instant};
11
12use crate::theme::MaterialAlpha;
13
14/// Controls how the ripple should be drawn.
15#[derive(Clone, Copy, Debug, PartialEq)]
16pub struct RippleSpec {
17    /// If true, the ripple originates from the pointer position and is clipped
18    /// by the component bounds.
19    ///
20    /// If false, the ripple originates from the component center.
21    pub bounded: bool,
22    /// Optional explicit ripple radius. When `None`, the radius is derived from
23    /// the component size.
24    pub radius: Option<Dp>,
25}
26
27impl Default for RippleSpec {
28    fn default() -> Self {
29        Self {
30            bounded: true,
31            radius: None,
32        }
33    }
34}
35
36/// A snapshot of the currently running ripple animation.
37#[derive(Clone, Copy, Debug, PartialEq)]
38pub struct RippleAnimation {
39    /// Current animation progress in `[0.0, 1.0)`.
40    pub progress: f32,
41    /// Ripple origin in normalized `[x, y]` coordinates (0.0..=1.0), relative
42    /// to the component bounds.
43    pub center: [f32; 2],
44    /// Ripple radius in normalized coordinates, relative to the minimum
45    /// component dimension.
46    pub radius: f32,
47    /// Alpha applied to the ripple wave before blending (0.0..=1.0).
48    pub alpha: f32,
49}
50
51#[derive(Clone, PartialEq, Copy, Debug)]
52struct RippleAnimationState {
53    start_instant: Instant,
54    start_frame_nanos: u64,
55    center: [f32; 2],
56    max_radius: f32,
57}
58
59impl RippleAnimationState {
60    fn animation_at_instant(self, now: Instant) -> Option<RippleAnimation> {
61        let elapsed_nanos = now
62            .saturating_duration_since(self.start_instant)
63            .as_nanos()
64            .min(u64::MAX as u128) as u64;
65        self.animation_from_elapsed_nanos(elapsed_nanos)
66    }
67
68    fn animation_at_frame_nanos(self, frame_nanos: u64) -> Option<RippleAnimation> {
69        let elapsed_nanos = frame_nanos.saturating_sub(self.start_frame_nanos);
70        self.animation_from_elapsed_nanos(elapsed_nanos)
71    }
72
73    fn animation_from_elapsed_nanos(self, elapsed_nanos: u64) -> Option<RippleAnimation> {
74        let duration_nanos = RippleState::ANIMATION_DURATION
75            .as_nanos()
76            .min(u64::MAX as u128) as u64;
77        let progress = (elapsed_nanos as f32 / duration_nanos as f32).clamp(0.0, 1.0);
78        if progress >= 1.0 {
79            return None;
80        }
81
82        let eased = ease_out_cubic(progress);
83        let radius = self.max_radius * eased;
84        let alpha = MaterialAlpha::PRESSED * (1.0 - progress);
85
86        Some(RippleAnimation {
87            progress,
88            center: self.center,
89            radius,
90            alpha,
91        })
92    }
93}
94
95fn ease_out_cubic(t: f32) -> f32 {
96    let t = t.clamp(0.0, 1.0);
97    1.0 - (1.0 - t).powi(3)
98}
99
100/// # RippleState
101///
102/// Manage ripple animations and hover state for interactive UI components.
103/// Use with `remember` to create persistent state across frames.
104///
105pub struct RippleState {
106    animation: Option<RippleAnimationState>,
107    is_hovered: bool,
108    is_focused: bool,
109    is_dragged: bool,
110    is_pressed: bool,
111}
112
113impl Default for RippleState {
114    /// Creates a new `RippleState` with all fields initialized to their default
115    /// values.
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl RippleState {
122    /// The default duration of the ripple animation.
123    pub const ANIMATION_DURATION: Duration = Duration::from_millis(300);
124
125    /// Creates a new `RippleState` with default values.
126    ///
127    /// # Example
128    /// ```
129    /// use tessera_components::ripple_state::RippleState;
130    /// let state = RippleState::new();
131    /// ```
132    pub fn new() -> Self {
133        Self {
134            animation: None,
135            is_hovered: false,
136            is_focused: false,
137            is_dragged: false,
138            is_pressed: false,
139        }
140    }
141
142    /// Starts a new ripple animation from the given click position.
143    ///
144    /// # Arguments
145    ///
146    /// * `click_pos` - The normalized `[x, y]` position in 0.0..=1.0 where the
147    ///   ripple originates.
148    ///
149    /// # Example
150    /// ```
151    /// use tessera_components::ripple_state::RippleState;
152    /// let session = tessera_ui::RuntimeSession::new();
153    /// let _scope = session.enter();
154    /// let mut state = RippleState::new();
155    /// state.start_animation([0.5, 0.5]);
156    /// ```
157    pub fn start_animation(&mut self, click_pos: [f32; 2]) {
158        let now = Instant::now();
159        self.animation = Some(RippleAnimationState {
160            start_instant: now,
161            start_frame_nanos: current_frame_nanos(),
162            center: [click_pos[0].clamp(0.0, 1.0), click_pos[1].clamp(0.0, 1.0)],
163            max_radius: 1.0,
164        });
165    }
166
167    /// Starts a ripple animation using the provided size and spec.
168    pub fn start_animation_with_spec(
169        &mut self,
170        click_pos: [f32; 2],
171        size: PxSize,
172        spec: RippleSpec,
173    ) {
174        let now = Instant::now();
175        let size = [size.width.to_f32(), size.height.to_f32()];
176        let center = if spec.bounded { click_pos } else { [0.5, 0.5] };
177
178        let min_dimension = size[0].min(size[1]).max(1.0);
179        let max_radius = if let Some(radius) = spec.radius {
180            radius.to_pixels_f32() / min_dimension
181        } else {
182            max_distance_to_corners(center, size) / min_dimension
183        };
184
185        self.animation = Some(RippleAnimationState {
186            start_instant: now,
187            start_frame_nanos: current_frame_nanos(),
188            center,
189            max_radius,
190        });
191    }
192
193    /// Marks the ripple as no longer pressed.
194    pub fn release(&mut self) {
195        self.set_pressed(false);
196    }
197
198    /// Sets whether the component is pressed.
199    pub fn set_pressed(&mut self, pressed: bool) {
200        self.is_pressed = pressed;
201    }
202
203    /// Returns the current ripple animation snapshot.
204    pub fn animation(&mut self) -> Option<RippleAnimation> {
205        self.animation_at_frame_nanos(current_frame_nanos())
206    }
207
208    /// Returns a read-only snapshot of the current ripple animation.
209    pub fn animation_snapshot(&self) -> Option<RippleAnimation> {
210        self.animation_snapshot_at_frame_nanos(current_frame_nanos())
211    }
212
213    /// Returns a read-only animation snapshot at `now`.
214    pub fn animation_snapshot_at(&self, now: Instant) -> Option<RippleAnimation> {
215        self.animation
216            .and_then(|state| state.animation_at_instant(now))
217    }
218
219    /// Returns the current ripple animation snapshot at `now`.
220    pub fn animation_at(&mut self, now: Instant) -> Option<RippleAnimation> {
221        let state = self.animation?;
222        match state.animation_at_instant(now) {
223            Some(animation) => Some(animation),
224            None => {
225                self.animation = None;
226                None
227            }
228        }
229    }
230
231    /// Returns a read-only animation snapshot at `frame_nanos`.
232    pub fn animation_snapshot_at_frame_nanos(&self, frame_nanos: u64) -> Option<RippleAnimation> {
233        self.animation
234            .and_then(|state| state.animation_at_frame_nanos(frame_nanos))
235    }
236
237    /// Returns the current ripple animation snapshot at `frame_nanos`.
238    pub fn animation_at_frame_nanos(&mut self, frame_nanos: u64) -> Option<RippleAnimation> {
239        let state = self.animation?;
240        match state.animation_at_frame_nanos(frame_nanos) {
241            Some(animation) => Some(animation),
242            None => {
243                self.animation = None;
244                None
245            }
246        }
247    }
248
249    /// Returns the current progress of the ripple animation and the origin
250    /// position.
251    ///
252    /// Returns `Some((progress, [x, y]))` if the animation is active, where:
253    /// - `progress` is a value in `[0.0, 1.0)` representing the animation
254    ///   progress.
255    /// - `[x, y]` is the normalized origin of the ripple in 0.0..=1.0.
256    ///
257    /// Returns `None` if the animation is not active or has completed.
258    ///
259    /// # Example
260    ///
261    /// ```
262    /// use tessera_components::ripple_state::RippleState;
263    /// let session = tessera_ui::RuntimeSession::new();
264    /// let _scope = session.enter();
265    /// let mut state = RippleState::new();
266    /// state.start_animation([0.5, 0.5]);
267    /// let (progress, center) = state.get_animation_progress().expect("active ripple");
268    /// assert_eq!(progress, 0.0);
269    /// assert_eq!(center, [0.5, 0.5]);
270    /// ```
271    pub fn get_animation_progress(&mut self) -> Option<(f32, [f32; 2])> {
272        self.animation()
273            .map(|animation| (animation.progress, animation.center))
274    }
275
276    /// Sets whether the component is hovered.
277    pub fn set_hovered(&mut self, hovered: bool) {
278        self.is_hovered = hovered;
279    }
280
281    /// Returns whether the component is hovered.
282    pub fn is_hovered(&self) -> bool {
283        self.is_hovered
284    }
285
286    /// Sets whether the component is focused.
287    pub fn set_focused(&mut self, focused: bool) {
288        self.is_focused = focused;
289    }
290
291    /// Returns whether the component is focused.
292    pub fn is_focused(&self) -> bool {
293        self.is_focused
294    }
295
296    /// Sets whether the component is dragged.
297    pub fn set_dragged(&mut self, dragged: bool) {
298        self.is_dragged = dragged;
299    }
300
301    /// Returns whether the component is dragged.
302    pub fn is_dragged(&self) -> bool {
303        self.is_dragged
304    }
305
306    /// Returns whether the component is pressed.
307    pub fn is_pressed(&self) -> bool {
308        self.is_pressed
309    }
310
311    /// Returns the state-layer alpha derived from the current interactions.
312    pub fn state_layer_alpha(&self) -> f32 {
313        if self.is_dragged {
314            MaterialAlpha::DRAGGED
315        } else if self.is_pressed {
316            MaterialAlpha::PRESSED
317        } else if self.is_focused {
318            MaterialAlpha::FOCUSED
319        } else if self.is_hovered {
320            MaterialAlpha::HOVER
321        } else {
322            0.0
323        }
324    }
325}
326
327fn max_distance_to_corners(center: [f32; 2], size: [f32; 2]) -> f32 {
328    let origin = [center[0] * size[0], center[1] * size[1]];
329    let corners = [
330        [0.0, 0.0],
331        [size[0], 0.0],
332        [size[0], size[1]],
333        [0.0, size[1]],
334    ];
335
336    corners
337        .into_iter()
338        .map(|corner| {
339            let dx = corner[0] - origin[0];
340            let dy = corner[1] - origin[1];
341            (dx * dx + dy * dy).sqrt()
342        })
343        .fold(0.0_f32, f32::max)
344}