//! `llimphi-widget-range-slider` — slider de dos thumbs sobre un track. //! //! Análogo a `RangeSlider` de Flutter o `RangeSlider` de Material 3. //! El caller mantiene `(lo, hi)` en su modelo (fracciones en `[0,1]`); //! el widget reporta el nuevo valor por `on_change(lo, hi)` cuando el //! usuario arrastra cualquiera de los dos thumbs. //! //! Diseño: //! - Track de 4 px, franja activa del color `accent` entre los dos thumbs. //! - Thumbs de 14 px (círculos), borde 2 px del color `accent`, //! sombra E1 — la firma de elevación canónica. //! - Drag con `draggable` por thumb → callback recibe (lo, hi) //! normalizado y monotónicamente ordenado (el bajo nunca supera al alto). #![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; use alloc::format; use alloc::string::ToString; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{length, percent, Size, Style}, Position, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::{DragPhase, Shadow, View}; use llimphi_theme::{elevation, Theme}; /// Paleta del range slider. #[derive(Debug, Clone, Copy)] pub struct RangeSliderPalette { pub track_idle: Color, pub track_active: Color, pub thumb_fill: Color, pub thumb_stroke: Color, } impl RangeSliderPalette { pub fn from_theme(t: &Theme) -> Self { Self { track_idle: t.border, track_active: t.accent, thumb_fill: t.bg_panel, thumb_stroke: t.accent, } } } /// Compone un range slider de ancho `width_px`, alto 28 px. /// `lo`/`hi` son fracciones en `[0,1]`. `on_change(lo, hi)` se dispara /// con cada delta de drag. pub fn range_slider_view( lo: f32, hi: f32, width_px: f32, palette: &RangeSliderPalette, on_change: F, ) -> View where Msg: Clone + 'static, F: Fn(f32, f32) -> Msg + Clone + Send + Sync + 'static, { let lo = lo.clamp(0.0, 1.0); let hi = hi.clamp(0.0, 1.0); let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) }; let (a, blur, dy) = elevation::E1; let thumb_shadow = Shadow { color: Color::from_rgba8(0, 0, 0, a), blur, dx: 0.0, dy, spread: 0.0, }; let track_w = width_px.max(1.0); let lo_x = lo * track_w; let hi_x = hi * track_w; let active_w = (hi_x - lo_x).max(0.0); let track_idle = View::new(Style { position: Position::Absolute, size: Size { width: percent(1.0_f32), height: length(4.0_f32) }, inset: llimphi_compositor::llimphi_layout::taffy::Rect { left: length(0.0_f32), right: length(0.0_f32), top: length(12.0_f32), bottom: llimphi_compositor::llimphi_layout::taffy::prelude::auto(), }, ..Default::default() }) .fill(palette.track_idle) .radius(2.0); let track_active = View::new(Style { position: Position::Absolute, size: Size { width: length(active_w), height: length(4.0_f32) }, inset: llimphi_compositor::llimphi_layout::taffy::Rect { left: length(lo_x), right: llimphi_compositor::llimphi_layout::taffy::prelude::auto(), top: length(12.0_f32), bottom: llimphi_compositor::llimphi_layout::taffy::prelude::auto(), }, ..Default::default() }) .fill(palette.track_active) .radius(2.0); let mk_thumb = |left_px: f32, is_lo: bool, on_change: F, lo: f32, hi: f32, track_w: f32| { // ACUMULADOR de deltas (igual que el `slider` hermano): durante un // drag el runtime reusa este handler con la vista CONGELADA, y los // `dx` llegan por-evento (no acumulados). Sin acumular, `lo + dx/w` // usa el `lo` fijo del press + sólo el último delta → el thumb queda // casi inmóvil. Sumamos los deltas dentro de la instancia del handler. // El acumulador del drag, sin `Mutex`: un `AtomicU32` que guarda los bits // del `f32`. No es un capricho no_std — el handler corre en el hilo de la UI // y jamás compite, así que el mutex sólo pagaba la portabilidad que ahora // paga el átomo. `Relaxed` alcanza por lo mismo: no hay otro hilo que // ordenar. let acc = alloc::sync::Arc::new(core::sync::atomic::AtomicU32::new(0.0_f32.to_bits())); View::new(Style { position: Position::Absolute, size: Size { width: length(14.0_f32), height: length(14.0_f32) }, inset: llimphi_compositor::llimphi_layout::taffy::Rect { left: length(left_px - 7.0), right: llimphi_compositor::llimphi_layout::taffy::prelude::auto(), top: length(7.0_f32), bottom: llimphi_compositor::llimphi_layout::taffy::prelude::auto(), }, ..Default::default() }) .fill(palette.thumb_fill) .radius(7.0) .border(2.0, palette.thumb_stroke) .shadow(thumb_shadow) .draggable(move |phase: DragPhase, dx: f32, _dy: f32| { use core::sync::atomic::Ordering::Relaxed; let mut a = f32::from_bits(acc.load(Relaxed)); a += dx / track_w; let dfrac = a; if matches!(phase, DragPhase::End) { a = 0.0; // listo para el próximo drag } acc.store(a.to_bits(), Relaxed); match phase { DragPhase::Move => { let (new_lo, new_hi) = apply_drag(is_lo, lo, hi, dfrac); Some(on_change(new_lo, new_hi)) } DragPhase::End => None, } }) .cursor(llimphi_compositor::Cursor::Pointer) }; let thumb_lo = mk_thumb(lo_x, true, on_change.clone(), lo, hi, track_w); let thumb_hi = mk_thumb(hi_x, false, on_change, lo, hi, track_w); let aria_value = format!("{:.0}%–{:.0}%", lo * 100.0, hi * 100.0); View::new(Style { size: Size { width: length(width_px), height: length(28.0_f32) }, ..Default::default() }) // a11y: el widget entero se anuncia como deslizador de rango con su // valor "lo–hi" (antes NO tenía rol ni aria — el slider simple sí). .role(llimphi_compositor::Role::Slider) .aria_label("rango".to_string()) .aria_value(aria_value) .children(vec![track_idle, track_active, thumb_lo, thumb_hi]) } /// Aplica un delta fraccional al thumb `lo` o `hi` desde su base, con clamp /// monotónico (el bajo nunca supera al alto ni sale de `[0,1]`, y viceversa). /// Pura y testeable — la usa el handler de drag tras acumular los deltas. pub fn apply_drag(is_lo: bool, lo: f32, hi: f32, dfrac: f32) -> (f32, f32) { if is_lo { ((lo + dfrac).clamp(0.0, hi), hi) } else { (lo, (hi + dfrac).clamp(lo, 1.0)) } } #[cfg(test)] mod tests { use super::*; #[test] fn drag_lo_no_supera_al_hi() { // Empujar el thumb bajo muy a la derecha: se clampea al hi, no lo cruza. let (lo, hi) = apply_drag(true, 0.3, 0.6, 0.9); assert_eq!(hi, 0.6); assert!((lo - 0.6).abs() < 1e-6, "lo se clampea a hi (dio {lo})"); } #[test] fn drag_hi_no_baja_del_lo_ni_pasa_de_uno() { let (lo, hi) = apply_drag(false, 0.3, 0.6, -0.9); assert_eq!(lo, 0.3); assert!((hi - 0.3).abs() < 1e-6, "hi se clampea a lo (dio {hi})"); let (_, hi2) = apply_drag(false, 0.3, 0.6, 0.9); assert!((hi2 - 1.0).abs() < 1e-6, "hi se clampea a 1.0 (dio {hi2})"); } #[test] fn drag_pequeno_mueve_proporcional() { let (lo, hi) = apply_drag(true, 0.30, 0.80, 0.10); assert!((lo - 0.40).abs() < 1e-6, "lo = base + dfrac (dio {lo})"); assert_eq!(hi, 0.80); } }