feat: llimphi standalone — framework UI soberano extraído del monorepo

Motor gráfico Llimphi como workspace independiente: bucle Elm
(input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley.
Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets
+ módulos, sin dependencias al resto del monorepo. cargo check --workspace
pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-skeleton"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-skeleton — bloque animado con shimmer para placeholders de contenido en carga. Alternativa a spinner cuando se conoce la forma del contenido (lista de N items, card con título+texto+imagen)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+145
View File
@@ -0,0 +1,145 @@
//! `llimphi-widget-skeleton` — placeholder de carga con shimmer.
//!
//! Cuando una pantalla está cargando contenido cuya forma es predecible
//! (ej. una lista de 5 cards, un avatar+nombre+timestamp), un skeleton
//! es más informativo que un spinner: el usuario ya ve QUÉ vendrá,
//! sólo no tiene los valores reales todavía.
//!
//! El brillo (shimmer) viene de una **banda de gradiente que cruza** el
//! rect de izquierda a derecha cíclicamente. Los stops son
//! `[low, high, low]` sobre una franja del ~50% del ancho, con `Extend::Pad`
//! por default — fuera de la banda el rect queda en `low`, dentro el
//! `high` pinta el destello. Es el patrón canónico de Material/Apple/
//! sistemas modernos, más legible que la oscilación uniforme previa.
//!
//! Como `spinner`, requiere que la app fuerce redraws periódicos para
//! que la animación corra (típico: `Handle::spawn_periodic(50ms, …)`
//! mientras hay skeletons visibles).
#![forbid(unsafe_code)]
use std::time::Instant;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Size, Style},
Position,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_theme::{radius, Theme};
/// Paleta del skeleton — dos tonos entre los que oscila.
#[derive(Debug, Clone, Copy)]
pub struct SkeletonPalette {
pub low: Color,
pub high: Color,
}
impl SkeletonPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
low: t.bg_panel_alt,
high: t.bg_button_hover,
}
}
}
/// Período del shimmer en segundos — un ciclo completo de la banda
/// cruzando el rect. 1.4s es el sweet spot: rápido para señalar
/// "esto se está cargando", lento para no marear.
const SHIMMER_CYCLE_SECS: f32 = 1.4;
/// Ancho de la banda como fracción del ancho del rect. 50% da una
/// transición suave; bajar a 30% da un destello más puntual.
const SHIMMER_BAND_FRAC: f64 = 0.5;
/// Ancho mínimo absoluto de la banda — evita que en skeletons cortos
/// (avatares chicos, line skeletons de ~80px) el destello sea un
/// pixel apretado.
const SHIMMER_BAND_MIN_PX: f64 = 40.0;
/// Bloque rectangular animado. La altura y forma viene del `Style`
/// que pasa el caller — el skeleton sólo aporta el `fill` animado.
///
/// Devuelve un `View` con `paint_with` que pinta una banda de
/// gradiente atravesando el rect. Para usarlo dentro de un layout
/// con tamaño definido, envolvelo en un contenedor con el `Style`
/// adecuado.
pub fn skeleton_view<Msg: Clone + 'static>(palette: &SkeletonPalette) -> View<Msg> {
let started = Instant::now();
let p = *palette;
View::new(Style {
position: Position::Absolute,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect};
use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient};
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
// Progress del ciclo en [0, 1).
let elapsed = started.elapsed().as_secs_f32();
let progress = (elapsed / SHIMMER_CYCLE_SECS).fract() as f64;
// Banda: ancho relativo al rect (con floor mínimo) que arranca
// a la izquierda del rect y termina a la derecha. Distancia
// total recorrida = rect.w + band_w, así el destello entra y
// sale por completo.
let rect_w = rect.w as f64;
let band_w = (rect_w * SHIMMER_BAND_FRAC).max(SHIMMER_BAND_MIN_PX);
let travel = rect_w + band_w;
let band_left = rect.x as f64 - band_w + progress * travel;
let band_right = band_left + band_w;
let cy = (rect.y + rect.h * 0.5) as f64;
// Single fill: gradient lineal con stops [low, high, low]. Fuera
// de [band_left, band_right] el Extend::Pad (default de peniko)
// extiende los stops endpoint — ambos `low` — así el resto del
// rect queda en `low` sin necesidad de un fill base separado.
let rr = RoundedRect::new(
rect.x as f64,
rect.y as f64,
(rect.x + rect.w) as f64,
(rect.y + rect.h) as f64,
radius::SM,
);
let gradient = Gradient::new_linear(
Point::new(band_left, cy),
Point::new(band_right, cy),
)
.with_stops([p.low, p.high, p.low].as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr);
})
}
/// Caja con tamaño explícito (ancho + alto en px) + skeleton adentro.
/// Helper para casos comunes: line skeleton (`skeleton_line_view(160)`).
pub fn skeleton_box_view<Msg: Clone + 'static>(
width_px: f32,
height_px: f32,
palette: &SkeletonPalette,
) -> View<Msg> {
View::new(Style {
size: Size {
width: length(width_px),
height: length(height_px),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![skeleton_view(palette)])
}
/// Línea horizontal típica para texto en carga (height fijo ~12px).
pub fn skeleton_line_view<Msg: Clone + 'static>(
width_px: f32,
palette: &SkeletonPalette,
) -> View<Msg> {
skeleton_box_view(width_px, 12.0, palette)
}