gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main widgets/slider/src/lib.rs Rust · 546 siq'ikuna · 20.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) b575c63f1 · Sergio ·

1//! `llimphi-widget-slider` — slider horizontal con label + track + valor.
2//!
3//! Pattern análogo a `llimphi-widget-splitter`: el widget no mantiene
4//! estado. El caller guarda el valor actual en su `Model` y le pasa un
5//! handler `Fn(DragPhase, f32) -> Option<Msg>` que recibe **el delta de
6//! valor** (no el delta de pixels) entre eventos consecutivos. El widget
7//! traduce internamente `dx_pixels` a `dv` usando `track_width`.
8//!
9//! Visualmente es un *fillbar*: el track entero es draggable y se rellena
10//! una fracción proporcional a `(value - min) / (max - min)`. No hay
11//! pulgar separado — el límite entre relleno y vacío es el indicador.
12//!
13//! Layout fila:
14//!
15//! ```text
16//!   [ label_width ]  [ ████░░░░░░ ]  [ value_width ]
17//!        "psique"      0.4 / 1.0           " 0.40"
18//! ```
19//!
20//! Uso típico (sliders sobre `LayerMods` de un Concepto):
21//!
22//! ```ignore
23//! slider_view(
24//!     "psique",
25//!     model.selected.mods.psique,
26//!     -1.0, 1.0,
27//!     &palette,
28//!     |phase, dv| match phase {
29//!         DragPhase::Move => Some(Msg::EditMod(Layer::Psique, dv)),
30//!         DragPhase::End => None,
31//!     },
32//! )
33//! ```
34
35#![forbid(unsafe_code)]
36
37
38// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
39// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
40// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
41#![no_std]
42
43extern crate alloc;
44
45use alloc::string::String;
46use alloc::vec;
47use alloc::format;
48use llimphi_compositor::llimphi_layout::taffy::{
49    prelude::{length, percent, FlexDirection, Size, Style},
50    AlignItems, JustifyContent, Rect,
51};
52use llimphi_compositor::peniko::Color;
53use llimphi_compositor::llimphi_text::Alignment;
54use llimphi_compositor::{DragPhase, View};
55
56/// Paleta del slider. Las dimensiones también viajan aquí porque definen
57/// el layout fila — el caller no toca el `Style` del slider directamente.
58#[derive(Debug, Clone, Copy)]
59pub struct SliderPalette {
60    pub track: Color,
61    pub track_filled: Color,
62    pub track_hover: Color,
63    pub fg_label: Color,
64    pub fg_value: Color,
65    pub radius: f64,
66    /// Alto total del widget en pixels.
67    pub row_height: f32,
68    /// Ancho fijo del bloque del label (a la izquierda).
69    pub label_width: f32,
70    /// Ancho fijo del bloque del valor numérico (a la derecha).
71    pub value_width: f32,
72    /// Ancho fijo del track draggable (al medio). Único valor que el
73    /// widget usa para convertir dx_pixels → dv_value.
74    pub track_width: f32,
75    /// Grosor (alto) del track en pixels.
76    pub track_thickness: f32,
77    /// Paradas de un **degradado** para la parte rellena, en vez del color
78    /// plano `track_filled`. `None` (el default) = relleno plano de siempre.
79    ///
80    /// El degradado se mapea al **ancho completo del track**, no al de la
81    /// parte rellena: así el color de un punto no cambia al mover el valor —
82    /// una posición es un color— y la barra se lee como una escala, no como
83    /// un rectángulo que se estira. Es lo que hace que el indicador de volumen
84    /// de media use el mismo degradado posicional que su línea de tiempo.
85    pub fill_gradient: Option<[Color; 4]>,
86}
87
88impl Default for SliderPalette {
89    fn default() -> Self {
90        Self::from_theme(&llimphi_theme::Theme::dark())
91    }
92}
93
94impl SliderPalette {
95    /// Construye la paleta desde un `Theme` semántico.
96    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
97        Self {
98            track: t.bg_button,
99            track_filled: t.accent,
100            track_hover: t.bg_button_hover,
101            fg_label: t.fg_muted,
102            fg_value: t.fg_text,
103            radius: 5.0,
104            row_height: 26.0,
105            label_width: 80.0,
106            value_width: 56.0,
107            track_width: 120.0,
108            // Más grueso (antes 6px): más fácil de agarrar y arrastrar con pulso
109            // tembloroso. El área de drag = todo el track.
110            track_thickness: 12.0,
111            fill_gradient: None,
112        }
113    }
114}
115
116/// Compone un slider horizontal: label + track-fillbar draggable + valor.
117///
118/// `value`, `min`, `max` son sólo para presentación visual y conversión
119/// `dx → dv`; el caller mantiene el estado y aplica el delta en su
120/// `update`. El handler recibe `(DragPhase, delta_value)`; devolver
121/// `None` deja el drag activo sin emitir Msg.
122pub fn slider_view<Msg, F>(
123    label: impl Into<String>,
124    value: f32,
125    min: f32,
126    max: f32,
127    palette: &SliderPalette,
128    on_change: F,
129) -> View<Msg>
130where
131    Msg: Clone + Send + Sync + 'static,
132    F: Fn(DragPhase, f32) -> Option<Msg> + Send + Sync + 'static,
133{
134    let label: String = label.into();
135    let aria_label = label.clone();
136    let range = (max - min).max(f32::EPSILON);
137    let ratio = ((value - min) / range).clamp(0.0, 1.0);
138    let track_width = palette.track_width.max(1.0);
139
140    // Drag: dx_pixels → dv_value. Escala FIJA (no depende del valor actual).
141    //
142    // Los deltas que llegan son **por evento** (no acumulados desde el press), y
143    // durante un drag el runtime reusa ESTE handler (la vista queda congelada)
144    // → si pasáramos el delta crudo, el caller (que hace `valor_base + dv`)
145    // sumaría sólo el último evento al valor de inicio y el slider quedaba «casi
146    // estático». Por eso ACUMULAMOS los deltas dentro de la instancia del
147    // handler y pasamos el total desde el press: `base + total` = posición real.
148    // (Si en cambio la vista se repinta por evento, cada handler nuevo arranca
149    // el acumulador en 0 y el `base` ya viene fresco → también correcto.)
150    let span = max - min;
151    // El acumulador del drag, sin `Mutex`: un `AtomicU32` que guarda los bits
152    // del `f32`. No es un capricho no_std — el handler corre en el hilo de la UI
153    // y jamás compite, así que el mutex sólo pagaba la portabilidad que ahora
154    // paga el átomo. `Relaxed` alcanza por lo mismo: no hay otro hilo que
155    // ordenar.
156    let acumulado = alloc::sync::Arc::new(core::sync::atomic::AtomicU32::new(0.0_f32.to_bits()));
157    let handler = move |phase: DragPhase, dx: f32, _dy: f32| -> Option<Msg> {
158        use core::sync::atomic::Ordering::Relaxed;
159        let mut acc = f32::from_bits(acumulado.load(Relaxed));
160        acc += dx * span / track_width;
161        let total = acc;
162        if matches!(phase, DragPhase::End) {
163            acc = 0.0; // listo para el próximo drag
164        }
165        acumulado.store(acc.to_bits(), Relaxed);
166        on_change(phase, total)
167    };
168
169    // Bloque del label.
170    let label_view = View::new(Style {
171        size: Size {
172            width: length(palette.label_width),
173            height: percent(1.0_f32),
174        },
175        flex_shrink: 0.0,
176        align_items: Some(AlignItems::CENTER),
177        ..Default::default()
178    })
179    .text_aligned(label, 12.0, palette.fg_label, Alignment::Start);
180
181    let track = View::new(Style {
182        size: Size {
183            width: length(track_width),
184            height: length(palette.track_thickness),
185        },
186        flex_shrink: 0.0,
187        ..Default::default()
188    })
189    .fill(palette.track)
190    .hover_fill(palette.track_hover)
191    .radius(palette.radius)
192    .draggable(handler)
193    .children(vec![parte_rellena::<Msg>(ratio, palette)]);
194
195    // Wrapper del track para centrarlo verticalmente sobre la fila.
196    let track_cell = View::new(Style {
197        size: Size {
198            width: length(track_width),
199            height: percent(1.0_f32),
200        },
201        flex_shrink: 0.0,
202        align_items: Some(AlignItems::CENTER),
203        justify_content: Some(JustifyContent::CENTER),
204        padding: Rect {
205            left: length(0.0_f32),
206            right: length(0.0_f32),
207            top: length(0.0_f32),
208            bottom: length(0.0_f32),
209        },
210        ..Default::default()
211    })
212    .children(vec![track]);
213
214    // Bloque del valor.
215    let value_text = format_value(value);
216    let aria_value = value_text.clone();
217    let value_view = View::new(Style {
218        size: Size {
219            width: length(palette.value_width),
220            height: percent(1.0_f32),
221        },
222        flex_shrink: 0.0,
223        align_items: Some(AlignItems::CENTER),
224        ..Default::default()
225    })
226    .text_aligned(value_text, 12.0, palette.fg_value, Alignment::End);
227
228    View::new(Style {
229        flex_direction: FlexDirection::Row,
230        size: Size {
231            width: percent(1.0_f32),
232            height: length(palette.row_height),
233        },
234        align_items: Some(AlignItems::CENTER),
235        gap: Size {
236            width: length(8.0_f32),
237            height: length(0.0_f32),
238        },
239        ..Default::default()
240    })
241    // Semántica: rol Slider + label + value (texto formateado). El lector
242    // dice "<label>, deslizador, <valor>" — exactamente lo esperado.
243    .role(llimphi_compositor::Role::Slider)
244    .aria_label(aria_label)
245    .aria_value(aria_value)
246    .children(vec![label_view, track_cell, value_view])
247}
248
249/// La **parte rellena** del track: color plano, o el degradado posicional de
250/// [`SliderPalette::fill_gradient`] si la paleta lo trae.
251///
252/// El nodo mide `ratio` del ancho del track, así que un degradado sobre su
253/// propio rect se estiraría con el valor (el mismo punto cambiaría de color al
254/// arrastrar). Para que el color sea **posicional** —lo que hace legible una
255/// escala— el degradado se extiende hasta `rect.w / ratio`, que es el ancho
256/// completo del track, y el nodo lo recorta.
257fn parte_rellena<Msg>(ratio: f32, palette: &SliderPalette) -> View<Msg>
258where
259    Msg: Clone + Send + Sync + 'static,
260{
261    let filled_radius = palette.radius;
262    let base = View::new(Style {
263        size: Size {
264            width: percent(ratio),
265            height: percent(1.0_f32),
266        },
267        ..Default::default()
268    })
269    .radius(filled_radius);
270
271    let Some(stops) = palette.fill_gradient else {
272        return base.fill(palette.track_filled).paint_with(move |scene, _ts, rect| {
273            // Gloss superior sobre la stripe accent — la barra se lee como
274            // luz que avanza, no como rect plano. Mismo patrón button/progress
275            // (P6/P7). Alpha bajo (40) porque el track es muy delgado (6px
276            // default) y un sheen fuerte le mete glitter.
277            use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect};
278            use llimphi_compositor::peniko::{Fill, Gradient};
279            if rect.w <= 0.0 || rect.h <= 0.0 {
280                return;
281            }
282            let x0 = rect.x as f64;
283            let y0 = rect.y as f64;
284            let x1 = (rect.x + rect.w) as f64;
285            let y1 = (rect.y + rect.h) as f64;
286            let y_mid = y0 + (y1 - y0) * 0.5;
287            let rr = RoundedRect::new(x0, y0, x1, y1, filled_radius);
288            let top = Color::from_rgba8(255, 255, 255, 40);
289            let bot = Color::from_rgba8(255, 255, 255, 0);
290            let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
291                .with_stops([top, bot].as_slice());
292            scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
293        });
294    };
295
296    let r = ratio.max(1e-3);
297    base.paint_with(move |scene, _ts, rect| {
298        use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect};
299        use llimphi_compositor::peniko::{Fill, Gradient};
300        if rect.w <= 0.0 || rect.h <= 0.0 {
301            return;
302        }
303        let x0 = rect.x as f64;
304        let y0 = rect.y as f64;
305        let x1 = (rect.x + rect.w) as f64;
306        let y1 = (rect.y + rect.h) as f64;
307        // Hasta dónde llegaría el track entero: el degradado se mapea AHÍ.
308        let x_track = x0 + (rect.w / r) as f64;
309        let rr = RoundedRect::new(x0, y0, x1, y1, filled_radius);
310        // Diagonal (varía en horizontal y en vertical), como la línea de
311        // tiempo: el degradado se nota aunque la barra sea de 8 px de alto.
312        let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x_track, y1))
313            .with_stops(stops.as_slice());
314        scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
315        // El mismo gloss del relleno plano, para que los dos sabores se lean
316        // como el mismo widget.
317        let y_mid = y0 + (y1 - y0) * 0.5;
318        let gloss = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)).with_stops(
319            [
320                Color::from_rgba8(255, 255, 255, 34),
321                Color::from_rgba8(255, 255, 255, 0),
322            ]
323            .as_slice(),
324        );
325        scene.fill(Fill::NonZero, Affine::IDENTITY, &gloss, None, &rr);
326    })
327}
328
329/// El slider **absoluto**: el valor va a donde está el cursor, no a donde lo
330/// empujó un delta.
331///
332/// Es el mismo layout que [`slider_view`] con otro gesto, y la diferencia se
333/// nota en las tres cosas que un `<input type=range>` hace y el sabor relativo
334/// no hacía:
335///
336/// - un **click** en el canal salta a ese valor (no suma un delta desde el que
337///   había),
338/// - el **arrastre** sigue al cursor en tiempo real,
339/// - si el arrastre se **pasa** del track, el valor se queda pegado en el
340///   extremo (`min`/`max`) en vez de seguir acumulando invisible — soltar
341///   afuera deja exactamente lo que se ve.
342///
343/// El handler recibe el **valor absoluto** ya clampeado a `[min, max]`, no un
344/// delta. `DragPhase::Move` llega por cada movimiento (tiempo real) y
345/// `DragPhase::End` al soltar; un click suelto llega sólo como `End`.
346pub fn slider_abs_view<Msg, F>(
347    label: impl Into<String>,
348    value: f32,
349    min: f32,
350    max: f32,
351    palette: &SliderPalette,
352    on_set: F,
353) -> View<Msg>
354where
355    Msg: Clone + Send + Sync + 'static,
356    F: Fn(DragPhase, f32) -> Option<Msg> + Send + Sync + 'static,
357{
358    let label: String = label.into();
359    let aria_label = label.clone();
360    let range = (max - min).max(f32::EPSILON);
361    let ratio = ((value - min) / range).clamp(0.0, 1.0);
362    let track_width = palette.track_width.max(1.0);
363
364    // `draggable_at` entrega `(fase, dx, dy, lx0, ly0)` con los deltas **por
365    // evento** y la posición del press relativa al nodo. Para saber dónde está
366    // el cursor AHORA hay que acumular los deltas: `lx0 + Σdx`. Sin acumular,
367    // el gesto se queda clavado donde empezó (era el defecto de la línea de
368    // tiempo: arrastrar y soltar buscaba donde se había apretado).
369    let acumulado = alloc::sync::Arc::new(core::sync::atomic::AtomicU32::new(0.0_f32.to_bits()));
370    let handler = move |phase: DragPhase, dx: f32, _dy: f32, lx0: f32, _ly0: f32| -> Option<Msg> {
371        use core::sync::atomic::Ordering::Relaxed;
372        let acc = f32::from_bits(acumulado.load(Relaxed)) + dx;
373        // El `End` llega con dx = 0: primero se usa el acumulado, DESPUÉS se
374        // limpia para el próximo gesto.
375        acumulado.store(if matches!(phase, DragPhase::End) { 0.0_f32 } else { acc }.to_bits(), Relaxed);
376        // El clamp de `value_at_x` es lo que deja el valor pegado al extremo
377        // cuando el cursor se va del track.
378        on_set(phase, value_at_x(lx0 + acc, track_width, min, max))
379    };
380
381    let label_view = View::new(Style {
382        size: Size {
383            width: length(palette.label_width),
384            height: percent(1.0_f32),
385        },
386        flex_shrink: 0.0,
387        align_items: Some(AlignItems::CENTER),
388        ..Default::default()
389    })
390    .text_aligned(label, 12.0, palette.fg_label, Alignment::Start);
391
392    let track = View::new(Style {
393        size: Size {
394            width: length(track_width),
395            height: length(palette.track_thickness),
396        },
397        flex_shrink: 0.0,
398        ..Default::default()
399    })
400    .fill(palette.track)
401    .hover_fill(palette.track_hover)
402    .radius(palette.radius)
403    .draggable_at(handler)
404    .children(vec![parte_rellena::<Msg>(ratio, palette)]);
405
406    let track_cell = View::new(Style {
407        size: Size {
408            width: length(track_width),
409            height: percent(1.0_f32),
410        },
411        flex_shrink: 0.0,
412        align_items: Some(AlignItems::CENTER),
413        justify_content: Some(JustifyContent::CENTER),
414        ..Default::default()
415    })
416    .children(vec![track]);
417
418    let value_text = format_value(value);
419    let aria_value = value_text.clone();
420    let value_view = View::new(Style {
421        size: Size {
422            width: length(palette.value_width),
423            height: percent(1.0_f32),
424        },
425        flex_shrink: 0.0,
426        align_items: Some(AlignItems::CENTER),
427        ..Default::default()
428    })
429    .text_aligned(value_text, 12.0, palette.fg_value, Alignment::End);
430
431    View::new(Style {
432        flex_direction: FlexDirection::Row,
433        size: Size {
434            width: percent(1.0_f32),
435            height: length(palette.row_height),
436        },
437        align_items: Some(AlignItems::CENTER),
438        gap: Size {
439            width: length(8.0_f32),
440            height: length(0.0_f32),
441        },
442        ..Default::default()
443    })
444    .role(llimphi_compositor::Role::Slider)
445    .aria_label(aria_label)
446    .aria_value(aria_value)
447    .children(vec![label_view, track_cell, value_view])
448}
449
450/// Dónde queda un gesto absoluto que arrancó en `lx0` y acumuló `acc` píxeles
451/// de arrastre. Extraída de [`slider_abs_view`] para poder certificar por texto
452/// las tres propiedades que se pidieron: va a donde se clickea, sigue al
453/// arrastre, y se queda en el extremo si el arrastre se pasa del track.
454pub fn valor_de_gesto(lx0: f32, acc: f32, track_w: f32, min: f32, max: f32) -> f32 {
455    value_at_x(lx0 + acc, track_w, min, max)
456}
457
458/// Valor absoluto correspondiente a un click en `local_x` px desde el borde
459/// izquierdo del track (posicionamiento directo, no relativo — lo que hace un
460/// `<input type=range>` al clickear el canal). El host lo cablea con
461/// `on_click_at` sobre el track. Pura y testeable.
462pub fn value_at_x(local_x: f32, track_w: f32, min: f32, max: f32) -> f32 {
463    let w = track_w.max(f32::EPSILON);
464    let ratio = (local_x / w).clamp(0.0, 1.0);
465    min + (max - min) * ratio
466}
467
468/// Nuevo valor tras un paso de teclado (←/→): `value + dir*step`, clampeado a
469/// `[min, max]`. Home/End = `min`/`max` los resuelve el host directamente.
470pub fn step_value(value: f32, min: f32, max: f32, step: f32, dir: i32) -> f32 {
471    (value + dir as f32 * step).clamp(min.min(max), min.max(max))
472}
473
474/// Formato uniforme para los valores: 2 decimales con signo explícito si
475/// la magnitud es chica, 1 decimal si es grande. Cabe en `value_width: 56`.
476fn format_value(v: f32) -> String {
477    let abs = v.abs();
478    if abs >= 1000.0 {
479        format!("{v:.0}")
480    } else if abs >= 10.0 {
481        format!("{v:.1}")
482    } else {
483        format!("{v:+.2}")
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn format_value_pretty_for_three_regimes() {
493        assert_eq!(format_value(0.34), "+0.34");
494        assert_eq!(format_value(-0.10), "-0.10");
495        assert_eq!(format_value(42.5), "42.5");
496        assert_eq!(format_value(1234.0), "1234");
497    }
498
499    #[test]
500    fn value_at_x_mapea_click_a_valor_absoluto() {
501        assert!((value_at_x(0.0, 100.0, 0.0, 1.0) - 0.0).abs() < 1e-6);
502        assert!((value_at_x(100.0, 100.0, 0.0, 1.0) - 1.0).abs() < 1e-6);
503        assert!((value_at_x(50.0, 100.0, 0.0, 1.0) - 0.5).abs() < 1e-6);
504        // Rango con offset y magnitud.
505        assert!((value_at_x(30.0, 120.0, -1.0, 1.0) - (-0.5)).abs() < 1e-6);
506        // Fuera de rango → clamp.
507        assert!((value_at_x(-20.0, 100.0, 0.0, 10.0) - 0.0).abs() < 1e-6);
508        assert!((value_at_x(999.0, 100.0, 0.0, 10.0) - 10.0).abs() < 1e-6);
509    }
510
511    #[test]
512    fn el_gesto_absoluto_va_a_donde_se_clickea() {
513        // Click suelto (sin arrastre): el valor es el del punto apretado.
514        assert!((valor_de_gesto(30.0, 0.0, 120.0, 0.0, 1.0) - 0.25).abs() < 1e-6);
515        assert!((valor_de_gesto(60.0, 0.0, 120.0, 0.0, 2.0) - 1.0).abs() < 1e-6);
516    }
517
518    #[test]
519    fn el_gesto_absoluto_sigue_al_arrastre() {
520        // Apretó al 25 % y arrastró medio track a la derecha → 75 %.
521        assert!((valor_de_gesto(30.0, 60.0, 120.0, 0.0, 1.0) - 0.75).abs() < 1e-6);
522        // Y hacia atrás.
523        assert!((valor_de_gesto(90.0, -60.0, 120.0, 0.0, 1.0) - 0.25).abs() < 1e-6);
524    }
525
526    #[test]
527    fn pasarse_del_track_deja_el_valor_en_el_extremo() {
528        // Arrastrar mucho más allá del ancho no sigue acumulando: queda en max.
529        assert!((valor_de_gesto(60.0, 5000.0, 120.0, 0.0, 2.0) - 2.0).abs() < 1e-6);
530        // Y del otro lado, en min — no en negativo.
531        assert!((valor_de_gesto(60.0, -5000.0, 120.0, 0.0, 2.0) - 0.0).abs() < 1e-6);
532        // Soltar afuera deja exactamente lo que se ve (el extremo), no un
533        // valor acumulado invisible que salte al volver.
534        let afuera = valor_de_gesto(60.0, 400.0, 120.0, 0.0, 2.0);
535        let mas_afuera = valor_de_gesto(60.0, 900.0, 120.0, 0.0, 2.0);
536        assert_eq!(afuera, mas_afuera);
537    }
538
539    #[test]
540    fn step_value_avanza_y_clampa() {
541        assert!((step_value(0.5, 0.0, 1.0, 0.1, 1) - 0.6).abs() < 1e-6);
542        assert!((step_value(0.5, 0.0, 1.0, 0.1, -1) - 0.4).abs() < 1e-6);
543        assert!((step_value(0.95, 0.0, 1.0, 0.1, 1) - 1.0).abs() < 1e-6); // clamp arriba
544        assert!((step_value(0.05, 0.0, 1.0, 0.1, -1) - 0.0).abs() < 1e-6); // clamp abajo
545    }
546}