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,201 @@
|
||||
//! `llimphi-widget-list` — lista vertical virtualizada.
|
||||
//!
|
||||
//! Compone una pila de filas con foco visual en la seleccionada y un Msg
|
||||
//! por click. Pensado como bloque reusable para file explorers, árboles
|
||||
//! lineales, paneles de log, listados de items, etc.
|
||||
//!
|
||||
//! El widget **no** maneja virtualización por sí mismo: el caller pasa
|
||||
//! únicamente las filas que deberían renderearse (las visibles según su
|
||||
//! propio `offset`/`scroll`). El widget se ocupa del resto: caption
|
||||
//! opcional con el conteo, fondo de selección, hint "… y N más" cuando
|
||||
//! `total > rows.len()`, y `clip` en el contenedor para que las filas no
|
||||
//! sangren a vecinos.
|
||||
//!
|
||||
//! Ejemplo:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let rows: Vec<ListRow<Msg>> = entries[offset..(offset + visible).min(entries.len())]
|
||||
//! .iter()
|
||||
//! .enumerate()
|
||||
//! .map(|(i, e)| ListRow {
|
||||
//! label: e.name.clone(),
|
||||
//! selected: offset + i == selected,
|
||||
//! on_click: Msg::Select(offset + i),
|
||||
//! })
|
||||
//! .collect();
|
||||
//!
|
||||
//! let panel = list_view(ListSpec {
|
||||
//! rows,
|
||||
//! total: entries.len(),
|
||||
//! caption: Some(format!("{} entradas", entries.len())),
|
||||
//! truncated_hint: (entries.len() > offset + rows.len())
|
||||
//! .then(|| format!("… y {} más", entries.len() - offset - rows.len())),
|
||||
//! row_height: 22.0,
|
||||
//! palette: ListPalette::default(),
|
||||
//! });
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use llimphi_ui::llimphi_layout::taffy::{
|
||||
prelude::{length, percent, FlexDirection, Size, Style},
|
||||
AlignItems, Rect,
|
||||
};
|
||||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||
use llimphi_ui::llimphi_text::Alignment;
|
||||
use llimphi_ui::View;
|
||||
|
||||
/// Paleta de la lista. Los defaults son una variante dark con selección
|
||||
/// azulada — equivalente conceptual a `nahual_theme` en su tema oscuro.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ListPalette {
|
||||
pub bg_panel: Color,
|
||||
pub bg_selected: Color,
|
||||
pub fg_text: Color,
|
||||
pub fg_muted: Color,
|
||||
}
|
||||
|
||||
impl Default for ListPalette {
|
||||
fn default() -> Self {
|
||||
Self::from_theme(&llimphi_theme::Theme::dark())
|
||||
}
|
||||
}
|
||||
|
||||
impl ListPalette {
|
||||
/// Construye la paleta desde un `Theme` semántico.
|
||||
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
|
||||
Self {
|
||||
bg_panel: t.bg_panel,
|
||||
bg_selected: t.bg_selected,
|
||||
fg_text: t.fg_text,
|
||||
fg_muted: t.fg_muted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Una fila a renderear. `selected` cambia el fondo; `on_click` se emite al
|
||||
/// hacer click sobre cualquier parte de la fila.
|
||||
pub struct ListRow<Msg> {
|
||||
pub label: String,
|
||||
pub selected: bool,
|
||||
pub on_click: Msg,
|
||||
}
|
||||
|
||||
/// Especificación completa de la lista a renderear.
|
||||
pub struct ListSpec<Msg> {
|
||||
/// Filas a renderear, ya filtradas a la ventana visible.
|
||||
pub rows: Vec<ListRow<Msg>>,
|
||||
/// Total de items del modelo (usado para el caption — la lista
|
||||
/// mostrada puede ser un subconjunto virtualizado).
|
||||
pub total: usize,
|
||||
/// Caption opcional arriba de las filas (p. ej. "120 entradas").
|
||||
pub caption: Option<String>,
|
||||
/// Mensaje opcional al pie ("… y 12 más") cuando hay items fuera de
|
||||
/// la ventana visible. El caller decide qué texto usar.
|
||||
pub truncated_hint: Option<String>,
|
||||
/// Altura de cada fila en pixels.
|
||||
pub row_height: f32,
|
||||
pub palette: ListPalette,
|
||||
}
|
||||
|
||||
/// Compone la lista como un `View<Msg>`. El contenedor tiene `clip = true`
|
||||
/// para evitar overflow visual cuando el llamador subestima el tamaño
|
||||
/// disponible — las filas que excedan el área del panel se recortan.
|
||||
pub fn list_view<Msg: Clone + 'static>(spec: ListSpec<Msg>) -> View<Msg> {
|
||||
let ListSpec {
|
||||
rows,
|
||||
total: _,
|
||||
caption,
|
||||
truncated_hint,
|
||||
row_height,
|
||||
palette,
|
||||
} = spec;
|
||||
|
||||
let mut children: Vec<View<Msg>> = Vec::with_capacity(rows.len() + 2);
|
||||
|
||||
if let Some(text) = caption {
|
||||
children.push(
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(20.0_f32),
|
||||
},
|
||||
padding: Rect {
|
||||
left: length(10.0_f32),
|
||||
right: length(10.0_f32),
|
||||
top: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(text, 10.0, palette.fg_muted, Alignment::Start),
|
||||
);
|
||||
}
|
||||
|
||||
for row in rows {
|
||||
children.push(row_view(row, row_height, &palette));
|
||||
}
|
||||
|
||||
if let Some(text) = truncated_hint {
|
||||
children.push(
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(16.0_f32),
|
||||
},
|
||||
padding: Rect {
|
||||
left: length(10.0_f32),
|
||||
right: length(10.0_f32),
|
||||
top: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(text, 10.0, palette.fg_muted, Alignment::Start),
|
||||
);
|
||||
}
|
||||
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
padding: Rect {
|
||||
left: length(0.0_f32),
|
||||
right: length(0.0_f32),
|
||||
top: length(6.0_f32),
|
||||
bottom: length(6.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.bg_panel)
|
||||
.clip(true)
|
||||
.children(children)
|
||||
}
|
||||
|
||||
fn row_view<Msg: Clone + 'static>(row: ListRow<Msg>, height: f32, palette: &ListPalette) -> View<Msg> {
|
||||
let bg = if row.selected {
|
||||
palette.bg_selected
|
||||
} else {
|
||||
palette.bg_panel
|
||||
};
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(height),
|
||||
},
|
||||
padding: Rect {
|
||||
left: length(10.0_f32),
|
||||
right: length(10.0_f32),
|
||||
top: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.fill(bg)
|
||||
.text_aligned(row.label, 12.0, palette.fg_text, Alignment::Start)
|
||||
.on_click(row.on_click)
|
||||
}
|
||||
Reference in New Issue
Block a user