tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main widgets/timeline/src/lib.rs Rust · 210 líneas · 7.2 KB Historia Quién tocó qué crudo

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

1//! `llimphi-widget-timeline` — barra de progreso/scrub clickeable.
2//!
3//! Pattern análogo a `llimphi-widget-slider`/`-progress`: el widget **no
4//! mantiene estado**. El caller guarda la posición actual en su `Model`,
5//! le pasa la **fracción de avance** (`0.0..=1.0` = posición/duración) y un
6//! handler `Fn(f32) -> Option<Msg>` que recibe la fracción **donde el
7//! usuario clickeó** (scrub absoluto, estilo VLC). El widget no sabe de
8//! tiempo ni de duración: sólo pinta el avance y reporta dónde se clickeó
9//! como fracción del ancho de la barra (`on_click_at`). Quien mapea esa
10//! fracción a un seek concreto es la app.
11//!
12//! ```text
13//!   [ ██████████▏░░░░░░░░░░░░ ]
14//!        recorrido  playhead   resto
15//! ```
16//!
17//! Uso típico (reproductor):
18//!
19//! ```ignore
20//! let frac = pos.as_secs_f64() / dur.as_secs_f64();
21//! timeline_view(frac as f32, &TimelinePalette::default(), |f| {
22//!     Some(Msg::Command(MediaCommand::SeekTo { fraction: f }))
23//! })
24//! ```
25
26#![forbid(unsafe_code)]
27
28
29// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
30// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
31// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
32#![no_std]
33
34extern crate alloc;
35
36use alloc::vec::Vec;
37use alloc::format;
38use llimphi_compositor::llimphi_layout::taffy::prelude::{length, percent, Size, Style};
39use llimphi_compositor::peniko::kurbo::{Affine, Rect};
40use llimphi_compositor::peniko::{Color, Fill};
41use llimphi_compositor::View;
42
43/// Paleta + dimensiones del timeline. Las medidas viajan aquí (igual que
44/// `SliderPalette`) porque definen cómo se ve la barra — el caller no
45/// toca el `Style` directamente.
46#[derive(Debug, Clone, Copy)]
47pub struct TimelinePalette {
48    /// Color de la pista de fondo (el track entero).
49    pub track: Color,
50    /// Color del tramo recorrido (de 0 al playhead).
51    pub fill: Color,
52    /// Color del playhead (la barrita vertical en la posición actual).
53    pub knob: Color,
54    /// Color de las marcas (bookmarks) pintadas sobre la barra.
55    pub mark: Color,
56    /// Alto total del widget en pixels.
57    pub height: f32,
58    /// Radio de las esquinas del track.
59    pub radius: f64,
60}
61
62impl Default for TimelinePalette {
63    fn default() -> Self {
64        Self::from_theme(&llimphi_theme::Theme::dark())
65    }
66}
67
68impl TimelinePalette {
69    /// Construye la paleta desde un `Theme` semántico.
70    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
71        Self {
72            track: t.bg_button,
73            fill: t.accent,
74            knob: t.fg_text,
75            // Ámbar cálido para que las marcas resalten tanto sobre la pista
76            // de fondo como sobre el tramo recorrido (accent).
77            mark: Color::from_rgba8(255, 196, 84, 255),
78            height: 14.0,
79            radius: 7.0,
80        }
81    }
82}
83
84/// Compone una barra de progreso clickeable.
85///
86/// `progress` es la fracción recorrida (`0.0..=1.0`); se clampea. El
87/// handler `on_seek` recibe la fracción `0.0..=1.0` donde el usuario
88/// clickeó (`local_x / ancho`) y devuelve el `Msg` a despachar (o `None`
89/// para ignorar el click). El widget es stateless: redibuja pasando un
90/// `progress` nuevo en cada frame y el playhead avanza solo.
91pub fn timeline_view<Msg, F>(progress: f32, palette: &TimelinePalette, on_seek: F) -> View<Msg>
92where
93    Msg: 'static,
94    F: Fn(f32) -> Option<Msg> + Send + Sync + 'static,
95{
96    timeline_view_marked(progress, &[], palette, on_seek)
97}
98
99/// Igual que [`timeline_view`] pero pinta además unas **marcas** (bookmarks)
100/// en las fracciones `marks` (`0.0..=1.0`): finas barras verticales con el
101/// color `palette.mark`, bajo el playhead. Las fracciones fuera de rango se
102/// ignoran. El resto del comportamiento (recorrido, playhead, click-to-seek)
103/// es idéntico — `timeline_view` es este con `marks` vacío.
104pub fn timeline_view_marked<Msg, F>(
105    progress: f32,
106    marks: &[f32],
107    palette: &TimelinePalette,
108    on_seek: F,
109) -> View<Msg>
110where
111    Msg: 'static,
112    F: Fn(f32) -> Option<Msg> + Send + Sync + 'static,
113{
114    let p = progress.clamp(0.0, 1.0);
115    let fill_color = palette.fill;
116    let knob_color = palette.knob;
117    let mark_color = palette.mark;
118    let marks: Vec<f32> = marks
119        .iter()
120        .copied()
121        .filter(|f| f.is_finite() && (0.0..=1.0).contains(f))
122        .collect();
123    View::new(Style {
124        size: Size {
125            width: percent(1.0_f32),
126            height: length(palette.height),
127        },
128        ..Default::default()
129    })
130    .fill(palette.track)
131    .radius(palette.radius)
132    .paint_with(move |scene, _ts, rect| {
133        if rect.w <= 2.0 || rect.h <= 2.0 {
134            return;
135        }
136        let pad: f32 = 2.0;
137        let x0 = rect.x + pad;
138        let y0 = rect.y + pad;
139        let w = (rect.w - 2.0 * pad).max(1.0);
140        let h = (rect.h - 2.0 * pad).max(1.0);
141        // Tramo recorrido.
142        let fw = (w * p).max(0.0);
143        if fw > 0.5 {
144            let fill = Rect::new(x0 as f64, y0 as f64, (x0 + fw) as f64, (y0 + h) as f64);
145            scene.fill(Fill::NonZero, Affine::IDENTITY, fill_color, None, &fill);
146        }
147        // Marcas — finas barras verticales en cada fracción (bajo el playhead).
148        let mw: f32 = 2.0;
149        for f in &marks {
150            let mx = x0 + w * f;
151            let mark = Rect::new(
152                (mx - mw * 0.5) as f64,
153                y0 as f64,
154                (mx + mw * 0.5) as f64,
155                (y0 + h) as f64,
156            );
157            scene.fill(Fill::NonZero, Affine::IDENTITY, mark_color, None, &mark);
158        }
159        // Playhead — fina barra vertical en la posición actual.
160        let kx = x0 + fw;
161        let kw: f32 = 3.0;
162        let knob = Rect::new(
163            (kx - kw * 0.5) as f64,
164            y0 as f64,
165            (kx + kw * 0.5) as f64,
166            (y0 + h) as f64,
167        );
168        scene.fill(Fill::NonZero, Affine::IDENTITY, knob_color, None, &knob);
169    })
170    .on_click_at(move |lx, _ly, w, _h| {
171        if w <= 0.0 {
172            return None;
173        }
174        on_seek((lx / w).clamp(0.0, 1.0))
175    })
176    // Toda la barra se pinta con `paint_with`: sin rol es un nodo vacío pese a
177    // ser el control principal de un reproductor.
178    .role(llimphi_compositor::Role::Slider)
179    .aria_label("Posición")
180    .aria_value(format!("{}%", (p * 100.0).round() as i32))
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    // Msg de prueba: el handler reporta la fracción clickeada.
188    #[derive(Debug, PartialEq)]
189    struct Seek(f32);
190
191    #[test]
192    fn from_theme_usa_colores_semanticos() {
193        let t = llimphi_theme::Theme::dark();
194        let p = TimelinePalette::from_theme(&t);
195        assert_eq!(p.track, t.bg_button);
196        assert_eq!(p.fill, t.accent);
197        assert_eq!(p.knob, t.fg_text);
198    }
199
200    #[test]
201    fn construye_sin_panic_en_extremos() {
202        // El widget se arma para fracciones fuera de rango (se clampea
203        // internamente al pintar) sin reventar.
204        let pal = TimelinePalette::default();
205        let _ = timeline_view(-0.5, &pal, |f| Some(Seek(f)));
206        let _ = timeline_view(0.0, &pal, |f| Some(Seek(f)));
207        let _ = timeline_view(1.0, &pal, |f| Some(Seek(f)));
208        let _ = timeline_view(2.0, &pal, |f| Some(Seek(f)));
209    }
210}