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
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "llimphi-widget-tiled"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-tiled — grid auto cols×rows con title bar por tile. Análogo Llimphi al `nahual-widget-tiled` GPUI (sin drag-to-swap todavía: requiere drop-targets globales que llimphi-ui aún no expone)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
[[example]]
name = "tiled_demo"
path = "examples/tiled_demo.rs"
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-tiled
> Tiled window manager dentro de la app para [llimphi](../../README.md).
Splits anidados horizontal/vertical sin overlap (estilo i3/sway intra-app). Atajos para split/swap/cerrar. Usado por `nada` cuando se abren múltiples buffers.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-tiled
> Intra-app tiled window manager for [llimphi](../../README.md).
Nested horizontal/vertical splits without overlap (i3/sway-style inside one app). Shortcuts for split/swap/close. Used by `nada` when multiple buffers are open.
+218
View File
@@ -0,0 +1,218 @@
//! Showcase de `llimphi-widget-tiled` con drag-to-swap. Cinco paneles
//! heterogéneos; arrastrá la title bar de uno sobre otro para
//! intercambiarlos. El destino se ilumina mientras está bajo el cursor.
//!
//! Corré con: `cargo run -p llimphi-widget-tiled --example tiled_demo --release`.
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use llimphi_widget_tiled::{tiled_view_reorderable, TileSpec, TiledPalette};
#[derive(Clone, Copy, PartialEq, Eq)]
enum TileId {
Logs,
Metrics,
Alerts,
Uptime,
Queue,
}
#[derive(Clone)]
enum Msg {
Swap { from: usize, to: usize },
}
struct Model {
tiles: Vec<TileId>,
}
struct Showcase;
impl App for Showcase {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · tiled showcase (drag titles para intercambiar)"
}
fn initial_size() -> (u32, u32) {
(1100, 720)
}
fn init(_: &Handle<Msg>) -> Model {
Model {
tiles: vec![
TileId::Logs,
TileId::Metrics,
TileId::Alerts,
TileId::Uptime,
TileId::Queue,
],
}
}
fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
let mut m = model;
match msg {
Msg::Swap { from, to } => {
if from != to && from < m.tiles.len() && to < m.tiles.len() {
m.tiles.swap(from, to);
}
}
}
m
}
fn view(model: &Model) -> View<Msg> {
let theme = Theme::dark();
let palette = TiledPalette::from_theme(&theme);
let tiles: Vec<TileSpec<Msg>> = model
.tiles
.iter()
.map(|id| match id {
TileId::Logs => TileSpec {
label: "logs".into(),
content: log_body(&theme),
},
TileId::Metrics => TileSpec {
label: "métricas".into(),
content: metrics_body(&theme),
},
TileId::Alerts => TileSpec {
label: "alertas".into(),
content: alerts_body(&theme),
},
TileId::Uptime => TileSpec {
label: "uptime".into(),
content: uptime_body(&theme),
},
TileId::Queue => TileSpec {
label: "queue".into(),
content: queue_body(&theme),
},
})
.collect();
tiled_view_reorderable(
tiles,
|from, to| Some(Msg::Swap { from, to }),
&palette,
)
}
}
fn padded(text: &str, size: f32, color: Color, align: Alignment) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
padding: Rect {
left: length(14.0_f32),
right: length(14.0_f32),
top: length(10.0_f32),
bottom: length(10.0_f32),
},
..Default::default()
})
.text_aligned(text.to_string(), size, color, align)
}
fn log_body(theme: &Theme) -> View<Msg> {
padded(
"[12:01:33] boot\n[12:01:34] config ok\n[12:01:35] esperando eventos…\n[12:02:01] cliente 1 conectó\n[12:02:02] cliente 2 conectó",
12.0,
theme.fg_text,
Alignment::Start,
)
}
fn metrics_body(theme: &Theme) -> View<Msg> {
let stat = |label: &str, value: &str, color: Color| -> View<Msg> {
let label_view = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(14.0_f32),
},
..Default::default()
})
.text_aligned(label.to_string(), 10.0, theme.fg_muted, Alignment::Start);
let value_view = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
..Default::default()
})
.text_aligned(value.to_string(), 22.0, color, Alignment::Start);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.children(vec![label_view, value_view])
};
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(12.0_f32),
height: length(0.0_f32),
},
padding: Rect {
left: length(14.0_f32),
right: length(14.0_f32),
top: length(10.0_f32),
bottom: length(10.0_f32),
},
..Default::default()
})
.children(vec![
stat("cpu", "37%", theme.accent),
stat("ram", "1.2 G", theme.fg_text),
stat("net", "12 kB/s", theme.fg_text),
])
}
fn alerts_body(theme: &Theme) -> View<Msg> {
padded(
"● info: dos clientes online\n● warn: latencia 250 ms\n● ok: backup nocturno verde",
12.0,
theme.fg_text,
Alignment::Start,
)
}
fn uptime_body(theme: &Theme) -> View<Msg> {
padded("4d 12h 33m", 26.0, theme.accent, Alignment::Center)
}
fn queue_body(theme: &Theme) -> View<Msg> {
padded(
"pending: 7\nin-flight: 2\ndone (24h): 1842",
13.0,
theme.fg_text,
Alignment::Start,
)
}
fn main() {
llimphi_ui::run::<Showcase>();
}
+322
View File
@@ -0,0 +1,322 @@
//! `llimphi-widget-tiled` — grilla auto cols×rows de tiles con title
//! bar fija arriba.
//!
//! Cada tile es un panel rectangular con:
//! - una franja superior (20 px) con `bg_panel_alt` + label centrado a
//! la izquierda en `fg_muted`;
//! - un cuerpo flex que aloja el `View<Msg>` provisto por el caller.
//!
//! La grilla se calcula como `cols = ⌈√n⌉`, `rows = ⌈n/cols⌉` — mismo
//! algoritmo que el `nahual-widget-tiled` GPUI. Las celdas son
//! equipesos: `flex_grow = 1` sobre ambos ejes.
//!
//! ## Variantes
//!
//! - [`tiled_view`] — grilla estática, sin reordenamiento.
//! - [`tiled_view_reorderable`] — drag-to-swap: arrastrar la title bar
//! de un tile y soltar sobre otro emite `on_reorder(from, to)`. El
//! tile destino se ilumina (`drop_hover_fill` = `accent`) mientras
//! el cursor está sobre él durante el drag. Usa los primitives
//! `drag_payload` + `on_drop` + `drop_hover_fill` de `llimphi-ui`.
//! - [`tiled_view_cols`] / [`tiled_view_reorderable_cols`] — fuerzan el
//! número de columnas (útil para sidebars verticales: `cols = 1`).
#![forbid(unsafe_code)]
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{DragPhase, View};
const TITLE_BAR_HEIGHT: f32 = 20.0;
const TITLE_TEXT_SIZE: f32 = 10.0;
const TILE_GAP: f32 = 4.0;
const TILE_PADDING: f32 = 4.0;
/// Paleta del tiled.
#[derive(Debug, Clone, Copy)]
pub struct TiledPalette {
/// Fondo del container outer (visible en los gaps entre tiles).
pub bg_outer: Color,
/// Fondo del cuerpo del tile.
pub bg_tile: Color,
/// Fondo de la title bar del tile.
pub bg_title: Color,
/// Color del label de la title bar.
pub fg_title: Color,
/// Color del tile destino durante un drag (drop hover).
pub bg_drop_hover: Color,
}
impl Default for TiledPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl TiledPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_outer: t.bg_app,
bg_tile: t.bg_panel,
bg_title: t.bg_panel_alt,
fg_title: t.fg_muted,
bg_drop_hover: t.bg_selected,
}
}
}
/// Un tile de la grilla: label que va en la title bar + view del cuerpo.
pub struct TileSpec<Msg> {
pub label: String,
pub content: View<Msg>,
}
type ReorderFn<Msg> = Arc<dyn Fn(usize, usize) -> Option<Msg> + Send + Sync>;
/// Construye una grilla estática (sin drag-to-swap). Equivalente a
/// [`tiled_view_reorderable`] sin handler de reorder.
pub fn tiled_view<Msg>(tiles: Vec<TileSpec<Msg>>, palette: &TiledPalette) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
{
build(tiles, palette, None, None)
}
/// Construye una grilla con drag-to-swap. Arrastrar la title bar de un
/// tile y soltar sobre otro invoca `on_reorder(from_index, to_index)`;
/// el `Msg` retornado se dispatchea al `update` antes de cerrar el
/// drag. El caller es responsable de filtrar `from == to`.
pub fn tiled_view_reorderable<Msg, F>(
tiles: Vec<TileSpec<Msg>>,
on_reorder: F,
palette: &TiledPalette,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
F: Fn(usize, usize) -> Option<Msg> + Send + Sync + 'static,
{
build(tiles, palette, Some(Arc::new(on_reorder)), None)
}
/// Como [`tiled_view`] pero con número fijo de columnas. Útil para
/// sidebars verticales (`cols = 1`) o filas horizontales (`cols = n`)
/// donde el algoritmo auto-sqrt no sirve. `cols.max(1)` se aplica por
/// seguridad.
pub fn tiled_view_cols<Msg>(
tiles: Vec<TileSpec<Msg>>,
cols: usize,
palette: &TiledPalette,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
{
build(tiles, palette, None, Some(cols))
}
/// Como [`tiled_view_reorderable`] pero con número fijo de columnas.
pub fn tiled_view_reorderable_cols<Msg, F>(
tiles: Vec<TileSpec<Msg>>,
cols: usize,
on_reorder: F,
palette: &TiledPalette,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
F: Fn(usize, usize) -> Option<Msg> + Send + Sync + 'static,
{
build(tiles, palette, Some(Arc::new(on_reorder)), Some(cols))
}
fn build<Msg>(
tiles: Vec<TileSpec<Msg>>,
palette: &TiledPalette,
on_reorder: Option<ReorderFn<Msg>>,
cols_override: Option<usize>,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
{
let n = tiles.len();
if n == 0 {
return View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(palette.bg_outer)
.text(
"(tiled vacío)".to_string(),
11.0,
palette.fg_title,
);
}
let cols = cols_override
.map(|c| c.max(1))
.unwrap_or_else(|| ((n as f32).sqrt().ceil() as usize).max(1));
let rows = (n + cols - 1) / cols;
let mut tiles_iter = tiles.into_iter().enumerate();
let mut rows_views: Vec<View<Msg>> = Vec::with_capacity(rows);
for _r in 0..rows {
let mut cells: Vec<View<Msg>> = Vec::with_capacity(cols);
for _c in 0..cols {
let cell = match tiles_iter.next() {
Some((idx, tile)) => tile_view(idx, tile, palette, on_reorder.clone()),
None => empty_cell_view(palette),
};
cells.push(cell);
}
rows_views.push(row_view(cells));
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(TILE_GAP),
},
padding: Rect {
left: length(TILE_PADDING),
right: length(TILE_PADDING),
top: length(TILE_PADDING),
bottom: length(TILE_PADDING),
},
..Default::default()
})
.fill(palette.bg_outer)
.children(rows_views)
}
fn row_view<Msg>(cells: Vec<View<Msg>>) -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
flex_basis: length(0.0_f32),
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
gap: Size {
width: length(TILE_GAP),
height: length(0.0_f32),
},
..Default::default()
})
.children(cells)
}
fn tile_view<Msg>(
idx: usize,
tile: TileSpec<Msg>,
palette: &TiledPalette,
on_reorder: Option<ReorderFn<Msg>>,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
{
let mut title = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(TITLE_BAR_HEIGHT),
},
flex_shrink: 0.0,
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg_title)
.text_aligned(tile.label, TITLE_TEXT_SIZE, palette.fg_title, Alignment::Start);
// Si hay reorder, la title bar arrastra con payload = idx.
if on_reorder.is_some() {
// Handler trivial: tiled no usa dx/dy. Devuelve None.
title = title
.draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None)
.drag_payload(idx as u64);
}
let body = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![tile.content]);
let mut tile_view = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: Dimension::auto(),
height: percent(1.0_f32),
},
flex_grow: 1.0,
flex_basis: length(0.0_f32),
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg_tile)
.radius(4.0)
.clip(true)
.children(vec![title, body]);
// Drop target: si hay reorder, este tile entero recibe drops.
if let Some(reorder) = on_reorder {
let to_idx = idx;
tile_view = tile_view
.on_drop(move |from: u64| (reorder)(from as usize, to_idx))
.drop_hover_fill(palette.bg_drop_hover);
}
tile_view
}
fn empty_cell_view<Msg>(palette: &TiledPalette) -> View<Msg>
where
Msg: Clone + 'static,
{
View::new(Style {
size: Size {
width: Dimension::auto(),
height: percent(1.0_f32),
},
flex_grow: 1.0,
flex_basis: length(0.0_f32),
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg_outer)
}