tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ dba855e446ca2bd4e7d146bdc168fb232cad15a4
llimphi-gallery/src/main.rs
Historia
Quién tocó qué
crudo
feat: llimphi standalone — framework UI soberano extraído del monorepo
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}