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-stepper` — asistente por pasos (wizard).
2//!
3//! Tira de pasos numerados arriba, cuerpo del paso actual en el medio, pie con
4//! **Atrás / Siguiente / Finalizar**. Stateless: el paso actual y el estado de
5//! cada uno viven en el `Model` del caller.
6//!
7//! Lo que no es trivial de un wizard —y por eso está acá y no en cada app— es
8//! **a dónde se puede ir**: adelante sólo si el paso actual está resuelto, atrás
9//! siempre, y a un paso ya hecho de un salto. Eso es [`puede_ir_a`], pura. Sin
10//! ella cada app inventa su regla y termina dejando avanzar con el formulario a
11//! medio llenar.
12//!
13//! ```ignore
14//! stepper_view(StepperSpec {
15//! pasos: &[Paso::hecho("Datos"), Paso::actual("Pago"), Paso::pendiente("Confirmar")],
16//! actual: 1,
17//! cuerpo: paso_pago_view(m),
18//! on_ir: Msg::IrAPaso, // Fn(usize) -> Msg
19//! palette: StepperPalette::from_theme(&theme),
20//! })
21//! ```
22
23#![forbid(unsafe_code)]
24// Sin `std`: sólo `alloc`, para que cruce a una jaula WASM o al kernel (W4.6).
25#![no_std]
26
27extern crate alloc;
28
29use alloc::format;
30use alloc::string::{String, ToString};
31use alloc::vec;
32use alloc::vec::Vec;
33
34use llimphi_compositor::llimphi_layout::taffy::{
35 prelude::{auto, length, percent, Dimension, FlexDirection, Size, Style},
36 AlignItems, JustifyContent, Rect,
37};
38use llimphi_compositor::llimphi_text::Alignment;
39use llimphi_compositor::peniko::Color;
40use llimphi_compositor::View;
41use llimphi_theme::Theme;
42
43/// En qué anda un paso.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum EstadoPaso {
46 /// Todavía no se llegó.
47 Pendiente,
48 /// El que se está viendo.
49 Actual,
50 /// Resuelto: se puede volver a él de un salto.
51 Hecho,
52 /// Se intentó y quedó mal. **No** es lo mismo que pendiente: al paso con
53 /// error se puede volver, y hay que poder verlo desde la tira.
54 Error,
55}
56
57/// Un paso de la tira.
58#[derive(Debug, Clone)]
59pub struct Paso {
60 pub titulo: String,
61 pub estado: EstadoPaso,
62 /// Renglón chico bajo el título (opcional).
63 pub detalle: Option<String>,
64}
65
66impl Paso {
67 pub fn new(titulo: impl Into<String>, estado: EstadoPaso) -> Self {
68 Self { titulo: titulo.into(), estado, detalle: None }
69 }
70
71 pub fn pendiente(titulo: impl Into<String>) -> Self {
72 Self::new(titulo, EstadoPaso::Pendiente)
73 }
74
75 pub fn actual(titulo: impl Into<String>) -> Self {
76 Self::new(titulo, EstadoPaso::Actual)
77 }
78
79 pub fn hecho(titulo: impl Into<String>) -> Self {
80 Self::new(titulo, EstadoPaso::Hecho)
81 }
82
83 pub fn con_error(titulo: impl Into<String>) -> Self {
84 Self::new(titulo, EstadoPaso::Error)
85 }
86
87 pub fn detalle(mut self, texto: impl Into<String>) -> Self {
88 self.detalle = Some(texto.into());
89 self
90 }
91}
92
93/// ¿Se puede saltar de `actual` a `destino`?
94///
95/// La regla, que es la de cualquier wizard que no deje avanzar en falso:
96///
97/// - **atrás, siempre** — revisar lo que ya se llenó no rompe nada;
98/// - **al siguiente**, sólo si el actual ya está `Hecho`;
99/// - **más adelante**, sólo si **todos** los pasos intermedios están `Hecho`
100/// (saltar el paso 2 para ir al 4 deja el 2 sin llenar, y el wizard existe
101/// justamente para que eso no pase);
102/// - a un paso en `Error` se puede ir: es adonde hay que ir a arreglarlo.
103pub fn puede_ir_a(pasos: &[Paso], actual: usize, destino: usize) -> bool {
104 if destino >= pasos.len() || destino == actual {
105 return false;
106 }
107 if destino < actual {
108 return true;
109 }
110 // Hacia adelante: todo lo que queda atrás del destino tiene que estar
111 // resuelto, el paso actual incluido.
112 (actual..destino).all(|i| matches!(pasos[i].estado, EstadoPaso::Hecho))
113}
114
115/// El paso siguiente alcanzable, o `None` si no se puede avanzar.
116pub fn siguiente(pasos: &[Paso], actual: usize) -> Option<usize> {
117 let d = actual.checked_add(1)?;
118 puede_ir_a(pasos, actual, d).then_some(d)
119}
120
121/// El paso anterior, o `None` si ya es el primero.
122pub fn anterior(actual: usize) -> Option<usize> {
123 actual.checked_sub(1)
124}
125
126/// ¿Es el último paso? (el pie dice **Finalizar** en vez de Siguiente).
127pub fn es_ultimo(pasos: &[Paso], actual: usize) -> bool {
128 !pasos.is_empty() && actual + 1 >= pasos.len()
129}
130
131/// Cuántos pasos están resueltos — para un «3 de 5» o una barra de avance.
132pub fn hechos(pasos: &[Paso]) -> usize {
133 pasos.iter().filter(|p| p.estado == EstadoPaso::Hecho).count()
134}
135
136/// Paleta del stepper.
137#[derive(Debug, Clone, Copy)]
138pub struct StepperPalette {
139 pub bg: Color,
140 pub bg_pie: Color,
141 pub fg_titulo: Color,
142 pub fg_tenue: Color,
143 pub acento: Color,
144 /// Texto sobre el círculo del paso actual/hecho.
145 pub fg_sobre_acento: Color,
146 pub error: Color,
147 pub borde: Color,
148}
149
150impl StepperPalette {
151 pub fn from_theme(t: &Theme) -> Self {
152 Self {
153 bg: t.bg_panel,
154 bg_pie: t.bg_panel_alt,
155 fg_titulo: t.fg_text,
156 fg_tenue: t.fg_muted,
157 acento: t.accent,
158 fg_sobre_acento: t.bg_app,
159 error: t.fg_destructive,
160 borde: t.border,
161 }
162 }
163}
164
165impl Default for StepperPalette {
166 fn default() -> Self {
167 Self::from_theme(&Theme::dark())
168 }
169}
170
171const TIRA_H: f32 = 52.0;
172const PIE_H: f32 = 44.0;
173const CIRCULO: f32 = 22.0;
174
175/// Especificación del stepper.
176pub struct StepperSpec<'a, Msg, F> {
177 pub pasos: &'a [Paso],
178 pub actual: usize,
179 /// Contenido del paso actual — lo arma el caller.
180 pub cuerpo: View<Msg>,
181 /// Ir a un paso (desde la tira o desde el pie).
182 pub on_ir: F,
183 /// Msg del botón **Finalizar** (último paso). `None` = sin botón final.
184 pub on_finalizar: Option<Msg>,
185 pub palette: StepperPalette,
186}
187
188/// Compone el asistente: tira + cuerpo + pie.
189pub fn stepper_view<Msg, F>(spec: StepperSpec<'_, Msg, F>) -> View<Msg>
190where
191 Msg: Clone + 'static,
192 F: Fn(usize) -> Msg,
193{
194 let StepperSpec { pasos, actual, cuerpo, on_ir, on_finalizar, palette } = spec;
195
196 // ── tira de pasos ───────────────────────────────────────────────────
197 let mut chips: Vec<View<Msg>> = Vec::with_capacity(pasos.len() * 2);
198 for (i, paso) in pasos.iter().enumerate() {
199 let es_actual = i == actual;
200 let (relleno, texto_circulo, fg) = match paso.estado {
201 EstadoPaso::Error => (palette.error, "!".to_string(), palette.fg_sobre_acento),
202 EstadoPaso::Hecho => (palette.acento, "✓".to_string(), palette.fg_sobre_acento),
203 _ if es_actual => (palette.acento, format!("{}", i + 1), palette.fg_sobre_acento),
204 _ => (palette.borde, format!("{}", i + 1), palette.fg_tenue),
205 };
206 let circulo = View::<Msg>::new(Style {
207 size: Size { width: length(CIRCULO), height: length(CIRCULO) },
208 flex_shrink: 0.0,
209 align_items: Some(AlignItems::CENTER),
210 justify_content: Some(JustifyContent::CENTER),
211 ..Default::default()
212 })
213 .fill(relleno)
214 .radius((CIRCULO / 2.0) as f64)
215 .text(texto_circulo, 11.0, fg);
216
217 let color_titulo = match paso.estado {
218 EstadoPaso::Error => palette.error,
219 _ if es_actual => palette.fg_titulo,
220 EstadoPaso::Hecho => palette.fg_titulo,
221 _ => palette.fg_tenue,
222 };
223 let mut textos: Vec<View<Msg>> = vec![View::<Msg>::new(Style {
224 size: Size { width: auto(), height: length(16.0_f32) },
225 align_items: Some(AlignItems::CENTER),
226 ..Default::default()
227 })
228 .text_aligned(paso.titulo.clone(), 12.0, color_titulo, Alignment::Start)
229 .ellipsis(1)];
230 if let Some(d) = &paso.detalle {
231 textos.push(
232 View::<Msg>::new(Style {
233 size: Size { width: auto(), height: length(13.0_f32) },
234 ..Default::default()
235 })
236 .text_aligned(d.clone(), 10.0, palette.fg_tenue, Alignment::Start)
237 .ellipsis(1),
238 );
239 }
240 let columna = View::<Msg>::new(Style {
241 flex_direction: FlexDirection::Column,
242 size: Size { width: auto(), height: Dimension::auto() },
243 justify_content: Some(JustifyContent::CENTER),
244 ..Default::default()
245 })
246 .children(textos);
247
248 let alcanzable = puede_ir_a(pasos, actual, i);
249 // El nombre accesible lleva el estado, que si no queda sólo en el
250 // color del círculo: «Paso 2 de 4: Pago (hecho)».
251 let etiqueta = format!(
252 "Paso {} de {}: {} ({})",
253 i + 1,
254 pasos.len(),
255 paso.titulo,
256 match paso.estado {
257 EstadoPaso::Pendiente => "pendiente",
258 EstadoPaso::Actual => "en curso",
259 EstadoPaso::Hecho => "hecho",
260 EstadoPaso::Error => "con error",
261 }
262 );
263 let mut chip = View::<Msg>::new(Style {
264 flex_direction: FlexDirection::Row,
265 size: Size { width: auto(), height: percent(1.0_f32) },
266 align_items: Some(AlignItems::CENTER),
267 gap: Size { width: length(7.0_f32), height: length(0.0_f32) },
268 padding: Rect {
269 left: length(6.0_f32),
270 right: length(8.0_f32),
271 top: length(0.0_f32),
272 bottom: length(0.0_f32),
273 },
274 ..Default::default()
275 })
276 .radius(6.0)
277 .role(llimphi_compositor::Role::Button)
278 .aria_label(etiqueta)
279 .aria_checked(es_actual)
280 .children(vec![circulo, columna]);
281 if alcanzable {
282 chip = chip
283 .cursor(llimphi_compositor::Cursor::Pointer)
284 .on_click(on_ir(i));
285 } else if !es_actual {
286 // Ni clickeable ni mudo: se declara deshabilitado para que un
287 // lector diga por qué no responde.
288 chip = chip.aria_disabled(true);
289 }
290 chips.push(chip);
291
292 if i + 1 < pasos.len() {
293 chips.push(
294 View::<Msg>::new(Style {
295 size: Size { width: auto(), height: length(1.0_f32) },
296 flex_grow: 1.0,
297 min_size: Size { width: length(12.0_f32), height: auto() },
298 ..Default::default()
299 })
300 .fill(if matches!(paso.estado, EstadoPaso::Hecho) {
301 palette.acento
302 } else {
303 palette.borde
304 }),
305 );
306 }
307 }
308 let tira = View::<Msg>::new(Style {
309 flex_direction: FlexDirection::Row,
310 size: Size { width: percent(1.0_f32), height: length(TIRA_H) },
311 flex_shrink: 0.0,
312 align_items: Some(AlignItems::CENTER),
313 padding: Rect {
314 left: length(10.0_f32),
315 right: length(10.0_f32),
316 top: length(0.0_f32),
317 bottom: length(0.0_f32),
318 },
319 ..Default::default()
320 })
321 .children(chips);
322
323 // ── cuerpo ──────────────────────────────────────────────────────────
324 let cuerpo = View::<Msg>::new(Style {
325 size: Size { width: percent(1.0_f32), height: Dimension::auto() },
326 flex_grow: 1.0,
327 flex_basis: length(0.0_f32),
328 min_size: Size { width: auto(), height: length(0.0_f32) },
329 padding: Rect {
330 left: length(12.0_f32),
331 right: length(12.0_f32),
332 top: length(6.0_f32),
333 bottom: length(6.0_f32),
334 },
335 ..Default::default()
336 })
337 .children(vec![cuerpo]);
338
339 // ── pie ─────────────────────────────────────────────────────────────
340 let ultimo = es_ultimo(pasos, actual);
341 let mut pie_cols: Vec<View<Msg>> = vec![boton(
342 "Atrás",
343 anterior(actual).map(&on_ir),
344 false,
345 &palette,
346 )];
347 pie_cols.push(
348 View::<Msg>::new(Style {
349 size: Size { width: auto(), height: percent(1.0_f32) },
350 flex_grow: 1.0,
351 align_items: Some(AlignItems::CENTER),
352 justify_content: Some(JustifyContent::CENTER),
353 ..Default::default()
354 })
355 .text(
356 format!("{} de {}", (actual + 1).min(pasos.len().max(1)), pasos.len().max(1)),
357 10.5,
358 palette.fg_tenue,
359 ),
360 );
361 // En el último paso el botón dice **Finalizar** y emite otro Msg: llamarlo
362 // «Siguiente» cuando no hay siguiente es la mentira más común de un wizard.
363 if ultimo {
364 pie_cols.push(boton("Finalizar", on_finalizar, true, &palette));
365 } else {
366 pie_cols.push(boton(
367 "Siguiente",
368 siguiente(pasos, actual).map(&on_ir),
369 true,
370 &palette,
371 ));
372 }
373 let pie = View::<Msg>::new(Style {
374 flex_direction: FlexDirection::Row,
375 size: Size { width: percent(1.0_f32), height: length(PIE_H) },
376 flex_shrink: 0.0,
377 align_items: Some(AlignItems::CENTER),
378 padding: Rect {
379 left: length(10.0_f32),
380 right: length(10.0_f32),
381 top: length(0.0_f32),
382 bottom: length(0.0_f32),
383 },
384 gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
385 ..Default::default()
386 })
387 .fill(palette.bg_pie)
388 .children(pie_cols);
389
390 View::<Msg>::new(Style {
391 flex_direction: FlexDirection::Column,
392 size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
393 ..Default::default()
394 })
395 .fill(palette.bg)
396 .children(vec![tira, cuerpo, pie])
397}
398
399/// Botón del pie. `msg = None` lo deja apagado — visible, para que el pie no
400/// cambie de forma entre pasos.
401fn boton<Msg: Clone + 'static>(
402 texto: &str,
403 msg: Option<Msg>,
404 primario: bool,
405 p: &StepperPalette,
406) -> View<Msg> {
407 let habil = msg.is_some();
408 let (fondo, fg) = match (primario, habil) {
409 (true, true) => (Some(p.acento), p.fg_sobre_acento),
410 (true, false) => (Some(p.borde), p.fg_tenue),
411 (false, true) => (None, p.fg_titulo),
412 (false, false) => (None, p.fg_tenue),
413 };
414 let mut v = View::<Msg>::new(Style {
415 size: Size { width: length(96.0_f32), height: length(28.0_f32) },
416 flex_shrink: 0.0,
417 align_items: Some(AlignItems::CENTER),
418 justify_content: Some(JustifyContent::CENTER),
419 ..Default::default()
420 })
421 .text(texto.to_string(), 12.0, fg)
422 .radius(6.0)
423 .role(llimphi_compositor::Role::Button)
424 .aria_label(texto.to_string());
425 if let Some(c) = fondo {
426 v = v.fill(c);
427 }
428 match msg {
429 Some(m) => v.cursor(llimphi_compositor::Cursor::Pointer).on_click(m),
430 None => v.aria_disabled(true),
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 fn pasos() -> Vec<Paso> {
439 vec![
440 Paso::hecho("Datos"),
441 Paso::actual("Pago"),
442 Paso::pendiente("Envío"),
443 Paso::pendiente("Confirmar"),
444 ]
445 }
446
447 #[test]
448 fn atras_siempre_se_puede() {
449 let p = pasos();
450 assert!(puede_ir_a(&p, 1, 0));
451 assert!(puede_ir_a(&p, 3, 0), "volver a revisar no rompe nada");
452 assert_eq!(anterior(1), Some(0));
453 assert_eq!(anterior(0), None);
454 }
455
456 #[test]
457 fn adelante_solo_si_el_actual_esta_resuelto() {
458 let p = pasos(); // el actual (1, «Pago») está en Actual, no Hecho
459 assert!(!puede_ir_a(&p, 1, 2), "el paso actual no está resuelto");
460 assert_eq!(siguiente(&p, 1), None);
461
462 let mut p2 = pasos();
463 p2[1].estado = EstadoPaso::Hecho;
464 assert!(puede_ir_a(&p2, 1, 2));
465 assert_eq!(siguiente(&p2, 1), Some(2));
466 }
467
468 #[test]
469 fn no_se_saltea_un_paso_del_medio() {
470 // Del 1 al 3 pasando por un 2 sin resolver: el wizard existe justamente
471 // para que eso no pase.
472 let mut p = pasos();
473 p[1].estado = EstadoPaso::Hecho;
474 assert!(!puede_ir_a(&p, 1, 3), "el paso 2 sigue pendiente");
475 p[2].estado = EstadoPaso::Hecho;
476 assert!(puede_ir_a(&p, 1, 3));
477 }
478
479 #[test]
480 fn a_un_paso_con_error_se_vuelve_porque_es_donde_hay_que_arreglarlo() {
481 let mut p = pasos();
482 p[0].estado = EstadoPaso::Error;
483 // Está antes: se puede (como cualquier paso anterior).
484 assert!(puede_ir_a(&p, 2, 0));
485 // Y un error ADELANTE no habilita saltar: sigue faltando resolver el
486 // camino hasta ahí.
487 let mut q = pasos();
488 q[2].estado = EstadoPaso::Error;
489 assert!(!puede_ir_a(&q, 1, 3));
490 }
491
492 #[test]
493 fn los_bordes_no_explotan() {
494 let p = pasos();
495 assert!(!puede_ir_a(&p, 1, 1), "ir a donde ya estás no es ir");
496 assert!(!puede_ir_a(&p, 1, 99), "fuera de rango");
497 assert!(!puede_ir_a(&[], 0, 0));
498 assert_eq!(siguiente(&p, 3), None, "no hay quinto paso");
499 assert!(!es_ultimo(&[], 0));
500 assert!(es_ultimo(&p, 3));
501 assert!(!es_ultimo(&p, 2));
502 }
503
504 #[test]
505 fn hechos_cuenta_solo_los_resueltos() {
506 let mut p = pasos();
507 assert_eq!(hechos(&p), 1);
508 p[2].estado = EstadoPaso::Error;
509 assert_eq!(hechos(&p), 1, "un error no es un hecho");
510 p[1].estado = EstadoPaso::Hecho;
511 assert_eq!(hechos(&p), 2);
512 }
513}