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