tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
feat: llimphi standalone — framework UI soberano extraído del monorepo
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: 3.0,
84 row_height: 22.0,
85 label_width: 80.0,
86 value_width: 56.0,
87 track_width: 120.0,
88 track_thickness: 6.0,
89 }
90 }
91}
92
93/// Compone un slider horizontal: label + track-fillbar draggable + valor.
94///
95/// `value`, `min`, `max` son sólo para presentación visual y conversión
96/// `dx → dv`; el caller mantiene el estado y aplica el delta en su
97/// `update`. El handler recibe `(DragPhase, delta_value)`; devolver
98/// `None` deja el drag activo sin emitir Msg.
99pub fn slider_view<Msg, F>(
100 label: impl Into<String>,
101 value: f32,
102 min: f32,
103 max: f32,
104 palette: &SliderPalette,
105 on_change: F,
106) -> View<Msg>
107where
108 Msg: Clone + Send + Sync + 'static,
109 F: Fn(DragPhase, f32) -> Option<Msg> + Send + Sync + 'static,
110{
111 let range = (max - min).max(f32::EPSILON);
112 let ratio = ((value - min) / range).clamp(0.0, 1.0);
113 let track_width = palette.track_width.max(1.0);
114
115 // Drag: dx_pixels → dv_value. Escala FIJA (no depende del valor actual).
116 let span = max - min;
117 let handler = move |phase: DragPhase, dx: f32, _dy: f32| -> Option<Msg> {
118 let dv = dx * span / track_width;
119 on_change(phase, dv)
120 };
121
122 // Bloque del label.
123 let label_view = View::new(Style {
124 size: Size {
125 width: length(palette.label_width),
126 height: percent(1.0_f32),
127 },
128 flex_shrink: 0.0,
129 align_items: Some(AlignItems::Center),
130 ..Default::default()
131 })
132 .text_aligned(label.into(), 12.0, palette.fg_label, Alignment::Start);
133
134 // Track draggable: fill = track bg, hijo = porción rellena (accent).
135 let filled_radius = palette.radius;
136 let filled = View::new(Style {
137 size: Size {
138 width: percent(ratio),
139 height: percent(1.0_f32),
140 },
141 ..Default::default()
142 })
143 .fill(palette.track_filled)
144 .radius(filled_radius)
145 .paint_with(move |scene, _ts, rect| {
146 // Gloss superior sobre la stripe accent — la barra se lee como
147 // luz que avanza, no como rect plano. Mismo patrón button/progress
148 // (P6/P7). Alpha bajo (40) porque el track es muy delgado (6px
149 // default) y un sheen fuerte le mete glitter.
150 use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect};
151 use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient};
152 if rect.w <= 0.0 || rect.h <= 0.0 {
153 return;
154 }
155 let x0 = rect.x as f64;
156 let y0 = rect.y as f64;
157 let x1 = (rect.x + rect.w) as f64;
158 let y1 = (rect.y + rect.h) as f64;
159 let y_mid = y0 + (y1 - y0) * 0.5;
160 let rr = RoundedRect::new(x0, y0, x1, y1, filled_radius);
161 let top = Color::from_rgba8(255, 255, 255, 40);
162 let bot = Color::from_rgba8(255, 255, 255, 0);
163 let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
164 .with_stops([top, bot].as_slice());
165 scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
166 });
167
168 let track = View::new(Style {
169 size: Size {
170 width: length(track_width),
171 height: length(palette.track_thickness),
172 },
173 flex_shrink: 0.0,
174 ..Default::default()
175 })
176 .fill(palette.track)
177 .hover_fill(palette.track_hover)
178 .radius(palette.radius)
179 .draggable(handler)
180 .children(vec![filled]);
181
182 // Wrapper del track para centrarlo verticalmente sobre la fila.
183 let track_cell = View::new(Style {
184 size: Size {
185 width: length(track_width),
186 height: percent(1.0_f32),
187 },
188 flex_shrink: 0.0,
189 align_items: Some(AlignItems::Center),
190 justify_content: Some(JustifyContent::Center),
191 padding: Rect {
192 left: length(0.0_f32),
193 right: length(0.0_f32),
194 top: length(0.0_f32),
195 bottom: length(0.0_f32),
196 },
197 ..Default::default()
198 })
199 .children(vec![track]);
200
201 // Bloque del valor.
202 let value_text = format_value(value);
203 let value_view = View::new(Style {
204 size: Size {
205 width: length(palette.value_width),
206 height: percent(1.0_f32),
207 },
208 flex_shrink: 0.0,
209 align_items: Some(AlignItems::Center),
210 ..Default::default()
211 })
212 .text_aligned(value_text, 12.0, palette.fg_value, Alignment::End);
213
214 View::new(Style {
215 flex_direction: FlexDirection::Row,
216 size: Size {
217 width: percent(1.0_f32),
218 height: length(palette.row_height),
219 },
220 align_items: Some(AlignItems::Center),
221 gap: Size {
222 width: length(8.0_f32),
223 height: length(0.0_f32),
224 },
225 ..Default::default()
226 })
227 .children(vec![label_view, track_cell, value_view])
228}
229
230/// Formato uniforme para los valores: 2 decimales con signo explícito si
231/// la magnitud es chica, 1 decimal si es grande. Cabe en `value_width: 56`.
232fn format_value(v: f32) -> String {
233 let abs = v.abs();
234 if abs >= 1000.0 {
235 format!("{v:.0}")
236 } else if abs >= 10.0 {
237 format!("{v:.1}")
238 } else {
239 format!("{v:+.2}")
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn format_value_pretty_for_three_regimes() {
249 assert_eq!(format_value(0.34), "+0.34");
250 assert_eq!(format_value(-0.10), "-0.10");
251 assert_eq!(format_value(42.5), "42.5");
252 assert_eq!(format_value(1234.0), "1234");
253 }
254}