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,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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user