tawasuyu

tawasuyu/llimphi

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

Rust 124Markdown 82TOML 78Shell 12149 Definitions

⎇ dba855e446ca2bd4e7d146bdc168fb232cad15a4 widgets/slider/src/lib.rs Rust · 279 lines · 9.5 KB History Who touched what raw

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · 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
37use llimphi_ui::llimphi_layout::taffy::{
38    prelude::{length, percent, FlexDirection, Size, Style},
39    AlignItems, JustifyContent, Rect,
40};
41use llimphi_ui::llimphi_raster::peniko::Color;
42use llimphi_ui::llimphi_text::Alignment;
43use llimphi_ui::{DragPhase, View};
44
45/// Paleta del slider. Las dimensiones también viajan acá porque definen
46/// el layout fila — el caller no toca el `Style` del slider directamente.
47#[derive(Debug, Clone, Copy)]
48pub struct SliderPalette {
49    pub track: Color,
50    pub track_filled: Color,
51    pub track_hover: Color,
52    pub fg_label: Color,
53    pub fg_value: Color,
54    pub radius: f64,
55    /// Alto total del widget en pixels.
56    pub row_height: f32,
57    /// Ancho fijo del bloque del label (a la izquierda).
58    pub label_width: f32,
59    /// Ancho fijo del bloque del valor numérico (a la derecha).
60    pub value_width: f32,
61    /// Ancho fijo del track draggable (al medio). Único valor que el
62    /// widget usa para convertir dx_pixels → dv_value.
63    pub track_width: f32,
64    /// Grosor (alto) del track en pixels.
65    pub track_thickness: f32,
66}
67
68impl Default for SliderPalette {
69    fn default() -> Self {
70        Self::from_theme(&llimphi_theme::Theme::dark())
71    }
72}
73
74impl SliderPalette {
75    /// Construye la paleta desde un `Theme` semántico.
76    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
77        Self {
78            track: t.bg_button,
79            track_filled: t.accent,
80            track_hover: t.bg_button_hover,
81            fg_label: t.fg_muted,
82            fg_value: t.fg_text,
83            radius: 5.0,
84            row_height: 26.0,
85            label_width: 80.0,
86            value_width: 56.0,
87            track_width: 120.0,
88            // Más grueso (antes 6px): más fácil de agarrar y arrastrar con pulso
89            // tembloroso. El área de drag = todo el track.
90            track_thickness: 12.0,
91        }
92    }
93}
94
95/// Compone un slider horizontal: label + track-fillbar draggable + valor.
96///
97/// `value`, `min`, `max` son sólo para presentación visual y conversión
98/// `dx → dv`; el caller mantiene el estado y aplica el delta en su
99/// `update`. El handler recibe `(DragPhase, delta_value)`; devolver
100/// `None` deja el drag activo sin emitir Msg.
101pub fn slider_view<Msg, F>(
102    label: impl Into<String>,
103    value: f32,
104    min: f32,
105    max: f32,
106    palette: &SliderPalette,
107    on_change: F,
108) -> View<Msg>
109where
110    Msg: Clone + Send + Sync + 'static,
111    F: Fn(DragPhase, f32) -> Option<Msg> + Send + Sync + 'static,
112{
113    let label: String = label.into();
114    let aria_label = label.clone();
115    let range = (max - min).max(f32::EPSILON);
116    let ratio = ((value - min) / range).clamp(0.0, 1.0);
117    let track_width = palette.track_width.max(1.0);
118
119    // Drag: dx_pixels → dv_value. Escala FIJA (no depende del valor actual).
120    //
121    // Los deltas que llegan son **por evento** (no acumulados desde el press), y
122    // durante un drag el runtime reusa ESTE handler (la vista queda congelada)
123    // → si pasáramos el delta crudo, el caller (que hace `valor_base + dv`)
124    // sumaría sólo el último evento al valor de inicio y el slider quedaba «casi
125    // estático». Por eso ACUMULAMOS los deltas dentro de la instancia del
126    // handler y pasamos el total desde el press: `base + total` = posición real.
127    // (Si en cambio la vista se repinta por evento, cada handler nuevo arranca
128    // el acumulador en 0 y el `base` ya viene fresco → también correcto.)
129    let span = max - min;
130    let acumulado = std::sync::Arc::new(std::sync::Mutex::new(0.0_f32));
131    let handler = move |phase: DragPhase, dx: f32, _dy: f32| -> Option<Msg> {
132        let mut acc = acumulado.lock().unwrap_or_else(|e| e.into_inner());
133        *acc += dx * span / track_width;
134        let total = *acc;
135        if matches!(phase, DragPhase::End) {
136            *acc = 0.0; // listo para el próximo drag
137        }
138        on_change(phase, total)
139    };
140
141    // Bloque del label.
142    let label_view = View::new(Style {
143        size: Size {
144            width: length(palette.label_width),
145            height: percent(1.0_f32),
146        },
147        flex_shrink: 0.0,
148        align_items: Some(AlignItems::Center),
149        ..Default::default()
150    })
151    .text_aligned(label, 12.0, palette.fg_label, Alignment::Start);
152
153    // Track draggable: fill = track bg, hijo = porción rellena (accent).
154    let filled_radius = palette.radius;
155    let filled = View::new(Style {
156        size: Size {
157            width: percent(ratio),
158            height: percent(1.0_f32),
159        },
160        ..Default::default()
161    })
162    .fill(palette.track_filled)
163    .radius(filled_radius)
164    .paint_with(move |scene, _ts, rect| {
165        // Gloss superior sobre la stripe accent — la barra se lee como
166        // luz que avanza, no como rect plano. Mismo patrón button/progress
167        // (P6/P7). Alpha bajo (40) porque el track es muy delgado (6px
168        // default) y un sheen fuerte le mete glitter.
169        use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect};
170        use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient};
171        if rect.w <= 0.0 || rect.h <= 0.0 {
172            return;
173        }
174        let x0 = rect.x as f64;
175        let y0 = rect.y as f64;
176        let x1 = (rect.x + rect.w) as f64;
177        let y1 = (rect.y + rect.h) as f64;
178        let y_mid = y0 + (y1 - y0) * 0.5;
179        let rr = RoundedRect::new(x0, y0, x1, y1, filled_radius);
180        let top = Color::from_rgba8(255, 255, 255, 40);
181        let bot = Color::from_rgba8(255, 255, 255, 0);
182        let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
183            .with_stops([top, bot].as_slice());
184        scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
185    });
186
187    let track = View::new(Style {
188        size: Size {
189            width: length(track_width),
190            height: length(palette.track_thickness),
191        },
192        flex_shrink: 0.0,
193        ..Default::default()
194    })
195    .fill(palette.track)
196    .hover_fill(palette.track_hover)
197    .radius(palette.radius)
198    .draggable(handler)
199    .children(vec![filled]);
200
201    // Wrapper del track para centrarlo verticalmente sobre la fila.
202    let track_cell = View::new(Style {
203        size: Size {
204            width: length(track_width),
205            height: percent(1.0_f32),
206        },
207        flex_shrink: 0.0,
208        align_items: Some(AlignItems::Center),
209        justify_content: Some(JustifyContent::Center),
210        padding: Rect {
211            left: length(0.0_f32),
212            right: length(0.0_f32),
213            top: length(0.0_f32),
214            bottom: length(0.0_f32),
215        },
216        ..Default::default()
217    })
218    .children(vec![track]);
219
220    // Bloque del valor.
221    let value_text = format_value(value);
222    let aria_value = value_text.clone();
223    let value_view = View::new(Style {
224        size: Size {
225            width: length(palette.value_width),
226            height: percent(1.0_f32),
227        },
228        flex_shrink: 0.0,
229        align_items: Some(AlignItems::Center),
230        ..Default::default()
231    })
232    .text_aligned(value_text, 12.0, palette.fg_value, Alignment::End);
233
234    View::new(Style {
235        flex_direction: FlexDirection::Row,
236        size: Size {
237            width: percent(1.0_f32),
238            height: length(palette.row_height),
239        },
240        align_items: Some(AlignItems::Center),
241        gap: Size {
242            width: length(8.0_f32),
243            height: length(0.0_f32),
244        },
245        ..Default::default()
246    })
247    // Semántica: rol Slider + label + value (texto formateado). El lector
248    // dice "<label>, deslizador, <valor>" — exactamente lo esperado.
249    .role(llimphi_ui::Role::Slider)
250    .aria_label(aria_label)
251    .aria_value(aria_value)
252    .children(vec![label_view, track_cell, value_view])
253}
254
255/// Formato uniforme para los valores: 2 decimales con signo explícito si
256/// la magnitud es chica, 1 decimal si es grande. Cabe en `value_width: 56`.
257fn format_value(v: f32) -> String {
258    let abs = v.abs();
259    if abs >= 1000.0 {
260        format!("{v:.0}")
261    } else if abs >= 10.0 {
262        format!("{v:.1}")
263    } else {
264        format!("{v:+.2}")
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn format_value_pretty_for_three_regimes() {
274        assert_eq!(format_value(0.34), "+0.34");
275        assert_eq!(format_value(-0.10), "-0.10");
276        assert_eq!(format_value(42.5), "42.5");
277        assert_eq!(format_value(1234.0), "1234");
278    }
279}