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

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

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · 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                body_scroll: None,
321            }));
322        }
323        if model.shortcuts_open {
324            return Some(shortcuts_help_view(ShortcutsHelpSpec {
325                title: "Atajos de teclado".to_string(),
326                groups: vec![
327                    ShortcutGroup::new(
328                        "General",
329                        vec![
330                            ShortcutEntry::new("?", "Mostrar/ocultar esta ayuda"),
331                            ShortcutEntry::new("Esc", "Cerrar overlay activo"),
332                        ],
333                    ),
334                    ShortcutGroup::new(
335                        "Demo",
336                        vec![
337                            ShortcutEntry::new("Click", "Toasts, modal y switches"),
338                            ShortcutEntry::new("Hover", "Tooltips sobre los avatares"),
339                        ],
340                    ),
341                ],
342                viewport: model.viewport,
343                on_dismiss: Msg::ToggleShortcuts,
344                palette: ShortcutsHelpPalette::from_theme(&theme),
345            }));
346        }
347        if let Some(anchor) = model.menu_open {
348            return Some(context_menu_view(ContextMenuSpec {
349                anchor,
350                viewport: model.viewport,
351                header: Some("Lienzo".into()),
352                items: vec![
353                    ContextMenuItem::action("Copiar").with_shortcut("Ctrl+C"),
354                    ContextMenuItem::action("Cortar").with_shortcut("Ctrl+X"),
355                    ContextMenuItem::action("Pegar").with_shortcut("Ctrl+V").disabled(),
356                    ContextMenuItem::separator(),
357                    ContextMenuItem::action("Eliminar")
358                        .with_shortcut("Del")
359                        .destructive(),
360                ],
361                active: model.menu_active,
362                on_pick: Arc::new(Msg::ContextMenuPick),
363                on_dismiss: Msg::CloseContextMenu,
364                palette: ContextMenuPalette::from_theme(&theme),
365            }));
366        }
367        // Dropdown de la barra de menú principal.
368        let menu = app_menu();
369        if let Some(v) = menubar_overlay(&menubar_spec(&menu, model, &theme)) {
370            return Some(v);
371        }
372        if !model.toasts.is_empty() {
373            return Some(toast_stack_view(
374                &model.toasts,
375                model.viewport,
376                Msg::DismissToast,
377            ));
378        }
379        None
380    }
381}
382
383// ---------------------------------------------------------------------
384// Barra de menú principal
385// ---------------------------------------------------------------------
386
387/// Menú principal de la vitrina. Sólo comandos que mapean a `Msg` reales.
388fn app_menu() -> AppMenu {
389    AppMenu::new()
390        .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "app.quit").shortcut("Ctrl+Q")))
391        .menu(
392            Menu::new("Ver")
393                .item(MenuItem::new("Mostrar toast", "view.toast"))
394                .item(MenuItem::new("Abrir modal", "view.modal"))
395                .item(MenuItem::new("Menú contextual", "view.context").separated()),
396        )
397        .menu(
398            Menu::new("Ayuda")
399                .item(MenuItem::new("Atajos", "help.shortcuts").shortcut("?"))
400                .item(MenuItem::new("Acerca de", "help.about")),
401        )
402}
403
404/// Arma el `MenuBarSpec` compartido entre `view` y `view_overlay`.
405fn menubar_spec<'a>(menu: &'a AppMenu, model: &Model, theme: &'a Theme) -> MenuBarSpec<'a, Msg> {
406    MenuBarSpec {
407        menu,
408        open: model.menubar_open,
409        theme,
410        viewport: model.viewport,
411        height: MENU_H,
412        on_open: Arc::new(Msg::MenuOpen),
413        on_command: Arc::new(|cmd: &str| Msg::MenuCommand(cmd.to_string())),
414    }
415}
416
417// ---------------------------------------------------------------------
418// Columnas
419// ---------------------------------------------------------------------
420
421fn column_left(model: &Model, theme: &Theme) -> View<Msg> {
422    let mut children: Vec<View<Msg>> = Vec::new();
423
424    children.push(section_title("Identidad"));
425    // Sello wawa en chico + grande.
426    children.push(
427        View::new(Style {
428            flex_direction: FlexDirection::Row,
429            size: Size {
430                width: percent(1.0_f32),
431                height: length(128.0_f32),
432            },
433            gap: Size {
434                width: length(16.0_f32),
435                height: length(0.0_f32),
436            },
437            align_items: Some(AlignItems::Center),
438            ..Default::default()
439        })
440        .children(vec![
441            wawa_frame(48.0),
442            wawa_frame(96.0),
443            wawa_frame(128.0),
444        ]),
445    );
446
447    children.push(section_title("Splash"));
448    children.push(
449        View::new(Style {
450            size: Size {
451                width: percent(1.0_f32),
452                height: length(220.0_f32),
453            },
454            flex_shrink: 0.0,
455            ..Default::default()
456        })
457        .fill(theme.bg_panel)
458        .radius(llimphi_theme::radius::MD)
459        .children(vec![splash_view(model.started_at, theme.bg_panel, theme.fg_text)]),
460    );
461
462    children.push(section_title("Empty state"));
463    children.push(
464        View::new(Style {
465            size: Size {
466                width: percent(1.0_f32),
467                height: length(200.0_f32),
468            },
469            flex_shrink: 0.0,
470            ..Default::default()
471        })
472        .fill(theme.bg_panel)
473        .radius(llimphi_theme::radius::MD)
474        .children(vec![empty_view(
475            Icon::Folder,
476            "Sin documentos abiertos",
477            Some("Abrí uno con Ctrl+O o crea un nuevo lienzo para empezar."),
478            &EmptyPalette::from_theme(theme),
479        )]),
480    );
481
482    panel_view(children, theme)
483}
484
485fn column_center(model: &Model, theme: &Theme) -> View<Msg> {
486    let mut children: Vec<View<Msg>> = Vec::new();
487
488    children.push(section_title("Navegación"));
489    children.push(
490        View::new(Style {
491            size: Size {
492                width: percent(1.0_f32),
493                height: auto(),
494            },
495            ..Default::default()
496        })
497        .children(vec![breadcrumb_view(
498            &["home", "docs", "2026", "elegancia.md"],
499            Msg::BreadcrumbJump,
500            &BreadcrumbPalette::from_theme(theme),
501        )]),
502    );
503
504    children.push(section_title("Controles"));
505    children.push(switch_row("Modo pleno", model.switch_a, Msg::ToggleA, theme));
506    children.push(switch_row("Telemetría", model.switch_b, Msg::ToggleB, theme));
507    children.push(spacer_v(8.0));
508    children.push(
509        View::new(Style {
510            size: Size {
511                width: percent(1.0_f32),
512                height: length(28.0_f32),
513            },
514            ..Default::default()
515        })
516        .children(vec![segmented_view(
517            &["lista", "grilla", "kanban"],
518            model.seg,
519            Msg::SelectSeg,
520            &SegmentedPalette::from_theme(theme),
521        )]),
522    );
523
524    children.push(section_title("Formulario"));
525    children.push(field_view(FieldSpec {
526        label: "Nombre del lienzo".to_string(),
527        control: fake_text_input("introducción a wawa", theme),
528        required: true,
529        helper: Some("Aparece como título en la pestaña.".to_string()),
530        error: None,
531        palette: FieldPalette::from_theme(theme),
532    }));
533    children.push(spacer_v(12.0));
534    children.push(field_view(FieldSpec {
535        label: "Slug".to_string(),
536        control: fake_text_input("intro-wawa-x@123", theme),
537        required: false,
538        helper: None,
539        error: Some("Sólo letras, números y guiones.".to_string()),
540        palette: FieldPalette::from_theme(theme),
541    }));
542
543    children.push(section_title("Acciones"));
544    children.push(button_row(theme));
545
546    panel_view(children, theme)
547}
548
549fn column_right(_model: &Model, theme: &Theme) -> View<Msg> {
550    let mut children: Vec<View<Msg>> = Vec::new();
551
552    children.push(section_title("Identidades"));
553    // Avatares en línea con badge encima.
554    children.push(
555        View::new(Style {
556            flex_direction: FlexDirection::Row,
557            size: Size {
558                width: percent(1.0_f32),
559                height: length(48.0_f32),
560            },
561            gap: Size {
562                width: length(8.0_f32),
563                height: length(0.0_f32),
564            },
565            align_items: Some(AlignItems::Center),
566            ..Default::default()
567        })
568        .children(vec![
569            avatar_view("sergio", 40.0),
570            avatar_view("calcetín", 40.0),
571            avatar_view("amaru", 40.0),
572            avatar_view("pacha", 40.0),
573            avatar_view("inti", 40.0),
574        ]),
575    );
576
577    children.push(section_title("Badges"));
578    children.push(
579        View::new(Style {
580            flex_direction: FlexDirection::Row,
581            size: Size {
582                width: percent(1.0_f32),
583                height: length(24.0_f32),
584            },
585            gap: Size {
586                width: length(10.0_f32),
587                height: length(0.0_f32),
588            },
589            align_items: Some(AlignItems::Center),
590            ..Default::default()
591        })
592        .children(vec![
593            count_badge_view(3, BadgeKind::Info),
594            count_badge_view(12, BadgeKind::Success),
595            count_badge_view(99, BadgeKind::Warning),
596            count_badge_view(120, BadgeKind::Error),
597            dot_badge_view(BadgeKind::Success),
598            dot_badge_view(BadgeKind::Warning),
599            dot_badge_view(BadgeKind::Error),
600        ]),
601    );
602
603    children.push(section_title("Carga"));
604    children.push(
605        View::new(Style {
606            flex_direction: FlexDirection::Row,
607            size: Size {
608                width: percent(1.0_f32),
609                height: length(48.0_f32),
610            },
611            gap: Size {
612                width: length(16.0_f32),
613                height: length(0.0_f32),
614            },
615            align_items: Some(AlignItems::Center),
616            ..Default::default()
617        })
618        .children(vec![
619            View::new(Style {
620                size: Size {
621                    width: length(40.0_f32),
622                    height: length(40.0_f32),
623                },
624                ..Default::default()
625            })
626            .children(vec![spinner_view(theme.accent, 0.12, 1.0)]),
627            View::new(Style {
628                size: Size {
629                    width: length(40.0_f32),
630                    height: length(40.0_f32),
631                },
632                ..Default::default()
633            })
634            .children(vec![radial_progress_view(
635                0.66,
636                theme.bg_button,
637                theme.accent,
638                0.14,
639            )]),
640            linear_progress_view(0.42, theme.bg_button, theme.accent, 8.0),
641        ]),
642    );
643
644    children.push(section_title("Skeleton"));
645    let palette = SkeletonPalette::from_theme(theme);
646    children.push(skeleton_line_view::<Msg>(200.0, &palette));
647    children.push(spacer_v(6.0));
648    children.push(skeleton_line_view::<Msg>(280.0, &palette));
649    children.push(spacer_v(6.0));
650    children.push(skeleton_line_view::<Msg>(160.0, &palette));
651    children.push(spacer_v(10.0));
652    children.push(skeleton_box_view::<Msg>(percent_to_px(0.9, 360.0), 60.0, &palette));
653
654    children.push(section_title("Cards"));
655    // Dos cards apilados: el primero con la firma (gradient sutil +
656    // hairline en el top), el segundo con `accent` lateral y fill plano.
657    // Para apreciar la firma hay que mirar de cerca: el ojo registra
658    // "tallado" sin saber por qué.
659    let card_palette = CardPalette::from_theme(theme);
660    children.push(card_view(
661        vec![
662            text_line("Documento — multilienzo", 13.0, theme.fg_text),
663            text_line("3 cuerpos · 412 átomos · BLAKE3 verificado", 11.0, theme.fg_muted),
664        ],
665        CardOptions::with_signature(theme),
666        &card_palette,
667    ));
668    children.push(spacer_v(8.0));
669    children.push(card_view(
670        vec![
671            text_line("Build pasó — wawa-kernel", 13.0, theme.fg_text),
672            text_line("x86_64-unknown-none · 1.42s · 0 warnings", 11.0, theme.fg_muted),
673        ],
674        CardOptions {
675            accent: Some(Color::from_rgba8(110, 200, 130, 255)),
676            ..Default::default()
677        },
678        &card_palette,
679    ));
680
681    children.push(section_title("Menú contextual"));
682    children.push(
683        View::new(Style {
684            size: Size {
685                width: percent(1.0_f32),
686                height: length(32.0_f32),
687            },
688            flex_shrink: 0.0,
689            align_items: Some(AlignItems::Center),
690            justify_content: Some(JustifyContent::Center),
691            padding: Rect {
692                left: length(12.0_f32),
693                right: length(12.0_f32),
694                top: length(0.0_f32),
695                bottom: length(0.0_f32),
696            },
697            ..Default::default()
698        })
699        .fill(theme.bg_button)
700        .hover_fill(theme.bg_button_hover)
701        .radius(llimphi_theme::radius::SM)
702        .text_aligned(
703            "Mostrar menú".to_string(),
704            12.0,
705            theme.fg_text,
706            Alignment::Center,
707        )
708        .on_click(Msg::OpenContextMenu),
709    );
710
711    children.push(section_title("Iconografía"));
712    children.push(icon_grid(theme));
713
714    panel_view(children, theme)
715}
716
717// ---------------------------------------------------------------------
718// Helpers de composición
719// ---------------------------------------------------------------------
720
721fn text_line(text: &str, size: f32, color: Color) -> View<Msg> {
722    View::new(Style {
723        size: Size {
724            width: percent(1.0_f32),
725            height: length(size + 6.0),
726        },
727        ..Default::default()
728    })
729    .text_aligned(text.to_string(), size, color, Alignment::Start)
730}
731
732fn section_title(text: &str) -> View<Msg> {
733    View::new(Style {
734        size: Size {
735            width: percent(1.0_f32),
736            height: length(20.0_f32),
737        },
738        flex_shrink: 0.0,
739        ..Default::default()
740    })
741    .text_aligned(
742        text.to_uppercase(),
743        10.0,
744        Color::from_rgba8(140, 160, 200, 255),
745        Alignment::Start,
746    )
747}
748
749fn panel_view(children: Vec<View<Msg>>, theme: &Theme) -> View<Msg> {
750    let style = PanelStyle::from_theme(theme);
751    View::new(Style {
752        flex_direction: FlexDirection::Column,
753        size: Size {
754            width: percent(1.0_f32),
755            height: percent(1.0_f32),
756        },
757        flex_grow: 1.0,
758        padding: Rect {
759            left: length(16.0_f32),
760            right: length(16.0_f32),
761            top: length(14.0_f32),
762            bottom: length(14.0_f32),
763        },
764        gap: Size {
765            width: length(0.0_f32),
766            height: length(10.0_f32),
767        },
768        ..Default::default()
769    })
770    .paint_with(panel_signature_painter(style))
771    .radius(style.radius)
772    .clip(true)
773    .children(children)
774}
775
776fn switch_row(label: &str, value: bool, msg: Msg, theme: &Theme) -> View<Msg> {
777    let progress = if value { 1.0 } else { 0.0 };
778    View::new(Style {
779        flex_direction: FlexDirection::Row,
780        size: Size {
781            width: percent(1.0_f32),
782            height: length(28.0_f32),
783        },
784        align_items: Some(AlignItems::Center),
785        justify_content: Some(JustifyContent::SpaceBetween),
786        ..Default::default()
787    })
788    .children(vec![
789        View::new(Style {
790            size: Size {
791                width: percent(1.0_f32),
792                height: percent(1.0_f32),
793            },
794            flex_grow: 1.0,
795            align_items: Some(AlignItems::Center),
796            ..Default::default()
797        })
798        .text_aligned(label.to_string(), 12.0, theme.fg_text, Alignment::Start),
799        switch_view(progress, msg, &SwitchPalette::from_theme(theme)),
800    ])
801}
802
803fn fake_text_input(text: &str, theme: &Theme) -> View<Msg> {
804    View::new(Style {
805        size: Size {
806            width: percent(1.0_f32),
807            height: length(28.0_f32),
808        },
809        padding: Rect {
810            left: length(8.0_f32),
811            right: length(8.0_f32),
812            top: length(0.0_f32),
813            bottom: length(0.0_f32),
814        },
815        align_items: Some(AlignItems::Center),
816        flex_shrink: 0.0,
817        ..Default::default()
818    })
819    .fill(theme.bg_input)
820    .radius(llimphi_theme::radius::SM)
821    .text_aligned(text.to_string(), 12.0, theme.fg_text, Alignment::Start)
822}
823
824fn button_row(theme: &Theme) -> View<Msg> {
825    View::new(Style {
826        flex_direction: FlexDirection::Row,
827        size: Size {
828            width: percent(1.0_f32),
829            height: length(32.0_f32),
830        },
831        gap: Size {
832            width: length(8.0_f32),
833            height: length(0.0_f32),
834        },
835        ..Default::default()
836    })
837    .children(vec![
838        btn("Mostrar toast", theme.accent, theme.bg_app, Msg::PushToast),
839        btn("Abrir modal", theme.bg_button, theme.fg_text, Msg::OpenModal),
840        btn("Atajos (?)", theme.bg_button, theme.fg_text, Msg::ToggleShortcuts),
841    ])
842}
843
844fn btn(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
845    let w = label.chars().count() as f32 * 7.5 + 24.0;
846    View::new(Style {
847        size: Size {
848            width: length(w),
849            height: length(32.0_f32),
850        },
851        align_items: Some(AlignItems::Center),
852        justify_content: Some(JustifyContent::Center),
853        flex_shrink: 0.0,
854        ..Default::default()
855    })
856    .fill(bg)
857    .radius(llimphi_theme::radius::SM)
858    .text_aligned(label.to_string(), 12.0, fg, Alignment::Center)
859    .on_click(msg)
860}
861
862fn icon_grid(theme: &Theme) -> View<Msg> {
863    let icons = [
864        Icon::File, Icon::Folder, Icon::Save, Icon::Open, Icon::Search,
865        Icon::Plus, Icon::Minus, Icon::X, Icon::Check, Icon::Edit,
866        Icon::Trash, Icon::Home, Icon::Settings, Icon::Bell, Icon::More,
867        Icon::Info, Icon::Warning, Icon::Error, Icon::ChevronUp,
868        Icon::ChevronDown, Icon::ChevronLeft, Icon::ChevronRight,
869        Icon::FolderOpen,
870    ];
871    let cells: Vec<View<Msg>> = icons
872        .iter()
873        .map(|i| {
874            View::new(Style {
875                size: Size {
876                    width: length(28.0_f32),
877                    height: length(28.0_f32),
878                },
879                flex_shrink: 0.0,
880                ..Default::default()
881            })
882            .fill(theme.bg_panel_alt)
883            .radius(llimphi_theme::radius::XS)
884            .children(vec![icon_view(*i, theme.fg_text, 1.6)])
885        })
886        .collect();
887
888    View::new(Style {
889        flex_direction: FlexDirection::Row,
890        size: Size {
891            width: percent(1.0_f32),
892            height: auto(),
893        },
894        gap: Size {
895            width: length(6.0_f32),
896            height: length(6.0_f32),
897        },
898        flex_wrap: llimphi_ui::llimphi_layout::taffy::FlexWrap::Wrap,
899        ..Default::default()
900    })
901    .children(cells)
902}
903
904fn modal_body_view(theme: &Theme) -> View<Msg> {
905    View::new(Style {
906        size: Size {
907            width: percent(1.0_f32),
908            height: percent(1.0_f32),
909        },
910        flex_grow: 1.0,
911        ..Default::default()
912    })
913    .text_aligned(
914        "Esta acción reescribirá la configuración local. \
915         Sólo dura mientras no salgas — al guardar quedará persistida en disco."
916            .to_string(),
917        12.0,
918        theme.fg_muted,
919        Alignment::Start,
920    )
921}
922
923fn wawa_frame(side: f32) -> View<Msg> {
924    View::new(Style {
925        size: Size {
926            width: length(side),
927            height: length(side),
928        },
929        align_items: Some(AlignItems::Center),
930        justify_content: Some(JustifyContent::Center),
931        flex_shrink: 0.0,
932        ..Default::default()
933    })
934    .children(vec![wawa_mark_view(&WawaMarkPalette::default())])
935}
936
937fn spacer_v(h: f32) -> View<Msg> {
938    View::new(Style {
939        size: Size {
940            width: percent(1.0_f32),
941            height: length(h),
942        },
943        flex_shrink: 0.0,
944        ..Default::default()
945    })
946}
947
948fn percent_to_px(p: f32, base: f32) -> f32 {
949    p * base
950}
951
952// Tooltip placeholder — la demo no instrumenta hover-to-show porque
953// requeriría más Msgs; queda como código de referencia para apps reales.
954#[allow(dead_code)]
955fn demo_tooltip(viewport: (f32, f32), text: &str, theme: &Theme) -> View<Msg> {
956    tooltip_view::<Msg>(TooltipSpec {
957        anchor: (viewport.0 * 0.5, viewport.1 * 0.5),
958        viewport,
959        side: Side::Bottom,
960        text: text.to_string(),
961        palette: TooltipPalette::from_theme(theme),
962    })
963}
964
965fn main() {
966    llimphi_ui::run::<Gallery>();
967}