tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 5ae7c737031521b9a873f3d7cff36261fbf7ae06
widgets/skeleton/src/lib.rs
Historia
Quién tocó qué
crudo
feat: llimphi standalone — framework UI soberano extraído del monorepo
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