tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Sutichaykuna

⎇ main widgets/stepper/src/lib.rs Rust · 513 siq'ikuna · 17.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

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}