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