tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Definitions

⎇ main widgets/segmented/src/lib.rs Rust · 342 lines · 11.7 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! `llimphi-widget-segmented` — control de opciones mutuamente exclusivas.
2//!
3//! N opciones horizontales con UNA activa. Patrón iOS/macOS para
4//! alternativas radio-style cuando son pocas (2-5) y caben en línea.
5//! Si son más, usar un `tabs` o un dropdown.
6//!
7//! Render-only: la app guarda `selected: usize` en el modelo y
8//! dispatcha `Msg::SelectSegment(usize)` al click.
9
10#![forbid(unsafe_code)]
11
12
13// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
14// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
15// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
16#![no_std]
17
18extern crate alloc;
19
20use alloc::vec::Vec;
21use alloc::vec;
22use alloc::string::ToString;
23use llimphi_compositor::llimphi_layout::taffy::{
24    prelude::{length, percent, FlexDirection, Size, Style},
25    AlignItems, JustifyContent, Rect,
26};
27use llimphi_compositor::peniko::Color;
28use llimphi_compositor::llimphi_text::Alignment;
29use llimphi_compositor::View;
30use llimphi_theme::{radius, Theme};
31
32/// Paleta del control.
33#[derive(Debug, Clone, Copy)]
34pub struct SegmentedPalette {
35    pub bg_track: Color,
36    pub bg_active: Color,
37    pub fg_active: Color,
38    pub fg_inactive: Color,
39    /// Relleno de hover sobre un segmento **inactivo**. Antes existía un
40    /// `fg_hover` (color de texto en hover) que NINGÚN código aplicaba —
41    /// un campo muerto que anunciaba un hover inexistente. `View` sólo
42    /// puede cambiar el fondo en hover (`hover_fill`), no el color de
43    /// texto, así que el hover real es de fondo.
44    pub bg_hover: Color,
45}
46
47impl SegmentedPalette {
48    pub fn from_theme(t: &Theme) -> Self {
49        Self {
50            bg_track: t.bg_button,
51            bg_active: t.bg_panel,
52            fg_active: t.fg_text,
53            fg_inactive: t.fg_muted,
54            bg_hover: t.bg_row_hover,
55        }
56    }
57}
58
59/// Posición del indicador deslizante como fracciones del ancho del track:
60/// `(left, width)` en `[0,1]`, con `width = 1/n` y `left = t/n` clampeado para
61/// no salirse. `t` es la posición **animada** (0..n-1) que el host tween-ea
62/// hacia el `selected`. Pura y testeable; casa exacto con segmentos de igual
63/// ancho y gap 0.
64pub fn indicator_bounds(t: f32, n: usize) -> (f32, f32) {
65    if n == 0 {
66        return (0.0, 1.0);
67    }
68    let nf = n as f32;
69    let width = 1.0 / nf;
70    let left = (t / nf).clamp(0.0, 1.0 - width);
71    (left, width)
72}
73
74/// Variante con **indicador deslizante animado**: en vez de pintar el fondo del
75/// segmento activo, un único realce flota y se desliza a la posición `indicator_t`
76/// (que el host anima con `llimphi-motion` hacia `selected`). Segmentos de igual
77/// ancho, gap 0 → el realce cae exacto sobre cada uno. `selected` sólo decide el
78/// color del texto. El resto (teclado con [`step_segment`], a11y) igual que
79/// [`segmented_view`].
80pub fn segmented_view_sliding<Msg, F>(
81    labels: &[&str],
82    selected: usize,
83    indicator_t: f32,
84    make_msg: F,
85    palette: &SegmentedPalette,
86) -> View<Msg>
87where
88    Msg: Clone + 'static,
89    F: Fn(usize) -> Msg,
90{
91    let n = labels.len();
92    let (left_frac, width_frac) = indicator_bounds(indicator_t, n);
93
94    // Realce flotante: nodo absoluto posicionado por porcentaje del track.
95    let seg_radius = radius::XS;
96    let indicator = View::new(Style {
97        position: llimphi_compositor::llimphi_layout::taffy::prelude::Position::Absolute,
98        inset: Rect {
99            left: percent(left_frac),
100            top: length(0.0_f32),
101            right: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
102            bottom: length(0.0_f32),
103        },
104        size: Size { width: percent(width_frac), height: percent(1.0_f32) },
105        ..Default::default()
106    })
107    .fill(palette.bg_active)
108    .radius(seg_radius)
109    .paint_with(move |scene, _ts, rect| {
110        // Mismo gloss superior que el segmento activo estático.
111        use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect};
112        use llimphi_compositor::peniko::{Fill, Gradient};
113        if rect.w <= 0.0 || rect.h <= 0.0 {
114            return;
115        }
116        let (x0, y0) = (rect.x as f64, rect.y as f64);
117        let (x1, y1) = ((rect.x + rect.w) as f64, (rect.y + rect.h) as f64);
118        let rr = RoundedRect::new(x0, y0, x1, y1, seg_radius);
119        let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y0 + (y1 - y0) * 0.5))
120            .with_stops([Color::from_rgba8(255, 255, 255, 28), Color::from_rgba8(255, 255, 255, 0)].as_slice());
121        scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
122    });
123
124    // Segmentos: sólo texto (el realce lo da el indicador) + hover en inactivos.
125    let seg_nodes: Vec<View<Msg>> = labels
126        .iter()
127        .enumerate()
128        .map(|(i, label)| {
129            let is_active = i == selected;
130            let fg = if is_active { palette.fg_active } else { palette.fg_inactive };
131            let mut node = View::new(Style {
132                size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
133                flex_grow: 1.0,
134                align_items: Some(AlignItems::CENTER),
135                justify_content: Some(JustifyContent::CENTER),
136                ..Default::default()
137            })
138            .radius(seg_radius)
139            .text_aligned(label.to_string(), 11.5, fg, Alignment::Center)
140            .role(llimphi_compositor::Role::Tab)
141            .aria_label(label.to_string())
142            .aria_pressed(is_active)
143            .on_click(make_msg(i));
144            if !is_active {
145                node = node.hover_fill(palette.bg_hover);
146            }
147            node
148        })
149        .collect();
150
151    let seg_row = View::new(Style {
152        position: llimphi_compositor::llimphi_layout::taffy::prelude::Position::Relative,
153        flex_direction: FlexDirection::Row,
154        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
155        ..Default::default()
156    })
157    .children(seg_nodes);
158
159    View::new(Style {
160        position: llimphi_compositor::llimphi_layout::taffy::prelude::Position::Relative,
161        size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
162        padding: Rect {
163            left: length(2.0_f32),
164            right: length(2.0_f32),
165            top: length(2.0_f32),
166            bottom: length(2.0_f32),
167        },
168        ..Default::default()
169    })
170    .fill(palette.bg_track)
171    .radius(radius::SM)
172    .children(vec![indicator, seg_row])
173}
174
175/// Construye el control. `labels` son los textos visibles; `selected`
176/// es el índice activo (0-based). `make_msg(i)` se llama al click.
177pub fn segmented_view<Msg, F>(
178    labels: &[&str],
179    selected: usize,
180    make_msg: F,
181    palette: &SegmentedPalette,
182) -> View<Msg>
183where
184    Msg: Clone + 'static,
185    F: Fn(usize) -> Msg,
186{
187    let children: Vec<View<Msg>> = labels
188        .iter()
189        .enumerate()
190        .map(|(i, label)| segment_view(i, label, i == selected, make_msg(i), palette))
191        .collect();
192
193    View::new(Style {
194        flex_direction: FlexDirection::Row,
195        size: Size {
196            width: percent(1.0_f32),
197            height: length(28.0_f32),
198        },
199        padding: Rect {
200            left: length(2.0_f32),
201            right: length(2.0_f32),
202            top: length(2.0_f32),
203            bottom: length(2.0_f32),
204        },
205        gap: Size {
206            width: length(2.0_f32),
207            height: length(0.0_f32),
208        },
209        ..Default::default()
210    })
211    .fill(palette.bg_track)
212    .radius(radius::SM)
213    .children(children)
214}
215
216fn segment_view<Msg: Clone + 'static>(
217    _idx: usize,
218    label: &str,
219    is_active: bool,
220    msg: Msg,
221    palette: &SegmentedPalette,
222) -> View<Msg> {
223    let (bg, fg) = if is_active {
224        (Some(palette.bg_active), palette.fg_active)
225    } else {
226        (None, palette.fg_inactive)
227    };
228
229    let seg_radius = radius::XS;
230    let mut node = View::new(Style {
231        size: Size {
232            width: percent(1.0_f32),
233            height: percent(1.0_f32),
234        },
235        flex_grow: 1.0,
236        align_items: Some(AlignItems::CENTER),
237        justify_content: Some(JustifyContent::CENTER),
238        padding: Rect {
239            left: length(8.0_f32),
240            right: length(8.0_f32),
241            top: length(0.0_f32),
242            bottom: length(0.0_f32),
243        },
244        ..Default::default()
245    })
246    .radius(seg_radius)
247    .text_aligned(label.to_string(), 11.5, fg, Alignment::Center)
248    // Semántica: rol Tab + label + pressed=is_active. AccessKit anuncia
249    // "Pestaña <label>, presionada / sin presionar".
250    .role(llimphi_compositor::Role::Tab)
251    .aria_label(label.to_string())
252    .aria_pressed(is_active)
253    .on_click(msg);
254
255    if !is_active {
256        // Hover real sobre segmentos inactivos (feedback que el viejo
257        // `fg_hover` prometía y no daba).
258        node = node.hover_fill(palette.bg_hover);
259    }
260
261    if let Some(c) = bg {
262        node = node.fill(c).paint_with(move |scene, _ts, rect| {
263            // Gloss superior sólo en el segmento activo — refuerza
264            // "esto está seleccionado" con la misma firma de button (P6).
265            // Los segmentos inactivos quedan planos para que el contraste
266            // sea inequívoco.
267            use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect};
268            use llimphi_compositor::peniko::{Fill, Gradient};
269            if rect.w <= 0.0 || rect.h <= 0.0 {
270                return;
271            }
272            let x0 = rect.x as f64;
273            let y0 = rect.y as f64;
274            let x1 = (rect.x + rect.w) as f64;
275            let y1 = (rect.y + rect.h) as f64;
276            let y_mid = y0 + (y1 - y0) * 0.5;
277            let rr = RoundedRect::new(x0, y0, x1, y1, seg_radius);
278            let top = Color::from_rgba8(255, 255, 255, 28);
279            let bot = Color::from_rgba8(255, 255, 255, 0);
280            let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
281                .with_stops([top, bot].as_slice());
282            scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
283        });
284    }
285    node
286}
287
288/// Mueve la selección con teclado (←/→): dado el índice activo, la
289/// dirección (`+1`/`-1`) y el total, devuelve el siguiente índice
290/// **envolviendo** en los extremos (rol `Tab` implica navegación por
291/// flechas). El host lo cablea en `update` mapeando ←/→ a esto y
292/// dispatchando su `Msg::SelectSegment(nuevo)`.
293pub fn step_segment(len: usize, current: usize, direction: i32) -> usize {
294    if len == 0 {
295        return 0;
296    }
297    let n = len as i32;
298    let mut i = current as i32 + direction;
299    if i < 0 {
300        i = n - 1;
301    } else if i >= n {
302        i = 0;
303    }
304    i as usize
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn step_segment_avanza_y_envuelve() {
313        assert_eq!(step_segment(3, 0, 1), 1);
314        assert_eq!(step_segment(3, 2, 1), 0); // envuelve al inicio
315        assert_eq!(step_segment(3, 0, -1), 2); // envuelve al final
316        assert_eq!(step_segment(3, 1, -1), 0);
317    }
318
319    #[test]
320    fn step_segment_lista_vacia_es_cero() {
321        assert_eq!(step_segment(0, 0, 1), 0);
322    }
323
324    #[test]
325    fn indicator_bounds_cae_sobre_cada_segmento() {
326        // n=3: ancho 1/3; left = t/3.
327        let (l0, w) = indicator_bounds(0.0, 3);
328        assert!((w - 1.0 / 3.0).abs() < 1e-4 && l0.abs() < 1e-4);
329        let (l1, _) = indicator_bounds(1.0, 3);
330        assert!((l1 - 1.0 / 3.0).abs() < 1e-4);
331        let (l2, _) = indicator_bounds(2.0, 3);
332        assert!((l2 - 2.0 / 3.0).abs() < 1e-4);
333        // A mitad de camino entre 0 y 1 → left intermedio.
334        let (lh, _) = indicator_bounds(0.5, 3);
335        assert!(lh > 0.0 && lh < 1.0 / 3.0);
336        // Clamp: no se sale por la derecha.
337        let (lc, wc) = indicator_bounds(5.0, 3);
338        assert!((lc + wc - 1.0).abs() < 1e-4, "el borde derecho no pasa de 1");
339        // n=0 defensivo.
340        assert_eq!(indicator_bounds(0.0, 0), (0.0, 1.0));
341    }
342}