llimphi_compositor/ripple.rs
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
29use std::time::{Duration, Instant};
30
31use vello::kurbo::{Affine, Circle};
32use vello::peniko::{BlendMode, Color, Fill};
33use vello::Scene;
34
35use crate::{ComputedLayout, Mounted};
36
37/// Declara que este nodo emite un **ripple** (salpicadura Material) al recibir
38/// un press. `key` debe ser estable entre rebuilds del `View` (igual que la
39/// key de [`crate::Anim`]) — es lo que enlaza la salpicadura retenida con el
40/// nodo entre frames. `color` es el tinte de la onda (típicamente
41/// semitransparente, p. ej. blanco a alpha ~0.25 sobre superficies oscuras o
42/// negro a alpha ~0.12 sobre claras); su alpha se multiplica por el fade.
43#[derive(Clone, Copy, Debug)]
44pub struct Ripple {
45 pub key: u64,
46 pub color: Color,
47 pub duration: Duration,
48}
49
50/// Una salpicadura viva: el punto de origen **relativo al rect del nodo** al
51/// momento del press, su color/duración y el reloj de expansión.
52struct Splash {
53 key: u64,
54 /// Origen del tap relativo a la esquina superior-izquierda del rect del
55 /// nodo (mismo espacio que los handlers `*_at`). Se reancla al rect actual
56 /// del nodo en cada frame, así la onda sigue al nodo si éste se mueve.
57 lx: f32,
58 ly: f32,
59 color: Color,
60 start: Instant,
61 duration: Duration,
62 easing: fn(f32) -> f32,
63}
64
65impl Splash {
66 /// Progreso `[0,1]` sin easing (lineal en el tiempo).
67 fn raw(&self, now: Instant) -> f32 {
68 if self.duration.is_zero() {
69 return 1.0;
70 }
71 let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
72 (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0)
73 }
74
75 fn done(&self, now: Instant) -> bool {
76 now.saturating_duration_since(self.start) >= self.duration
77 }
78}
79
80/// Registro de ripples vivos, retenido por el runtime entre frames. Una
81/// instancia por ventana; el runtime llama [`Self::trigger`] en el press y
82/// [`Self::paint`] tras el paint del contenido.
83#[derive(Default)]
84pub struct RippleRegistry {
85 splashes: Vec<Splash>,
86}
87
88impl RippleRegistry {
89 pub fn new() -> Self {
90 Self::default()
91 }
92
93 /// Registra una salpicadura nueva sobre el nodo de key `key`, originada en
94 /// `(lx, ly)` relativo a su rect. `now` es el instante del press. Varios
95 /// presses rápidos apilan ondas concurrentes (como Material).
96 pub fn trigger(
97 &mut self,
98 key: u64,
99 lx: f32,
100 ly: f32,
101 color: Color,
102 duration: Duration,
103 now: Instant,
104 ) {
105 self.splashes.push(Splash {
106 key,
107 lx,
108 ly,
109 color,
110 start: now,
111 duration,
112 easing: crate::ease_out_cubic,
113 });
114 }
115
116 /// `true` si hay alguna salpicadura viva (el runtime ya lo sabe por el
117 /// retorno de [`Self::paint`], pero es cómodo para decidir antes).
118 pub fn animating(&self) -> bool {
119 !self.splashes.is_empty()
120 }
121
122 /// Pinta las salpicaduras vivas sobre `scene`, cada una como un círculo que
123 /// crece (radio con ease-out hasta cubrir el nodo) y se desvanece, recortado
124 /// al contorno redondeado del nodo. Resuelve el rect de cada nodo por su
125 /// `ripple.key` en `mounted`/`computed` (así sigue al nodo si se redimensiona).
126 /// Descarta las agotadas. Devuelve `true` si queda alguna viva → pedir frame.
127 ///
128 /// Llamar DESPUÉS del paint del contenido (la onda va encima, translúcida).
129 pub fn paint<Msg>(
130 &mut self,
131 scene: &mut Scene,
132 mounted: &Mounted<Msg>,
133 computed: &ComputedLayout,
134 now: Instant,
135 ) -> bool {
136 // Descartá primero las agotadas (no dependen del nodo).
137 self.splashes.retain(|s| !s.done(now));
138 if self.splashes.is_empty() {
139 return false;
140 }
141 for s in &self.splashes {
142 // Resolvé el nodo ripple de esta key (el primero que la declare).
143 let Some(node) = mounted.nodes.iter().find(|n| {
144 n.ripple.map(|r| r.key) == Some(s.key)
145 }) else {
146 continue;
147 };
148 let Some(r) = computed.get(node.id) else {
149 continue;
150 };
151 if r.w <= 0.0 || r.h <= 0.0 {
152 continue;
153 }
154 let cx = r.x as f64 + s.lx as f64;
155 let cy = r.y as f64 + s.ly as f64;
156 // Radio máximo = distancia al rincón más lejano, así la onda llega a
157 // cubrir todo el nodo cualquiera sea el punto de origen.
158 let corners = [
159 (r.x as f64, r.y as f64),
160 ((r.x + r.w) as f64, r.y as f64),
161 (r.x as f64, (r.y + r.h) as f64),
162 ((r.x + r.w) as f64, (r.y + r.h) as f64),
163 ];
164 let max_radius = corners
165 .iter()
166 .map(|(px, py)| ((px - cx).powi(2) + (py - cy).powi(2)).sqrt())
167 .fold(0.0_f64, f64::max);
168 let t = s.raw(now);
169 let radius = (s.easing)(t) as f64 * max_radius;
170 if radius <= 0.0 {
171 continue;
172 }
173 // Fade: la onda arranca a su alpha y se apaga al expandirse.
174 let fade = 1.0 - t;
175 let mut col = s.color;
176 col.components[3] *= fade;
177 if col.components[3] <= 0.0 {
178 continue;
179 }
180 // Recorte al contorno del nodo (respeta radio/esquinas), para que la
181 // onda no sangre fuera de un botón redondeado.
182 let rrect = crate::render::node_rrect(
183 r.x as f64,
184 r.y as f64,
185 (r.x + r.w) as f64,
186 (r.y + r.h) as f64,
187 node.radius,
188 node.corner_radii,
189 0.0,
190 );
191 scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, Affine::IDENTITY, &rrect);
192 let circle = Circle::new((cx, cy), radius);
193 scene.fill(Fill::NonZero, Affine::IDENTITY, col, None, &circle);
194 scene.pop_layer();
195 }
196 !self.splashes.is_empty()
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::{mount, View};
204 use llimphi_layout::taffy::prelude::*;
205 use llimphi_layout::{LayoutTree, Style};
206
207 fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color {
208 Color::from_rgba8(r, g, b, a)
209 }
210
211 /// Monta un botón 100×100 con ripple(key=5) y devuelve (mounted, computed).
212 fn boton() -> (Mounted<()>, ComputedLayout) {
213 let v = View::<()>::new(Style {
214 size: Size { width: length(100.0), height: length(100.0) },
215 ..Default::default()
216 })
217 .ripple(5, rgba(255, 255, 255, 80));
218 let mut layout = LayoutTree::new();
219 let m = mount(&mut layout, v);
220 let c = layout.compute(m.root, (200.0, 200.0)).expect("layout");
221 (m, c)
222 }
223
224 #[test]
225 fn sin_trigger_no_anima() {
226 let mut reg = RippleRegistry::new();
227 let (m, c) = boton();
228 let mut scene = Scene::new();
229 assert!(!reg.paint(&mut scene, &m, &c, Instant::now()));
230 assert!(!reg.animating());
231 }
232
233 #[test]
234 fn trigger_anima_y_se_autodetiene() {
235 let mut reg = RippleRegistry::new();
236 let (m, c) = boton();
237 let t0 = Instant::now();
238 reg.trigger(5, 50.0, 50.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0);
239 assert!(reg.animating(), "tras el trigger hay onda viva");
240 let mut scene = Scene::new();
241 // A mitad de la duración sigue animando.
242 assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(100)));
243 // Pasada la duración, se descarta y el ticker para.
244 assert!(!reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(250)));
245 assert!(!reg.animating());
246 }
247
248 #[test]
249 fn presses_concurrentes_apilan_ondas() {
250 let mut reg = RippleRegistry::new();
251 let t0 = Instant::now();
252 reg.trigger(5, 10.0, 10.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0);
253 reg.trigger(5, 90.0, 90.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0 + Duration::from_millis(20));
254 assert_eq!(reg.splashes.len(), 2);
255 let (m, c) = boton();
256 let mut scene = Scene::new();
257 // En t0+100 la primera vive (80ms restantes) y la segunda también.
258 assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(100)));
259 assert_eq!(reg.splashes.len(), 2);
260 // En t0+210 la primera murió (210>200) pero la segunda vive (190<200).
261 assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(210)));
262 assert_eq!(reg.splashes.len(), 1);
263 }
264
265 #[test]
266 fn key_inexistente_se_descarta_al_agotarse_sin_panico() {
267 // Una onda cuya key no existe en el árbol no debe pintar ni panico;
268 // simplemente no encuentra nodo y se descarta cuando su reloj vence.
269 let mut reg = RippleRegistry::new();
270 let t0 = Instant::now();
271 reg.trigger(999, 0.0, 0.0, rgba(255, 255, 255, 80), Duration::from_millis(100), t0);
272 let (m, c) = boton();
273 let mut scene = Scene::new();
274 assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(50)));
275 assert!(!reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(150)));
276 }
277}