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,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