tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 484Markdown 258TOML 131RON 38118 Definiciones

⎇ main widgets/range-slider/src/lib.rs Rust · 222 líneas · 7.9 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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}