tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-gallery/src/main.rs Rust · 1473 líneas · 51.4 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) b575c63f1 · Sergio ·

1//! `llimphi-gallery` — demo único del kit transversal de elegancia.
2//!
3//! Una sola ventana que muestra cómo se ven los widgets del kit
4//! juntos sobre el theme dark. Útil para verificar paleta, escala,
5//! cinética y consistencia visual de un vistazo.
6//!
7//! `cargo run -p llimphi-gallery --release`
8//!
9//! Controles:
10//! - Click en switches/segments/breadcrumb: dispatchea Msg
11//! - Click en "Mostrar toast": apila un toast en bottom-right
12//! - Click en "Abrir modal": muestra el modal
13//! - `?`: abre/cierra el overlay de atajos
14//! - Esc: cierra overlay activo
15
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use llimphi_ui::llimphi_layout::taffy::{
20    prelude::{auto, length, percent, FlexDirection, Size, Style},
21    AlignItems, JustifyContent, Rect,
22};
23use llimphi_ui::llimphi_raster::peniko::Color;
24use llimphi_ui::llimphi_text::Alignment;
25use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
26
27use llimphi_icons::{icon_view, Icon};
28use llimphi_theme::Theme;
29
30use app_bus::{AppMenu, Menu, MenuItem};
31use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
32
33use llimphi_widget_avatar::avatar_view;
34use llimphi_widget_badge::{count_badge_view, dot_badge_view, BadgeKind};
35use llimphi_widget_breadcrumb::{breadcrumb_view, BreadcrumbPalette};
36use llimphi_widget_card::{card_view, CardOptions, CardPalette};
37use llimphi_widget_context_menu::{
38    context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
39};
40use llimphi_widget_empty::{empty_view, EmptyPalette};
41use llimphi_widget_field::{field_view, FieldPalette, FieldSpec};
42use llimphi_widget_modal::{modal_view, ModalButton, ModalPalette, ModalSpec};
43use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
44use llimphi_widget_progress::{linear_progress_view, radial_progress_view};
45use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
46use llimphi_widget_shortcuts_help::{
47    shortcuts_help_view, ShortcutEntry, ShortcutGroup, ShortcutsHelpPalette, ShortcutsHelpSpec,
48};
49use llimphi_widget_skeleton::{skeleton_box_view, skeleton_line_view, SkeletonPalette};
50use llimphi_widget_spinner::spinner_view;
51use llimphi_widget_splash::splash_view;
52use llimphi_widget_status_bar::{status_bar_view, StatusBarPalette, StatusSegment};
53use llimphi_widget_switch::{switch_view, SwitchPalette};
54use llimphi_widget_toast::{toast_stack_view, Toast};
55use llimphi_widget_tooltip::{tooltip_view, Side, TooltipPalette, TooltipSpec};
56use llimphi_widget_wawa_mark::{wawa_mark_view, WawaMarkPalette};
57use chrono::NaiveDate;
58use llimphi_widget_accordion::{accordion_view, alternar, AccordionPalette, AccordionSpec, Modo, Seccion};
59use llimphi_widget_calendar::{CalendarOpciones, Limites, WeekStart};
60use llimphi_widget_datetime_picker::{
61    date_picker_popup, date_picker_trigger, time_picker_popup, time_picker_trigger, Acciones,
62    DatePopup, DateTrigger, FormatoFecha, FormatoHora, Hora, PickerPalette, TimePopup, TimeTrigger,
63};
64use llimphi_widget_form::{
65    campo_view, resumen_errores_view, CampoDef, EstadoForm, FormPalette, Formulario, Regla,
66};
67use llimphi_widget_scroll::{clamp_offset, cuerpo_scrolleable, AltoVisible, DesplazamientoV, ScrollPalette};
68use llimphi_widget_stepper::{
69    puede_ir_a, siguiente, stepper_view, EstadoPaso, Paso, StepperPalette, StepperSpec,
70};
71
72#[derive(Clone)]
73enum Msg {
74    /// Tick para forzar repaint (animaciones por reloj absoluto).
75    Tick,
76    ToggleA,
77    ToggleB,
78    SelectSeg(usize),
79    #[allow(dead_code)]
80    BreadcrumbJump(usize),
81    PushToast,
82    DismissToast(u64),
83    OpenModal,
84    CloseModal,
85    ConfirmModal,
86    ToggleShortcuts,
87    OpenContextMenu,
88    CloseContextMenu,
89    ContextMenuPick(usize),
90    /// Abrir/cerrar un menú raíz de la barra principal (`None` = cerrar).
91    MenuOpen(Option<usize>),
92    /// Comando elegido en la barra principal (id `menu.<verbo>`).
93    MenuCommand(String),
94    // --- Widgets de la tanda nueva ---
95    /// Rueda sobre una columna: `(cuál, delta px)`.
96    ColScroll(usize, f32),
97    /// Cabecera del acordeón.
98    Plegar(usize),
99    /// Ir a un paso del asistente.
100    IrAPaso(usize),
101    /// El campo del formulario perdió el foco (así se destapa su error).
102    CampoBlur(CampoForm),
103    /// Se intentó enviar el formulario: desde acá se muestran todos.
104    Enviar,
105    AbrirFecha,
106    AbrirHora,
107    ElegirFecha(NaiveDate),
108    LimpiarFecha,
109    ElegirHora(Hora),
110    VerMes(i32, u32),
111    CerrarPopup,
112}
113
114/// Los campos del formulario de muestra.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116enum CampoForm {
117    Nombre,
118    Correo,
119}
120
121/// Qué popup de picker está abierto.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123enum Popup {
124    Fecha,
125    Hora,
126}
127
128struct Model {
129    started_at: Instant,
130    switch_a: bool,
131    switch_b: bool,
132    seg: usize,
133    toasts: Vec<Toast>,
134    next_toast_id: u64,
135    modal_open: bool,
136    shortcuts_open: bool,
137    viewport: (f32, f32),
138    /// Anchor del context-menu si está abierto. None = cerrado.
139    menu_open: Option<(f32, f32)>,
140    /// Item resaltado del menú (`usize::MAX` = ninguno, estado inicial).
141    menu_active: usize,
142    /// Última opción elegida del menú — se muestra como toast.
143    menu_last_pick: Option<String>,
144    /// Índice del menú raíz de la barra principal abierto. `None` = ninguno.
145    menubar_open: Option<usize>,
146    // --- Widgets de la tanda nueva ---
147    /// Desplazamiento y alto medido de cada columna (izq/centro/der).
148    col_offset: [f32; 3],
149    col_alto: [AltoVisible; 3],
150    /// Secciones abiertas del acordeón.
151    plegables: Vec<usize>,
152    /// Paso actual del asistente y cuáles quedaron resueltos.
153    paso: usize,
154    pasos_hechos: [bool; 3],
155    /// Estado de «cuándo mostrar» del formulario de muestra.
156    form_estado: EstadoForm<CampoForm>,
157    fecha: Option<NaiveDate>,
158    hora: Option<Hora>,
159    cal_view: (i32, u32),
160    popup: Option<Popup>,
161}
162
163struct Gallery;
164
165impl App for Gallery {
166    type Model = Model;
167    type Msg = Msg;
168
169    fn title() -> &'static str {
170        "llimphi · gallery"
171    }
172
173    fn initial_size() -> (u32, u32) {
174        (1280, 800)
175    }
176
177    fn init(handle: &Handle<Self::Msg>) -> Self::Model {
178        // Loop infinito de ticks para animar spinner/skeleton/splash.
179        // En una app real esto se gateaba según haya animaciones vivas.
180        handle.spawn_periodic(Duration::from_millis(50), || Msg::Tick);
181        Model {
182            started_at: Instant::now(),
183            switch_a: true,
184            switch_b: false,
185            seg: 1,
186            toasts: Vec::new(),
187            next_toast_id: 0,
188            modal_open: false,
189            shortcuts_open: false,
190            viewport: (1280.0, 800.0),
191            menu_open: None,
192            menu_active: usize::MAX,
193            menu_last_pick: None,
194            menubar_open: None,
195            col_offset: [0.0; 3],
196            col_alto: Default::default(),
197            plegables: vec![0],
198            paso: 1,
199            pasos_hechos: [true, false, false],
200            form_estado: EstadoForm::nuevo(),
201            fecha: None,
202            hora: None,
203            cal_view: (2026, 8),
204            popup: None,
205        }
206    }
207
208    fn update(model: Self::Model, msg: Self::Msg, _handle: &Handle<Self::Msg>) -> Self::Model {
209        let mut m = model;
210        // Filtrar toasts expirados oportunamente.
211        let now = Instant::now();
212        m.toasts.retain(|t| t.is_alive(now));
213        match msg {
214            Msg::Tick => {}
215            Msg::ToggleA => m.switch_a = !m.switch_a,
216            Msg::ToggleB => m.switch_b = !m.switch_b,
217            Msg::SelectSeg(i) => m.seg = i,
218            Msg::BreadcrumbJump(_) => {} // sólo demo
219            Msg::PushToast => {
220                let kinds = [
221                    (BadgeKind::Info, "guardado en disco"),
222                    (BadgeKind::Success, "publicado correctamente"),
223                    (BadgeKind::Warning, "espacio bajo en cache"),
224                    (BadgeKind::Error, "no se pudo conectar"),
225                ];
226                let (kind, text) = kinds[(m.next_toast_id as usize) % kinds.len()];
227                let id = m.next_toast_id;
228                m.next_toast_id += 1;
229                let toast = match kind {
230                    BadgeKind::Info => Toast::info(id, text, Duration::from_secs(4)),
231                    BadgeKind::Success => Toast::success(id, text, Duration::from_secs(4)),
232                    BadgeKind::Warning => Toast::warning(id, text, Duration::from_secs(4)),
233                    BadgeKind::Error => Toast::error(id, text, Duration::from_secs(4)),
234                    BadgeKind::Neutral => Toast::info(id, text, Duration::from_secs(4)),
235                };
236                m.toasts.push(toast);
237            }
238            Msg::DismissToast(id) => m.toasts.retain(|t| t.id != id),
239            Msg::OpenModal => m.modal_open = true,
240            Msg::CloseModal => m.modal_open = false,
241            Msg::ConfirmModal => m.modal_open = false,
242            Msg::ToggleShortcuts => m.shortcuts_open = !m.shortcuts_open,
243            Msg::OpenContextMenu => {
244                // Posición fija razonable — el botón está en la columna
245                // derecha; abrir el menú con anchor relativo al
246                // viewport mantiene la demo predecible aunque la
247                // ventana cambie de tamaño.
248                m.menu_open = Some((m.viewport.0 * 0.72, m.viewport.1 * 0.55));
249                m.menu_active = usize::MAX;
250                m.menubar_open = None;
251            }
252            Msg::CloseContextMenu => {
253                m.menu_open = None;
254                m.menu_active = usize::MAX;
255            }
256            Msg::ContextMenuPick(idx) => {
257                let labels = ["Copiar", "Cortar", "Pegar", "", "Eliminar"];
258                let label = labels.get(idx).copied().unwrap_or("?");
259                m.menu_last_pick = Some(label.to_string());
260                m.menu_open = None;
261                m.menu_active = usize::MAX;
262                // Confirmación visible.
263                let id = m.next_toast_id;
264                m.next_toast_id += 1;
265                m.toasts.push(Toast::info(
266                    id,
267                    format!("Menú → {label}"),
268                    Duration::from_secs(3),
269                ));
270            }
271            Msg::MenuOpen(idx) => {
272                m.menubar_open = idx;
273                // El dropdown de la barra y el contextual son mutuamente
274                // excluyentes.
275                m.menu_open = None;
276            }
277            Msg::MenuCommand(cmd) => {
278                m.menubar_open = None;
279                match cmd.as_str() {
280                    "app.quit" => std::process::exit(0),
281                    "view.toast" => return Self::update(m, Msg::PushToast, _handle),
282                    "view.modal" => m.modal_open = true,
283                    "view.context" => {
284                        m.menu_open = Some((m.viewport.0 * 0.5, m.viewport.1 * 0.45));
285                        m.menu_active = usize::MAX;
286                    }
287                    "help.shortcuts" => m.shortcuts_open = true,
288                    "help.about" => {
289                        let id = m.next_toast_id;
290                        m.next_toast_id += 1;
291                        m.toasts.push(Toast::info(
292                            id,
293                            "llimphi · gallery — vitrina del kit de elegancia",
294                            Duration::from_secs(4),
295                        ));
296                    }
297                    _ => {}
298                }
299            }
300            // --- Widgets de la tanda nueva ---
301            Msg::ColScroll(col, d) => {
302                let alto = [ALTO_COL_IZQ, ALTO_COL_CEN, ALTO_COL_DER][col.min(2)];
303                let i = col.min(2);
304                m.col_offset[i] = clamp_offset(m.col_offset[i] + d, alto, m.col_alto[i].px());
305            }
306            Msg::Plegar(i) => m.plegables = alternar(&m.plegables, i, Modo::Una),
307            Msg::IrAPaso(i) => {
308                if puede_ir_a(&pasos_de(&m), m.paso, i) {
309                    m.paso = i;
310                }
311            }
312            Msg::CampoBlur(id) => m.form_estado.tocar(id),
313            Msg::Enviar => {
314                // El botón del pie del asistente: valida, marca el paso como
315                // resuelto y avanza. Es el mismo camino que en una app real.
316                m.form_estado.enviar();
317                if formulario().validar(&valores_form()).es_valido() {
318                    m.pasos_hechos[m.paso.min(2)] = true;
319                    let ps = pasos_de(&m);
320                    if let Some(sig) = siguiente(&ps, m.paso) {
321                        m.paso = sig;
322                    }
323                }
324            }
325            Msg::AbrirFecha => m.popup = Some(Popup::Fecha),
326            Msg::AbrirHora => m.popup = Some(Popup::Hora),
327            Msg::CerrarPopup => m.popup = None,
328            Msg::ElegirFecha(d) => {
329                m.fecha = Some(d);
330                m.popup = None;
331            }
332            Msg::LimpiarFecha => {
333                m.fecha = None;
334                m.popup = None;
335            }
336            Msg::ElegirHora(h) => m.hora = Some(h),
337            Msg::VerMes(y, mo) => m.cal_view = (y, mo),
338        }
339        m
340    }
341
342    fn on_key(_model: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
343        if ev.state != KeyState::Pressed {
344            return None;
345        }
346        match &ev.key {
347            Key::Named(NamedKey::Escape) => Some(Msg::CloseModal),
348            Key::Character(s) if s == "?" => Some(Msg::ToggleShortcuts),
349            _ => None,
350        }
351    }
352
353    fn view(model: &Self::Model) -> View<Self::Msg> {
354        let theme = Theme::dark();
355
356        // Tres columnas equilibradas + status bar inferior.
357        let left = column_left(model, &theme);
358        let center = column_center(model, &theme);
359        let right = column_right(model, &theme);
360
361        let cols = View::new(Style {
362            flex_direction: FlexDirection::Row,
363            size: Size {
364                width: percent(1.0_f32),
365                height: percent(1.0_f32),
366            },
367            flex_grow: 1.0,
368            gap: Size {
369                width: length(16.0_f32),
370                height: length(0.0_f32),
371            },
372            padding: Rect {
373                left: length(16.0_f32),
374                right: length(16.0_f32),
375                top: length(16.0_f32),
376                bottom: length(8.0_f32),
377            },
378            ..Default::default()
379        })
380        .children(vec![left, center, right]);
381
382        let status = status_bar_view(
383            vec![
384                StatusSegment::text("llimphi-gallery").with_icon(Icon::Home),
385                StatusSegment::text(if model.switch_a { "modo: pleno" } else { "modo: simple" })
386                    .emphasized(),
387            ],
388            vec![],
389            vec![
390                StatusSegment::text("Ln 1, Col 1"),
391                StatusSegment::text("UTF-8"),
392                StatusSegment::text("?  atajos")
393                    .clickable(Msg::ToggleShortcuts)
394                    .with_icon(Icon::Info),
395            ],
396            &StatusBarPalette::from_theme(&theme),
397        );
398
399        let menu = app_menu();
400        let bar = menubar_view(&menubar_spec(&menu, model, &theme));
401
402        View::new(Style {
403            flex_direction: FlexDirection::Column,
404            size: Size {
405                width: percent(1.0_f32),
406                height: percent(1.0_f32),
407            },
408            ..Default::default()
409        })
410        .fill(theme.bg_app)
411        .children(vec![bar, cols, status])
412    }
413
414    fn view_overlay(model: &Self::Model) -> Option<View<Self::Msg>> {
415        let theme = Theme::dark();
416        // Los popups de los pickers van primero: son los únicos que se abren
417        // desde un control de la vitrina y no desde el menú.
418        if let Some(p) = model.popup {
419            return Some(popup_pane(model, p, &theme));
420        }
421        // Prioridad: modal > shortcuts > toasts.
422        if model.modal_open {
423            return Some(modal_view(ModalSpec {
424                title: "Confirmar acción".to_string(),
425                body: modal_body_view(&theme),
426                buttons: vec![
427                    ModalButton::cancel("Cancelar", Msg::CloseModal),
428                    ModalButton::primary("Aplicar", Msg::ConfirmModal),
429                ],
430                size: (440.0, 220.0),
431                viewport: model.viewport,
432                on_dismiss: Msg::CloseModal,
433                palette: ModalPalette::from_theme(&theme),
434                body_scroll: None,
435            }));
436        }
437        if model.shortcuts_open {
438            return Some(shortcuts_help_view(ShortcutsHelpSpec {
439                title: "Atajos de teclado".to_string(),
440                groups: vec![
441                    ShortcutGroup::new(
442                        "General",
443                        vec![
444                            ShortcutEntry::new("?", "Mostrar/ocultar esta ayuda"),
445                            ShortcutEntry::new("Esc", "Cerrar overlay activo"),
446                        ],
447                    ),
448                    ShortcutGroup::new(
449                        "Demo",
450                        vec![
451                            ShortcutEntry::new("Click", "Toasts, modal y switches"),
452                            ShortcutEntry::new("Hover", "Tooltips sobre los avatares"),
453                        ],
454                    ),
455                ],
456                viewport: model.viewport,
457                on_dismiss: Msg::ToggleShortcuts,
458                palette: ShortcutsHelpPalette::from_theme(&theme),
459            }));
460        }
461        if let Some(anchor) = model.menu_open {
462            return Some(context_menu_view(ContextMenuSpec {
463                anchor,
464                viewport: model.viewport,
465                header: Some("Lienzo".into()),
466                items: vec![
467                    ContextMenuItem::action("Copiar").with_shortcut("Ctrl+C"),
468                    ContextMenuItem::action("Cortar").with_shortcut("Ctrl+X"),
469                    ContextMenuItem::action("Pegar").with_shortcut("Ctrl+V").disabled(),
470                    ContextMenuItem::separator(),
471                    ContextMenuItem::action("Eliminar")
472                        .with_shortcut("Del")
473                        .destructive(),
474                ],
475                active: model.menu_active,
476                on_pick: Arc::new(Msg::ContextMenuPick),
477                on_dismiss: Msg::CloseContextMenu,
478                palette: ContextMenuPalette::from_theme(&theme),
479            }));
480        }
481        // Dropdown de la barra de menú principal.
482        let menu = app_menu();
483        if let Some(v) = menubar_overlay(&menubar_spec(&menu, model, &theme)) {
484            return Some(v);
485        }
486        if !model.toasts.is_empty() {
487            return Some(toast_stack_view(
488                &model.toasts,
489                model.viewport,
490                Msg::DismissToast,
491            ));
492        }
493        None
494    }
495}
496
497// ---------------------------------------------------------------------
498// Barra de menú principal
499// ---------------------------------------------------------------------
500
501/// Menú principal de la vitrina. Sólo comandos que mapean a `Msg` reales.
502fn app_menu() -> AppMenu {
503    AppMenu::new()
504        .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "app.quit").shortcut("Ctrl+Q")))
505        .menu(
506            Menu::new("Ver")
507                .item(MenuItem::new("Mostrar toast", "view.toast"))
508                .item(MenuItem::new("Abrir modal", "view.modal"))
509                .item(MenuItem::new("Menú contextual", "view.context").separated()),
510        )
511        .menu(
512            Menu::new("Ayuda")
513                .item(MenuItem::new("Atajos", "help.shortcuts").shortcut("?"))
514                .item(MenuItem::new("Acerca de", "help.about")),
515        )
516}
517
518/// Arma el `MenuBarSpec` compartido entre `view` y `view_overlay`.
519fn menubar_spec<'a>(menu: &'a AppMenu, model: &Model, theme: &'a Theme) -> MenuBarSpec<'a, Msg> {
520    MenuBarSpec {
521        menu,
522        open: model.menubar_open,
523        theme,
524        viewport: model.viewport,
525        height: MENU_H,
526        on_open: Arc::new(Msg::MenuOpen),
527        on_command: Arc::new(|cmd: &str| Msg::MenuCommand(cmd.to_string())),
528    }
529}
530
531// ---------------------------------------------------------------------
532// Columnas
533// ---------------------------------------------------------------------
534
535fn column_left(model: &Model, theme: &Theme) -> View<Msg> {
536    let mut children: Vec<View<Msg>> = Vec::new();
537
538    children.push(section_title("Identidad"));
539    // Sello wawa en chico + grande.
540    children.push(
541        View::new(Style {
542            flex_direction: FlexDirection::Row,
543            size: Size {
544                width: percent(1.0_f32),
545                height: length(128.0_f32),
546            },
547            gap: Size {
548                width: length(16.0_f32),
549                height: length(0.0_f32),
550            },
551            align_items: Some(AlignItems::CENTER),
552            ..Default::default()
553        })
554        .children(vec![
555            wawa_frame(48.0),
556            wawa_frame(96.0),
557            wawa_frame(128.0),
558        ]),
559    );
560
561    children.push(section_title("Splash"));
562    children.push(
563        View::new(Style {
564            size: Size {
565                width: percent(1.0_f32),
566                height: length(220.0_f32),
567            },
568            flex_shrink: 0.0,
569            ..Default::default()
570        })
571        .fill(theme.bg_panel)
572        .radius(llimphi_theme::radius::MD)
573        .children(vec![splash_view(model.started_at, theme.bg_panel, theme.fg_text)]),
574    );
575
576    children.push(section_title("Empty state"));
577    children.push(
578        View::new(Style {
579            size: Size {
580                width: percent(1.0_f32),
581                height: length(200.0_f32),
582            },
583            flex_shrink: 0.0,
584            ..Default::default()
585        })
586        .fill(theme.bg_panel)
587        .radius(llimphi_theme::radius::MD)
588        .children(vec![empty_view(
589            Icon::Folder,
590            "Sin documentos abiertos",
591            Some("Abrí uno con Ctrl+O o crea un nuevo lienzo para empezar."),
592            &EmptyPalette::from_theme(theme),
593        )]),
594    );
595
596    panel_scrolleable(children, ALTO_COL_IZQ, 0, model, theme)
597}
598
599fn column_center(model: &Model, theme: &Theme) -> View<Msg> {
600    let mut children: Vec<View<Msg>> = Vec::new();
601
602    children.push(section_title("Navegación"));
603    children.push(
604        View::new(Style {
605            size: Size {
606                width: percent(1.0_f32),
607                height: auto(),
608            },
609            ..Default::default()
610        })
611        .children(vec![breadcrumb_view(
612            &["home", "docs", "2026", "elegancia.md"],
613            Msg::BreadcrumbJump,
614            &BreadcrumbPalette::from_theme(theme),
615        )]),
616    );
617
618    children.push(section_title("Controles"));
619    children.push(switch_row("Modo pleno", model.switch_a, Msg::ToggleA, theme));
620    children.push(switch_row("Telemetría", model.switch_b, Msg::ToggleB, theme));
621    children.push(spacer_v(8.0));
622    children.push(
623        View::new(Style {
624            size: Size {
625                width: percent(1.0_f32),
626                height: length(28.0_f32),
627            },
628            ..Default::default()
629        })
630        .children(vec![segmented_view(
631            &["lista", "grilla", "kanban"],
632            model.seg,
633            Msg::SelectSeg,
634            &SegmentedPalette::from_theme(theme),
635        )]),
636    );
637
638    children.push(section_title("Formulario"));
639    children.push(field_view(FieldSpec {
640        label: "Nombre del lienzo".to_string(),
641        control: fake_text_input("introducción a wawa", theme),
642        required: true,
643        helper: Some("Aparece como título en la pestaña.".to_string()),
644        error: None,
645        palette: FieldPalette::from_theme(theme),
646    }));
647    children.push(spacer_v(12.0));
648    children.push(field_view(FieldSpec {
649        label: "Slug".to_string(),
650        control: fake_text_input("intro-wawa-x@123", theme),
651        required: false,
652        helper: None,
653        error: Some("Sólo letras, números y guiones.".to_string()),
654        palette: FieldPalette::from_theme(theme),
655    }));
656
657    children.push(section_title("Acciones"));
658    children.push(button_row(theme));
659
660    panel_scrolleable(children, ALTO_COL_CEN, 1, model, theme)
661}
662
663fn column_right(model: &Model, theme: &Theme) -> View<Msg> {
664    let mut children: Vec<View<Msg>> = Vec::new();
665
666    children.push(section_title("Identidades"));
667    // Avatares en línea con badge encima.
668    children.push(
669        View::new(Style {
670            flex_direction: FlexDirection::Row,
671            size: Size {
672                width: percent(1.0_f32),
673                height: length(48.0_f32),
674            },
675            gap: Size {
676                width: length(8.0_f32),
677                height: length(0.0_f32),
678            },
679            align_items: Some(AlignItems::CENTER),
680            ..Default::default()
681        })
682        .children(vec![
683            avatar_view("sergio", 40.0),
684            avatar_view("calcetín", 40.0),
685            avatar_view("amaru", 40.0),
686            avatar_view("pacha", 40.0),
687            avatar_view("inti", 40.0),
688        ]),
689    );
690
691    children.push(section_title("Badges"));
692    children.push(
693        View::new(Style {
694            flex_direction: FlexDirection::Row,
695            size: Size {
696                width: percent(1.0_f32),
697                height: length(24.0_f32),
698            },
699            gap: Size {
700                width: length(10.0_f32),
701                height: length(0.0_f32),
702            },
703            align_items: Some(AlignItems::CENTER),
704            ..Default::default()
705        })
706        .children(vec![
707            count_badge_view(3, BadgeKind::Info),
708            count_badge_view(12, BadgeKind::Success),
709            count_badge_view(99, BadgeKind::Warning),
710            count_badge_view(120, BadgeKind::Error),
711            dot_badge_view(BadgeKind::Success),
712            dot_badge_view(BadgeKind::Warning),
713            dot_badge_view(BadgeKind::Error),
714        ]),
715    );
716
717    children.push(section_title("Carga"));
718    children.push(
719        View::new(Style {
720            flex_direction: FlexDirection::Row,
721            size: Size {
722                width: percent(1.0_f32),
723                height: length(48.0_f32),
724            },
725            gap: Size {
726                width: length(16.0_f32),
727                height: length(0.0_f32),
728            },
729            align_items: Some(AlignItems::CENTER),
730            ..Default::default()
731        })
732        .children(vec![
733            View::new(Style {
734                size: Size {
735                    width: length(40.0_f32),
736                    height: length(40.0_f32),
737                },
738                ..Default::default()
739            })
740            .children(vec![spinner_view(theme.accent, 0.12, 1.0)]),
741            View::new(Style {
742                size: Size {
743                    width: length(40.0_f32),
744                    height: length(40.0_f32),
745                },
746                ..Default::default()
747            })
748            .children(vec![radial_progress_view(
749                0.66,
750                theme.bg_button,
751                theme.accent,
752                0.14,
753            )]),
754            linear_progress_view(0.42, theme.bg_button, theme.accent, 8.0),
755        ]),
756    );
757
758    children.push(section_title("Skeleton"));
759    let palette = SkeletonPalette::from_theme(theme);
760    children.push(skeleton_line_view::<Msg>(200.0, &palette));
761    children.push(spacer_v(6.0));
762    children.push(skeleton_line_view::<Msg>(280.0, &palette));
763    children.push(spacer_v(6.0));
764    children.push(skeleton_line_view::<Msg>(160.0, &palette));
765    children.push(spacer_v(10.0));
766    children.push(skeleton_box_view::<Msg>(percent_to_px(0.9, 360.0), 60.0, &palette));
767
768    children.push(section_title("Cards"));
769    // Dos cards apilados: el primero con la firma (gradient sutil +
770    // hairline en el top), el segundo con `accent` lateral y fill plano.
771    // Para apreciar la firma hay que mirar de cerca: el ojo registra
772    // "tallado" sin saber por qué.
773    let card_palette = CardPalette::from_theme(theme);
774    children.push(card_view(
775        vec![
776            text_line("Documento — multilienzo", 13.0, theme.fg_text),
777            text_line("3 cuerpos · 412 átomos · BLAKE3 verificado", 11.0, theme.fg_muted),
778        ],
779        CardOptions::with_signature(theme),
780        &card_palette,
781    ));
782    children.push(spacer_v(8.0));
783    children.push(card_view(
784        vec![
785            text_line("Build pasó — wawa-kernel", 13.0, theme.fg_text),
786            text_line("x86_64-unknown-none · 1.42s · 0 warnings", 11.0, theme.fg_muted),
787        ],
788        CardOptions {
789            accent: Some(Color::from_rgba8(110, 200, 130, 255)),
790            ..Default::default()
791        },
792        &card_palette,
793    ));
794
795    children.push(section_title("Menú contextual"));
796    children.push(
797        View::new(Style {
798            size: Size {
799                width: percent(1.0_f32),
800                height: length(32.0_f32),
801            },
802            flex_shrink: 0.0,
803            align_items: Some(AlignItems::CENTER),
804            justify_content: Some(JustifyContent::CENTER),
805            padding: Rect {
806                left: length(12.0_f32),
807                right: length(12.0_f32),
808                top: length(0.0_f32),
809                bottom: length(0.0_f32),
810            },
811            ..Default::default()
812        })
813        .fill(theme.bg_button)
814        .hover_fill(theme.bg_button_hover)
815        .radius(llimphi_theme::radius::SM)
816        .text_aligned(
817            "Mostrar menú".to_string(),
818            12.0,
819            theme.fg_text,
820            Alignment::Center,
821        )
822        .on_click(Msg::OpenContextMenu),
823    );
824
825    children.push(section_title("Iconografía"));
826    children.push(icon_grid(theme));
827
828    // ── La tanda nueva de widgets, en su primer caller vivo ────────────
829    children.push(section_title("Plegables"));
830    children.push(bloque_acordeon(model, theme));
831
832    children.push(section_title("Asistente"));
833    children.push(bloque_asistente(model, theme));
834
835    children.push(section_title("Formulario validado"));
836    children.push(bloque_formulario(model, theme));
837
838    children.push(section_title("Fecha y hora"));
839    children.push(bloque_pickers(model, theme));
840
841    panel_scrolleable(children, ALTO_COL_DER, 2, model, theme)
842}
843
844// ---------------------------------------------------------------------
845// Helpers de composición
846// ---------------------------------------------------------------------
847
848fn text_line(text: &str, size: f32, color: Color) -> View<Msg> {
849    View::new(Style {
850        size: Size {
851            width: percent(1.0_f32),
852            height: length(size + 6.0),
853        },
854        ..Default::default()
855    })
856    .text_aligned(text.to_string(), size, color, Alignment::Start)
857}
858
859fn section_title(text: &str) -> View<Msg> {
860    View::new(Style {
861        size: Size {
862            width: percent(1.0_f32),
863            height: length(20.0_f32),
864        },
865        flex_shrink: 0.0,
866        ..Default::default()
867    })
868    .text_aligned(
869        text.to_uppercase(),
870        10.0,
871        Color::from_rgba8(140, 160, 200, 255),
872        Alignment::Start,
873    )
874}
875
876/// [`panel_view`] con **scroll real** del contenido: la vitrina crece con cada
877/// widget nuevo y hasta acá lo que no entraba se recortaba y punto (el panel
878/// tiene `clip`). `col` es cuál de las tres columnas, para que cada una lleve
879/// su propio desplazamiento.
880fn panel_scrolleable(
881    children: Vec<View<Msg>>,
882    alto_contenido: f32,
883    col: usize,
884    model: &Model,
885    theme: &Theme,
886) -> View<Msg> {
887    let cuerpo = View::new(Style {
888        flex_direction: FlexDirection::Column,
889        size: Size {
890            width: percent(1.0_f32),
891            height: length(alto_contenido),
892        },
893        gap: Size {
894            width: length(0.0_f32),
895            height: length(10.0_f32),
896        },
897        ..Default::default()
898    })
899    .children(children);
900    let area = cuerpo_scrolleable(
901        cuerpo,
902        alto_contenido,
903        DesplazamientoV {
904            offset: model.col_offset[col],
905            viewport_len: model.col_alto[col].px(),
906            on_scroll: Arc::new(move |d: f32| Msg::ColScroll(col, d)),
907        },
908        Some(model.col_alto[col].clone()),
909        &ScrollPalette::from_theme(theme),
910    );
911    panel_view(vec![area], theme)
912}
913
914fn panel_view(children: Vec<View<Msg>>, theme: &Theme) -> View<Msg> {
915    let style = PanelStyle::from_theme(theme);
916    View::new(Style {
917        flex_direction: FlexDirection::Column,
918        size: Size {
919            width: percent(1.0_f32),
920            height: percent(1.0_f32),
921        },
922        flex_grow: 1.0,
923        padding: Rect {
924            left: length(16.0_f32),
925            right: length(16.0_f32),
926            top: length(14.0_f32),
927            bottom: length(14.0_f32),
928        },
929        gap: Size {
930            width: length(0.0_f32),
931            height: length(10.0_f32),
932        },
933        ..Default::default()
934    })
935    .paint_with(panel_signature_painter(style))
936    .radius(style.radius)
937    .clip(true)
938    .children(children)
939}
940
941fn switch_row(label: &str, value: bool, msg: Msg, theme: &Theme) -> View<Msg> {
942    let progress = if value { 1.0 } else { 0.0 };
943    View::new(Style {
944        flex_direction: FlexDirection::Row,
945        size: Size {
946            width: percent(1.0_f32),
947            height: length(28.0_f32),
948        },
949        align_items: Some(AlignItems::CENTER),
950        justify_content: Some(JustifyContent::SPACE_BETWEEN),
951        ..Default::default()
952    })
953    .children(vec![
954        View::new(Style {
955            size: Size {
956                width: percent(1.0_f32),
957                height: percent(1.0_f32),
958            },
959            flex_grow: 1.0,
960            align_items: Some(AlignItems::CENTER),
961            ..Default::default()
962        })
963        .text_aligned(label.to_string(), 12.0, theme.fg_text, Alignment::Start),
964        switch_view(progress, msg, &SwitchPalette::from_theme(theme)),
965    ])
966}
967
968fn fake_text_input(text: &str, theme: &Theme) -> View<Msg> {
969    View::new(Style {
970        size: Size {
971            width: percent(1.0_f32),
972            height: length(28.0_f32),
973        },
974        padding: Rect {
975            left: length(8.0_f32),
976            right: length(8.0_f32),
977            top: length(0.0_f32),
978            bottom: length(0.0_f32),
979        },
980        align_items: Some(AlignItems::CENTER),
981        flex_shrink: 0.0,
982        ..Default::default()
983    })
984    .fill(theme.bg_input)
985    .radius(llimphi_theme::radius::SM)
986    .text_aligned(text.to_string(), 12.0, theme.fg_text, Alignment::Start)
987}
988
989fn button_row(theme: &Theme) -> View<Msg> {
990    View::new(Style {
991        flex_direction: FlexDirection::Row,
992        size: Size {
993            width: percent(1.0_f32),
994            height: length(32.0_f32),
995        },
996        gap: Size {
997            width: length(8.0_f32),
998            height: length(0.0_f32),
999        },
1000        ..Default::default()
1001    })
1002    .children(vec![
1003        btn("Mostrar toast", theme.accent, theme.bg_app, Msg::PushToast),
1004        btn("Abrir modal", theme.bg_button, theme.fg_text, Msg::OpenModal),
1005        btn("Atajos (?)", theme.bg_button, theme.fg_text, Msg::ToggleShortcuts),
1006    ])
1007}
1008
1009fn btn(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
1010    let w = label.chars().count() as f32 * 7.5 + 24.0;
1011    View::new(Style {
1012        size: Size {
1013            width: length(w),
1014            height: length(32.0_f32),
1015        },
1016        align_items: Some(AlignItems::CENTER),
1017        justify_content: Some(JustifyContent::CENTER),
1018        flex_shrink: 0.0,
1019        ..Default::default()
1020    })
1021    .fill(bg)
1022    .radius(llimphi_theme::radius::SM)
1023    .text_aligned(label.to_string(), 12.0, fg, Alignment::Center)
1024    .on_click(msg)
1025}
1026
1027fn icon_grid(theme: &Theme) -> View<Msg> {
1028    let icons = [
1029        Icon::File, Icon::Folder, Icon::Save, Icon::Open, Icon::Search,
1030        Icon::Plus, Icon::Minus, Icon::X, Icon::Check, Icon::Edit,
1031        Icon::Trash, Icon::Home, Icon::Settings, Icon::Bell, Icon::More,
1032        Icon::Info, Icon::Warning, Icon::Error, Icon::ChevronUp,
1033        Icon::ChevronDown, Icon::ChevronLeft, Icon::ChevronRight,
1034        Icon::FolderOpen,
1035    ];
1036    let cells: Vec<View<Msg>> = icons
1037        .iter()
1038        .map(|i| {
1039            View::new(Style {
1040                size: Size {
1041                    width: length(28.0_f32),
1042                    height: length(28.0_f32),
1043                },
1044                flex_shrink: 0.0,
1045                ..Default::default()
1046            })
1047            .fill(theme.bg_panel_alt)
1048            .radius(llimphi_theme::radius::XS)
1049            .children(vec![icon_view(*i, theme.fg_text, 1.6)])
1050        })
1051        .collect();
1052
1053    View::new(Style {
1054        flex_direction: FlexDirection::Row,
1055        size: Size {
1056            width: percent(1.0_f32),
1057            height: auto(),
1058        },
1059        gap: Size {
1060            width: length(6.0_f32),
1061            height: length(6.0_f32),
1062        },
1063        flex_wrap: llimphi_ui::llimphi_layout::taffy::FlexWrap::Wrap,
1064        ..Default::default()
1065    })
1066    .children(cells)
1067}
1068
1069fn modal_body_view(theme: &Theme) -> View<Msg> {
1070    View::new(Style {
1071        size: Size {
1072            width: percent(1.0_f32),
1073            height: percent(1.0_f32),
1074        },
1075        flex_grow: 1.0,
1076        ..Default::default()
1077    })
1078    .text_aligned(
1079        "Esta acción reescribirá la configuración local. \
1080         Sólo dura mientras no salgas — al guardar quedará persistida en disco."
1081            .to_string(),
1082        12.0,
1083        theme.fg_muted,
1084        Alignment::Start,
1085    )
1086}
1087
1088fn wawa_frame(side: f32) -> View<Msg> {
1089    View::new(Style {
1090        size: Size {
1091            width: length(side),
1092            height: length(side),
1093        },
1094        align_items: Some(AlignItems::CENTER),
1095        justify_content: Some(JustifyContent::CENTER),
1096        flex_shrink: 0.0,
1097        ..Default::default()
1098    })
1099    .children(vec![wawa_mark_view(&WawaMarkPalette::default())])
1100}
1101
1102fn spacer_v(h: f32) -> View<Msg> {
1103    View::new(Style {
1104        size: Size {
1105            width: percent(1.0_f32),
1106            height: length(h),
1107        },
1108        flex_shrink: 0.0,
1109        ..Default::default()
1110    })
1111}
1112
1113fn percent_to_px(p: f32, base: f32) -> f32 {
1114    p * base
1115}
1116
1117// Tooltip placeholder — la demo no instrumenta hover-to-show porque
1118// requeriría más Msgs; queda como código de referencia para apps reales.
1119#[allow(dead_code)]
1120fn demo_tooltip(viewport: (f32, f32), text: &str, theme: &Theme) -> View<Msg> {
1121    tooltip_view::<Msg>(TooltipSpec {
1122        anchor: (viewport.0 * 0.5, viewport.1 * 0.5),
1123        viewport,
1124        side: Side::Bottom,
1125        text: text.to_string(),
1126        palette: TooltipPalette::from_theme(theme),
1127    })
1128}
1129
1130
1131// ---------------------------------------------------------------------
1132// La tanda nueva: acordeón · asistente · formulario validado · pickers
1133// ---------------------------------------------------------------------
1134
1135/// Alto del contenido de cada columna. Es lo que el scroll necesita saber para
1136/// clampear; se estima por lo que hay adentro (la vitrina es fija, no crece en
1137/// caliente).
1138const ALTO_COL_IZQ: f32 = 1180.0;
1139const ALTO_COL_CEN: f32 = 1120.0;
1140const ALTO_COL_DER: f32 = 1500.0;
1141
1142/// El «hoy» de la vitrina: fijo, para que una captura de pantalla no cambie de
1143/// día entre corridas.
1144const HOY: NaiveDate = match NaiveDate::from_ymd_opt(2026, 8, 5) {
1145    Some(d) => d,
1146    None => panic!("fecha fija válida"),
1147};
1148
1149fn bloque_acordeon(model: &Model, theme: &Theme) -> View<Msg> {
1150    let t1 = *theme;
1151    let t2 = *theme;
1152    accordion_view(AccordionSpec {
1153        secciones: vec![
1154            Seccion::new("Qué es", move || {
1155                text_line("Secciones plegables; el cuerpo cerrado ni se construye.", 11.5, t1.fg_muted)
1156            })
1157            .con_resumen("expansion panel"),
1158            Seccion::new("Cómo se usa", move || {
1159                text_line("alternar(&abiertas, i, Modo::Una) en el update.", 11.5, t2.fg_muted)
1160            })
1161            .con_resumen("una línea"),
1162            Seccion::new("Deshabilitada", || text_line("no debería verse", 11.5, Color::WHITE))
1163                .habilitada(false),
1164        ],
1165        abiertas: &model.plegables,
1166        modo: Modo::Una,
1167        on_toggle: Msg::Plegar,
1168        palette: AccordionPalette::from_theme(theme),
1169    })
1170}
1171
1172/// Los tres pasos del asistente de muestra.
1173fn pasos_de(m: &Model) -> Vec<Paso> {
1174    ["Datos", "Pago", "Listo"]
1175        .iter()
1176        .enumerate()
1177        .map(|(i, t)| {
1178            let estado = if m.pasos_hechos[i] {
1179                EstadoPaso::Hecho
1180            } else if i == m.paso {
1181                EstadoPaso::Actual
1182            } else {
1183                EstadoPaso::Pendiente
1184            };
1185            Paso::new(*t, estado)
1186        })
1187        .collect()
1188}
1189
1190fn bloque_asistente(model: &Model, theme: &Theme) -> View<Msg> {
1191    let pasos = pasos_de(model);
1192    let cuerpo = text_line(
1193        match model.paso {
1194            0 => "Paso 1 · los datos ya están cargados.",
1195            1 => "Paso 2 · «Siguiente» valida el formulario de abajo.",
1196            _ => "Listo. El pie dice Finalizar, no Siguiente.",
1197        },
1198        11.5,
1199        theme.fg_muted,
1200    );
1201    View::new(Style {
1202        size: Size { width: percent(1.0_f32), height: length(160.0_f32) },
1203        ..Default::default()
1204    })
1205    .children(vec![stepper_view(StepperSpec {
1206        pasos: &pasos,
1207        actual: model.paso,
1208        cuerpo,
1209        on_ir: Msg::IrAPaso,
1210        on_finalizar: Some(Msg::Enviar),
1211        palette: StepperPalette::from_theme(theme),
1212    })])
1213}
1214
1215/// El formulario de muestra. Las reglas son la fuente del asterisco y del
1216/// error; la vista no los repite.
1217fn formulario() -> Formulario<CampoForm> {
1218    Formulario::new(vec![
1219        CampoDef::new(CampoForm::Nombre, "Nombre")
1220            .regla(Regla::Requerido)
1221            .ayuda("Como figura en el documento"),
1222        CampoDef::new(CampoForm::Correo, "Correo")
1223            .reglas(vec![Regla::Requerido, Regla::Correo]),
1224    ])
1225}
1226
1227/// Valores fijos de la vitrina: el nombre va cargado y el correo, mal escrito
1228/// —así se ve el error de formato sin tener que tipear.
1229fn valores_form() -> Vec<(CampoForm, &'static str)> {
1230    vec![(CampoForm::Nombre, "Sergio"), (CampoForm::Correo, "sergio(arroba)gioser")]
1231}
1232
1233fn bloque_formulario(model: &Model, theme: &Theme) -> View<Msg> {
1234    let form = formulario();
1235    let errores = form.validar(&valores_form());
1236    let visibles = model.form_estado.visibles(&errores);
1237    let pal = FormPalette::from_theme(theme);
1238
1239    let mut hijos: Vec<View<Msg>> = Vec::new();
1240    hijos.extend(resumen_errores_view(&visibles, |_| Msg::Enviar, &pal));
1241    for (id, valor) in valores_form() {
1242        // El control es un campo de muestra; el click marca el campo como
1243        // «tocado», que es lo que destapa SU error y no el de los otros.
1244        let control = fake_text_input(valor, theme).on_click(Msg::CampoBlur(id));
1245        hijos.push(campo_view(&form, id, control, &visibles, &pal));
1246    }
1247    hijos.push(text_line(
1248        if model.form_estado.se_intento_enviar() {
1249            "Tras «Finalizar» se muestran todos los errores."
1250        } else {
1251            "Clickeá un campo (blur) o «Finalizar» para ver los errores."
1252        },
1253        10.5,
1254        theme.fg_muted,
1255    ));
1256    View::new(Style {
1257        flex_direction: FlexDirection::Column,
1258        size: Size { width: percent(1.0_f32), height: length(230.0_f32) },
1259        gap: Size { width: length(0.0_f32), height: length(6.0_f32) },
1260        ..Default::default()
1261    })
1262    .children(hijos)
1263}
1264
1265fn bloque_pickers(model: &Model, theme: &Theme) -> View<Msg> {
1266    let pal = PickerPalette::from_theme(theme);
1267    View::new(Style {
1268        flex_direction: FlexDirection::Row,
1269        size: Size { width: percent(1.0_f32), height: length(34.0_f32) },
1270        gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
1271        ..Default::default()
1272    })
1273    .children(vec![
1274        date_picker_trigger(DateTrigger {
1275            valor: model.fecha,
1276            formato: FormatoFecha::DiaMesAnio,
1277            placeholder: "Elegí una fecha".into(),
1278            enabled: true,
1279            on_open: Msg::AbrirFecha,
1280            palette: pal,
1281        }),
1282        time_picker_trigger(TimeTrigger {
1283            valor: model.hora,
1284            formato: FormatoHora::H24,
1285            placeholder: "Elegí una hora".into(),
1286            enabled: true,
1287            on_open: Msg::AbrirHora,
1288            palette: pal,
1289        }),
1290    ])
1291}
1292
1293/// El popup abierto, montado en el overlay como manda el widget (un panel con
1294/// scrim no puede colgar del árbol de una columna).
1295fn popup_pane(model: &Model, cual: Popup, theme: &Theme) -> View<Msg> {
1296    let pal = PickerPalette::from_theme(theme);
1297    match cual {
1298        Popup::Fecha => date_picker_popup(DatePopup {
1299            anchor: (model.viewport.0 * 0.62, model.viewport.1 * 0.35),
1300            viewport: model.viewport,
1301            valor: model.fecha,
1302            view: model.cal_view,
1303            today: Some(HOY),
1304            week_start: WeekStart::Monday,
1305            opciones: CalendarOpciones {
1306                // Una fecha de muestra no se agenda para ayer.
1307                limites: Limites::desde(HOY),
1308                navegacion_anual: true,
1309                ..Default::default()
1310            },
1311            on_pick: Arc::new(Msg::ElegirFecha),
1312            on_view_change: Arc::new(Msg::VerMes),
1313            on_dismiss: Msg::CerrarPopup,
1314            on_clear: Some(Msg::LimpiarFecha),
1315            acciones: Acciones::hoy_y_limpiar(),
1316            palette: pal,
1317            theme: *theme,
1318        }),
1319        Popup::Hora => time_picker_popup(TimePopup {
1320            anchor: (model.viewport.0 * 0.62, model.viewport.1 * 0.40),
1321            viewport: model.viewport,
1322            valor: model.hora,
1323            formato: FormatoHora::H24,
1324            paso_minutos: 15,
1325            on_pick: Arc::new(Msg::ElegirHora),
1326            on_dismiss: Msg::CerrarPopup,
1327            palette: pal,
1328        }),
1329    }
1330}
1331
1332fn main() {
1333    // `--census`: mide el reparto `ViewRare` (Bloque 21, palanca 2b) contra este
1334    // árbol real, sin abrir ventana ni tocar la GPU. Ver `llimphi_ui::censo`.
1335    if std::env::args().any(|a| a == "--census") {
1336        llimphi_ui::censo_app::<Gallery>();
1337        return;
1338    }
1339    llimphi_ui::run::<Gallery>();
1340}
1341
1342// ---------------------------------------------------------------------
1343// La vitrina como CALLER: que los widgets estén en el árbol y respondan
1344// ---------------------------------------------------------------------
1345//
1346// Un `cargo check` verde sólo dice que compila. Lo que se prueba acá es que la
1347// tanda nueva quedó de verdad enchufada: que sus nodos existen en el árbol que
1348// la gallery pinta, que los clicks emiten el `Msg` que el `update` espera, y
1349// que el ciclo completo (click → update → vista nueva) cambia lo que se ve.
1350#[cfg(test)]
1351mod tests {
1352    use super::*;
1353    use llimphi_test::query::{Sel, Ui};
1354    use llimphi_ui::{Handle, Role};
1355
1356    fn modelo() -> Model {
1357        Gallery::init(&Handle::<Msg>::for_test_with(|_| {}))
1358    }
1359
1360    fn vista(m: &Model) -> Ui<Msg> {
1361        Ui::new(Gallery::view(m), 1280.0, 800.0)
1362    }
1363
1364    fn aplicar(m: Model, msg: Msg) -> Model {
1365        Gallery::update(m, msg, &Handle::<Msg>::for_test_with(|_| {}))
1366    }
1367
1368    #[test]
1369    fn el_acordeon_esta_en_la_vitrina_y_pliega() {
1370        let m = modelo();
1371        let ui = vista(&m);
1372        let cab = ui.get(Sel::new().role(Role::Button).label("Cómo se usa"));
1373        assert_eq!(cab.is_expanded(), Some(false), "{}", ui.dump());
1374        // Clickear la cabecera abre ESA y cierra la que estaba (Modo::Una).
1375        let m = aplicar(m, cab.click().expect("la cabecera emite su Msg"));
1376        let ui = vista(&m);
1377        assert_eq!(
1378            ui.get(Sel::new().role(Role::Button).label("Cómo se usa")).is_expanded(),
1379            Some(true)
1380        );
1381        assert_eq!(
1382            ui.get(Sel::new().role(Role::Button).label("Qué es")).is_expanded(),
1383            Some(false),
1384            "el modo acordeón tiene que haber cerrado la otra"
1385        );
1386    }
1387
1388    #[test]
1389    fn el_asistente_muestra_sus_pasos_con_estado() {
1390        let ui = vista(&modelo());
1391        assert!(ui.find("Paso 1 de 3: Datos (hecho)").is_some(), "{}", ui.dump());
1392        assert!(ui.find("Paso 2 de 3: Pago (en curso)").is_some());
1393        assert!(ui.find("Paso 3 de 3: Listo (pendiente)").is_some());
1394    }
1395
1396    #[test]
1397    fn el_formulario_no_grita_hasta_que_se_intenta_enviar() {
1398        let m = modelo();
1399        // Recién abierto: el correo de muestra está mal, pero no se dice.
1400        assert!(vista(&m).find("Correo no parece un correo").is_none());
1401        // «Finalizar» del asistente = intentar enviar: ahí sí.
1402        let m = aplicar(m, Msg::Enviar);
1403        let ui = vista(&m);
1404        assert!(ui.find("Correo no parece un correo").is_some(), "{}", ui.dump());
1405        // Y el resumen se anuncia como alerta, que es el punto de entrada.
1406        assert_eq!(ui.get(Role::Alert).name(), Some("Hay 1 error que corregir"));
1407    }
1408
1409    #[test]
1410    fn tocar_un_campo_destapa_solo_su_error() {
1411        let m = aplicar(modelo(), Msg::CampoBlur(CampoForm::Correo));
1412        assert!(vista(&m).find("Correo no parece un correo").is_some());
1413        // El nombre está bien cargado, así que no hay nada más que decir.
1414        assert_eq!(vista(&m).find_all(Sel::new().role(Role::Alert)).len(), 1);
1415    }
1416
1417    #[test]
1418    fn los_pickers_abren_su_popup_y_lo_que_se_elige_vuelve_al_disparador() {
1419        let m = modelo();
1420        let ui = vista(&m);
1421        let campo = ui.get(Sel::new().role(Role::Button).label("Elegir fecha"));
1422        let m = aplicar(m, campo.click().expect("el disparador emite"));
1423        assert_eq!(m.popup, Some(Popup::Fecha));
1424
1425        // El popup vive en el overlay, no en la vista.
1426        let overlay = Gallery::view_overlay(&m).expect("hay popup");
1427        let ov = Ui::new(overlay, 1280.0, 800.0);
1428        let dia = ov.get(Sel::new().role(Role::Button).label("14 de agosto de 2026"));
1429        let m = aplicar(m, dia.click().expect("el día emite su fecha"));
1430
1431        // Se cerró y el disparador ahora muestra la fecha elegida.
1432        assert!(m.popup.is_none());
1433        assert!(vista(&m).find("14/08/2026").is_some(), "{}", vista(&m).dump());
1434    }
1435
1436    #[test]
1437    fn el_limite_del_calendario_llega_al_popup_de_la_vitrina() {
1438        // La vitrina pide `Limites::desde(HOY)`: ayer no se puede elegir.
1439        let m = aplicar(modelo(), Msg::AbrirFecha);
1440        let ov = Ui::new(Gallery::view_overlay(&m).expect("hay popup"), 1280.0, 800.0);
1441        let ayer = ov.get(Sel::new().role(Role::Button).label("4 de agosto de 2026"));
1442        assert!(!ayer.is_enabled(), "{}", ov.dump());
1443    }
1444
1445    #[test]
1446    fn las_columnas_scrollean_de_verdad() {
1447        // Antes las columnas recortaban y lo que no entraba no se alcanzaba.
1448        // Se mide sobre la cabecera del acordeón —que sí lleva nombre
1449        // accesible— y no sobre el rótulo de sección, que es texto decorativo
1450        // y no está en el árbol semántico.
1451        let ancla = |m: &Model| {
1452            vista(m)
1453                .get(Sel::new().role(Role::Button).label("Qué es"))
1454                .rect()
1455                .y
1456        };
1457        let m = modelo();
1458        // El alto visible lo publica el widget al PINTAR, y este test no pinta.
1459        // Sin ese dato el scroll se queda en cero a propósito (`offset_efectivo`:
1460        // mejor un cuadro desde arriba que un salto a un lugar que todavía no se
1461        // puede validar), así que acá se deja el alto que un cuadro real ya
1462        // habría dejado. Ese es el frame de latencia del diseño, dicho en un
1463        // test en vez de en un comentario.
1464        m.col_alto[2].escribir(620.0);
1465        let y_antes = ancla(&m);
1466        let m = aplicar(m, Msg::ColScroll(2, 120.0));
1467        let y_despues = ancla(&m);
1468        assert!(
1469            (y_antes - y_despues - 120.0).abs() < 0.5,
1470            "la columna tenía que correrse 120 px: {y_antes} → {y_despues}"
1471        );
1472    }
1473}