gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main widgets/gallery/src/main.rs Rust · 581 líneas · 19.0 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-widget-gallery` — todos los widgets de Llimphi en una sola
2//! ventana. Útil como referencia visual y smoke test al cambiar el
3//! theme o cualquier widget.
4//!
5//! Corre con: `cargo run -p llimphi-widget-gallery --release`.
6
7use std::sync::Arc;
8
9use llimphi_theme::Theme;
10use llimphi_ui::llimphi_layout::taffy::{
11    prelude::{length, percent, FlexDirection, Size, Style},
12    Rect,
13};
14use llimphi_ui::llimphi_raster::peniko::Color;
15use llimphi_ui::llimphi_text::Alignment;
16use llimphi_ui::{App, DragPhase, Handle, View};
17use llimphi_widget_app_header::{app_header_iconed, AppHeaderPalette, AppIcon};
18use llimphi_widget_banner::{banner_view, BannerKind};
19use llimphi_widget_button::{button_view, ButtonPalette};
20use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
21use llimphi_widget_splitter::{splitter_two, Direction, PaneSize, SplitterPalette};
22use llimphi_widget_stat_card::{stat_card_view, StatCardPalette};
23use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec};
24use llimphi_widget_text_input::{
25    text_input_view_full, MemClipboard, TextInputEvent, TextInputPalette, TextInputState,
26};
27use llimphi_widget_theme_switcher::theme_switcher_view;
28use llimphi_widget_tiled::{tiled_view_reorderable, TileSpec, TiledPalette};
29use llimphi_widget_context_menu::{
30    context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
31};
32use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
33
34use app_bus::{AppMenu, Menu, MenuItem};
35
36#[derive(Clone)]
37enum Msg {
38    /// Todo lo del text-input (teclado/IME/mouse) por un solo Msg — el camino
39    /// frictionless de F1: `state.handle(ev, &mut clip)`.
40    Field(TextInputEvent),
41    SelectRow(usize),
42    SelectTab(usize),
43    ClickAction(u32),
44    ResizeOuter(f32),
45    SwapTile { from: usize, to: usize },
46    ChangeTheme(Theme),
47    CycleTheme,
48    // --- Barra de menú + contextual ---
49    MenuOpen(Option<usize>),
50    MenuCommand(String),
51    CloseMenus,
52    ContextMenuOpen(f32, f32),
53}
54
55struct Model {
56    text: TextInputState,
57    /// Portapapeles intra-app para copiar/pegar en el campo.
58    clip: MemClipboard,
59    list_sel: usize,
60    tab: usize,
61    last_action: Option<u32>,
62    left_w: f32,
63    tile_order: Vec<usize>,
64    theme: Theme,
65    /// Índice del menú raíz abierto en la barra (None = ninguno).
66    menu_open: Option<usize>,
67    /// Posición (en coords de ventana) del menú contextual, si está abierto.
68    context_menu: Option<(f32, f32)>,
69}
70
71struct Gallery;
72
73impl App for Gallery {
74    type Model = Model;
75    type Msg = Msg;
76
77    fn title() -> &'static str {
78        "llimphi · widget gallery"
79    }
80
81    fn initial_size() -> (u32, u32) {
82        (1280, 820)
83    }
84
85    fn init(_: &Handle<Msg>) -> Model {
86        Model {
87            text: TextInputState::new(),
88            clip: MemClipboard::new(),
89            list_sel: 0,
90            tab: 0,
91            last_action: None,
92            left_w: 380.0,
93            tile_order: vec![0, 1, 2, 3],
94            theme: Theme::dark(),
95            menu_open: None,
96            context_menu: None,
97        }
98    }
99
100    fn on_key(_: &Model, e: &llimphi_ui::KeyEvent) -> Option<Msg> {
101        Some(Msg::Field(TextInputEvent::Key(e.clone())))
102    }
103
104    fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
105        let mut m = model;
106        match msg {
107            Msg::Field(ev) => {
108                m.text.handle(ev, &mut m.clip);
109            }
110            Msg::SelectRow(i) => m.list_sel = i,
111            Msg::SelectTab(i) => m.tab = i,
112            Msg::ClickAction(id) => m.last_action = Some(id),
113            Msg::ResizeOuter(dx) => m.left_w = (m.left_w + dx).clamp(220.0, 800.0),
114            Msg::SwapTile { from, to } => {
115                if from != to && from < m.tile_order.len() && to < m.tile_order.len() {
116                    m.tile_order.swap(from, to);
117                }
118            }
119            Msg::ChangeTheme(t) => m.theme = t,
120            Msg::CycleTheme => m.theme = Theme::next_after(m.theme.name),
121            Msg::MenuOpen(which) => {
122                m.menu_open = which;
123                // Abrir un menú raíz cierra cualquier contextual.
124                m.context_menu = None;
125            }
126            Msg::CloseMenus => {
127                m.menu_open = None;
128                m.context_menu = None;
129            }
130            Msg::ContextMenuOpen(x, y) => {
131                m.menu_open = None;
132                m.context_menu = Some((x, y));
133            }
134            Msg::MenuCommand(cmd) => {
135                m.menu_open = None;
136                m.context_menu = None;
137                handle_menu_command(&cmd, &mut m, handle);
138            }
139        }
140        m
141    }
142
143    fn view(model: &Model) -> View<Msg> {
144        let theme = model.theme;
145        let menu = app_menu();
146        let menubar = menubar_view(&menubar_spec(&menu, model));
147        let header_palette = AppHeaderPalette::from_theme(&theme);
148        let btn_palette = ButtonPalette::from_theme(&theme);
149        let list_palette = ListPalette::from_theme(&theme);
150        let splitter_palette = SplitterPalette::from_theme(&theme);
151        let tabs_palette = TabsPalette::from_theme(&theme);
152        let stat_palette = StatCardPalette::from_theme(&theme);
153        let input_palette = TextInputPalette::from_theme(&theme);
154
155        // --- Header con acción a la derecha ---
156        let header = app_header_iconed(
157            AppIcon::Llimphi,
158            format!(
159                "llimphi widget gallery · última acción: {}",
160                match model.last_action {
161                    Some(i) => format!("button #{i}"),
162                    None => "ninguna".to_string(),
163                }
164            ),
165            vec![
166                {
167                    let mut btn = button_view("acción A", &btn_palette, Msg::ClickAction(1));
168                    btn.style.size = Size {
169                        width: length(110.0_f32),
170                        height: length(28.0_f32),
171                    };
172                    btn
173                },
174                {
175                    let mut btn = button_view("acción B", &btn_palette, Msg::ClickAction(2));
176                    btn.style.size = Size {
177                        width: length(110.0_f32),
178                        height: length(28.0_f32),
179                    };
180                    btn
181                },
182                theme_switcher_view::<Msg>(&theme, Msg::ChangeTheme),
183            ],
184            &header_palette,
185        );
186
187        // --- Panel izquierdo: lista virtualizada ---
188        let entries = (0..40)
189            .map(|i| format!("entry {:02}", i))
190            .collect::<Vec<_>>();
191        let visible_rows: Vec<ListRow<Msg>> = entries
192            .iter()
193            .enumerate()
194            .take(20)
195            .map(|(i, label)| ListRow {
196                label: label.clone(),
197                selected: i == model.list_sel,
198                on_click: Msg::SelectRow(i),
199            })
200            .collect();
201        let list = list_view(ListSpec {
202            rows: visible_rows,
203            total: entries.len(),
204            caption: Some(format!("{} entradas", entries.len())),
205            truncated_hint: Some(format!("… y {} más", entries.len() - 20)),
206            row_height: 22.0,
207            palette: list_palette,
208        });
209
210        // --- Panel derecho: tabs con stat cards + banners + input + tiled ---
211        let tiled_palette = TiledPalette::from_theme(&theme);
212        let tab_content = match model.tab {
213            0 => stats_pane(&theme, &stat_palette),
214            1 => alerts_pane(),
215            2 => input_pane(&model.text, &input_palette, &theme),
216            _ => tiled_pane(&theme, &tiled_palette, &model.tile_order),
217        };
218        let tabs = tabs_view(TabsSpec {
219            labels: vec!["Stats".into(), "Banners".into(), "Input".into(), "Tiled".into()],
220            active: model.tab,
221            on_select: Msg::SelectTab,
222            content: tab_content,
223            tab_height: 32.0,
224            palette: tabs_palette,
225            tab_width: Some(120.0),
226            on_close: None,
227            on_reorder: None,
228            strip_offset: 0.0,
229            on_strip_scroll: None,
230        });
231
232        let body = splitter_two(
233            Direction::Row,
234            list,
235            PaneSize::Fixed(model.left_w),
236            tabs,
237            PaneSize::Flex,
238            |phase, dx| match phase {
239                DragPhase::Move => Some(Msg::ResizeOuter(dx)),
240                DragPhase::End => None,
241            },
242            &splitter_palette,
243        );
244
245        View::new(Style {
246            flex_direction: FlexDirection::Column,
247            size: Size {
248                width: percent(1.0_f32),
249                height: percent(1.0_f32),
250            },
251            ..Default::default()
252        })
253        .fill(theme.bg_app)
254        // Origen (0,0) ⇒ las coords locales del right-click son coords de
255        // ventana, listas para anclar el contextual.
256        .on_right_click_at(|x, y, _, _| Some(Msg::ContextMenuOpen(x, y)))
257        .children(vec![menubar, header, body])
258    }
259
260    fn view_overlay(model: &Model) -> Option<View<Msg>> {
261        // Prioridad: contextual sobre el dropdown del menú principal.
262        if let Some((x, y)) = model.context_menu {
263            return Some(context_menu_for_gallery(model, x, y));
264        }
265        let menu = app_menu();
266        menubar_overlay(&menubar_spec(&menu, model))
267    }
268}
269
270// ---------------------------------------------------------------------
271// Menú principal (barra) + contextual
272// ---------------------------------------------------------------------
273
274/// Viewport para clampear overlays. La gallery no trackea el tamaño real
275/// de ventana, así que usamos las constantes de `initial_size()`.
276fn viewport_of(_model: &Model) -> (f32, f32) {
277    let (w, h) = Gallery::initial_size();
278    (w as f32, h as f32)
279}
280
281/// `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
282fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
283    MenuBarSpec {
284        menu,
285        open: model.menu_open,
286        theme: &model.theme,
287        viewport: viewport_of(model),
288        height: MENU_H,
289        on_open: Arc::new(Msg::MenuOpen),
290        on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
291    }
292}
293
294/// Menú principal de la vitrina. Archivo / Ver / Ayuda — sólo comandos que
295/// mapean a `Msg` reales. No hay "Editar": el único campo de texto es el
296/// `text_input` del tab Input, que ya recibe teclas por `on_key`.
297fn app_menu() -> AppMenu {
298    AppMenu::new()
299        .menu(
300            Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Esc")),
301        )
302        .menu(
303            Menu::new("Ver")
304                .item(MenuItem::new("Cambiar tema", "view.theme"))
305                .item(MenuItem::new("Pestaña: Stats", "view.tab.0").separated())
306                .item(MenuItem::new("Pestaña: Banners", "view.tab.1"))
307                .item(MenuItem::new("Pestaña: Input", "view.tab.2"))
308                .item(MenuItem::new("Pestaña: Tiled", "view.tab.3")),
309        )
310        .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
311}
312
313/// Traduce un command id (de la barra o del contextual) al `Msg` real y lo
314/// aplica. `file.quit` cierra el proceso directo (no hay diálogo).
315fn handle_menu_command(cmd: &str, m: &mut Model, handle: &Handle<Msg>) {
316    match cmd {
317        "file.quit" => std::process::exit(0),
318        // Reusa el Msg de ciclo de tema en vez de duplicar la lógica.
319        "view.theme" => handle.dispatch(Msg::CycleTheme),
320        "view.tab.0" => m.tab = 0,
321        "view.tab.1" => m.tab = 1,
322        "view.tab.2" => m.tab = 2,
323        "view.tab.3" => m.tab = 3,
324        // "help.about" y desconocidos: no-op (sin diálogo todavía).
325        _ => {}
326    }
327}
328
329/// Menú contextual de la vitrina. No hay objeto seleccionable en un canvas:
330/// el "ítem seleccionado" es la entrada resaltada de la lista izquierda, así
331/// que el contextual la nombra y ofrece navegar las pestañas + cambiar tema.
332fn context_menu_for_gallery(model: &Model, x: f32, y: f32) -> View<Msg> {
333    let header = format!("entrada seleccionada: {:02}", model.list_sel);
334
335    let items = vec![
336        ContextMenuItem::action("Cambiar tema"),
337        ContextMenuItem::separator(),
338        ContextMenuItem::action("Pestaña: Stats"),
339        ContextMenuItem::action("Pestaña: Banners"),
340        ContextMenuItem::action("Pestaña: Input"),
341        ContextMenuItem::action("Pestaña: Tiled"),
342    ];
343    // Mapeo índice de item → command id de `handle_menu_command`.
344    let cmds: Vec<&'static str> = vec![
345        "view.theme",
346        "",
347        "view.tab.0",
348        "view.tab.1",
349        "view.tab.2",
350        "view.tab.3",
351    ];
352    let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
353        Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
354    });
355
356    context_menu_view(ContextMenuSpec {
357        anchor: (x, y),
358        viewport: viewport_of(model),
359        header: Some(header),
360        items,
361        active: usize::MAX,
362        on_pick,
363        on_dismiss: Msg::CloseMenus,
364        palette: ContextMenuPalette::from_theme(&model.theme),
365    })
366}
367
368// ---------------------------------------------------------------------
369// Paneles de tabs
370// ---------------------------------------------------------------------
371
372fn stats_pane(theme: &Theme, palette: &StatCardPalette) -> View<Msg> {
373    let valid = Color::from_rgba8(94, 184, 124, 255);
374    let warn = Color::from_rgba8(238, 178, 53, 255);
375    let danger = Color::from_rgba8(225, 84, 75, 255);
376
377    let row = View::new(Style {
378        flex_direction: FlexDirection::Row,
379        size: Size {
380            width: percent(1.0_f32),
381            height: length(160.0_f32),
382        },
383        gap: Size {
384            width: length(12.0_f32),
385            height: length(0.0_f32),
386        },
387        ..Default::default()
388    })
389    .children(vec![
390        wrap_card_cell(stat_card_view::<Msg>(
391            "Coherencia",
392            "247",
393            "átomos válidos",
394            valid,
395            &[],
396            palette,
397        )),
398        wrap_card_cell(stat_card_view::<Msg>(
399            "Por evaluar",
400            "12",
401            "esperando re-cómputo",
402            warn,
403            &[],
404            palette,
405        )),
406        wrap_card_cell(stat_card_view::<Msg>(
407            "En conflicto",
408            "3",
409            "contradicen su origen",
410            danger,
411            &[
412                "puerta_amanecer".into(),
413                "muelle_soledad".into(),
414                "viento_nuevo".into(),
415            ],
416            palette,
417        )),
418    ]);
419
420    let _ = theme;
421    View::new(Style {
422        flex_direction: FlexDirection::Column,
423        size: Size {
424            width: percent(1.0_f32),
425            height: percent(1.0_f32),
426        },
427        padding: Rect {
428            left: length(20.0_f32),
429            right: length(20.0_f32),
430            top: length(20.0_f32),
431            bottom: length(20.0_f32),
432        },
433        ..Default::default()
434    })
435    .children(vec![row])
436}
437
438fn wrap_card_cell(view: View<Msg>) -> View<Msg> {
439    View::new(Style {
440        size: Size {
441            width: percent(1.0_f32),
442            height: percent(1.0_f32),
443        },
444        flex_grow: 1.0,
445        ..Default::default()
446    })
447    .children(vec![view])
448}
449
450fn alerts_pane() -> View<Msg> {
451    View::new(Style {
452        flex_direction: FlexDirection::Column,
453        size: Size {
454            width: percent(1.0_f32),
455            height: percent(1.0_f32),
456        },
457        gap: Size {
458            width: length(0.0_f32),
459            height: length(10.0_f32),
460        },
461        padding: Rect {
462            left: length(20.0_f32),
463            right: length(20.0_f32),
464            top: length(20.0_f32),
465            bottom: length(20.0_f32),
466        },
467        ..Default::default()
468    })
469    .children(vec![
470        banner_view(BannerKind::Info, "info: gallery iniciada con widgets verdes"),
471        banner_view(BannerKind::Success, "success: 12 widgets cargados ok"),
472        banner_view(BannerKind::Warning, "warning: el tema light aún tiene contraste subóptimo"),
473        banner_view(BannerKind::Error, "error: ningún error real — sólo un demo"),
474    ])
475}
476
477fn input_pane(state: &TextInputState, palette: &TextInputPalette, theme: &Theme) -> View<Msg> {
478    let label = View::new(Style {
479        size: Size {
480            width: percent(1.0_f32),
481            height: length(18.0_f32),
482        },
483        ..Default::default()
484    })
485    .text_aligned(
486        "Prueba tipear aquí:".to_string(),
487        12.0,
488        theme.fg_muted,
489        Alignment::Start,
490    );
491    let input = text_input_view_full(
492        state,
493        "lo que sea",
494        true, // siempre focado en este demo
495        palette,
496        Msg::Field,
497    );
498
499    View::new(Style {
500        flex_direction: FlexDirection::Column,
501        size: Size {
502            width: percent(1.0_f32),
503            height: percent(1.0_f32),
504        },
505        gap: Size {
506            width: length(0.0_f32),
507            height: length(8.0_f32),
508        },
509        padding: Rect {
510            left: length(20.0_f32),
511            right: length(20.0_f32),
512            top: length(20.0_f32),
513            bottom: length(20.0_f32),
514        },
515        ..Default::default()
516    })
517    .children(vec![label, input])
518}
519
520fn tiled_pane(theme: &Theme, palette: &TiledPalette, order: &[usize]) -> View<Msg> {
521    let body = |text: &str, size: f32, color: Color, align: Alignment| -> View<Msg> {
522        View::new(Style {
523            size: Size {
524                width: percent(1.0_f32),
525                height: percent(1.0_f32),
526            },
527            flex_grow: 1.0,
528            padding: Rect {
529                left: length(12.0_f32),
530                right: length(12.0_f32),
531                top: length(10.0_f32),
532                bottom: length(10.0_f32),
533            },
534            ..Default::default()
535        })
536        .text_aligned(text.to_string(), size, color, align)
537    };
538
539    let make_tile = |id: usize| -> TileSpec<Msg> {
540        match id {
541            0 => TileSpec {
542                label: "logs".into(),
543                content: body(
544                    "[12:01] boot\n[12:02] config ok\n[12:03] esperando…",
545                    12.0,
546                    theme.fg_text,
547                    Alignment::Start,
548                ),
549            },
550            1 => TileSpec {
551                label: "métricas".into(),
552                content: body("cpu 37%\nram 1.2 G\nnet 12 kB/s", 12.0, theme.fg_text, Alignment::Start),
553            },
554            2 => TileSpec {
555                label: "uptime".into(),
556                content: body("4d 12h", 26.0, theme.accent, Alignment::Center),
557            },
558            _ => TileSpec {
559                label: "queue".into(),
560                content: body(
561                    "pending 7\nin-flight 2\ndone 1842",
562                    12.0,
563                    theme.fg_text,
564                    Alignment::Start,
565                ),
566            },
567        }
568    };
569
570    let tiles: Vec<TileSpec<Msg>> = order.iter().map(|&id| make_tile(id)).collect();
571
572    tiled_view_reorderable(
573        tiles,
574        |from, to| Some(Msg::SwapTile { from, to }),
575        palette,
576    )
577}
578
579fn main() {
580    llimphi_ui::run::<Gallery>();
581}