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-timeline"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-timeline — barra de progreso/scrub clickeable (seek absoluto). El widget es stateless: el caller pasa la fracción de avance y un handler fracción→Msg."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+154
View File
@@ -0,0 +1,154 @@
//! `llimphi-widget-timeline` — barra de progreso/scrub clickeable.
//!
//! Pattern análogo a `llimphi-widget-slider`/`-progress`: el widget **no
//! mantiene estado**. El caller guarda la posición actual en su `Model`,
//! le pasa la **fracción de avance** (`0.0..=1.0` = posición/duración) y un
//! handler `Fn(f32) -> Option<Msg>` que recibe la fracción **donde el
//! usuario clickeó** (scrub absoluto, estilo VLC). El widget no sabe de
//! tiempo ni de duración: sólo pinta el avance y reporta dónde se clickeó
//! como fracción del ancho de la barra (`on_click_at`). Quien mapea esa
//! fracción a un seek concreto es la app.
//!
//! ```text
//! [ ██████████▏░░░░░░░░░░░░ ]
//! recorrido playhead resto
//! ```
//!
//! Uso típico (reproductor):
//!
//! ```ignore
//! let frac = pos.as_secs_f64() / dur.as_secs_f64();
//! timeline_view(frac as f32, &TimelinePalette::default(), |f| {
//! Some(Msg::Command(MediaCommand::SeekTo { fraction: f }))
//! })
//! ```
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, Size, Style};
use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::View;
/// Paleta + dimensiones del timeline. Las medidas viajan acá (igual que
/// `SliderPalette`) porque definen cómo se ve la barra — el caller no
/// toca el `Style` directamente.
#[derive(Debug, Clone, Copy)]
pub struct TimelinePalette {
/// Color de la pista de fondo (el track entero).
pub track: Color,
/// Color del tramo recorrido (de 0 al playhead).
pub fill: Color,
/// Color del playhead (la barrita vertical en la posición actual).
pub knob: Color,
/// Alto total del widget en pixels.
pub height: f32,
/// Radio de las esquinas del track.
pub radius: f64,
}
impl Default for TimelinePalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl TimelinePalette {
/// Construye la paleta desde un `Theme` semántico.
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
track: t.bg_button,
fill: t.accent,
knob: t.fg_text,
height: 14.0,
radius: 7.0,
}
}
}
/// Compone una barra de progreso clickeable.
///
/// `progress` es la fracción recorrida (`0.0..=1.0`); se clampea. El
/// handler `on_seek` recibe la fracción `0.0..=1.0` donde el usuario
/// clickeó (`local_x / ancho`) y devuelve el `Msg` a despachar (o `None`
/// para ignorar el click). El widget es stateless: redibujá pasando un
/// `progress` nuevo en cada frame y el playhead avanza solo.
pub fn timeline_view<Msg, F>(progress: f32, palette: &TimelinePalette, on_seek: F) -> View<Msg>
where
Msg: 'static,
F: Fn(f32) -> Option<Msg> + Send + Sync + 'static,
{
let p = progress.clamp(0.0, 1.0);
let fill_color = palette.fill;
let knob_color = palette.knob;
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(palette.height),
},
..Default::default()
})
.fill(palette.track)
.radius(palette.radius)
.paint_with(move |scene, _ts, rect| {
if rect.w <= 2.0 || rect.h <= 2.0 {
return;
}
let pad: f32 = 2.0;
let x0 = rect.x + pad;
let y0 = rect.y + pad;
let w = (rect.w - 2.0 * pad).max(1.0);
let h = (rect.h - 2.0 * pad).max(1.0);
// Tramo recorrido.
let fw = (w * p).max(0.0);
if fw > 0.5 {
let fill = Rect::new(x0 as f64, y0 as f64, (x0 + fw) as f64, (y0 + h) as f64);
scene.fill(Fill::NonZero, Affine::IDENTITY, fill_color, None, &fill);
}
// Playhead — fina barra vertical en la posición actual.
let kx = x0 + fw;
let kw: f32 = 3.0;
let knob = Rect::new(
(kx - kw * 0.5) as f64,
y0 as f64,
(kx + kw * 0.5) as f64,
(y0 + h) as f64,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, knob_color, None, &knob);
})
.on_click_at(move |lx, _ly, w, _h| {
if w <= 0.0 {
return None;
}
on_seek((lx / w).clamp(0.0, 1.0))
})
}
#[cfg(test)]
mod tests {
use super::*;
// Msg de prueba: el handler reporta la fracción clickeada.
#[derive(Debug, PartialEq)]
struct Seek(f32);
#[test]
fn from_theme_usa_colores_semanticos() {
let t = llimphi_theme::Theme::dark();
let p = TimelinePalette::from_theme(&t);
assert_eq!(p.track, t.bg_button);
assert_eq!(p.fill, t.accent);
assert_eq!(p.knob, t.fg_text);
}
#[test]
fn construye_sin_panic_en_extremos() {
// El widget se arma para fracciones fuera de rango (se clampea
// internamente al pintar) sin reventar.
let pal = TimelinePalette::default();
let _ = timeline_view(-0.5, &pal, |f| Some(Seek(f)));
let _ = timeline_view(0.0, &pal, |f| Some(Seek(f)));
let _ = timeline_view(1.0, &pal, |f| Some(Seek(f)));
let _ = timeline_view(2.0, &pal, |f| Some(Seek(f)));
}
}