feat: llimphi standalone — framework UI soberano extraído del monorepo
Motor gráfico Llimphi como workspace independiente: bucle Elm (input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley. Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets + módulos, sin dependencias al resto del monorepo. cargo check --workspace pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "llimphi-gallery"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "llimphi-gallery — demo único que prueba el kit transversal de elegancia. Binario standalone; `cargo run -p llimphi-gallery --release`."
|
||||
|
||||
[[bin]]
|
||||
name = "llimphi-gallery"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
llimphi-ui = { workspace = true }
|
||||
llimphi-theme = { workspace = true }
|
||||
llimphi-motion = { workspace = true }
|
||||
llimphi-icons = { workspace = true }
|
||||
llimphi-widget-wawa-mark = { workspace = true }
|
||||
llimphi-widget-tooltip = { workspace = true }
|
||||
llimphi-widget-spinner = { workspace = true }
|
||||
llimphi-widget-progress = { workspace = true }
|
||||
llimphi-widget-toast = { workspace = true }
|
||||
llimphi-widget-modal = { workspace = true }
|
||||
llimphi-widget-empty = { workspace = true }
|
||||
llimphi-widget-status-bar = { workspace = true }
|
||||
llimphi-widget-shortcuts-help = { workspace = true }
|
||||
llimphi-widget-splash = { workspace = true }
|
||||
llimphi-widget-switch = { workspace = true }
|
||||
llimphi-widget-segmented = { workspace = true }
|
||||
llimphi-widget-breadcrumb = { workspace = true }
|
||||
llimphi-widget-badge = { workspace = true }
|
||||
llimphi-widget-avatar = { workspace = true }
|
||||
llimphi-widget-skeleton = { workspace = true }
|
||||
llimphi-widget-field = { workspace = true }
|
||||
llimphi-widget-panel = { workspace = true }
|
||||
llimphi-widget-card = { workspace = true }
|
||||
llimphi-widget-context-menu = { workspace = true }
|
||||
llimphi-widget-menubar = { workspace = true }
|
||||
app-bus = { workspace = true }
|
||||
@@ -0,0 +1,966 @@
|
||||
//! `llimphi-gallery` — demo único del kit transversal de elegancia.
|
||||
//!
|
||||
//! Una sola ventana que muestra cómo se ven los widgets del kit
|
||||
//! juntos sobre el theme dark. Útil para verificar paleta, escala,
|
||||
//! cinética y consistencia visual de un vistazo.
|
||||
//!
|
||||
//! `cargo run -p llimphi-gallery --release`
|
||||
//!
|
||||
//! Controles:
|
||||
//! - Click en switches/segments/breadcrumb: dispatchea Msg
|
||||
//! - Click en "Mostrar toast": apila un toast en bottom-right
|
||||
//! - Click en "Abrir modal": muestra el modal
|
||||
//! - `?`: abre/cierra el overlay de atajos
|
||||
//! - Esc: cierra overlay activo
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use llimphi_ui::llimphi_layout::taffy::{
|
||||
prelude::{auto, length, percent, FlexDirection, Size, Style},
|
||||
AlignItems, JustifyContent, Rect,
|
||||
};
|
||||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||
use llimphi_ui::llimphi_text::Alignment;
|
||||
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
|
||||
|
||||
use llimphi_icons::{icon_view, Icon};
|
||||
use llimphi_theme::Theme;
|
||||
|
||||
use app_bus::{AppMenu, Menu, MenuItem};
|
||||
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
|
||||
|
||||
use llimphi_widget_avatar::avatar_view;
|
||||
use llimphi_widget_badge::{count_badge_view, dot_badge_view, BadgeKind};
|
||||
use llimphi_widget_breadcrumb::{breadcrumb_view, BreadcrumbPalette};
|
||||
use llimphi_widget_card::{card_view, CardOptions, CardPalette};
|
||||
use llimphi_widget_context_menu::{
|
||||
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
|
||||
};
|
||||
use llimphi_widget_empty::{empty_view, EmptyPalette};
|
||||
use llimphi_widget_field::{field_view, FieldPalette, FieldSpec};
|
||||
use llimphi_widget_modal::{modal_view, ModalButton, ModalPalette, ModalSpec};
|
||||
use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
|
||||
use llimphi_widget_progress::{linear_progress_view, radial_progress_view};
|
||||
use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
|
||||
use llimphi_widget_shortcuts_help::{
|
||||
shortcuts_help_view, ShortcutEntry, ShortcutGroup, ShortcutsHelpPalette, ShortcutsHelpSpec,
|
||||
};
|
||||
use llimphi_widget_skeleton::{skeleton_box_view, skeleton_line_view, SkeletonPalette};
|
||||
use llimphi_widget_spinner::spinner_view;
|
||||
use llimphi_widget_splash::splash_view;
|
||||
use llimphi_widget_status_bar::{status_bar_view, StatusBarPalette, StatusSegment};
|
||||
use llimphi_widget_switch::{switch_view, SwitchPalette};
|
||||
use llimphi_widget_toast::{toast_stack_view, Toast};
|
||||
use llimphi_widget_tooltip::{tooltip_view, Side, TooltipPalette, TooltipSpec};
|
||||
use llimphi_widget_wawa_mark::{wawa_mark_view, WawaMarkPalette};
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Msg {
|
||||
/// Tick para forzar repaint (animaciones por reloj absoluto).
|
||||
Tick,
|
||||
ToggleA,
|
||||
ToggleB,
|
||||
SelectSeg(usize),
|
||||
#[allow(dead_code)]
|
||||
BreadcrumbJump(usize),
|
||||
PushToast,
|
||||
DismissToast(u64),
|
||||
OpenModal,
|
||||
CloseModal,
|
||||
ConfirmModal,
|
||||
ToggleShortcuts,
|
||||
OpenContextMenu,
|
||||
CloseContextMenu,
|
||||
ContextMenuPick(usize),
|
||||
/// Abrir/cerrar un menú raíz de la barra principal (`None` = cerrar).
|
||||
MenuOpen(Option<usize>),
|
||||
/// Comando elegido en la barra principal (id `menu.<verbo>`).
|
||||
MenuCommand(String),
|
||||
}
|
||||
|
||||
struct Model {
|
||||
started_at: Instant,
|
||||
switch_a: bool,
|
||||
switch_b: bool,
|
||||
seg: usize,
|
||||
toasts: Vec<Toast>,
|
||||
next_toast_id: u64,
|
||||
modal_open: bool,
|
||||
shortcuts_open: bool,
|
||||
viewport: (f32, f32),
|
||||
/// Anchor del context-menu si está abierto. None = cerrado.
|
||||
menu_open: Option<(f32, f32)>,
|
||||
/// Item resaltado del menú (`usize::MAX` = ninguno, estado inicial).
|
||||
menu_active: usize,
|
||||
/// Última opción elegida del menú — se muestra como toast.
|
||||
menu_last_pick: Option<String>,
|
||||
/// Índice del menú raíz de la barra principal abierto. `None` = ninguno.
|
||||
menubar_open: Option<usize>,
|
||||
}
|
||||
|
||||
struct Gallery;
|
||||
|
||||
impl App for Gallery {
|
||||
type Model = Model;
|
||||
type Msg = Msg;
|
||||
|
||||
fn title() -> &'static str {
|
||||
"llimphi · gallery"
|
||||
}
|
||||
|
||||
fn initial_size() -> (u32, u32) {
|
||||
(1280, 800)
|
||||
}
|
||||
|
||||
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
|
||||
// Loop infinito de ticks para animar spinner/skeleton/splash.
|
||||
// En una app real esto se gateaba según haya animaciones vivas.
|
||||
handle.spawn_periodic(Duration::from_millis(50), || Msg::Tick);
|
||||
Model {
|
||||
started_at: Instant::now(),
|
||||
switch_a: true,
|
||||
switch_b: false,
|
||||
seg: 1,
|
||||
toasts: Vec::new(),
|
||||
next_toast_id: 0,
|
||||
modal_open: false,
|
||||
shortcuts_open: false,
|
||||
viewport: (1280.0, 800.0),
|
||||
menu_open: None,
|
||||
menu_active: usize::MAX,
|
||||
menu_last_pick: None,
|
||||
menubar_open: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(model: Self::Model, msg: Self::Msg, _handle: &Handle<Self::Msg>) -> Self::Model {
|
||||
let mut m = model;
|
||||
// Filtrar toasts expirados oportunamente.
|
||||
let now = Instant::now();
|
||||
m.toasts.retain(|t| t.is_alive(now));
|
||||
match msg {
|
||||
Msg::Tick => {}
|
||||
Msg::ToggleA => m.switch_a = !m.switch_a,
|
||||
Msg::ToggleB => m.switch_b = !m.switch_b,
|
||||
Msg::SelectSeg(i) => m.seg = i,
|
||||
Msg::BreadcrumbJump(_) => {} // sólo demo
|
||||
Msg::PushToast => {
|
||||
let kinds = [
|
||||
(BadgeKind::Info, "guardado en disco"),
|
||||
(BadgeKind::Success, "publicado correctamente"),
|
||||
(BadgeKind::Warning, "espacio bajo en cache"),
|
||||
(BadgeKind::Error, "no se pudo conectar"),
|
||||
];
|
||||
let (kind, text) = kinds[(m.next_toast_id as usize) % kinds.len()];
|
||||
let id = m.next_toast_id;
|
||||
m.next_toast_id += 1;
|
||||
let toast = match kind {
|
||||
BadgeKind::Info => Toast::info(id, text, Duration::from_secs(4)),
|
||||
BadgeKind::Success => Toast::success(id, text, Duration::from_secs(4)),
|
||||
BadgeKind::Warning => Toast::warning(id, text, Duration::from_secs(4)),
|
||||
BadgeKind::Error => Toast::error(id, text, Duration::from_secs(4)),
|
||||
BadgeKind::Neutral => Toast::info(id, text, Duration::from_secs(4)),
|
||||
};
|
||||
m.toasts.push(toast);
|
||||
}
|
||||
Msg::DismissToast(id) => m.toasts.retain(|t| t.id != id),
|
||||
Msg::OpenModal => m.modal_open = true,
|
||||
Msg::CloseModal => m.modal_open = false,
|
||||
Msg::ConfirmModal => m.modal_open = false,
|
||||
Msg::ToggleShortcuts => m.shortcuts_open = !m.shortcuts_open,
|
||||
Msg::OpenContextMenu => {
|
||||
// Posición fija razonable — el botón está en la columna
|
||||
// derecha; abrir el menú con anchor relativo al
|
||||
// viewport mantiene la demo predecible aunque la
|
||||
// ventana cambie de tamaño.
|
||||
m.menu_open = Some((m.viewport.0 * 0.72, m.viewport.1 * 0.55));
|
||||
m.menu_active = usize::MAX;
|
||||
m.menubar_open = None;
|
||||
}
|
||||
Msg::CloseContextMenu => {
|
||||
m.menu_open = None;
|
||||
m.menu_active = usize::MAX;
|
||||
}
|
||||
Msg::ContextMenuPick(idx) => {
|
||||
let labels = ["Copiar", "Cortar", "Pegar", "", "Eliminar"];
|
||||
let label = labels.get(idx).copied().unwrap_or("?");
|
||||
m.menu_last_pick = Some(label.to_string());
|
||||
m.menu_open = None;
|
||||
m.menu_active = usize::MAX;
|
||||
// Confirmación visible.
|
||||
let id = m.next_toast_id;
|
||||
m.next_toast_id += 1;
|
||||
m.toasts.push(Toast::info(
|
||||
id,
|
||||
format!("Menú → {label}"),
|
||||
Duration::from_secs(3),
|
||||
));
|
||||
}
|
||||
Msg::MenuOpen(idx) => {
|
||||
m.menubar_open = idx;
|
||||
// El dropdown de la barra y el contextual son mutuamente
|
||||
// excluyentes.
|
||||
m.menu_open = None;
|
||||
}
|
||||
Msg::MenuCommand(cmd) => {
|
||||
m.menubar_open = None;
|
||||
match cmd.as_str() {
|
||||
"app.quit" => std::process::exit(0),
|
||||
"view.toast" => return Self::update(m, Msg::PushToast, _handle),
|
||||
"view.modal" => m.modal_open = true,
|
||||
"view.context" => {
|
||||
m.menu_open = Some((m.viewport.0 * 0.5, m.viewport.1 * 0.45));
|
||||
m.menu_active = usize::MAX;
|
||||
}
|
||||
"help.shortcuts" => m.shortcuts_open = true,
|
||||
"help.about" => {
|
||||
let id = m.next_toast_id;
|
||||
m.next_toast_id += 1;
|
||||
m.toasts.push(Toast::info(
|
||||
id,
|
||||
"llimphi · gallery — vitrina del kit de elegancia",
|
||||
Duration::from_secs(4),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn on_key(_model: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
|
||||
if ev.state != KeyState::Pressed {
|
||||
return None;
|
||||
}
|
||||
match &ev.key {
|
||||
Key::Named(NamedKey::Escape) => Some(Msg::CloseModal),
|
||||
Key::Character(s) if s == "?" => Some(Msg::ToggleShortcuts),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn view(model: &Self::Model) -> View<Self::Msg> {
|
||||
let theme = Theme::dark();
|
||||
|
||||
// Tres columnas equilibradas + status bar inferior.
|
||||
let left = column_left(model, &theme);
|
||||
let center = column_center(model, &theme);
|
||||
let right = column_right(model, &theme);
|
||||
|
||||
let cols = View::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
flex_grow: 1.0,
|
||||
gap: Size {
|
||||
width: length(16.0_f32),
|
||||
height: length(0.0_f32),
|
||||
},
|
||||
padding: Rect {
|
||||
left: length(16.0_f32),
|
||||
right: length(16.0_f32),
|
||||
top: length(16.0_f32),
|
||||
bottom: length(8.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![left, center, right]);
|
||||
|
||||
let status = status_bar_view(
|
||||
vec![
|
||||
StatusSegment::text("llimphi-gallery").with_icon(Icon::Home),
|
||||
StatusSegment::text(if model.switch_a { "modo: pleno" } else { "modo: simple" })
|
||||
.emphasized(),
|
||||
],
|
||||
vec![],
|
||||
vec![
|
||||
StatusSegment::text("Ln 1, Col 1"),
|
||||
StatusSegment::text("UTF-8"),
|
||||
StatusSegment::text("? atajos")
|
||||
.clickable(Msg::ToggleShortcuts)
|
||||
.with_icon(Icon::Info),
|
||||
],
|
||||
&StatusBarPalette::from_theme(&theme),
|
||||
);
|
||||
|
||||
let menu = app_menu();
|
||||
let bar = menubar_view(&menubar_spec(&menu, model, &theme));
|
||||
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(theme.bg_app)
|
||||
.children(vec![bar, cols, status])
|
||||
}
|
||||
|
||||
fn view_overlay(model: &Self::Model) -> Option<View<Self::Msg>> {
|
||||
let theme = Theme::dark();
|
||||
// Prioridad: modal > shortcuts > toasts.
|
||||
if model.modal_open {
|
||||
return Some(modal_view(ModalSpec {
|
||||
title: "Confirmar acción".to_string(),
|
||||
body: modal_body_view(&theme),
|
||||
buttons: vec![
|
||||
ModalButton::cancel("Cancelar", Msg::CloseModal),
|
||||
ModalButton::primary("Aplicar", Msg::ConfirmModal),
|
||||
],
|
||||
size: (440.0, 220.0),
|
||||
viewport: model.viewport,
|
||||
on_dismiss: Msg::CloseModal,
|
||||
palette: ModalPalette::from_theme(&theme),
|
||||
}));
|
||||
}
|
||||
if model.shortcuts_open {
|
||||
return Some(shortcuts_help_view(ShortcutsHelpSpec {
|
||||
title: "Atajos de teclado".to_string(),
|
||||
groups: vec![
|
||||
ShortcutGroup::new(
|
||||
"General",
|
||||
vec![
|
||||
ShortcutEntry::new("?", "Mostrar/ocultar esta ayuda"),
|
||||
ShortcutEntry::new("Esc", "Cerrar overlay activo"),
|
||||
],
|
||||
),
|
||||
ShortcutGroup::new(
|
||||
"Demo",
|
||||
vec![
|
||||
ShortcutEntry::new("Click", "Toasts, modal y switches"),
|
||||
ShortcutEntry::new("Hover", "Tooltips sobre los avatares"),
|
||||
],
|
||||
),
|
||||
],
|
||||
viewport: model.viewport,
|
||||
on_dismiss: Msg::ToggleShortcuts,
|
||||
palette: ShortcutsHelpPalette::from_theme(&theme),
|
||||
}));
|
||||
}
|
||||
if let Some(anchor) = model.menu_open {
|
||||
return Some(context_menu_view(ContextMenuSpec {
|
||||
anchor,
|
||||
viewport: model.viewport,
|
||||
header: Some("Lienzo".into()),
|
||||
items: vec![
|
||||
ContextMenuItem::action("Copiar").with_shortcut("Ctrl+C"),
|
||||
ContextMenuItem::action("Cortar").with_shortcut("Ctrl+X"),
|
||||
ContextMenuItem::action("Pegar").with_shortcut("Ctrl+V").disabled(),
|
||||
ContextMenuItem::separator(),
|
||||
ContextMenuItem::action("Eliminar")
|
||||
.with_shortcut("Del")
|
||||
.destructive(),
|
||||
],
|
||||
active: model.menu_active,
|
||||
on_pick: Arc::new(Msg::ContextMenuPick),
|
||||
on_dismiss: Msg::CloseContextMenu,
|
||||
palette: ContextMenuPalette::from_theme(&theme),
|
||||
}));
|
||||
}
|
||||
// Dropdown de la barra de menú principal.
|
||||
let menu = app_menu();
|
||||
if let Some(v) = menubar_overlay(&menubar_spec(&menu, model, &theme)) {
|
||||
return Some(v);
|
||||
}
|
||||
if !model.toasts.is_empty() {
|
||||
return Some(toast_stack_view(
|
||||
&model.toasts,
|
||||
model.viewport,
|
||||
Msg::DismissToast,
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Barra de menú principal
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// Menú principal de la vitrina. Sólo comandos que mapean a `Msg` reales.
|
||||
fn app_menu() -> AppMenu {
|
||||
AppMenu::new()
|
||||
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "app.quit").shortcut("Ctrl+Q")))
|
||||
.menu(
|
||||
Menu::new("Ver")
|
||||
.item(MenuItem::new("Mostrar toast", "view.toast"))
|
||||
.item(MenuItem::new("Abrir modal", "view.modal"))
|
||||
.item(MenuItem::new("Menú contextual", "view.context").separated()),
|
||||
)
|
||||
.menu(
|
||||
Menu::new("Ayuda")
|
||||
.item(MenuItem::new("Atajos", "help.shortcuts").shortcut("?"))
|
||||
.item(MenuItem::new("Acerca de", "help.about")),
|
||||
)
|
||||
}
|
||||
|
||||
/// Arma el `MenuBarSpec` compartido entre `view` y `view_overlay`.
|
||||
fn menubar_spec<'a>(menu: &'a AppMenu, model: &Model, theme: &'a Theme) -> MenuBarSpec<'a, Msg> {
|
||||
MenuBarSpec {
|
||||
menu,
|
||||
open: model.menubar_open,
|
||||
theme,
|
||||
viewport: model.viewport,
|
||||
height: MENU_H,
|
||||
on_open: Arc::new(Msg::MenuOpen),
|
||||
on_command: Arc::new(|cmd: &str| Msg::MenuCommand(cmd.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Columnas
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fn column_left(model: &Model, theme: &Theme) -> View<Msg> {
|
||||
let mut children: Vec<View<Msg>> = Vec::new();
|
||||
|
||||
children.push(section_title("Identidad"));
|
||||
// Sello wawa en chico + grande.
|
||||
children.push(
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(128.0_f32),
|
||||
},
|
||||
gap: Size {
|
||||
width: length(16.0_f32),
|
||||
height: length(0.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![
|
||||
wawa_frame(48.0),
|
||||
wawa_frame(96.0),
|
||||
wawa_frame(128.0),
|
||||
]),
|
||||
);
|
||||
|
||||
children.push(section_title("Splash"));
|
||||
children.push(
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(220.0_f32),
|
||||
},
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.fill(theme.bg_panel)
|
||||
.radius(llimphi_theme::radius::MD)
|
||||
.children(vec![splash_view(model.started_at, theme.bg_panel, theme.fg_text)]),
|
||||
);
|
||||
|
||||
children.push(section_title("Empty state"));
|
||||
children.push(
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(200.0_f32),
|
||||
},
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.fill(theme.bg_panel)
|
||||
.radius(llimphi_theme::radius::MD)
|
||||
.children(vec![empty_view(
|
||||
Icon::Folder,
|
||||
"Sin documentos abiertos",
|
||||
Some("Abrí uno con Ctrl+O o creá un nuevo lienzo para empezar."),
|
||||
&EmptyPalette::from_theme(theme),
|
||||
)]),
|
||||
);
|
||||
|
||||
panel_view(children, theme)
|
||||
}
|
||||
|
||||
fn column_center(model: &Model, theme: &Theme) -> View<Msg> {
|
||||
let mut children: Vec<View<Msg>> = Vec::new();
|
||||
|
||||
children.push(section_title("Navegación"));
|
||||
children.push(
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: auto(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![breadcrumb_view(
|
||||
&["home", "docs", "2026", "elegancia.md"],
|
||||
Msg::BreadcrumbJump,
|
||||
&BreadcrumbPalette::from_theme(theme),
|
||||
)]),
|
||||
);
|
||||
|
||||
children.push(section_title("Controles"));
|
||||
children.push(switch_row("Modo pleno", model.switch_a, Msg::ToggleA, theme));
|
||||
children.push(switch_row("Telemetría", model.switch_b, Msg::ToggleB, theme));
|
||||
children.push(spacer_v(8.0));
|
||||
children.push(
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(28.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![segmented_view(
|
||||
&["lista", "grilla", "kanban"],
|
||||
model.seg,
|
||||
Msg::SelectSeg,
|
||||
&SegmentedPalette::from_theme(theme),
|
||||
)]),
|
||||
);
|
||||
|
||||
children.push(section_title("Formulario"));
|
||||
children.push(field_view(FieldSpec {
|
||||
label: "Nombre del lienzo".to_string(),
|
||||
control: fake_text_input("introducción a wawa", theme),
|
||||
required: true,
|
||||
helper: Some("Aparece como título en la pestaña.".to_string()),
|
||||
error: None,
|
||||
palette: FieldPalette::from_theme(theme),
|
||||
}));
|
||||
children.push(spacer_v(12.0));
|
||||
children.push(field_view(FieldSpec {
|
||||
label: "Slug".to_string(),
|
||||
control: fake_text_input("intro-wawa-x@123", theme),
|
||||
required: false,
|
||||
helper: None,
|
||||
error: Some("Sólo letras, números y guiones.".to_string()),
|
||||
palette: FieldPalette::from_theme(theme),
|
||||
}));
|
||||
|
||||
children.push(section_title("Acciones"));
|
||||
children.push(button_row(theme));
|
||||
|
||||
panel_view(children, theme)
|
||||
}
|
||||
|
||||
fn column_right(_model: &Model, theme: &Theme) -> View<Msg> {
|
||||
let mut children: Vec<View<Msg>> = Vec::new();
|
||||
|
||||
children.push(section_title("Identidades"));
|
||||
// Avatares en línea con badge encima.
|
||||
children.push(
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(48.0_f32),
|
||||
},
|
||||
gap: Size {
|
||||
width: length(8.0_f32),
|
||||
height: length(0.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![
|
||||
avatar_view("sergio", 40.0),
|
||||
avatar_view("calcetín", 40.0),
|
||||
avatar_view("amaru", 40.0),
|
||||
avatar_view("pacha", 40.0),
|
||||
avatar_view("inti", 40.0),
|
||||
]),
|
||||
);
|
||||
|
||||
children.push(section_title("Badges"));
|
||||
children.push(
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(24.0_f32),
|
||||
},
|
||||
gap: Size {
|
||||
width: length(10.0_f32),
|
||||
height: length(0.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![
|
||||
count_badge_view(3, BadgeKind::Info),
|
||||
count_badge_view(12, BadgeKind::Success),
|
||||
count_badge_view(99, BadgeKind::Warning),
|
||||
count_badge_view(120, BadgeKind::Error),
|
||||
dot_badge_view(BadgeKind::Success),
|
||||
dot_badge_view(BadgeKind::Warning),
|
||||
dot_badge_view(BadgeKind::Error),
|
||||
]),
|
||||
);
|
||||
|
||||
children.push(section_title("Carga"));
|
||||
children.push(
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(48.0_f32),
|
||||
},
|
||||
gap: Size {
|
||||
width: length(16.0_f32),
|
||||
height: length(0.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: length(40.0_f32),
|
||||
height: length(40.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![spinner_view(theme.accent, 0.12, 1.0)]),
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: length(40.0_f32),
|
||||
height: length(40.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![radial_progress_view(
|
||||
0.66,
|
||||
theme.bg_button,
|
||||
theme.accent,
|
||||
0.14,
|
||||
)]),
|
||||
linear_progress_view(0.42, theme.bg_button, theme.accent, 8.0),
|
||||
]),
|
||||
);
|
||||
|
||||
children.push(section_title("Skeleton"));
|
||||
let palette = SkeletonPalette::from_theme(theme);
|
||||
children.push(skeleton_line_view::<Msg>(200.0, &palette));
|
||||
children.push(spacer_v(6.0));
|
||||
children.push(skeleton_line_view::<Msg>(280.0, &palette));
|
||||
children.push(spacer_v(6.0));
|
||||
children.push(skeleton_line_view::<Msg>(160.0, &palette));
|
||||
children.push(spacer_v(10.0));
|
||||
children.push(skeleton_box_view::<Msg>(percent_to_px(0.9, 360.0), 60.0, &palette));
|
||||
|
||||
children.push(section_title("Cards"));
|
||||
// Dos cards apilados: el primero con la firma (gradient sutil +
|
||||
// hairline en el top), el segundo con `accent` lateral y fill plano.
|
||||
// Para apreciar la firma hay que mirar de cerca: el ojo registra
|
||||
// "tallado" sin saber por qué.
|
||||
let card_palette = CardPalette::from_theme(theme);
|
||||
children.push(card_view(
|
||||
vec![
|
||||
text_line("Documento — multilienzo", 13.0, theme.fg_text),
|
||||
text_line("3 cuerpos · 412 átomos · BLAKE3 verificado", 11.0, theme.fg_muted),
|
||||
],
|
||||
CardOptions::with_signature(theme),
|
||||
&card_palette,
|
||||
));
|
||||
children.push(spacer_v(8.0));
|
||||
children.push(card_view(
|
||||
vec![
|
||||
text_line("Build pasó — wawa-kernel", 13.0, theme.fg_text),
|
||||
text_line("x86_64-unknown-none · 1.42s · 0 warnings", 11.0, theme.fg_muted),
|
||||
],
|
||||
CardOptions {
|
||||
accent: Some(Color::from_rgba8(110, 200, 130, 255)),
|
||||
..Default::default()
|
||||
},
|
||||
&card_palette,
|
||||
));
|
||||
|
||||
children.push(section_title("Menú contextual"));
|
||||
children.push(
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(32.0_f32),
|
||||
},
|
||||
flex_shrink: 0.0,
|
||||
align_items: Some(AlignItems::Center),
|
||||
justify_content: Some(JustifyContent::Center),
|
||||
padding: Rect {
|
||||
left: length(12.0_f32),
|
||||
right: length(12.0_f32),
|
||||
top: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(theme.bg_button)
|
||||
.hover_fill(theme.bg_button_hover)
|
||||
.radius(llimphi_theme::radius::SM)
|
||||
.text_aligned(
|
||||
"Mostrar menú".to_string(),
|
||||
12.0,
|
||||
theme.fg_text,
|
||||
Alignment::Center,
|
||||
)
|
||||
.on_click(Msg::OpenContextMenu),
|
||||
);
|
||||
|
||||
children.push(section_title("Iconografía"));
|
||||
children.push(icon_grid(theme));
|
||||
|
||||
panel_view(children, theme)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers de composición
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fn text_line(text: &str, size: f32, color: Color) -> View<Msg> {
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(size + 6.0),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(text.to_string(), size, color, Alignment::Start)
|
||||
}
|
||||
|
||||
fn section_title(text: &str) -> View<Msg> {
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(20.0_f32),
|
||||
},
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(
|
||||
text.to_uppercase(),
|
||||
10.0,
|
||||
Color::from_rgba8(140, 160, 200, 255),
|
||||
Alignment::Start,
|
||||
)
|
||||
}
|
||||
|
||||
fn panel_view(children: Vec<View<Msg>>, theme: &Theme) -> View<Msg> {
|
||||
let style = PanelStyle::from_theme(theme);
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
flex_grow: 1.0,
|
||||
padding: Rect {
|
||||
left: length(16.0_f32),
|
||||
right: length(16.0_f32),
|
||||
top: length(14.0_f32),
|
||||
bottom: length(14.0_f32),
|
||||
},
|
||||
gap: Size {
|
||||
width: length(0.0_f32),
|
||||
height: length(10.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.paint_with(panel_signature_painter(style))
|
||||
.radius(style.radius)
|
||||
.clip(true)
|
||||
.children(children)
|
||||
}
|
||||
|
||||
fn switch_row(label: &str, value: bool, msg: Msg, theme: &Theme) -> View<Msg> {
|
||||
let progress = if value { 1.0 } else { 0.0 };
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(28.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
justify_content: Some(JustifyContent::SpaceBetween),
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
flex_grow: 1.0,
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(label.to_string(), 12.0, theme.fg_text, Alignment::Start),
|
||||
switch_view(progress, msg, &SwitchPalette::from_theme(theme)),
|
||||
])
|
||||
}
|
||||
|
||||
fn fake_text_input(text: &str, theme: &Theme) -> View<Msg> {
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(28.0_f32),
|
||||
},
|
||||
padding: Rect {
|
||||
left: length(8.0_f32),
|
||||
right: length(8.0_f32),
|
||||
top: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.fill(theme.bg_input)
|
||||
.radius(llimphi_theme::radius::SM)
|
||||
.text_aligned(text.to_string(), 12.0, theme.fg_text, Alignment::Start)
|
||||
}
|
||||
|
||||
fn button_row(theme: &Theme) -> View<Msg> {
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(32.0_f32),
|
||||
},
|
||||
gap: Size {
|
||||
width: length(8.0_f32),
|
||||
height: length(0.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![
|
||||
btn("Mostrar toast", theme.accent, theme.bg_app, Msg::PushToast),
|
||||
btn("Abrir modal", theme.bg_button, theme.fg_text, Msg::OpenModal),
|
||||
btn("Atajos (?)", theme.bg_button, theme.fg_text, Msg::ToggleShortcuts),
|
||||
])
|
||||
}
|
||||
|
||||
fn btn(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
|
||||
let w = label.chars().count() as f32 * 7.5 + 24.0;
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: length(w),
|
||||
height: length(32.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
justify_content: Some(JustifyContent::Center),
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.fill(bg)
|
||||
.radius(llimphi_theme::radius::SM)
|
||||
.text_aligned(label.to_string(), 12.0, fg, Alignment::Center)
|
||||
.on_click(msg)
|
||||
}
|
||||
|
||||
fn icon_grid(theme: &Theme) -> View<Msg> {
|
||||
let icons = [
|
||||
Icon::File, Icon::Folder, Icon::Save, Icon::Open, Icon::Search,
|
||||
Icon::Plus, Icon::Minus, Icon::X, Icon::Check, Icon::Edit,
|
||||
Icon::Trash, Icon::Home, Icon::Settings, Icon::Bell, Icon::More,
|
||||
Icon::Info, Icon::Warning, Icon::Error, Icon::ChevronUp,
|
||||
Icon::ChevronDown, Icon::ChevronLeft, Icon::ChevronRight,
|
||||
Icon::FolderOpen,
|
||||
];
|
||||
let cells: Vec<View<Msg>> = icons
|
||||
.iter()
|
||||
.map(|i| {
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: length(28.0_f32),
|
||||
height: length(28.0_f32),
|
||||
},
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.fill(theme.bg_panel_alt)
|
||||
.radius(llimphi_theme::radius::XS)
|
||||
.children(vec![icon_view(*i, theme.fg_text, 1.6)])
|
||||
})
|
||||
.collect();
|
||||
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: auto(),
|
||||
},
|
||||
gap: Size {
|
||||
width: length(6.0_f32),
|
||||
height: length(6.0_f32),
|
||||
},
|
||||
flex_wrap: llimphi_ui::llimphi_layout::taffy::FlexWrap::Wrap,
|
||||
..Default::default()
|
||||
})
|
||||
.children(cells)
|
||||
}
|
||||
|
||||
fn modal_body_view(theme: &Theme) -> View<Msg> {
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
flex_grow: 1.0,
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(
|
||||
"Esta acción reescribirá la configuración local. \
|
||||
Sólo dura mientras no salgas — al guardar quedará persistida en disco."
|
||||
.to_string(),
|
||||
12.0,
|
||||
theme.fg_muted,
|
||||
Alignment::Start,
|
||||
)
|
||||
}
|
||||
|
||||
fn wawa_frame(side: f32) -> View<Msg> {
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: length(side),
|
||||
height: length(side),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
justify_content: Some(JustifyContent::Center),
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![wawa_mark_view(&WawaMarkPalette::default())])
|
||||
}
|
||||
|
||||
fn spacer_v(h: f32) -> View<Msg> {
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(h),
|
||||
},
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn percent_to_px(p: f32, base: f32) -> f32 {
|
||||
p * base
|
||||
}
|
||||
|
||||
// Tooltip placeholder — la demo no instrumenta hover-to-show porque
|
||||
// requeriría más Msgs; queda como código de referencia para apps reales.
|
||||
#[allow(dead_code)]
|
||||
fn demo_tooltip(viewport: (f32, f32), text: &str, theme: &Theme) -> View<Msg> {
|
||||
tooltip_view::<Msg>(TooltipSpec {
|
||||
anchor: (viewport.0 * 0.5, viewport.1 * 0.5),
|
||||
viewport,
|
||||
side: Side::Bottom,
|
||||
text: text.to_string(),
|
||||
palette: TooltipPalette::from_theme(theme),
|
||||
})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
llimphi_ui::run::<Gallery>();
|
||||
}
|
||||
Reference in New Issue
Block a user