//! `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` 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)] // Sin `std`: este widget no usa nada del sistema —sólo `alloc` para // `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula // WASM o al kernel. Antes enlazaba `std` por omisión (W4.6). #![no_std] extern crate alloc; use alloc::vec::Vec; use alloc::format; use llimphi_compositor::llimphi_layout::taffy::prelude::{length, percent, Size, Style}; use llimphi_compositor::peniko::kurbo::{Affine, Rect}; use llimphi_compositor::peniko::{Color, Fill}; use llimphi_compositor::View; /// Paleta + dimensiones del timeline. Las medidas viajan aquí (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, /// Color de las marcas (bookmarks) pintadas sobre la barra. pub mark: 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, // Ámbar cálido para que las marcas resalten tanto sobre la pista // de fondo como sobre el tramo recorrido (accent). mark: Color::from_rgba8(255, 196, 84, 255), 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: redibuja pasando un /// `progress` nuevo en cada frame y el playhead avanza solo. pub fn timeline_view(progress: f32, palette: &TimelinePalette, on_seek: F) -> View where Msg: 'static, F: Fn(f32) -> Option + Send + Sync + 'static, { timeline_view_marked(progress, &[], palette, on_seek) } /// Igual que [`timeline_view`] pero pinta además unas **marcas** (bookmarks) /// en las fracciones `marks` (`0.0..=1.0`): finas barras verticales con el /// color `palette.mark`, bajo el playhead. Las fracciones fuera de rango se /// ignoran. El resto del comportamiento (recorrido, playhead, click-to-seek) /// es idéntico — `timeline_view` es este con `marks` vacío. pub fn timeline_view_marked( progress: f32, marks: &[f32], palette: &TimelinePalette, on_seek: F, ) -> View where Msg: 'static, F: Fn(f32) -> Option + Send + Sync + 'static, { let p = progress.clamp(0.0, 1.0); let fill_color = palette.fill; let knob_color = palette.knob; let mark_color = palette.mark; let marks: Vec = marks .iter() .copied() .filter(|f| f.is_finite() && (0.0..=1.0).contains(f)) .collect(); 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); } // Marcas — finas barras verticales en cada fracción (bajo el playhead). let mw: f32 = 2.0; for f in &marks { let mx = x0 + w * f; let mark = Rect::new( (mx - mw * 0.5) as f64, y0 as f64, (mx + mw * 0.5) as f64, (y0 + h) as f64, ); scene.fill(Fill::NonZero, Affine::IDENTITY, mark_color, None, &mark); } // 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)) }) // Toda la barra se pinta con `paint_with`: sin rol es un nodo vacío pese a // ser el control principal de un reproductor. .role(llimphi_compositor::Role::Slider) .aria_label("Posición") .aria_value(format!("{}%", (p * 100.0).round() as i32)) } #[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))); } }