tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ main widgets/skeleton/src/lib.rs Rust · 324 lines · 12.9 KB History Who touched what raw

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

1//! `llimphi-widget-skeleton` — placeholder de carga con shimmer.
2//!
3//! Cuando una pantalla está cargando contenido cuya forma es predecible
4//! (ej. una lista de 5 cards, un avatar+nombre+timestamp), un skeleton
5//! es más informativo que un spinner: el usuario ya ve QUÉ vendrá,
6//! sólo no tiene los valores reales todavía.
7//!
8//! El brillo (shimmer) viene de una **banda de gradiente que cruza** el
9//! rect de izquierda a derecha cíclicamente. Los stops son
10//! `[low, high, low]` sobre una franja del ~50% del ancho, con `Extend::Pad`
11//! por default — fuera de la banda el rect queda en `low`, dentro el
12//! `high` pinta el destello. Es el patrón canónico de Material/Apple/
13//! sistemas modernos, más legible que la oscilación uniforme previa.
14//!
15//! Como `spinner`, requiere que la app fuerce redraws periódicos para
16//! que la animación corra (típico: `Handle::spawn_periodic(50ms, …)`
17//! mientras hay skeletons visibles).
18
19#![forbid(unsafe_code)]
20
21
22// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
23// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
24// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
25#![no_std]
26
27extern crate alloc;
28
29// `vec!` y `length` los usan sólo las vistas gateadas a `std`
30// (`skeleton_box_view`); sin el gate, el perfil del kernel compila con dos
31// warnings de import muerto.
32#[cfg(feature = "std")]
33use alloc::vec;
34// El reloj del compositor: `web_time::Instant` bajo std, y SIN `now()` en el
35// perfil no_std — ahí el instante del fotograma entra por argumento, y un uso
36// equivocado es un error de compilación en vez de una animación congelada.
37use llimphi_compositor::Instant;
38
39#[cfg(feature = "std")]
40use llimphi_compositor::llimphi_layout::taffy::prelude::length;
41use llimphi_compositor::llimphi_layout::taffy::{
42    prelude::{percent, Size, Style},
43    Position,
44};
45use llimphi_compositor::peniko::Color;
46use llimphi_compositor::View;
47use llimphi_theme::{radius, Theme};
48
49/// Paleta del skeleton — dos tonos entre los que oscila.
50#[derive(Debug, Clone, Copy)]
51pub struct SkeletonPalette {
52    pub low: Color,
53    pub high: Color,
54}
55
56impl SkeletonPalette {
57    pub fn from_theme(t: &Theme) -> Self {
58        Self {
59            low: t.bg_panel_alt,
60            high: t.bg_button_hover,
61        }
62    }
63}
64
65/// Período del shimmer en segundos — un ciclo completo de la banda
66/// cruzando el rect. 1.4s es el sweet spot: rápido para señalar
67/// "esto se está cargando", lento para no marear.
68const SHIMMER_CYCLE_SECS: f32 = 1.4;
69/// Ancho de la banda como fracción del ancho del rect. 50% da una
70/// transición suave; bajar a 30% da un destello más puntual.
71const SHIMMER_BAND_FRAC: f64 = 0.5;
72/// Ancho mínimo absoluto de la banda — evita que en skeletons cortos
73/// (avatares chicos, line skeletons de ~80px) el destello sea un
74/// pixel apretado.
75const SHIMMER_BAND_MIN_PX: f64 = 40.0;
76
77
78/// Dónde está la banda del destello, en coordenadas del rect: `(izquierda,
79/// derecha)` en px absolutos.
80///
81/// `progreso` es la fase del ciclo en `[0, 1)`. La banda arranca **entera
82/// afuera** por la izquierda y termina entera afuera por la derecha (recorre
83/// `ancho + banda`), así el destello entra y sale completo en vez de aparecer
84/// ya empezado sobre el borde.
85///
86/// Es la aritmética que hasta ahora vivía suelta dentro del painter, sin un
87/// solo test: una banda que nunca entra, o que salta en el borde del ciclo, se
88/// ve como «el skeleton no brilla» y no hay nada que leer para saber por qué.
89pub fn banda_shimmer(ancho_rect: f64, progreso: f64) -> (f64, f64) {
90    let ancho_banda = (ancho_rect * SHIMMER_BAND_FRAC).max(SHIMMER_BAND_MIN_PX);
91    let recorrido = ancho_rect + ancho_banda;
92    let izq = -ancho_banda + progreso.clamp(0.0, 1.0) * recorrido;
93    (izq, izq + ancho_banda)
94}
95
96/// La fase del ciclo `[0, 1)` para un tiempo transcurrido en segundos.
97pub fn progreso_shimmer(segundos: f32) -> f64 {
98    if !segundos.is_finite() || segundos <= 0.0 {
99        return 0.0;
100    }
101    let ciclos = segundos / SHIMMER_CYCLE_SECS;
102    // `fract()` es de `std`; sin él, la parte fraccionaria a mano (el mismo
103    // motivo por el que el compositor tiene su propio `ceil`).
104    let enteros = ciclos as u64 as f32;
105    (ciclos - enteros).clamp(0.0, 1.0) as f64
106}
107
108/// Bloque rectangular animado. La altura y forma viene del `Style`
109/// que pasa el caller — el skeleton sólo aporta el `fill` animado.
110///
111/// Devuelve un `View` con `paint_with` que pinta una banda de
112/// gradiente atravesando el rect. Para usarlo dentro de un layout
113/// con tamaño definido, envolvelo en un contenedor con el `Style`
114/// adecuado.
115// GATEADO A `std`: esta vista arranca su reloj con `Instant::now()`, que en el
116// perfil sin sistema NO EXISTE a propósito — allá el instante del fotograma
117// entra por argumento, y un uso equivocado debe ser un error de compilación, no
118// una animación congelada. Cuando la jaula tenga reloj de fotograma, el camino
119// es pasar el `Instant` por parámetro, no inventarle un `now()` al kernel.
120#[cfg(feature = "std")]
121pub fn skeleton_view<Msg: Clone + 'static>(palette: &SkeletonPalette) -> View<Msg> {
122    let started = Instant::now();
123    let p = *palette;
124    View::new(Style {
125        position: Position::Absolute,
126        size: Size {
127            width: percent(1.0_f32),
128            height: percent(1.0_f32),
129        },
130        ..Default::default()
131    })
132    .paint_with(move |scene, _ts, rect| {
133        use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect};
134        use llimphi_compositor::peniko::{Fill, Gradient};
135
136        if rect.w <= 0.0 || rect.h <= 0.0 {
137            return;
138        }
139
140        // Fase del ciclo y posición de la banda — aritmética pura y testeada
141        // ([`progreso_shimmer`], [`banda_shimmer`]).
142        let progress = progreso_shimmer(started.elapsed().as_secs_f32());
143        let (izq, der) = banda_shimmer(rect.w as f64, progress);
144        let band_left = rect.x as f64 + izq;
145        let band_right = rect.x as f64 + der;
146        let cy = (rect.y + rect.h * 0.5) as f64;
147
148        // Single fill: gradient lineal con stops [low, high, low]. Fuera
149        // de [band_left, band_right] el Extend::Pad (default de peniko)
150        // extiende los stops endpoint — ambos `low` — así el resto del
151        // rect queda en `low` sin necesidad de un fill base separado.
152        let rr = RoundedRect::new(
153            rect.x as f64,
154            rect.y as f64,
155            (rect.x + rect.w) as f64,
156            (rect.y + rect.h) as f64,
157            radius::SM,
158        );
159        let gradient = Gradient::new_linear(
160            Point::new(band_left, cy),
161            Point::new(band_right, cy),
162        )
163        .with_stops([p.low, p.high, p.low].as_slice());
164        scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr);
165    })
166}
167
168/// Caja con tamaño explícito (ancho + alto en px) + skeleton adentro.
169/// Helper para casos comunes: line skeleton (`skeleton_line_view(160)`).
170// Llama a `skeleton_view`, que arranca reloj — mismo gate que ella.
171#[cfg(feature = "std")]
172pub fn skeleton_box_view<Msg: Clone + 'static>(
173    width_px: f32,
174    height_px: f32,
175    palette: &SkeletonPalette,
176) -> View<Msg> {
177    View::new(Style {
178        size: Size {
179            width: length(width_px),
180            height: length(height_px),
181        },
182        flex_shrink: 0.0,
183        ..Default::default()
184    })
185    .children(vec![skeleton_view(palette)])
186}
187
188/// Línea horizontal típica para texto en carga (height fijo ~12px).
189// Llama a `skeleton_view`, que arranca reloj — mismo gate que ella.
190#[cfg(feature = "std")]
191pub fn skeleton_line_view<Msg: Clone + 'static>(
192    width_px: f32,
193    palette: &SkeletonPalette,
194) -> View<Msg> {
195    skeleton_box_view(width_px, 12.0, palette)
196}
197
198/// [`skeleton_view`] con el reloj **inyectado**: el instante en que arrancó la
199/// carga y el del fotograma actual.
200///
201/// Es la única forma de que el skeleton brille sin `std`. `skeleton_view`
202/// arranca su propio reloj con `Instant::now()`, que en el perfil sin sistema
203/// **no existe a propósito** —el kernel de wawa arma su `Instant` desde el PIT—,
204/// así que allá el widget quedaba sin animación. Misma doctrina que el `today`
205/// del calendar o el `now` del text-input: el reloj entra por argumento.
206pub fn skeleton_view_en<Msg: Clone + 'static>(
207    inicio: Instant,
208    ahora: Instant,
209    palette: &SkeletonPalette,
210) -> View<Msg> {
211    let p = *palette;
212    let transcurrido = ahora.saturating_duration_since(inicio).as_secs_f32();
213    View::new(Style {
214        position: Position::Absolute,
215        size: Size {
216            width: percent(1.0_f32),
217            height: percent(1.0_f32),
218        },
219        ..Default::default()
220    })
221    .paint_with(move |scene, _ts, rect| {
222        use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect};
223        use llimphi_compositor::peniko::{Fill, Gradient};
224        if rect.w <= 0.0 || rect.h <= 0.0 {
225            return;
226        }
227        let (izq, der) = banda_shimmer(rect.w as f64, progreso_shimmer(transcurrido));
228        let cy = (rect.y + rect.h * 0.5) as f64;
229        let rr = RoundedRect::new(
230            rect.x as f64,
231            rect.y as f64,
232            (rect.x + rect.w) as f64,
233            (rect.y + rect.h) as f64,
234            radius::SM,
235        );
236        let gradient = Gradient::new_linear(
237            Point::new(rect.x as f64 + izq, cy),
238            Point::new(rect.x as f64 + der, cy),
239        )
240        .with_stops([p.low, p.high, p.low].as_slice());
241        scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr);
242    })
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn la_banda_entra_y_sale_entera_del_rect() {
251        let ancho = 200.0;
252        // Al arrancar el ciclo está TODA afuera por la izquierda: si empezara
253        // pisando el borde, el destello aparecería ya empezado.
254        let (i0, d0) = banda_shimmer(ancho, 0.0);
255        assert!(d0 <= 0.0, "al arrancar la banda toca el borde: {i0}..{d0}");
256        // Al final está toda afuera por la derecha.
257        let (i1, _) = banda_shimmer(ancho, 1.0);
258        assert!(i1 >= ancho, "al terminar todavía pisa el rect: {i1}");
259        // Y a mitad de camino cruza el medio.
260        let (i, d) = banda_shimmer(ancho, 0.5);
261        assert!(i < ancho / 2.0 && d > ancho / 2.0, "{i}..{d}");
262    }
263
264    #[test]
265    fn el_ancho_de_la_banda_tiene_piso_para_los_skeletons_cortos() {
266        // En un avatar chico, el 50% del ancho sería un destello de un píxel.
267        let (i, d) = banda_shimmer(20.0, 0.0);
268        assert!((d - i - SHIMMER_BAND_MIN_PX).abs() < 1e-6, "{}", d - i);
269        // En uno grande manda la fracción.
270        let (i, d) = banda_shimmer(400.0, 0.0);
271        assert!((d - i - 400.0 * SHIMMER_BAND_FRAC).abs() < 1e-6);
272    }
273
274    #[test]
275    fn la_banda_avanza_monotonamente_dentro_del_ciclo() {
276        let mut previa = f64::NEG_INFINITY;
277        for k in 0..=20 {
278            let (i, _) = banda_shimmer(200.0, k as f64 / 20.0);
279            assert!(i > previa, "el destello retrocedió en {k}/20");
280            previa = i;
281        }
282    }
283
284    #[test]
285    fn el_progreso_cicla_sin_saltar_hacia_atras_a_mitad_de_ciclo() {
286        assert_eq!(progreso_shimmer(0.0), 0.0);
287        // A mitad del período, media fase.
288        let mitad = progreso_shimmer(SHIMMER_CYCLE_SECS / 2.0);
289        assert!((mitad - 0.5).abs() < 1e-3, "{mitad}");
290        // Al completar un ciclo vuelve a cero (no a 1), que es lo que hace que
291        // el destello reaparezca por la izquierda en vez de quedarse clavado.
292        let vuelta = progreso_shimmer(SHIMMER_CYCLE_SECS);
293        assert!(vuelta < 1e-3, "{vuelta}");
294        // Y sigue ciclando en el segundo giro.
295        let seg = progreso_shimmer(SHIMMER_CYCLE_SECS * 1.25);
296        assert!((seg - 0.25).abs() < 1e-3, "{seg}");
297    }
298
299    #[test]
300    fn un_tiempo_absurdo_no_rompe_la_fase() {
301        // Un reloj que vuelve atrás o un NaN no pueden dejar la banda fuera de
302        // rango: en un kernel eso sería un gradiente con puntos infinitos.
303        assert_eq!(progreso_shimmer(-5.0), 0.0);
304        assert_eq!(progreso_shimmer(f32::NAN), 0.0);
305        let p = progreso_shimmer(f32::MAX);
306        assert!((0.0..=1.0).contains(&p), "{p}");
307    }
308
309    // Bajo `std` el `Instant` es el del anfitrión; sin `std` es el del kernel
310    // (µs desde el arranque). Lo que se prueba acá —que la fase salga de la
311    // RESTA de dos instantes— vale igual en los dos, así que se escribe con el
312    // que el test tiene a mano.
313    #[cfg(feature = "std")]
314    #[test]
315    fn el_reloj_inyectado_da_la_misma_fase_que_el_transcurrido() {
316        let inicio = Instant::now();
317        let medio = inicio + core::time::Duration::from_secs_f32(SHIMMER_CYCLE_SECS / 2.0);
318        let t = medio.saturating_duration_since(inicio).as_secs_f32();
319        assert!((progreso_shimmer(t) - 0.5).abs() < 1e-3, "{t}");
320        // Un reloj que va para atrás satura en cero en vez de entrar en pánico:
321        // en un kernel, que el tiempo retroceda no puede tumbar el compositor.
322        assert_eq!(inicio.saturating_duration_since(medio).as_secs_f32(), 0.0);
323    }
324}