tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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}