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

⎇ dba855e446ca2bd4e7d146bdc168fb232cad15a4 widgets/gallery/src/main.rs Rust · 569 líneas · 18.5 KB Historia Quién tocó qué crudo

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