tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! **Ripple / InkWell** — el feedback de tap de Material: un círculo que se
2//! expande desde el punto donde el dedo/cursor presionó, clipeado al contorno
3//! del nodo, desvaneciéndose mientras crece. Es puro feedback visual: no vive
4//! en el `Model` de la app (igual que las animaciones implícitas de
5//! [`crate::AnimRegistry`]) sino en un registro retenido por el runtime entre
6//! frames.
7//!
8//! **Flujo.** Un `View` se marca ripple-capaz con [`crate::View::ripple`]
9//! (key estable + color). Cuando un press izquierdo cae sobre ese nodo, el
10//! runtime hace [`crate::hit_test_ripple`], calcula el punto local del tap y
11//! llama [`RippleRegistry::trigger`] — que guarda una "salpicadura" con su
12//! reloj. En cada redraw, DESPUÉS del paint del contenido, el runtime llama
13//! [`RippleRegistry::paint`], que por cada salpicadura viva resuelve el rect
14//! actual del nodo (puede haber cambiado de tamaño), dibuja el círculo
15//! expansivo recortado al rrect del nodo y devuelve `true` si alguna sigue
16//! viva → el runtime pide otro frame (ticker autodetenido, sin `spawn_periodic`).
17//!
18//! **Aditivo.** El ripple NO toca el camino click/drag: se dispara en el press
19//! por su propio hit-test, conviva o no el nodo con `on_click`. Un botón normal
20//! (`on_click` + `.ripple(...)`) recibe ambos.
21//!
22//! **Limitación v1.** Como la captura de subescenas del fade-out
23//! ([`crate::AnimRegistry`]), el paint usa el rect en coordenadas absolutas del
24//! layout e ignora los `transform` de ancestros — alcanza para botones/cards
25//! (rara vez transformados). La salpicadura es one-shot (expande + se desvanece
26//! en `duration`); no hay "mantener mientras se sostiene el press" (Material
27//! `hold`), que requeriría rastrear el release por key.
28
29// Preludio de `alloc`: bajo `no_std` estos tipos ya no vienen solos.
30#[allow(unused_imports)]
31use alloc::{boxed::Box, string::{String, ToString}, vec::Vec};
32
33use core::time::Duration;
34use crate::Instant;
35
36use llimphi_text::peniko::kurbo::{Affine, Circle};
37use llimphi_text::peniko::{BlendMode, Color, Fill};
38use crate::Lienzo as Scene;
39
40use crate::{ComputedLayout, Mounted};
41
42/// Declara que este nodo emite un **ripple** (salpicadura Material) al recibir
43/// un press. `key` debe ser estable entre rebuilds del `View` (igual que la
44/// key de [`crate::Anim`]) — es lo que enlaza la salpicadura retenida con el
45/// nodo entre frames. `color` es el tinte de la onda (típicamente
46/// semitransparente, p. ej. blanco a alpha ~0.25 sobre superficies oscuras o
47/// negro a alpha ~0.12 sobre claras); su alpha se multiplica por el fade.
48#[derive(Clone, Copy, Debug)]
49pub struct Ripple {
50 pub key: u64,
51 pub color: Color,
52 pub duration: Duration,
53}
54
55/// Una salpicadura viva: el punto de origen **relativo al rect del nodo** al
56/// momento del press, su color/duración y el reloj de expansión.
57struct Splash {
58 key: u64,
59 /// Origen del tap relativo a la esquina superior-izquierda del rect del
60 /// nodo (mismo espacio que los handlers `*_at`). Se reancla al rect actual
61 /// del nodo en cada frame, así la onda sigue al nodo si éste se mueve.
62 lx: f32,
63 ly: f32,
64 color: Color,
65 start: Instant,
66 duration: Duration,
67 easing: fn(f32) -> f32,
68}
69
70impl Splash {
71 /// Progreso `[0,1]` sin easing (lineal en el tiempo).
72 fn raw(&self, now: Instant) -> f32 {
73 if self.duration.is_zero() {
74 return 1.0;
75 }
76 let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
77 (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0)
78 }
79
80 fn done(&self, now: Instant) -> bool {
81 now.saturating_duration_since(self.start) >= self.duration
82 }
83}
84
85/// Registro de ripples vivos, retenido por el runtime entre frames. Una
86/// instancia por ventana; el runtime llama [`Self::trigger`] en el press y
87/// [`Self::paint`] tras el paint del contenido.
88#[derive(Default)]
89pub struct RippleRegistry {
90 splashes: Vec<Splash>,
91}
92
93impl RippleRegistry {
94 pub fn new() -> Self {
95 Self::default()
96 }
97
98 /// Registra una salpicadura nueva sobre el nodo de key `key`, originada en
99 /// `(lx, ly)` relativo a su rect. `now` es el instante del press. Varios
100 /// presses rápidos apilan ondas concurrentes (como Material).
101 pub fn trigger(
102 &mut self,
103 key: u64,
104 lx: f32,
105 ly: f32,
106 color: Color,
107 duration: Duration,
108 now: Instant,
109 ) {
110 self.splashes.push(Splash {
111 key,
112 lx,
113 ly,
114 color,
115 start: now,
116 duration,
117 easing: crate::ease_out_cubic,
118 });
119 }
120
121 /// `true` si hay alguna salpicadura viva (el runtime ya lo sabe por el
122 /// retorno de [`Self::paint`], pero es cómodo para decidir antes).
123 pub fn animating(&self) -> bool {
124 !self.splashes.is_empty()
125 }
126
127 /// Pinta las salpicaduras vivas sobre `scene`, cada una como un círculo que
128 /// crece (radio con ease-out hasta cubrir el nodo) y se desvanece, recortado
129 /// al contorno redondeado del nodo. Resuelve el rect de cada nodo por su
130 /// `ripple.key` en `mounted`/`computed` (así sigue al nodo si se redimensiona).
131 /// Descarta las agotadas. Devuelve `true` si queda alguna viva → pedir frame.
132 ///
133 /// Llamar DESPUÉS del paint del contenido (la onda va encima, translúcida).
134 pub fn paint<Msg>(
135 &mut self,
136 scene: &mut Scene,
137 mounted: &Mounted<Msg>,
138 computed: &ComputedLayout,
139 now: Instant,
140 ) -> bool {
141 // Descarta primero las agotadas (no dependen del nodo).
142 self.splashes.retain(|s| !s.done(now));
143 if self.splashes.is_empty() {
144 return false;
145 }
146 for s in &self.splashes {
147 // Resuelve el nodo ripple de esta key (el primero que la declare).
148 let Some(node) = mounted.nodes.iter().find(|n| {
149 n.ripple.map(|r| r.key) == Some(s.key)
150 }) else {
151 continue;
152 };
153 let Some(r) = computed.get(node.id) else {
154 continue;
155 };
156 if r.w <= 0.0 || r.h <= 0.0 {
157 continue;
158 }
159 let cx = r.x as f64 + s.lx as f64;
160 let cy = r.y as f64 + s.ly as f64;
161 // Radio máximo = distancia al rincón más lejano, así la onda llega a
162 // cubrir todo el nodo cualquiera sea el punto de origen.
163 let corners = [
164 (r.x as f64, r.y as f64),
165 ((r.x + r.w) as f64, r.y as f64),
166 (r.x as f64, (r.y + r.h) as f64),
167 ((r.x + r.w) as f64, (r.y + r.h) as f64),
168 ];
169 let max_radius = corners
170 .iter()
171 .map(|(px, py)| {
172 let (dx, dy) = (px - cx, py - cy);
173 crate::sqrt_f64(dx * dx + dy * dy)
174 })
175 .fold(0.0_f64, f64::max);
176 let t = s.raw(now);
177 let radius = (s.easing)(t) as f64 * max_radius;
178 if radius <= 0.0 {
179 continue;
180 }
181 // Fade: la onda arranca a su alpha y se apaga al expandirse.
182 let fade = 1.0 - t;
183 let mut col = s.color;
184 col.components[3] *= fade;
185 if col.components[3] <= 0.0 {
186 continue;
187 }
188 // Recorte al contorno del nodo (respeta radio/esquinas), para que la
189 // onda no sangre fuera de un botón redondeado.
190 let rrect = crate::render::node_rrect(
191 r.x as f64,
192 r.y as f64,
193 (r.x + r.w) as f64,
194 (r.y + r.h) as f64,
195 node.radius,
196 node.corner_radii,
197 0.0,
198 );
199 scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, Affine::IDENTITY, &rrect);
200 let circle = Circle::new((cx, cy), radius);
201 scene.fill(Fill::NonZero, Affine::IDENTITY, col, None, &circle);
202 scene.pop_layer();
203 }
204 !self.splashes.is_empty()
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::{mount, View};
212 use llimphi_layout::taffy::prelude::*;
213 use llimphi_layout::{LayoutTree, Style};
214
215 fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color {
216 Color::from_rgba8(r, g, b, a)
217 }
218
219 /// Monta un botón 100×100 con ripple(key=5) y devuelve (mounted, computed).
220 fn boton() -> (Mounted<()>, ComputedLayout) {
221 let v = View::<()>::new(Style {
222 size: Size { width: length(100.0), height: length(100.0) },
223 ..Default::default()
224 })
225 .ripple(5, rgba(255, 255, 255, 80));
226 let mut layout = LayoutTree::new();
227 let m = mount(&mut layout, v);
228 let c = layout.compute(m.root, (200.0, 200.0)).expect("layout");
229 (m, c)
230 }
231
232 #[test]
233 fn sin_trigger_no_anima() {
234 let mut reg = RippleRegistry::new();
235 let (m, c) = boton();
236 let mut scene = Scene::new();
237 assert!(!reg.paint(&mut scene, &m, &c, Instant::now()));
238 assert!(!reg.animating());
239 }
240
241 #[test]
242 fn trigger_anima_y_se_autodetiene() {
243 let mut reg = RippleRegistry::new();
244 let (m, c) = boton();
245 let t0 = Instant::now();
246 reg.trigger(5, 50.0, 50.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0);
247 assert!(reg.animating(), "tras el trigger hay onda viva");
248 let mut scene = Scene::new();
249 // A mitad de la duración sigue animando.
250 assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(100)));
251 // Pasada la duración, se descarta y el ticker para.
252 assert!(!reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(250)));
253 assert!(!reg.animating());
254 }
255
256 #[test]
257 fn presses_concurrentes_apilan_ondas() {
258 let mut reg = RippleRegistry::new();
259 let t0 = Instant::now();
260 reg.trigger(5, 10.0, 10.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0);
261 reg.trigger(5, 90.0, 90.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0 + Duration::from_millis(20));
262 assert_eq!(reg.splashes.len(), 2);
263 let (m, c) = boton();
264 let mut scene = Scene::new();
265 // En t0+100 la primera vive (80ms restantes) y la segunda también.
266 assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(100)));
267 assert_eq!(reg.splashes.len(), 2);
268 // En t0+210 la primera murió (210>200) pero la segunda vive (190<200).
269 assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(210)));
270 assert_eq!(reg.splashes.len(), 1);
271 }
272
273 #[test]
274 fn key_inexistente_se_descarta_al_agotarse_sin_panico() {
275 // Una onda cuya key no existe en el árbol no debe pintar ni panico;
276 // simplemente no encuentra nodo y se descarta cuando su reloj vence.
277 let mut reg = RippleRegistry::new();
278 let t0 = Instant::now();
279 reg.trigger(999, 0.0, 0.0, rgba(255, 255, 255, 80), Duration::from_millis(100), t0);
280 let (m, c) = boton();
281 let mut scene = Scene::new();
282 assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(50)));
283 assert!(!reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(150)));
284 }
285}