tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
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}