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