gioser

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ 5ae7c737031521b9a873f3d7cff36261fbf7ae06 widgets/skeleton/src/lib.rs Rust · 145 líneas · 5.2 KB Historia Quién tocó qué crudo

feat: llimphi standalone — framework UI soberano extraído del monorepo e65e9cc62 · 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
21use std::time::Instant;
22
23use llimphi_ui::llimphi_layout::taffy::{
24    prelude::{length, percent, Size, Style},
25    Position,
26};
27use llimphi_ui::llimphi_raster::peniko::Color;
28use llimphi_ui::View;
29use llimphi_theme::{radius, Theme};
30
31/// Paleta del skeleton — dos tonos entre los que oscila.
32#[derive(Debug, Clone, Copy)]
33pub struct SkeletonPalette {
34    pub low: Color,
35    pub high: Color,
36}
37
38impl SkeletonPalette {
39    pub fn from_theme(t: &Theme) -> Self {
40        Self {
41            low: t.bg_panel_alt,
42            high: t.bg_button_hover,
43        }
44    }
45}
46
47/// Período del shimmer en segundos — un ciclo completo de la banda
48/// cruzando el rect. 1.4s es el sweet spot: rápido para señalar
49/// "esto se está cargando", lento para no marear.
50const SHIMMER_CYCLE_SECS: f32 = 1.4;
51/// Ancho de la banda como fracción del ancho del rect. 50% da una
52/// transición suave; bajar a 30% da un destello más puntual.
53const SHIMMER_BAND_FRAC: f64 = 0.5;
54/// Ancho mínimo absoluto de la banda — evita que en skeletons cortos
55/// (avatares chicos, line skeletons de ~80px) el destello sea un
56/// pixel apretado.
57const SHIMMER_BAND_MIN_PX: f64 = 40.0;
58
59/// Bloque rectangular animado. La altura y forma viene del `Style`
60/// que pasa el caller — el skeleton sólo aporta el `fill` animado.
61///
62/// Devuelve un `View` con `paint_with` que pinta una banda de
63/// gradiente atravesando el rect. Para usarlo dentro de un layout
64/// con tamaño definido, envolvelo en un contenedor con el `Style`
65/// adecuado.
66pub fn skeleton_view<Msg: Clone + 'static>(palette: &SkeletonPalette) -> View<Msg> {
67    let started = Instant::now();
68    let p = *palette;
69    View::new(Style {
70        position: Position::Absolute,
71        size: Size {
72            width: percent(1.0_f32),
73            height: percent(1.0_f32),
74        },
75        ..Default::default()
76    })
77    .paint_with(move |scene, _ts, rect| {
78        use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect};
79        use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient};
80
81        if rect.w <= 0.0 || rect.h <= 0.0 {
82            return;
83        }
84
85        // Progress del ciclo en [0, 1).
86        let elapsed = started.elapsed().as_secs_f32();
87        let progress = (elapsed / SHIMMER_CYCLE_SECS).fract() as f64;
88
89        // Banda: ancho relativo al rect (con floor mínimo) que arranca
90        // a la izquierda del rect y termina a la derecha. Distancia
91        // total recorrida = rect.w + band_w, así el destello entra y
92        // sale por completo.
93        let rect_w = rect.w as f64;
94        let band_w = (rect_w * SHIMMER_BAND_FRAC).max(SHIMMER_BAND_MIN_PX);
95        let travel = rect_w + band_w;
96        let band_left = rect.x as f64 - band_w + progress * travel;
97        let band_right = band_left + band_w;
98        let cy = (rect.y + rect.h * 0.5) as f64;
99
100        // Single fill: gradient lineal con stops [low, high, low]. Fuera
101        // de [band_left, band_right] el Extend::Pad (default de peniko)
102        // extiende los stops endpoint — ambos `low` — así el resto del
103        // rect queda en `low` sin necesidad de un fill base separado.
104        let rr = RoundedRect::new(
105            rect.x as f64,
106            rect.y as f64,
107            (rect.x + rect.w) as f64,
108            (rect.y + rect.h) as f64,
109            radius::SM,
110        );
111        let gradient = Gradient::new_linear(
112            Point::new(band_left, cy),
113            Point::new(band_right, cy),
114        )
115        .with_stops([p.low, p.high, p.low].as_slice());
116        scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr);
117    })
118}
119
120/// Caja con tamaño explícito (ancho + alto en px) + skeleton adentro.
121/// Helper para casos comunes: line skeleton (`skeleton_line_view(160)`).
122pub fn skeleton_box_view<Msg: Clone + 'static>(
123    width_px: f32,
124    height_px: f32,
125    palette: &SkeletonPalette,
126) -> View<Msg> {
127    View::new(Style {
128        size: Size {
129            width: length(width_px),
130            height: length(height_px),
131        },
132        flex_shrink: 0.0,
133        ..Default::default()
134    })
135    .children(vec![skeleton_view(palette)])
136}
137
138/// Línea horizontal típica para texto en carga (height fijo ~12px).
139pub fn skeleton_line_view<Msg: Clone + 'static>(
140    width_px: f32,
141    palette: &SkeletonPalette,
142) -> View<Msg> {
143    skeleton_box_view(width_px, 12.0, palette)
144}
145