tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-gallery/src/main.rs
Historia
Quién tocó qué
crudo
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}