//! `llimphi-widget-stepper` — asistente por pasos (wizard). //! //! Tira de pasos numerados arriba, cuerpo del paso actual en el medio, pie con //! **Atrás / Siguiente / Finalizar**. Stateless: el paso actual y el estado de //! cada uno viven en el `Model` del caller. //! //! Lo que no es trivial de un wizard —y por eso está acá y no en cada app— es //! **a dónde se puede ir**: adelante sólo si el paso actual está resuelto, atrás //! siempre, y a un paso ya hecho de un salto. Eso es [`puede_ir_a`], pura. Sin //! ella cada app inventa su regla y termina dejando avanzar con el formulario a //! medio llenar. //! //! ```ignore //! stepper_view(StepperSpec { //! pasos: &[Paso::hecho("Datos"), Paso::actual("Pago"), Paso::pendiente("Confirmar")], //! actual: 1, //! cuerpo: paso_pago_view(m), //! on_ir: Msg::IrAPaso, // Fn(usize) -> Msg //! palette: StepperPalette::from_theme(&theme), //! }) //! ``` #![forbid(unsafe_code)] // Sin `std`: sólo `alloc`, para que cruce a una jaula WASM o al kernel (W4.6). #![no_std] extern crate alloc; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec; use alloc::vec::Vec; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{auto, length, percent, Dimension, FlexDirection, Size, Style}, AlignItems, JustifyContent, Rect, }; use llimphi_compositor::llimphi_text::Alignment; use llimphi_compositor::peniko::Color; use llimphi_compositor::View; use llimphi_theme::Theme; /// En qué anda un paso. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EstadoPaso { /// Todavía no se llegó. Pendiente, /// El que se está viendo. Actual, /// Resuelto: se puede volver a él de un salto. Hecho, /// Se intentó y quedó mal. **No** es lo mismo que pendiente: al paso con /// error se puede volver, y hay que poder verlo desde la tira. Error, } /// Un paso de la tira. #[derive(Debug, Clone)] pub struct Paso { pub titulo: String, pub estado: EstadoPaso, /// Renglón chico bajo el título (opcional). pub detalle: Option, } impl Paso { pub fn new(titulo: impl Into, estado: EstadoPaso) -> Self { Self { titulo: titulo.into(), estado, detalle: None } } pub fn pendiente(titulo: impl Into) -> Self { Self::new(titulo, EstadoPaso::Pendiente) } pub fn actual(titulo: impl Into) -> Self { Self::new(titulo, EstadoPaso::Actual) } pub fn hecho(titulo: impl Into) -> Self { Self::new(titulo, EstadoPaso::Hecho) } pub fn con_error(titulo: impl Into) -> Self { Self::new(titulo, EstadoPaso::Error) } pub fn detalle(mut self, texto: impl Into) -> Self { self.detalle = Some(texto.into()); self } } /// ¿Se puede saltar de `actual` a `destino`? /// /// La regla, que es la de cualquier wizard que no deje avanzar en falso: /// /// - **atrás, siempre** — revisar lo que ya se llenó no rompe nada; /// - **al siguiente**, sólo si el actual ya está `Hecho`; /// - **más adelante**, sólo si **todos** los pasos intermedios están `Hecho` /// (saltar el paso 2 para ir al 4 deja el 2 sin llenar, y el wizard existe /// justamente para que eso no pase); /// - a un paso en `Error` se puede ir: es adonde hay que ir a arreglarlo. pub fn puede_ir_a(pasos: &[Paso], actual: usize, destino: usize) -> bool { if destino >= pasos.len() || destino == actual { return false; } if destino < actual { return true; } // Hacia adelante: todo lo que queda atrás del destino tiene que estar // resuelto, el paso actual incluido. (actual..destino).all(|i| matches!(pasos[i].estado, EstadoPaso::Hecho)) } /// El paso siguiente alcanzable, o `None` si no se puede avanzar. pub fn siguiente(pasos: &[Paso], actual: usize) -> Option { let d = actual.checked_add(1)?; puede_ir_a(pasos, actual, d).then_some(d) } /// El paso anterior, o `None` si ya es el primero. pub fn anterior(actual: usize) -> Option { actual.checked_sub(1) } /// ¿Es el último paso? (el pie dice **Finalizar** en vez de Siguiente). pub fn es_ultimo(pasos: &[Paso], actual: usize) -> bool { !pasos.is_empty() && actual + 1 >= pasos.len() } /// Cuántos pasos están resueltos — para un «3 de 5» o una barra de avance. pub fn hechos(pasos: &[Paso]) -> usize { pasos.iter().filter(|p| p.estado == EstadoPaso::Hecho).count() } /// Paleta del stepper. #[derive(Debug, Clone, Copy)] pub struct StepperPalette { pub bg: Color, pub bg_pie: Color, pub fg_titulo: Color, pub fg_tenue: Color, pub acento: Color, /// Texto sobre el círculo del paso actual/hecho. pub fg_sobre_acento: Color, pub error: Color, pub borde: Color, } impl StepperPalette { pub fn from_theme(t: &Theme) -> Self { Self { bg: t.bg_panel, bg_pie: t.bg_panel_alt, fg_titulo: t.fg_text, fg_tenue: t.fg_muted, acento: t.accent, fg_sobre_acento: t.bg_app, error: t.fg_destructive, borde: t.border, } } } impl Default for StepperPalette { fn default() -> Self { Self::from_theme(&Theme::dark()) } } const TIRA_H: f32 = 52.0; const PIE_H: f32 = 44.0; const CIRCULO: f32 = 22.0; /// Especificación del stepper. pub struct StepperSpec<'a, Msg, F> { pub pasos: &'a [Paso], pub actual: usize, /// Contenido del paso actual — lo arma el caller. pub cuerpo: View, /// Ir a un paso (desde la tira o desde el pie). pub on_ir: F, /// Msg del botón **Finalizar** (último paso). `None` = sin botón final. pub on_finalizar: Option, pub palette: StepperPalette, } /// Compone el asistente: tira + cuerpo + pie. pub fn stepper_view(spec: StepperSpec<'_, Msg, F>) -> View where Msg: Clone + 'static, F: Fn(usize) -> Msg, { let StepperSpec { pasos, actual, cuerpo, on_ir, on_finalizar, palette } = spec; // ── tira de pasos ─────────────────────────────────────────────────── let mut chips: Vec> = Vec::with_capacity(pasos.len() * 2); for (i, paso) in pasos.iter().enumerate() { let es_actual = i == actual; let (relleno, texto_circulo, fg) = match paso.estado { EstadoPaso::Error => (palette.error, "!".to_string(), palette.fg_sobre_acento), EstadoPaso::Hecho => (palette.acento, "✓".to_string(), palette.fg_sobre_acento), _ if es_actual => (palette.acento, format!("{}", i + 1), palette.fg_sobre_acento), _ => (palette.borde, format!("{}", i + 1), palette.fg_tenue), }; let circulo = View::::new(Style { size: Size { width: length(CIRCULO), height: length(CIRCULO) }, flex_shrink: 0.0, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .fill(relleno) .radius((CIRCULO / 2.0) as f64) .text(texto_circulo, 11.0, fg); let color_titulo = match paso.estado { EstadoPaso::Error => palette.error, _ if es_actual => palette.fg_titulo, EstadoPaso::Hecho => palette.fg_titulo, _ => palette.fg_tenue, }; let mut textos: Vec> = vec![View::::new(Style { size: Size { width: auto(), height: length(16.0_f32) }, align_items: Some(AlignItems::CENTER), ..Default::default() }) .text_aligned(paso.titulo.clone(), 12.0, color_titulo, Alignment::Start) .ellipsis(1)]; if let Some(d) = &paso.detalle { textos.push( View::::new(Style { size: Size { width: auto(), height: length(13.0_f32) }, ..Default::default() }) .text_aligned(d.clone(), 10.0, palette.fg_tenue, Alignment::Start) .ellipsis(1), ); } let columna = View::::new(Style { flex_direction: FlexDirection::Column, size: Size { width: auto(), height: Dimension::auto() }, justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .children(textos); let alcanzable = puede_ir_a(pasos, actual, i); // El nombre accesible lleva el estado, que si no queda sólo en el // color del círculo: «Paso 2 de 4: Pago (hecho)». let etiqueta = format!( "Paso {} de {}: {} ({})", i + 1, pasos.len(), paso.titulo, match paso.estado { EstadoPaso::Pendiente => "pendiente", EstadoPaso::Actual => "en curso", EstadoPaso::Hecho => "hecho", EstadoPaso::Error => "con error", } ); let mut chip = View::::new(Style { flex_direction: FlexDirection::Row, size: Size { width: auto(), height: percent(1.0_f32) }, align_items: Some(AlignItems::CENTER), gap: Size { width: length(7.0_f32), height: length(0.0_f32) }, padding: Rect { left: length(6.0_f32), right: length(8.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, ..Default::default() }) .radius(6.0) .role(llimphi_compositor::Role::Button) .aria_label(etiqueta) .aria_checked(es_actual) .children(vec![circulo, columna]); if alcanzable { chip = chip .cursor(llimphi_compositor::Cursor::Pointer) .on_click(on_ir(i)); } else if !es_actual { // Ni clickeable ni mudo: se declara deshabilitado para que un // lector diga por qué no responde. chip = chip.aria_disabled(true); } chips.push(chip); if i + 1 < pasos.len() { chips.push( View::::new(Style { size: Size { width: auto(), height: length(1.0_f32) }, flex_grow: 1.0, min_size: Size { width: length(12.0_f32), height: auto() }, ..Default::default() }) .fill(if matches!(paso.estado, EstadoPaso::Hecho) { palette.acento } else { palette.borde }), ); } } let tira = View::::new(Style { flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: length(TIRA_H) }, flex_shrink: 0.0, align_items: Some(AlignItems::CENTER), padding: Rect { left: length(10.0_f32), right: length(10.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, ..Default::default() }) .children(chips); // ── cuerpo ────────────────────────────────────────────────────────── let cuerpo = View::::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto() }, flex_grow: 1.0, flex_basis: length(0.0_f32), min_size: Size { width: auto(), height: length(0.0_f32) }, padding: Rect { left: length(12.0_f32), right: length(12.0_f32), top: length(6.0_f32), bottom: length(6.0_f32), }, ..Default::default() }) .children(vec![cuerpo]); // ── pie ───────────────────────────────────────────────────────────── let ultimo = es_ultimo(pasos, actual); let mut pie_cols: Vec> = vec![boton( "Atrás", anterior(actual).map(&on_ir), false, &palette, )]; pie_cols.push( View::::new(Style { size: Size { width: auto(), height: percent(1.0_f32) }, flex_grow: 1.0, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .text( format!("{} de {}", (actual + 1).min(pasos.len().max(1)), pasos.len().max(1)), 10.5, palette.fg_tenue, ), ); // En el último paso el botón dice **Finalizar** y emite otro Msg: llamarlo // «Siguiente» cuando no hay siguiente es la mentira más común de un wizard. if ultimo { pie_cols.push(boton("Finalizar", on_finalizar, true, &palette)); } else { pie_cols.push(boton( "Siguiente", siguiente(pasos, actual).map(&on_ir), true, &palette, )); } let pie = View::::new(Style { flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: length(PIE_H) }, flex_shrink: 0.0, align_items: Some(AlignItems::CENTER), padding: Rect { left: length(10.0_f32), right: length(10.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, ..Default::default() }) .fill(palette.bg_pie) .children(pie_cols); View::::new(Style { flex_direction: FlexDirection::Column, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }) .fill(palette.bg) .children(vec![tira, cuerpo, pie]) } /// Botón del pie. `msg = None` lo deja apagado — visible, para que el pie no /// cambie de forma entre pasos. fn boton( texto: &str, msg: Option, primario: bool, p: &StepperPalette, ) -> View { let habil = msg.is_some(); let (fondo, fg) = match (primario, habil) { (true, true) => (Some(p.acento), p.fg_sobre_acento), (true, false) => (Some(p.borde), p.fg_tenue), (false, true) => (None, p.fg_titulo), (false, false) => (None, p.fg_tenue), }; let mut v = View::::new(Style { size: Size { width: length(96.0_f32), height: length(28.0_f32) }, flex_shrink: 0.0, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .text(texto.to_string(), 12.0, fg) .radius(6.0) .role(llimphi_compositor::Role::Button) .aria_label(texto.to_string()); if let Some(c) = fondo { v = v.fill(c); } match msg { Some(m) => v.cursor(llimphi_compositor::Cursor::Pointer).on_click(m), None => v.aria_disabled(true), } } #[cfg(test)] mod tests { use super::*; fn pasos() -> Vec { vec![ Paso::hecho("Datos"), Paso::actual("Pago"), Paso::pendiente("Envío"), Paso::pendiente("Confirmar"), ] } #[test] fn atras_siempre_se_puede() { let p = pasos(); assert!(puede_ir_a(&p, 1, 0)); assert!(puede_ir_a(&p, 3, 0), "volver a revisar no rompe nada"); assert_eq!(anterior(1), Some(0)); assert_eq!(anterior(0), None); } #[test] fn adelante_solo_si_el_actual_esta_resuelto() { let p = pasos(); // el actual (1, «Pago») está en Actual, no Hecho assert!(!puede_ir_a(&p, 1, 2), "el paso actual no está resuelto"); assert_eq!(siguiente(&p, 1), None); let mut p2 = pasos(); p2[1].estado = EstadoPaso::Hecho; assert!(puede_ir_a(&p2, 1, 2)); assert_eq!(siguiente(&p2, 1), Some(2)); } #[test] fn no_se_saltea_un_paso_del_medio() { // Del 1 al 3 pasando por un 2 sin resolver: el wizard existe justamente // para que eso no pase. let mut p = pasos(); p[1].estado = EstadoPaso::Hecho; assert!(!puede_ir_a(&p, 1, 3), "el paso 2 sigue pendiente"); p[2].estado = EstadoPaso::Hecho; assert!(puede_ir_a(&p, 1, 3)); } #[test] fn a_un_paso_con_error_se_vuelve_porque_es_donde_hay_que_arreglarlo() { let mut p = pasos(); p[0].estado = EstadoPaso::Error; // Está antes: se puede (como cualquier paso anterior). assert!(puede_ir_a(&p, 2, 0)); // Y un error ADELANTE no habilita saltar: sigue faltando resolver el // camino hasta ahí. let mut q = pasos(); q[2].estado = EstadoPaso::Error; assert!(!puede_ir_a(&q, 1, 3)); } #[test] fn los_bordes_no_explotan() { let p = pasos(); assert!(!puede_ir_a(&p, 1, 1), "ir a donde ya estás no es ir"); assert!(!puede_ir_a(&p, 1, 99), "fuera de rango"); assert!(!puede_ir_a(&[], 0, 0)); assert_eq!(siguiente(&p, 3), None, "no hay quinto paso"); assert!(!es_ultimo(&[], 0)); assert!(es_ultimo(&p, 3)); assert!(!es_ultimo(&p, 2)); } #[test] fn hechos_cuenta_solo_los_resueltos() { let mut p = pasos(); assert_eq!(hechos(&p), 1); p[2].estado = EstadoPaso::Error; assert_eq!(hechos(&p), 1, "un error no es un hecho"); p[1].estado = EstadoPaso::Hecho; assert_eq!(hechos(&p), 2); } }