gioser

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ c1b11b2c01d7623a4f346519b9d1ec435be30b95 widgets/slider/src/lib.rs Rust · 254 líneas · 8.1 KB Historia Quién tocó qué crudo

feat: llimphi standalone — framework UI soberano extraído del monorepo e65e9cc62 · 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: 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}