tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
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}