tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-compositor/src/hero.rs Rust · 278 lines · 11.8 KB History Who touched what raw

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

1//! **Hero / shared-element transitions** — un mismo nodo lógico (key estable)
2//! que aparece en posiciones distintas entre frames "vuela" del rect anterior
3//! al actual en vez de saltar. Es el Hero de Flutter auténtico.
4//!
5//! Modelo:
6//! - El caller marca un nodo con [`View::hero(key, duration)`](crate::View::hero).
7//!   `key` enlaza "el mismo nodo lógico" entre dos `view()` distintos (entre
8//!   rutas, paneles, layouts) — dos nodos con la misma `key` en frames distintos
9//!   son la misma identidad para el runtime.
10//! - El runtime mantiene una instancia de [`HeroRegistry`] entre frames y llama
11//!   [`HeroRegistry::reconcile`] DESPUÉS de `compute` y ANTES de `paint`. Por
12//!   cada nodo hero:
13//!   - Lee su rect absoluto del [`ComputedLayout`].
14//!   - Si en el frame anterior la misma `key` vivió en un rect distinto,
15//!     arranca un tween: durante `duration`, escribe en `node.transform` una
16//!     afín que "lleva visualmente" el nodo del rect actual al rect anterior y
17//!     converge a `IDENTITY`. El nodo se ve VOLAR del rect anterior al actual.
18//!   - Mientras el tween esté vivo, devuelve `true` y el runtime pide otro
19//!     frame (ticker autodetenido).
20//! - Al asentarse, deja `node.transform = None`: cero costo de transform
21//!   residual en frames posteriores.
22//!
23//! No depende de [`crate::AnimRegistry`] — el wiring es independiente; sólo
24//! reusa el campo `transform` del [`MountedNode`](crate::MountedNode), que el
25//! `paint` ya respeta como cualquier otro afín.
26//!
27//! ## Reglas de uso
28//!
29//! - `key` debe ser estable y **única** entre los nodos hero presentes en un
30//!   mismo frame. Dos hero con la misma key en el mismo árbol generan
31//!   ambigüedad; el runtime se queda con la última que recorra.
32//! - El rect "anterior" es el del frame anterior — no funciona como
33//!   shared-element entre dos *vistas montadas a la vez* (eso requeriría dos
34//!   rect simultáneos por key). Funciona entre transiciones de rutas.
35
36// Preludio de `alloc`: bajo `no_std` estos tipos ya no vienen solos.
37#[allow(unused_imports)]
38use alloc::{boxed::Box, string::{String, ToString}, vec::Vec};
39
40use hashbrown::HashMap;
41use core::time::Duration;
42use crate::Instant;
43
44use llimphi_layout::{ComputedLayout, Rect};
45use llimphi_text::peniko::kurbo::Affine;
46
47/// Declara un nodo como **hero**: la `key` enlaza la identidad entre frames; si
48/// el rect cambia, el runtime anima la transición.
49#[derive(Clone, Copy, Debug)]
50pub struct Hero {
51    pub key: u64,
52    pub duration: Duration,
53    /// Easing aplicado a `t ∈ [0,1]`. Por defecto, los setters de [`View`]
54    /// usan un ease-out cúbico (igual que las animaciones implícitas).
55    pub easing: fn(f32) -> f32,
56}
57
58/// Registro de heroes, vivo entre frames. Guarda el último rect por `key` para
59/// detectar el delta y un tween activo si está animando.
60#[derive(Default)]
61pub struct HeroRegistry {
62    /// Último rect donde se pintó un nodo con esta `key`. Se actualiza en cada
63    /// `reconcile`. Es contra esto que detectamos el cambio que dispara el
64    /// tween.
65    last: HashMap<u64, Rect>,
66    /// Tweens en curso. Cada uno conoce su `from_rect`, el reloj y el easing.
67    /// Una key con tween activo NO arranca uno nuevo si vuelve a moverse —
68    /// reusamos el `from_rect` original para que la trayectoria sea continua
69    /// (si el target cambia a mitad, vuela hacia el nuevo destino, no cambia
70    /// el origen).
71    tweens: HashMap<u64, Tween>,
72}
73
74struct Tween {
75    from_rect: Rect,
76    start: Instant,
77    duration: Duration,
78    easing: fn(f32) -> f32,
79}
80
81impl HeroRegistry {
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// Reconcilia heroes con el árbol montado. Para cada nodo con [`Hero`]:
87    /// - Si el rect cambió respecto del frame anterior, arranca tween.
88    /// - Si hay tween activo y vivo, escribe `node.transform` con la afín
89    ///   interpolada (cur → from).
90    /// - Cuando el tween termina, lo limpia y deja `node.transform = None`.
91    ///
92    /// Llamar DESPUÉS de `compute` y ANTES de `paint`. Devuelve `true` si
93    /// algún tween sigue en curso → el runtime pide otro frame.
94    pub fn reconcile<Msg>(
95        &mut self,
96        mounted: &mut crate::Mounted<Msg>,
97        computed: &ComputedLayout,
98        now: Instant,
99    ) -> bool {
100        let mut animating = false;
101        let mut seen: Vec<u64> = Vec::new();
102        for node in &mut mounted.nodes {
103            let Some(hero) = node.hero else { continue };
104            let Some(cur) = computed.get(node.id) else { continue };
105            seen.push(hero.key);
106
107            // ¿Cambió el rect respecto del último frame? Arrancar tween (si no
108            // hay uno activo; si lo hay, no re-resetamos el origen).
109            if let Some(last) = self.last.get(&hero.key).copied() {
110                if last != cur && !self.tweens.contains_key(&hero.key) {
111                    self.tweens.insert(
112                        hero.key,
113                        Tween {
114                            from_rect: last,
115                            start: now,
116                            duration: hero.duration,
117                            easing: hero.easing,
118                        },
119                    );
120                }
121            }
122
123            // Aplicar tween si está vivo. Calcula la afín que mapea `cur` al
124            // `from_rect` y la interpola hacia identidad a medida que `t` crece.
125            if let Some(tw) = self.tweens.get(&hero.key) {
126                let elapsed = now.saturating_duration_since(tw.start).as_secs_f32();
127                let raw = (elapsed / tw.duration.as_secs_f32().max(1e-6)).clamp(0.0, 1.0);
128                if raw >= 1.0 {
129                    // Aterrizó: dejamos el nodo sin transform y limpiamos.
130                    node.transform = None;
131                    self.tweens.remove(&hero.key);
132                } else {
133                    let t = (tw.easing)(raw);
134                    let back = back_transform(cur, tw.from_rect);
135                    let xf = lerp_affine(back, Affine::IDENTITY, t);
136                    node.transform = Some(xf);
137                    animating = true;
138                }
139            }
140
141            self.last.insert(hero.key, cur);
142        }
143        // Las keys que no aparecieron este frame se descartan (un hero que se
144        // va deja de recordarse; si vuelve, su rect "anterior" será el nuevo
145        // primero — no anima desde el último que tuvo hace varios frames).
146        if self.last.len() != seen.len() {
147            self.last.retain(|k, _| seen.contains(k));
148            self.tweens.retain(|k, _| seen.contains(k));
149        }
150        animating
151    }
152}
153
154/// Afín local que, aplicada con [`View::transform`]'s convención (alrededor
155/// del centro del rect actual), mapea visualmente cada punto del `cur_rect`
156/// al punto correspondiente del `from_rect`. Es la base de un "fly":
157/// el nodo se pinta en `cur` pero con esta xf VOLVIÓ a `from` —
158/// interpolando hacia identidad, "vuela" de `from` a `cur`.
159fn back_transform(cur: Rect, from: Rect) -> Affine {
160    // El compositor aplica xf como `T(centro_cur) · xf_local · T(-centro_cur)`,
161    // así que xf_local debe ser `scale + translate` que mapea:
162    //   esquina superior izquierda de cur → esquina superior izquierda de from.
163    //
164    // Si scale = (from.w/cur.w, from.h/cur.h) y t = (cx_from - cx_cur,
165    // cy_from - cy_cur), entonces `T(t) · S` cumple esa propiedad (despejo en
166    // los comentarios del módulo).
167    let sx = (from.w as f64) / (cur.w as f64).max(1e-6);
168    let sy = (from.h as f64) / (cur.h as f64).max(1e-6);
169    let cx_cur = (cur.x + cur.w * 0.5) as f64;
170    let cy_cur = (cur.y + cur.h * 0.5) as f64;
171    let cx_from = (from.x + from.w * 0.5) as f64;
172    let cy_from = (from.y + from.h * 0.5) as f64;
173    Affine::translate((cx_from - cx_cur, cy_from - cy_cur)) * Affine::scale_non_uniform(sx, sy)
174}
175
176/// Lerp componente-a-componente de las 6 coefs del afín. Idéntica al helper
177/// privado de [`crate::anim`] — vive separada para mantener el módulo `hero`
178/// auto-contenido (sin acoplar a Anim).
179fn lerp_affine(a: Affine, b: Affine, t: f32) -> Affine {
180    let p = a.as_coeffs();
181    let q = b.as_coeffs();
182    let ft = t as f64;
183    Affine::new([
184        p[0] + (q[0] - p[0]) * ft,
185        p[1] + (q[1] - p[1]) * ft,
186        p[2] + (q[2] - p[2]) * ft,
187        p[3] + (q[3] - p[3]) * ft,
188        p[4] + (q[4] - p[4]) * ft,
189        p[5] + (q[5] - p[5]) * ft,
190    ])
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::{mount, View};
197    use llimphi_layout::{LayoutTree, Style};
198    use llimphi_layout::taffy::prelude::length;
199    use llimphi_layout::taffy::Size;
200
201    /// Monta un único nodo hero con su Style + key=1 + dur=200ms. Devuelve
202    /// `Mounted` y el `ComputedLayout` ya resuelto contra un viewport de
203    /// 1000×1000 — los rects salen del propio Style.
204    fn one(x: f32, y: f32, w: f32, h: f32) -> (crate::Mounted<()>, ComputedLayout) {
205        let v = View::<()>::new(Style {
206            size: Size { width: length(w), height: length(h) },
207            inset: llimphi_layout::taffy::Rect {
208                left: length(x),
209                top: length(y),
210                right: llimphi_layout::taffy::prelude::auto(),
211                bottom: llimphi_layout::taffy::prelude::auto(),
212            },
213            position: llimphi_layout::taffy::Position::Absolute,
214            ..Default::default()
215        })
216        .hero(1, Duration::from_millis(200));
217        let mut layout = LayoutTree::new();
218        let mounted = mount(&mut layout, v);
219        let computed = layout
220            .compute(mounted.root, (1000.0_f32, 1000.0_f32))
221            .expect("layout");
222        (mounted, computed)
223    }
224
225    #[test]
226    fn primera_aparicion_no_anima() {
227        let mut reg = HeroRegistry::new();
228        let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
229        let animating = reg.reconcile(&mut m, &c, Instant::now());
230        assert!(!animating, "primera aparición no debe animar");
231        assert!(m.nodes[0].transform.is_none(), "sin xf en primer frame");
232    }
233
234    #[test]
235    fn cambio_de_rect_arranca_tween_y_aplica_xf() {
236        let mut reg = HeroRegistry::new();
237        let t0 = Instant::now();
238        // Frame 1: rect (10, 10, 50, 50).
239        let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
240        reg.reconcile(&mut m, &c, t0);
241        // Frame 2: el nodo ahora vive en (200, 200, 100, 100) → arranca tween.
242        let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
243        let animating = reg.reconcile(&mut m, &c, t0 + Duration::from_millis(50));
244        assert!(animating, "cambio de rect → tween");
245        let xf = m.nodes[0].transform.expect("xf");
246        // A 50ms en una anim de 200ms, raw ≈ 0.25; con ease-out cúbico t > 0.25.
247        // La afín NO debe ser identidad (algún coef se ve).
248        let c = xf.as_coeffs();
249        assert!(c[0] != 1.0 || c[3] != 1.0 || c[4] != 0.0 || c[5] != 0.0,
250                "xf no debe ser identidad a mitad del tween: {:?}", c);
251    }
252
253    #[test]
254    fn al_terminar_limpia_la_xf() {
255        let mut reg = HeroRegistry::new();
256        let t0 = Instant::now();
257        let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
258        reg.reconcile(&mut m, &c, t0);
259        let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
260        reg.reconcile(&mut m, &c, t0 + Duration::from_millis(10));
261        // Pasada la duración: el tween se descarta y deja el nodo sin xf.
262        let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
263        let animating = reg.reconcile(&mut m, &c, t0 + Duration::from_millis(500));
264        assert!(!animating);
265        assert!(m.nodes[0].transform.is_none());
266    }
267
268    #[test]
269    fn back_transform_es_identidad_si_los_rects_coinciden() {
270        let r = Rect { x: 50.0, y: 50.0, w: 100.0, h: 100.0 };
271        let xf = back_transform(r, r);
272        let c = xf.as_coeffs();
273        assert!((c[0] - 1.0).abs() < 1e-9);
274        assert!((c[3] - 1.0).abs() < 1e-9);
275        assert!(c[4].abs() < 1e-9);
276        assert!(c[5].abs() < 1e-9);
277    }
278}