Skip to main content

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}