tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ main llimphi-compositor/src/ripple.rs Rust · 285 siq'ikuna · 11.1 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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}