Skip to main content

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}