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

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

feat: llimphi standalone — framework UI soberano extraído del monorepo e65e9cc62 · 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};
57
58#[derive(Clone)]
59enum Msg {
60    /// Tick para forzar repaint (animaciones por reloj absoluto).
61    Tick,
62    ToggleA,
63    ToggleB,
64    SelectSeg(usize),
65    #[allow(dead_code)]
66    BreadcrumbJump(usize),
67    PushToast,
68    DismissToast(u64),
69    OpenModal,
70    CloseModal,
71    ConfirmModal,
72    ToggleShortcuts,
73    OpenContextMenu,
74    CloseContextMenu,
75    ContextMenuPick(usize),
76    /// Abrir/cerrar un menú raíz de la barra principal (`None` = cerrar).
77    MenuOpen(Option<usize>),
78    /// Comando elegido en la barra principal (id `menu.<verbo>`).
79    MenuCommand(String),
80}
81
82struct Model {
83    started_at: Instant,
84    switch_a: bool,
85    switch_b: bool,
86    seg: usize,
87    toasts: Vec<Toast>,
88    next_toast_id: u64,
89    modal_open: bool,
90    shortcuts_open: bool,
91    viewport: (f32, f32),
92    /// Anchor del context-menu si está abierto. None = cerrado.
93    menu_open: Option<(f32, f32)>,
94    /// Item resaltado del menú (`usize::MAX` = ninguno, estado inicial).
95    menu_active: usize,
96    /// Última opción elegida del menú — se muestra como toast.
97    menu_last_pick: Option<String>,
98    /// Índice del menú raíz de la barra principal abierto. `None` = ninguno.
99    menubar_open: Option<usize>,
100}
101
102struct Gallery;
103
104impl App for Gallery {
105    type Model = Model;
106    type Msg = Msg;
107
108    fn title() -> &'static str {
109        "llimphi · gallery"
110    }
111
112    fn initial_size() -> (u32, u32) {
113        (1280, 800)
114    }
115
116    fn init(handle: &Handle<Self::Msg>) -> Self::Model {
117        // Loop infinito de ticks para animar spinner/skeleton/splash.
118        // En una app real esto se gateaba según haya animaciones vivas.
119        handle.spawn_periodic(Duration::from_millis(50), || Msg::Tick);
120        Model {
121            started_at: Instant::now(),
122            switch_a: true,
123            switch_b: false,
124            seg: 1,
125            toasts: Vec::new(),
126            next_toast_id: 0,
127            modal_open: false,
128            shortcuts_open: false,
129            viewport: (1280.0, 800.0),
130            menu_open: None,
131            menu_active: usize::MAX,
132            menu_last_pick: None,
133            menubar_open: None,
134        }
135    }
136
137    fn update(model: Self::Model, msg: Self::Msg, _handle: &Handle<Self::Msg>) -> Self::Model {
138        let mut m = model;
139        // Filtrar toasts expirados oportunamente.
140        let now = Instant::now();
141        m.toasts.retain(|t| t.is_alive(now));
142        match msg {
143            Msg::Tick => {}
144            Msg::ToggleA => m.switch_a = !m.switch_a,
145            Msg::ToggleB => m.switch_b = !m.switch_b,
146            Msg::SelectSeg(i) => m.seg = i,
147            Msg::BreadcrumbJump(_) => {} // sólo demo
148            Msg::PushToast => {
149                let kinds = [
150                    (BadgeKind::Info, "guardado en disco"),
151                    (BadgeKind::Success, "publicado correctamente"),
152                    (BadgeKind::Warning, "espacio bajo en cache"),
153                    (BadgeKind::Error, "no se pudo conectar"),
154                ];
155                let (kind, text) = kinds[(m.next_toast_id as usize) % kinds.len()];
156                let id = m.next_toast_id;
157                m.next_toast_id += 1;
158                let toast = match kind {
159                    BadgeKind::Info => Toast::info(id, text, Duration::from_secs(4)),
160                    BadgeKind::Success => Toast::success(id, text, Duration::from_secs(4)),
161                    BadgeKind::Warning => Toast::warning(id, text, Duration::from_secs(4)),
162                    BadgeKind::Error => Toast::error(id, text, Duration::from_secs(4)),
163                    BadgeKind::Neutral => Toast::info(id, text, Duration::from_secs(4)),
164                };
165                m.toasts.push(toast);
166            }
167            Msg::DismissToast(id) => m.toasts.retain(|t| t.id != id),
168            Msg::OpenModal => m.modal_open = true,
169            Msg::CloseModal => m.modal_open = false,
170            Msg::ConfirmModal => m.modal_open = false,
171            Msg::ToggleShortcuts => m.shortcuts_open = !m.shortcuts_open,
172            Msg::OpenContextMenu => {
173                // Posición fija razonable — el botón está en la columna
174                // derecha; abrir el menú con anchor relativo al
175                // viewport mantiene la demo predecible aunque la
176                // ventana cambie de tamaño.
177                m.menu_open = Some((m.viewport.0 * 0.72, m.viewport.1 * 0.55));
178                m.menu_active = usize::MAX;
179                m.menubar_open = None;
180            }
181            Msg::CloseContextMenu => {
182                m.menu_open = None;
183                m.menu_active = usize::MAX;
184            }
185            Msg::ContextMenuPick(idx) => {
186                let labels = ["Copiar", "Cortar", "Pegar", "", "Eliminar"];
187                let label = labels.get(idx).copied().unwrap_or("?");
188                m.menu_last_pick = Some(label.to_string());
189                m.menu_open = None;
190                m.menu_active = usize::MAX;
191                // Confirmación visible.
192                let id = m.next_toast_id;
193                m.next_toast_id += 1;
194                m.toasts.push(Toast::info(
195                    id,
196                    format!("Menú → {label}"),
197                    Duration::from_secs(3),
198                ));
199            }
200            Msg::MenuOpen(idx) => {
201                m.menubar_open = idx;
202                // El dropdown de la barra y el contextual son mutuamente
203                // excluyentes.
204                m.menu_open = None;
205            }
206            Msg::MenuCommand(cmd) => {
207                m.menubar_open = None;
208                match cmd.as_str() {
209                    "app.quit" => std::process::exit(0),
210                    "view.toast" => return Self::update(m, Msg::PushToast, _handle),
211                    "view.modal" => m.modal_open = true,
212                    "view.context" => {
213                        m.menu_open = Some((m.viewport.0 * 0.5, m.viewport.1 * 0.45));
214                        m.menu_active = usize::MAX;
215                    }
216                    "help.shortcuts" => m.shortcuts_open = true,
217                    "help.about" => {
218                        let id = m.next_toast_id;
219                        m.next_toast_id += 1;
220                        m.toasts.push(Toast::info(
221                            id,
222                            "llimphi · gallery — vitrina del kit de elegancia",
223                            Duration::from_secs(4),
224                        ));
225                    }
226                    _ => {}
227                }
228            }
229        }
230        m
231    }
232
233    fn on_key(_model: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
234        if ev.state != KeyState::Pressed {
235            return None;
236        }
237        match &ev.key {
238            Key::Named(NamedKey::Escape) => Some(Msg::CloseModal),
239            Key::Character(s) if s == "?" => Some(Msg::ToggleShortcuts),
240            _ => None,
241        }
242    }
243
244    fn view(model: &Self::Model) -> View<Self::Msg> {
245        let theme = Theme::dark();
246
247        // Tres columnas equilibradas + status bar inferior.
248        let left = column_left(model, &theme);
249        let center = column_center(model, &theme);
250        let right = column_right(model, &theme);
251
252        let cols = View::new(Style {
253            flex_direction: FlexDirection::Row,
254            size: Size {
255                width: percent(1.0_f32),
256                height: percent(1.0_f32),
257            },
258            flex_grow: 1.0,
259            gap: Size {
260                width: length(16.0_f32),
261                height: length(0.0_f32),
262            },
263            padding: Rect {
264                left: length(16.0_f32),
265                right: length(16.0_f32),
266                top: length(16.0_f32),
267                bottom: length(8.0_f32),
268            },
269            ..Default::default()
270        })
271        .children(vec![left, center, right]);
272
273        let status = status_bar_view(
274            vec![
275                StatusSegment::text("llimphi-gallery").with_icon(Icon::Home),
276                StatusSegment::text(if model.switch_a { "modo: pleno" } else { "modo: simple" })
277                    .emphasized(),
278            ],
279            vec![],
280            vec![
281                StatusSegment::text("Ln 1, Col 1"),
282                StatusSegment::text("UTF-8"),
283                StatusSegment::text("?  atajos")
284                    .clickable(Msg::ToggleShortcuts)
285                    .with_icon(Icon::Info),
286            ],
287            &StatusBarPalette::from_theme(&theme),
288        );
289
290        let menu = app_menu();
291        let bar = menubar_view(&menubar_spec(&menu, model, &theme));
292
293        View::new(Style {
294            flex_direction: FlexDirection::Column,
295            size: Size {
296                width: percent(1.0_f32),
297                height: percent(1.0_f32),
298            },
299            ..Default::default()
300        })
301        .fill(theme.bg_app)
302        .children(vec![bar, cols, status])
303    }
304
305    fn view_overlay(model: &Self::Model) -> Option<View<Self::Msg>> {
306        let theme = Theme::dark();
307        // Prioridad: modal > shortcuts > toasts.
308        if model.modal_open {
309            return Some(modal_view(ModalSpec {
310                title: "Confirmar acción".to_string(),
311                body: modal_body_view(&theme),
312                buttons: vec![
313                    ModalButton::cancel("Cancelar", Msg::CloseModal),
314                    ModalButton::primary("Aplicar", Msg::ConfirmModal),
315                ],
316                size: (440.0, 220.0),
317                viewport: model.viewport,
318                on_dismiss: Msg::CloseModal,
319                palette: ModalPalette::from_theme(&theme),
320            }));
321        }
322        if model.shortcuts_open {
323            return Some(shortcuts_help_view(ShortcutsHelpSpec {
324                title: "Atajos de teclado".to_string(),
325                groups: vec![
326                    ShortcutGroup::new(
327                        "General",
328                        vec![
329                            ShortcutEntry::new("?", "Mostrar/ocultar esta ayuda"),
330                            ShortcutEntry::new("Esc", "Cerrar overlay activo"),
331                        ],
332                    ),
333                    ShortcutGroup::new(
334                        "Demo",
335                        vec![
336                            ShortcutEntry::new("Click", "Toasts, modal y switches"),
337                            ShortcutEntry::new("Hover", "Tooltips sobre los avatares"),
338                        ],
339                    ),
340                ],
341                viewport: model.viewport,
342                on_dismiss: Msg::ToggleShortcuts,
343                palette: ShortcutsHelpPalette::from_theme(&theme),
344            }));
345        }
346        if let Some(anchor) = model.menu_open {
347            return Some(context_menu_view(ContextMenuSpec {
348                anchor,
349                viewport: model.viewport,
350                header: Some("Lienzo".into()),
351                items: vec![
352                    ContextMenuItem::action("Copiar").with_shortcut("Ctrl+C"),
353                    ContextMenuItem::action("Cortar").with_shortcut("Ctrl+X"),
354                    ContextMenuItem::action("Pegar").with_shortcut("Ctrl+V").disabled(),
355                    ContextMenuItem::separator(),
356                    ContextMenuItem::action("Eliminar")
357                        .with_shortcut("Del")
358                        .destructive(),
359                ],
360                active: model.menu_active,
361                on_pick: Arc::new(Msg::ContextMenuPick),
362                on_dismiss: Msg::CloseContextMenu,
363                palette: ContextMenuPalette::from_theme(&theme),
364            }));
365        }
366        // Dropdown de la barra de menú principal.
367        let menu = app_menu();
368        if let Some(v) = menubar_overlay(&menubar_spec(&menu, model, &theme)) {
369            return Some(v);
370        }
371        if !model.toasts.is_empty() {
372            return Some(toast_stack_view(
373                &model.toasts,
374                model.viewport,
375                Msg::DismissToast,
376            ));
377        }
378        None
379    }
380}
381
382// ---------------------------------------------------------------------
383// Barra de menú principal
384// ---------------------------------------------------------------------
385
386/// Menú principal de la vitrina. Sólo comandos que mapean a `Msg` reales.
387fn app_menu() -> AppMenu {
388    AppMenu::new()
389        .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "app.quit").shortcut("Ctrl+Q")))
390        .menu(
391            Menu::new("Ver")
392                .item(MenuItem::new("Mostrar toast", "view.toast"))
393                .item(MenuItem::new("Abrir modal", "view.modal"))
394                .item(MenuItem::new("Menú contextual", "view.context").separated()),
395        )
396        .menu(
397            Menu::new("Ayuda")
398                .item(MenuItem::new("Atajos", "help.shortcuts").shortcut("?"))
399                .item(MenuItem::new("Acerca de", "help.about")),
400        )
401}
402
403/// Arma el `MenuBarSpec` compartido entre `view` y `view_overlay`.
404fn menubar_spec<'a>(menu: &'a AppMenu, model: &Model, theme: &'a Theme) -> MenuBarSpec<'a, Msg> {
405    MenuBarSpec {
406        menu,
407        open: model.menubar_open,
408        theme,
409        viewport: model.viewport,
410        height: MENU_H,
411        on_open: Arc::new(Msg::MenuOpen),
412        on_command: Arc::new(|cmd: &str| Msg::MenuCommand(cmd.to_string())),
413    }
414}
415
416// ---------------------------------------------------------------------
417// Columnas
418// ---------------------------------------------------------------------
419
420fn column_left(model: &Model, theme: &Theme) -> View<Msg> {
421    let mut children: Vec<View<Msg>> = Vec::new();
422
423    children.push(section_title("Identidad"));
424    // Sello wawa en chico + grande.
425    children.push(
426        View::new(Style {
427            flex_direction: FlexDirection::Row,
428            size: Size {
429                width: percent(1.0_f32),
430                height: length(128.0_f32),
431            },
432            gap: Size {
433                width: length(16.0_f32),
434                height: length(0.0_f32),
435            },
436            align_items: Some(AlignItems::Center),
437            ..Default::default()
438        })
439        .children(vec![
440            wawa_frame(48.0),
441            wawa_frame(96.0),
442            wawa_frame(128.0),
443        ]),
444    );
445
446    children.push(section_title("Splash"));
447    children.push(
448        View::new(Style {
449            size: Size {
450                width: percent(1.0_f32),
451                height: length(220.0_f32),
452            },
453            flex_shrink: 0.0,
454            ..Default::default()
455        })
456        .fill(theme.bg_panel)
457        .radius(llimphi_theme::radius::MD)
458        .children(vec![splash_view(model.started_at, theme.bg_panel, theme.fg_text)]),
459    );
460
461    children.push(section_title("Empty state"));
462    children.push(
463        View::new(Style {
464            size: Size {
465                width: percent(1.0_f32),
466                height: length(200.0_f32),
467            },
468            flex_shrink: 0.0,
469            ..Default::default()
470        })
471        .fill(theme.bg_panel)
472        .radius(llimphi_theme::radius::MD)
473        .children(vec![empty_view(
474            Icon::Folder,
475            "Sin documentos abiertos",
476            Some("Abrí uno con Ctrl+O o creá un nuevo lienzo para empezar."),
477            &EmptyPalette::from_theme(theme),
478        )]),
479    );
480
481    panel_view(children, theme)
482}
483
484fn column_center(model: &Model, theme: &Theme) -> View<Msg> {
485    let mut children: Vec<View<Msg>> = Vec::new();
486
487    children.push(section_title("Navegación"));
488    children.push(
489        View::new(Style {
490            size: Size {
491                width: percent(1.0_f32),
492                height: auto(),
493            },
494            ..Default::default()
495        })
496        .children(vec![breadcrumb_view(
497            &["home", "docs", "2026", "elegancia.md"],
498            Msg::BreadcrumbJump,
499            &BreadcrumbPalette::from_theme(theme),
500        )]),
501    );
502
503    children.push(section_title("Controles"));
504    children.push(switch_row("Modo pleno", model.switch_a, Msg::ToggleA, theme));
505    children.push(switch_row("Telemetría", model.switch_b, Msg::ToggleB, theme));
506    children.push(spacer_v(8.0));
507    children.push(
508        View::new(Style {
509            size: Size {
510                width: percent(1.0_f32),
511                height: length(28.0_f32),
512            },
513            ..Default::default()
514        })
515        .children(vec![segmented_view(
516            &["lista", "grilla", "kanban"],
517            model.seg,
518            Msg::SelectSeg,
519            &SegmentedPalette::from_theme(theme),
520        )]),
521    );
522
523    children.push(section_title("Formulario"));
524    children.push(field_view(FieldSpec {
525        label: "Nombre del lienzo".to_string(),
526        control: fake_text_input("introducción a wawa", theme),
527        required: true,
528        helper: Some("Aparece como título en la pestaña.".to_string()),
529        error: None,
530        palette: FieldPalette::from_theme(theme),
531    }));
532    children.push(spacer_v(12.0));
533    children.push(field_view(FieldSpec {
534        label: "Slug".to_string(),
535        control: fake_text_input("intro-wawa-x@123", theme),
536        required: false,
537        helper: None,
538        error: Some("Sólo letras, números y guiones.".to_string()),
539        palette: FieldPalette::from_theme(theme),
540    }));
541
542    children.push(section_title("Acciones"));
543    children.push(button_row(theme));
544
545    panel_view(children, theme)
546}
547
548fn column_right(_model: &Model, theme: &Theme) -> View<Msg> {
549    let mut children: Vec<View<Msg>> = Vec::new();
550
551    children.push(section_title("Identidades"));
552    // Avatares en línea con badge encima.
553    children.push(
554        View::new(Style {
555            flex_direction: FlexDirection::Row,
556            size: Size {
557                width: percent(1.0_f32),
558                height: length(48.0_f32),
559            },
560            gap: Size {
561                width: length(8.0_f32),
562                height: length(0.0_f32),
563            },
564            align_items: Some(AlignItems::Center),
565            ..Default::default()
566        })
567        .children(vec![
568            avatar_view("sergio", 40.0),
569            avatar_view("calcetín", 40.0),
570            avatar_view("amaru", 40.0),
571            avatar_view("pacha", 40.0),
572            avatar_view("inti", 40.0),
573        ]),
574    );
575
576    children.push(section_title("Badges"));
577    children.push(
578        View::new(Style {
579            flex_direction: FlexDirection::Row,
580            size: Size {
581                width: percent(1.0_f32),
582                height: length(24.0_f32),
583            },
584            gap: Size {
585                width: length(10.0_f32),
586                height: length(0.0_f32),
587            },
588            align_items: Some(AlignItems::Center),
589            ..Default::default()
590        })
591        .children(vec![
592            count_badge_view(3, BadgeKind::Info),
593            count_badge_view(12, BadgeKind::Success),
594            count_badge_view(99, BadgeKind::Warning),
595            count_badge_view(120, BadgeKind::Error),
596            dot_badge_view(BadgeKind::Success),
597            dot_badge_view(BadgeKind::Warning),
598            dot_badge_view(BadgeKind::Error),
599        ]),
600    );
601
602    children.push(section_title("Carga"));
603    children.push(
604        View::new(Style {
605            flex_direction: FlexDirection::Row,
606            size: Size {
607                width: percent(1.0_f32),
608                height: length(48.0_f32),
609            },
610            gap: Size {
611                width: length(16.0_f32),
612                height: length(0.0_f32),
613            },
614            align_items: Some(AlignItems::Center),
615            ..Default::default()
616        })
617        .children(vec![
618            View::new(Style {
619                size: Size {
620                    width: length(40.0_f32),
621                    height: length(40.0_f32),
622                },
623                ..Default::default()
624            })
625            .children(vec![spinner_view(theme.accent, 0.12, 1.0)]),
626            View::new(Style {
627                size: Size {
628                    width: length(40.0_f32),
629                    height: length(40.0_f32),
630                },
631                ..Default::default()
632            })
633            .children(vec![radial_progress_view(
634                0.66,
635                theme.bg_button,
636                theme.accent,
637                0.14,
638            )]),
639            linear_progress_view(0.42, theme.bg_button, theme.accent, 8.0),
640        ]),
641    );
642
643    children.push(section_title("Skeleton"));
644    let palette = SkeletonPalette::from_theme(theme);
645    children.push(skeleton_line_view::<Msg>(200.0, &palette));
646    children.push(spacer_v(6.0));
647    children.push(skeleton_line_view::<Msg>(280.0, &palette));
648    children.push(spacer_v(6.0));
649    children.push(skeleton_line_view::<Msg>(160.0, &palette));
650    children.push(spacer_v(10.0));
651    children.push(skeleton_box_view::<Msg>(percent_to_px(0.9, 360.0), 60.0, &palette));
652
653    children.push(section_title("Cards"));
654    // Dos cards apilados: el primero con la firma (gradient sutil +
655    // hairline en el top), el segundo con `accent` lateral y fill plano.
656    // Para apreciar la firma hay que mirar de cerca: el ojo registra
657    // "tallado" sin saber por qué.
658    let card_palette = CardPalette::from_theme(theme);
659    children.push(card_view(
660        vec![
661            text_line("Documento — multilienzo", 13.0, theme.fg_text),
662            text_line("3 cuerpos · 412 átomos · BLAKE3 verificado", 11.0, theme.fg_muted),
663        ],
664        CardOptions::with_signature(theme),
665        &card_palette,
666    ));
667    children.push(spacer_v(8.0));
668    children.push(card_view(
669        vec![
670            text_line("Build pasó — wawa-kernel", 13.0, theme.fg_text),
671            text_line("x86_64-unknown-none · 1.42s · 0 warnings", 11.0, theme.fg_muted),
672        ],
673        CardOptions {
674            accent: Some(Color::from_rgba8(110, 200, 130, 255)),
675            ..Default::default()
676        },
677        &card_palette,
678    ));
679
680    children.push(section_title("Menú contextual"));
681    children.push(
682        View::new(Style {
683            size: Size {
684                width: percent(1.0_f32),
685                height: length(32.0_f32),
686            },
687            flex_shrink: 0.0,
688            align_items: Some(AlignItems::Center),
689            justify_content: Some(JustifyContent::Center),
690            padding: Rect {
691                left: length(12.0_f32),
692                right: length(12.0_f32),
693                top: length(0.0_f32),
694                bottom: length(0.0_f32),
695            },
696            ..Default::default()
697        })
698        .fill(theme.bg_button)
699        .hover_fill(theme.bg_button_hover)
700        .radius(llimphi_theme::radius::SM)
701        .text_aligned(
702            "Mostrar menú".to_string(),
703            12.0,
704            theme.fg_text,
705            Alignment::Center,
706        )
707        .on_click(Msg::OpenContextMenu),
708    );
709
710    children.push(section_title("Iconografía"));
711    children.push(icon_grid(theme));
712
713    panel_view(children, theme)
714}
715
716// ---------------------------------------------------------------------
717// Helpers de composición
718// ---------------------------------------------------------------------
719
720fn text_line(text: &str, size: f32, color: Color) -> View<Msg> {
721    View::new(Style {
722        size: Size {
723            width: percent(1.0_f32),
724            height: length(size + 6.0),
725        },
726        ..Default::default()
727    })
728    .text_aligned(text.to_string(), size, color, Alignment::Start)
729}
730
731fn section_title(text: &str) -> View<Msg> {
732    View::new(Style {
733        size: Size {
734            width: percent(1.0_f32),
735            height: length(20.0_f32),
736        },
737        flex_shrink: 0.0,
738        ..Default::default()
739    })
740    .text_aligned(
741        text.to_uppercase(),
742        10.0,
743        Color::from_rgba8(140, 160, 200, 255),
744        Alignment::Start,
745    )
746}
747
748fn panel_view(children: Vec<View<Msg>>, theme: &Theme) -> View<Msg> {
749    let style = PanelStyle::from_theme(theme);
750    View::new(Style {
751        flex_direction: FlexDirection::Column,
752        size: Size {
753            width: percent(1.0_f32),
754            height: percent(1.0_f32),
755        },
756        flex_grow: 1.0,
757        padding: Rect {
758            left: length(16.0_f32),
759            right: length(16.0_f32),
760            top: length(14.0_f32),
761            bottom: length(14.0_f32),
762        },
763        gap: Size {
764            width: length(0.0_f32),
765            height: length(10.0_f32),
766        },
767        ..Default::default()
768    })
769    .paint_with(panel_signature_painter(style))
770    .radius(style.radius)
771    .clip(true)
772    .children(children)
773}
774
775fn switch_row(label: &str, value: bool, msg: Msg, theme: &Theme) -> View<Msg> {
776    let progress = if value { 1.0 } else { 0.0 };
777    View::new(Style {
778        flex_direction: FlexDirection::Row,
779        size: Size {
780            width: percent(1.0_f32),
781            height: length(28.0_f32),
782        },
783        align_items: Some(AlignItems::Center),
784        justify_content: Some(JustifyContent::SpaceBetween),
785        ..Default::default()
786    })
787    .children(vec![
788        View::new(Style {
789            size: Size {
790                width: percent(1.0_f32),
791                height: percent(1.0_f32),
792            },
793            flex_grow: 1.0,
794            align_items: Some(AlignItems::Center),
795            ..Default::default()
796        })
797        .text_aligned(label.to_string(), 12.0, theme.fg_text, Alignment::Start),
798        switch_view(progress, msg, &SwitchPalette::from_theme(theme)),
799    ])
800}
801
802fn fake_text_input(text: &str, theme: &Theme) -> View<Msg> {
803    View::new(Style {
804        size: Size {
805            width: percent(1.0_f32),
806            height: length(28.0_f32),
807        },
808        padding: Rect {
809            left: length(8.0_f32),
810            right: length(8.0_f32),
811            top: length(0.0_f32),
812            bottom: length(0.0_f32),
813        },
814        align_items: Some(AlignItems::Center),
815        flex_shrink: 0.0,
816        ..Default::default()
817    })
818    .fill(theme.bg_input)
819    .radius(llimphi_theme::radius::SM)
820    .text_aligned(text.to_string(), 12.0, theme.fg_text, Alignment::Start)
821}
822
823fn button_row(theme: &Theme) -> View<Msg> {
824    View::new(Style {
825        flex_direction: FlexDirection::Row,
826        size: Size {
827            width: percent(1.0_f32),
828            height: length(32.0_f32),
829        },
830        gap: Size {
831            width: length(8.0_f32),
832            height: length(0.0_f32),
833        },
834        ..Default::default()
835    })
836    .children(vec![
837        btn("Mostrar toast", theme.accent, theme.bg_app, Msg::PushToast),
838        btn("Abrir modal", theme.bg_button, theme.fg_text, Msg::OpenModal),
839        btn("Atajos (?)", theme.bg_button, theme.fg_text, Msg::ToggleShortcuts),
840    ])
841}
842
843fn btn(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
844    let w = label.chars().count() as f32 * 7.5 + 24.0;
845    View::new(Style {
846        size: Size {
847            width: length(w),
848            height: length(32.0_f32),
849        },
850        align_items: Some(AlignItems::Center),
851        justify_content: Some(JustifyContent::Center),
852        flex_shrink: 0.0,
853        ..Default::default()
854    })
855    .fill(bg)
856    .radius(llimphi_theme::radius::SM)
857    .text_aligned(label.to_string(), 12.0, fg, Alignment::Center)
858    .on_click(msg)
859}
860
861fn icon_grid(theme: &Theme) -> View<Msg> {
862    let icons = [
863        Icon::File, Icon::Folder, Icon::Save, Icon::Open, Icon::Search,
864        Icon::Plus, Icon::Minus, Icon::X, Icon::Check, Icon::Edit,
865        Icon::Trash, Icon::Home, Icon::Settings, Icon::Bell, Icon::More,
866        Icon::Info, Icon::Warning, Icon::Error, Icon::ChevronUp,
867        Icon::ChevronDown, Icon::ChevronLeft, Icon::ChevronRight,
868        Icon::FolderOpen,
869    ];
870    let cells: Vec<View<Msg>> = icons
871        .iter()
872        .map(|i| {
873            View::new(Style {
874                size: Size {
875                    width: length(28.0_f32),
876                    height: length(28.0_f32),
877                },
878                flex_shrink: 0.0,
879                ..Default::default()
880            })
881            .fill(theme.bg_panel_alt)
882            .radius(llimphi_theme::radius::XS)
883            .children(vec![icon_view(*i, theme.fg_text, 1.6)])
884        })
885        .collect();
886
887    View::new(Style {
888        flex_direction: FlexDirection::Row,
889        size: Size {
890            width: percent(1.0_f32),
891            height: auto(),
892        },
893        gap: Size {
894            width: length(6.0_f32),
895            height: length(6.0_f32),
896        },
897        flex_wrap: llimphi_ui::llimphi_layout::taffy::FlexWrap::Wrap,
898        ..Default::default()
899    })
900    .children(cells)
901}
902
903fn modal_body_view(theme: &Theme) -> View<Msg> {
904    View::new(Style {
905        size: Size {
906            width: percent(1.0_f32),
907            height: percent(1.0_f32),
908        },
909        flex_grow: 1.0,
910        ..Default::default()
911    })
912    .text_aligned(
913        "Esta acción reescribirá la configuración local. \
914         Sólo dura mientras no salgas — al guardar quedará persistida en disco."
915            .to_string(),
916        12.0,
917        theme.fg_muted,
918        Alignment::Start,
919    )
920}
921
922fn wawa_frame(side: f32) -> View<Msg> {
923    View::new(Style {
924        size: Size {
925            width: length(side),
926            height: length(side),
927        },
928        align_items: Some(AlignItems::Center),
929        justify_content: Some(JustifyContent::Center),
930        flex_shrink: 0.0,
931        ..Default::default()
932    })
933    .children(vec![wawa_mark_view(&WawaMarkPalette::default())])
934}
935
936fn spacer_v(h: f32) -> View<Msg> {
937    View::new(Style {
938        size: Size {
939            width: percent(1.0_f32),
940            height: length(h),
941        },
942        flex_shrink: 0.0,
943        ..Default::default()
944    })
945}
946
947fn percent_to_px(p: f32, base: f32) -> f32 {
948    p * base
949}
950
951// Tooltip placeholder — la demo no instrumenta hover-to-show porque
952// requeriría más Msgs; queda como código de referencia para apps reales.
953#[allow(dead_code)]
954fn demo_tooltip(viewport: (f32, f32), text: &str, theme: &Theme) -> View<Msg> {
955    tooltip_view::<Msg>(TooltipSpec {
956        anchor: (viewport.0 * 0.5, viewport.1 * 0.5),
957        viewport,
958        side: Side::Bottom,
959        text: text.to_string(),
960        palette: TooltipPalette::from_theme(theme),
961    })
962}
963
964fn main() {
965    llimphi_ui::run::<Gallery>();
966}