llimphi_widget_timeline/lib.rs
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
28use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, Size, Style};
29use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect};
30use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
31use llimphi_ui::View;
32
33/// Paleta + dimensiones del timeline. Las medidas viajan acá (igual que
34/// `SliderPalette`) porque definen cómo se ve la barra — el caller no
35/// toca el `Style` directamente.
36#[derive(Debug, Clone, Copy)]
37pub struct TimelinePalette {
38 /// Color de la pista de fondo (el track entero).
39 pub track: Color,
40 /// Color del tramo recorrido (de 0 al playhead).
41 pub fill: Color,
42 /// Color del playhead (la barrita vertical en la posición actual).
43 pub knob: Color,
44 /// Color de las marcas (bookmarks) pintadas sobre la barra.
45 pub mark: Color,
46 /// Alto total del widget en pixels.
47 pub height: f32,
48 /// Radio de las esquinas del track.
49 pub radius: f64,
50}
51
52impl Default for TimelinePalette {
53 fn default() -> Self {
54 Self::from_theme(&llimphi_theme::Theme::dark())
55 }
56}
57
58impl TimelinePalette {
59 /// Construye la paleta desde un `Theme` semántico.
60 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
61 Self {
62 track: t.bg_button,
63 fill: t.accent,
64 knob: t.fg_text,
65 // Ámbar cálido para que las marcas resalten tanto sobre la pista
66 // de fondo como sobre el tramo recorrido (accent).
67 mark: Color::from_rgba8(255, 196, 84, 255),
68 height: 14.0,
69 radius: 7.0,
70 }
71 }
72}
73
74/// Compone una barra de progreso clickeable.
75///
76/// `progress` es la fracción recorrida (`0.0..=1.0`); se clampea. El
77/// handler `on_seek` recibe la fracción `0.0..=1.0` donde el usuario
78/// clickeó (`local_x / ancho`) y devuelve el `Msg` a despachar (o `None`
79/// para ignorar el click). El widget es stateless: redibujá pasando un
80/// `progress` nuevo en cada frame y el playhead avanza solo.
81pub fn timeline_view<Msg, F>(progress: f32, palette: &TimelinePalette, on_seek: F) -> View<Msg>
82where
83 Msg: 'static,
84 F: Fn(f32) -> Option<Msg> + Send + Sync + 'static,
85{
86 timeline_view_marked(progress, &[], palette, on_seek)
87}
88
89/// Igual que [`timeline_view`] pero pinta además unas **marcas** (bookmarks)
90/// en las fracciones `marks` (`0.0..=1.0`): finas barras verticales con el
91/// color `palette.mark`, bajo el playhead. Las fracciones fuera de rango se
92/// ignoran. El resto del comportamiento (recorrido, playhead, click-to-seek)
93/// es idéntico — `timeline_view` es este con `marks` vacío.
94pub fn timeline_view_marked<Msg, F>(
95 progress: f32,
96 marks: &[f32],
97 palette: &TimelinePalette,
98 on_seek: F,
99) -> View<Msg>
100where
101 Msg: 'static,
102 F: Fn(f32) -> Option<Msg> + Send + Sync + 'static,
103{
104 let p = progress.clamp(0.0, 1.0);
105 let fill_color = palette.fill;
106 let knob_color = palette.knob;
107 let mark_color = palette.mark;
108 let marks: Vec<f32> = marks
109 .iter()
110 .copied()
111 .filter(|f| f.is_finite() && (0.0..=1.0).contains(f))
112 .collect();
113 View::new(Style {
114 size: Size {
115 width: percent(1.0_f32),
116 height: length(palette.height),
117 },
118 ..Default::default()
119 })
120 .fill(palette.track)
121 .radius(palette.radius)
122 .paint_with(move |scene, _ts, rect| {
123 if rect.w <= 2.0 || rect.h <= 2.0 {
124 return;
125 }
126 let pad: f32 = 2.0;
127 let x0 = rect.x + pad;
128 let y0 = rect.y + pad;
129 let w = (rect.w - 2.0 * pad).max(1.0);
130 let h = (rect.h - 2.0 * pad).max(1.0);
131 // Tramo recorrido.
132 let fw = (w * p).max(0.0);
133 if fw > 0.5 {
134 let fill = Rect::new(x0 as f64, y0 as f64, (x0 + fw) as f64, (y0 + h) as f64);
135 scene.fill(Fill::NonZero, Affine::IDENTITY, fill_color, None, &fill);
136 }
137 // Marcas — finas barras verticales en cada fracción (bajo el playhead).
138 let mw: f32 = 2.0;
139 for f in &marks {
140 let mx = x0 + w * f;
141 let mark = Rect::new(
142 (mx - mw * 0.5) as f64,
143 y0 as f64,
144 (mx + mw * 0.5) as f64,
145 (y0 + h) as f64,
146 );
147 scene.fill(Fill::NonZero, Affine::IDENTITY, mark_color, None, &mark);
148 }
149 // Playhead — fina barra vertical en la posición actual.
150 let kx = x0 + fw;
151 let kw: f32 = 3.0;
152 let knob = Rect::new(
153 (kx - kw * 0.5) as f64,
154 y0 as f64,
155 (kx + kw * 0.5) as f64,
156 (y0 + h) as f64,
157 );
158 scene.fill(Fill::NonZero, Affine::IDENTITY, knob_color, None, &knob);
159 })
160 .on_click_at(move |lx, _ly, w, _h| {
161 if w <= 0.0 {
162 return None;
163 }
164 on_seek((lx / w).clamp(0.0, 1.0))
165 })
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 // Msg de prueba: el handler reporta la fracción clickeada.
173 #[derive(Debug, PartialEq)]
174 struct Seek(f32);
175
176 #[test]
177 fn from_theme_usa_colores_semanticos() {
178 let t = llimphi_theme::Theme::dark();
179 let p = TimelinePalette::from_theme(&t);
180 assert_eq!(p.track, t.bg_button);
181 assert_eq!(p.fill, t.accent);
182 assert_eq!(p.knob, t.fg_text);
183 }
184
185 #[test]
186 fn construye_sin_panic_en_extremos() {
187 // El widget se arma para fracciones fuera de rango (se clampea
188 // internamente al pintar) sin reventar.
189 let pal = TimelinePalette::default();
190 let _ = timeline_view(-0.5, &pal, |f| Some(Seek(f)));
191 let _ = timeline_view(0.0, &pal, |f| Some(Seek(f)));
192 let _ = timeline_view(1.0, &pal, |f| Some(Seek(f)));
193 let _ = timeline_view(2.0, &pal, |f| Some(Seek(f)));
194 }
195}