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-range-slider` — slider de dos thumbs sobre un track.
2//!
3//! Análogo a `RangeSlider` de Flutter o `RangeSlider` de Material 3.
4//! El caller mantiene `(lo, hi)` en su modelo (fracciones en `[0,1]`);
5//! el widget reporta el nuevo valor por `on_change(lo, hi)` cuando el
6//! usuario arrastra cualquiera de los dos thumbs.
7//!
8//! Diseño:
9//! - Track de 4 px, franja activa del color `accent` entre los dos thumbs.
10//! - Thumbs de 14 px (círculos), borde 2 px del color `accent`,
11//! sombra E1 — la firma de elevación canónica.
12//! - Drag con `draggable` por thumb → callback recibe (lo, hi)
13//! normalizado y monotónicamente ordenado (el bajo nunca supera al alto).
14
15#![forbid(unsafe_code)]
16
17
18// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
19// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
20// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
21#![no_std]
22
23extern crate alloc;
24
25use alloc::vec;
26use alloc::format;
27use alloc::string::ToString;
28use llimphi_compositor::llimphi_layout::taffy::{
29 prelude::{length, percent, Size, Style},
30 Position,
31};
32use llimphi_compositor::peniko::Color;
33use llimphi_compositor::{DragPhase, Shadow, View};
34use llimphi_theme::{elevation, Theme};
35
36/// Paleta del range slider.
37#[derive(Debug, Clone, Copy)]
38pub struct RangeSliderPalette {
39 pub track_idle: Color,
40 pub track_active: Color,
41 pub thumb_fill: Color,
42 pub thumb_stroke: Color,
43}
44
45impl RangeSliderPalette {
46 pub fn from_theme(t: &Theme) -> Self {
47 Self {
48 track_idle: t.border,
49 track_active: t.accent,
50 thumb_fill: t.bg_panel,
51 thumb_stroke: t.accent,
52 }
53 }
54}
55
56/// Compone un range slider de ancho `width_px`, alto 28 px.
57/// `lo`/`hi` son fracciones en `[0,1]`. `on_change(lo, hi)` se dispara
58/// con cada delta de drag.
59pub fn range_slider_view<Msg, F>(
60 lo: f32,
61 hi: f32,
62 width_px: f32,
63 palette: &RangeSliderPalette,
64 on_change: F,
65) -> View<Msg>
66where
67 Msg: Clone + 'static,
68 F: Fn(f32, f32) -> Msg + Clone + Send + Sync + 'static,
69{
70 let lo = lo.clamp(0.0, 1.0);
71 let hi = hi.clamp(0.0, 1.0);
72 let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) };
73
74 let (a, blur, dy) = elevation::E1;
75 let thumb_shadow = Shadow {
76 color: Color::from_rgba8(0, 0, 0, a),
77 blur,
78 dx: 0.0,
79 dy,
80 spread: 0.0,
81 };
82
83 let track_w = width_px.max(1.0);
84 let lo_x = lo * track_w;
85 let hi_x = hi * track_w;
86 let active_w = (hi_x - lo_x).max(0.0);
87
88 let track_idle = View::new(Style {
89 position: Position::Absolute,
90 size: Size { width: percent(1.0_f32), height: length(4.0_f32) },
91 inset: llimphi_compositor::llimphi_layout::taffy::Rect {
92 left: length(0.0_f32),
93 right: length(0.0_f32),
94 top: length(12.0_f32),
95 bottom: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
96 },
97 ..Default::default()
98 })
99 .fill(palette.track_idle)
100 .radius(2.0);
101
102 let track_active = View::new(Style {
103 position: Position::Absolute,
104 size: Size { width: length(active_w), height: length(4.0_f32) },
105 inset: llimphi_compositor::llimphi_layout::taffy::Rect {
106 left: length(lo_x),
107 right: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
108 top: length(12.0_f32),
109 bottom: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
110 },
111 ..Default::default()
112 })
113 .fill(palette.track_active)
114 .radius(2.0);
115
116 let mk_thumb = |left_px: f32,
117 is_lo: bool,
118 on_change: F,
119 lo: f32,
120 hi: f32,
121 track_w: f32| {
122 // ACUMULADOR de deltas (igual que el `slider` hermano): durante un
123 // drag el runtime reusa este handler con la vista CONGELADA, y los
124 // `dx` llegan por-evento (no acumulados). Sin acumular, `lo + dx/w`
125 // usa el `lo` fijo del press + sólo el último delta → el thumb queda
126 // casi inmóvil. Sumamos los deltas dentro de la instancia del handler.
127 // El acumulador del drag, sin `Mutex`: un `AtomicU32` que guarda los bits
128 // del `f32`. No es un capricho no_std — el handler corre en el hilo de la UI
129 // y jamás compite, así que el mutex sólo pagaba la portabilidad que ahora
130 // paga el átomo. `Relaxed` alcanza por lo mismo: no hay otro hilo que
131 // ordenar.
132 let acc = alloc::sync::Arc::new(core::sync::atomic::AtomicU32::new(0.0_f32.to_bits()));
133 View::new(Style {
134 position: Position::Absolute,
135 size: Size { width: length(14.0_f32), height: length(14.0_f32) },
136 inset: llimphi_compositor::llimphi_layout::taffy::Rect {
137 left: length(left_px - 7.0),
138 right: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
139 top: length(7.0_f32),
140 bottom: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
141 },
142 ..Default::default()
143 })
144 .fill(palette.thumb_fill)
145 .radius(7.0)
146 .border(2.0, palette.thumb_stroke)
147 .shadow(thumb_shadow)
148 .draggable(move |phase: DragPhase, dx: f32, _dy: f32| {
149 use core::sync::atomic::Ordering::Relaxed;
150 let mut a = f32::from_bits(acc.load(Relaxed));
151 a += dx / track_w;
152 let dfrac = a;
153 if matches!(phase, DragPhase::End) {
154 a = 0.0; // listo para el próximo drag
155 }
156 acc.store(a.to_bits(), Relaxed);
157 match phase {
158 DragPhase::Move => {
159 let (new_lo, new_hi) = apply_drag(is_lo, lo, hi, dfrac);
160 Some(on_change(new_lo, new_hi))
161 }
162 DragPhase::End => None,
163 }
164 })
165 .cursor(llimphi_compositor::Cursor::Pointer)
166 };
167
168 let thumb_lo = mk_thumb(lo_x, true, on_change.clone(), lo, hi, track_w);
169 let thumb_hi = mk_thumb(hi_x, false, on_change, lo, hi, track_w);
170
171 let aria_value = format!("{:.0}%–{:.0}%", lo * 100.0, hi * 100.0);
172 View::new(Style {
173 size: Size { width: length(width_px), height: length(28.0_f32) },
174 ..Default::default()
175 })
176 // a11y: el widget entero se anuncia como deslizador de rango con su
177 // valor "lo–hi" (antes NO tenía rol ni aria — el slider simple sí).
178 .role(llimphi_compositor::Role::Slider)
179 .aria_label("rango".to_string())
180 .aria_value(aria_value)
181 .children(vec![track_idle, track_active, thumb_lo, thumb_hi])
182}
183
184/// Aplica un delta fraccional al thumb `lo` o `hi` desde su base, con clamp
185/// monotónico (el bajo nunca supera al alto ni sale de `[0,1]`, y viceversa).
186/// Pura y testeable — la usa el handler de drag tras acumular los deltas.
187pub fn apply_drag(is_lo: bool, lo: f32, hi: f32, dfrac: f32) -> (f32, f32) {
188 if is_lo {
189 ((lo + dfrac).clamp(0.0, hi), hi)
190 } else {
191 (lo, (hi + dfrac).clamp(lo, 1.0))
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn drag_lo_no_supera_al_hi() {
201 // Empujar el thumb bajo muy a la derecha: se clampea al hi, no lo cruza.
202 let (lo, hi) = apply_drag(true, 0.3, 0.6, 0.9);
203 assert_eq!(hi, 0.6);
204 assert!((lo - 0.6).abs() < 1e-6, "lo se clampea a hi (dio {lo})");
205 }
206
207 #[test]
208 fn drag_hi_no_baja_del_lo_ni_pasa_de_uno() {
209 let (lo, hi) = apply_drag(false, 0.3, 0.6, -0.9);
210 assert_eq!(lo, 0.3);
211 assert!((hi - 0.3).abs() < 1e-6, "hi se clampea a lo (dio {hi})");
212 let (_, hi2) = apply_drag(false, 0.3, 0.6, 0.9);
213 assert!((hi2 - 1.0).abs() < 1e-6, "hi se clampea a 1.0 (dio {hi2})");
214 }
215
216 #[test]
217 fn drag_pequeno_mueve_proporcional() {
218 let (lo, hi) = apply_drag(true, 0.30, 0.80, 0.10);
219 assert!((lo - 0.40).abs() < 1e-6, "lo = base + dfrac (dio {lo})");
220 assert_eq!(hi, 0.80);
221 }
222}