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:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-toast"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-toast — notificaciones efímeras apiladas bottom-right. Severidades info/success/warning/error. Auto-dismiss configurable. Render-only; el ciclo de vida lo maneja la app."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-icons = { workspace = true }
+238
View File
@@ -0,0 +1,238 @@
//! `llimphi-widget-toast` — notificaciones efímeras apiladas.
//!
//! Cuatro severidades (Info / Success / Warning / Error) con color
//! semántico hardcoded — un Error debe leerse rojo aunque la app esté
//! en tema "sunset". Cada toast lleva un icono de `llimphi-icons` y
//! un texto corto.
//!
//! El widget es **render-only**: recibe una lista de [`Toast`]s ya
//! filtrados por la app (los que aún no expiraron) y los apila en la
//! esquina bottom-right. El ciclo de vida (push, auto-dismiss tras
//! `duration`, dismiss manual al click) lo maneja la app desde su
//! `update`/`spawn`.
//!
//! Patrón típico:
//! 1. App tiene `Vec<Toast>` en el modelo + `next_id: u64`.
//! 2. Para pushear: agregar Toast con `expires_at = Instant::now() + dur`
//! + `handle.spawn(move || { sleep(dur); Msg::ToastExpire(id) })`.
//! 3. `view_overlay` filtra los no expirados y los pasa a `toast_stack_view`.
#![forbid(unsafe_code)]
use std::time::Instant;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Position, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_icons::{icon_view, Icon};
use llimphi_theme::radius;
/// Severidad del toast — define color e icono.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastKind {
Info,
Success,
Warning,
Error,
}
impl ToastKind {
/// Color de fondo (semántico, no dependiente del theme).
pub fn bg(self) -> Color {
match self {
ToastKind::Info => Color::from_rgba8(28, 56, 88, 245),
ToastKind::Success => Color::from_rgba8(28, 72, 44, 245),
ToastKind::Warning => Color::from_rgba8(88, 64, 20, 245),
ToastKind::Error => Color::from_rgba8(96, 32, 32, 245),
}
}
/// Color del trazo y del texto principal.
pub fn fg(self) -> Color {
match self {
ToastKind::Info => Color::from_rgba8(180, 220, 250, 255),
ToastKind::Success => Color::from_rgba8(180, 240, 200, 255),
ToastKind::Warning => Color::from_rgba8(250, 220, 160, 255),
ToastKind::Error => Color::from_rgba8(250, 200, 200, 255),
}
}
pub fn icon(self) -> Icon {
match self {
ToastKind::Info => Icon::Info,
ToastKind::Success => Icon::Check,
ToastKind::Warning => Icon::Warning,
ToastKind::Error => Icon::Error,
}
}
}
/// Un toast en cola. La app mantiene `Vec<Toast>` y descarta los
/// expirados antes de pasarlos al render.
#[derive(Debug, Clone)]
pub struct Toast {
/// Id estable para que la app pueda correlacionar con su Msg de
/// dismiss (`Msg::ToastDismiss(u64)`).
pub id: u64,
pub kind: ToastKind,
pub text: String,
/// Cuándo expira. El render no chequea esto — sólo apila lo que
/// recibe; la app filtra antes.
pub expires_at: Instant,
}
const TOAST_W: f32 = 320.0;
const TOAST_H: f32 = 44.0;
const ICON_BOX: f32 = 24.0;
const GAP: f32 = 8.0;
const MARGIN: f32 = 16.0;
/// Ancho del "rail" de severidad en el edge izquierdo. 3px es el sweet
/// spot — visible al pasar sin chocar con el icono. Look Linear/Slack.
const RAIL_W: f32 = 3.0;
/// Apila los toasts en la esquina bottom-right del viewport. `on_click`
/// se construye por toast vía `make_dismiss(id)`. Devuelve un `View`
/// para colgar de `view_overlay`.
pub fn toast_stack_view<Msg, F>(
toasts: &[Toast],
viewport: (f32, f32),
make_dismiss: F,
) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn(u64) -> Msg,
{
let n = toasts.len() as f32;
let stack_h = n * TOAST_H + (n - 1.0).max(0.0) * GAP;
let stack_y = (viewport.1 - stack_h - MARGIN).max(MARGIN);
let stack_x = (viewport.0 - TOAST_W - MARGIN).max(MARGIN);
let children: Vec<View<Msg>> = toasts
.iter()
.map(|t| single_toast_view(t, make_dismiss(t.id)))
.collect();
let stack = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(stack_x),
top: length(stack_y),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(TOAST_W),
height: length(stack_h.max(0.0)),
},
flex_direction: FlexDirection::Column,
gap: Size {
width: length(0.0_f32),
height: length(GAP),
},
..Default::default()
})
.children(children);
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![stack])
}
fn single_toast_view<Msg: Clone + 'static>(toast: &Toast, on_dismiss: Msg) -> View<Msg> {
let bg = toast.kind.bg();
let fg = toast.kind.fg();
let icon = toast.kind.icon();
// Rail de severidad: stripe del color fg semántico (más brillante
// que el bg) en el edge izquierdo. Visible al pasar el ojo sin
// chocar con el icono — refuerza la severidad para usuarios que ya
// están mirando a otra parte de la UI.
let rail = View::new(Style {
size: Size {
width: length(RAIL_W),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(fg);
let icon_cell = View::new(Style {
size: Size {
width: length(ICON_BOX),
height: length(ICON_BOX),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![icon_view(icon, fg, 1.6)]);
let text = 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(toast.text.clone(), 12.0, fg, Alignment::Start);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(TOAST_H),
},
align_items: Some(AlignItems::Center),
// El rail vive en el edge — sin padding-left propio para que
// pegue al borde; el padding del contenido arranca después.
padding: Rect {
left: length(0.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
gap: Size {
width: length(10.0_f32),
height: length(0.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.radius(radius::MD)
.clip(true)
.on_click(on_dismiss)
.children(vec![rail, icon_cell, text])
}
/// Helper de construcción para uso inmediato:
/// `Toast::info(1, "guardado", Duration::from_secs(3))`.
impl Toast {
pub fn info(id: u64, text: impl Into<String>, dur: std::time::Duration) -> Self {
Self { id, kind: ToastKind::Info, text: text.into(), expires_at: Instant::now() + dur }
}
pub fn success(id: u64, text: impl Into<String>, dur: std::time::Duration) -> Self {
Self { id, kind: ToastKind::Success, text: text.into(), expires_at: Instant::now() + dur }
}
pub fn warning(id: u64, text: impl Into<String>, dur: std::time::Duration) -> Self {
Self { id, kind: ToastKind::Warning, text: text.into(), expires_at: Instant::now() + dur }
}
pub fn error(id: u64, text: impl Into<String>, dur: std::time::Duration) -> Self {
Self { id, kind: ToastKind::Error, text: text.into(), expires_at: Instant::now() + dur }
}
pub fn is_alive(&self, now: Instant) -> bool {
now < self.expires_at
}
}