tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
feat: llimphi standalone — framework UI soberano extraído del monorepo
1//! `llimphi-widget-grid` — grilla virtualizada 2D para Llimphi.
2//!
3//! Hermano de [`llimphi-widget-list`], pero en mosaico: celdas clicables
4//! dispuestas en `cols` columnas × filas, con selección, caption/hint
5//! opcionales y recorte de overflow. Pensado como base para galerías de
6//! miniaturas tipo gThumb / FastStone — capaz de listar miles de archivos
7//! sin montar todo: el caller renderea **sólo la ventana visible**.
8//!
9//! Como `list`, el widget **no** scrollea por sí mismo. La virtualización
10//! es del caller, que mantiene `scroll_fila` (primera fila de celdas
11//! visible) en su estado y lo actualiza con la rueda (calco de
12//! `nahual-file-explorer`). La diferencia con `list` es que en 2D el
13//! cálculo de la ventana no es trivial — cuántas columnas caben depende
14//! del ancho del viewport — así que este crate lo provee como función
15//! pura testeable: [`ventana_visible`].
16//!
17//! El widget es **agnóstico del contenido**: cada [`GridCell`] lleva un
18//! `View<Msg>` que el caller arma (un thumb `peniko::Image`, un skeleton
19//! mientras decodifica, un ícono…). Así el pipeline de miniaturas (cola
20//! async + cache) vive afuera y sólo llena la celda con imagen o
21//! placeholder.
22//!
23//! Flujo típico del caller:
24//!
25//! ```ignore
26//! let v = ventana_visible(total, viewport_w, viewport_h, scroll_fila, &metrics);
27//! let cells: Vec<GridCell<Msg>> = (v.first..v.first + v.count)
28//! .map(|i| GridCell {
29//! content: thumb_o_placeholder(i), // el caller decide
30//! label: Some(nombre(i)),
31//! selected: i == seleccionado,
32//! on_click: Msg::Seleccionar(i),
33//! })
34//! .collect();
35//! let grid = grid_view(GridSpec {
36//! cells, cols: v.cols, metrics,
37//! caption: Some(format!("{total} imágenes")),
38//! truncated_hint: (v.first + v.count < total)
39//! .then(|| format!("… y {} más", total - (v.first + v.count))),
40//! palette: GridPalette::default(),
41//! });
42//! ```
43
44#![forbid(unsafe_code)]
45
46use llimphi_ui::llimphi_layout::taffy::{
47 prelude::{length, percent, FlexDirection, Size, Style},
48 AlignItems, JustifyContent, Rect,
49};
50use llimphi_ui::llimphi_raster::peniko::Color;
51use llimphi_ui::llimphi_text::Alignment;
52use llimphi_ui::View;
53
54/// Geometría de la grilla en pixels. `tile_h` debe incluir el alto del
55/// label si el caller lo usa — el widget reserva una franja inferior para
56/// él dentro de la celda. `gap` es el espacio entre celdas (y entre filas);
57/// `pad` el margen interno del contenedor (cada lado).
58#[derive(Debug, Clone, Copy)]
59pub struct GridMetrics {
60 pub tile_w: f32,
61 pub tile_h: f32,
62 pub gap: f32,
63 pub pad: f32,
64}
65
66impl Default for GridMetrics {
67 fn default() -> Self {
68 // Default ~thumb mediano estilo gThumb.
69 Self {
70 tile_w: 128.0,
71 tile_h: 148.0, // 128 imagen + ~20 label
72 gap: 8.0,
73 pad: 8.0,
74 }
75 }
76}
77
78/// Resultado del cálculo de virtualización: qué celdas montar. `first` y
79/// `count` delimitan el rango de índices `[first, first + count)` que el
80/// caller debe renderear; `cols` cuántas columnas caben (para `grid_view`).
81/// Los demás campos son informativos (scrollbars, "fila X de Y", clamping).
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct VisibleWindow {
84 /// Columnas que caben en el ancho del viewport (≥ 1).
85 pub cols: usize,
86 /// Índice del primer item a renderear.
87 pub first: usize,
88 /// Cantidad de items a renderear desde `first`.
89 pub count: usize,
90 /// Fila (0-based) del primer item visible — ya clampeada al rango.
91 pub first_row: usize,
92 /// Total de filas que ocupa la colección completa.
93 pub total_rows: usize,
94 /// Filas que entran en el alto del viewport (incluye 1 de margen).
95 pub filas_visibles: usize,
96}
97
98/// Calcula la ventana visible de una grilla virtualizada. **Función pura.**
99///
100/// - `total`: cantidad total de items.
101/// - `viewport_w` / `viewport_h`: dimensiones del área de la grilla en px.
102/// - `scroll_fila`: primera fila que el caller quiere ver arriba (se
103/// clampa al rango válido; el caller no necesita pre-clampar).
104/// - `m`: geometría (tile + gap + pad).
105///
106/// El número de columnas se deriva del ancho: `cols = ⌊(ancho_útil + gap)
107/// / (tile_w + gap)⌋`, mínimo 1. Las filas visibles incluyen una extra de
108/// margen para que una fila parcial al borde no aparezca en blanco al
109/// scrollear.
110pub fn ventana_visible(
111 total: usize,
112 viewport_w: f32,
113 viewport_h: f32,
114 scroll_fila: usize,
115 m: &GridMetrics,
116) -> VisibleWindow {
117 let paso_w = (m.tile_w + m.gap).max(1.0);
118 let paso_h = (m.tile_h + m.gap).max(1.0);
119
120 let util_w = (viewport_w - 2.0 * m.pad + m.gap).max(0.0);
121 let cols = ((util_w / paso_w).floor() as usize).max(1);
122
123 let total_rows = total.div_ceil(cols);
124
125 let util_h = (viewport_h - 2.0 * m.pad + m.gap).max(0.0);
126 let filas_visibles = (util_h / paso_h).ceil() as usize + 1;
127
128 let max_first_row = total_rows.saturating_sub(1);
129 let first_row = scroll_fila.min(max_first_row);
130 let first = first_row * cols;
131 let last_row = (first_row + filas_visibles).min(total_rows);
132 let count = (last_row * cols).min(total).saturating_sub(first);
133
134 VisibleWindow {
135 cols,
136 first,
137 count,
138 first_row,
139 total_rows,
140 filas_visibles,
141 }
142}
143
144/// Paleta de la grilla. Defaults dark con selección azulada (calco de
145/// `ListPalette`).
146#[derive(Debug, Clone, Copy)]
147pub struct GridPalette {
148 pub bg_panel: Color,
149 pub bg_cell: Color,
150 pub bg_selected: Color,
151 pub fg_text: Color,
152 pub fg_muted: Color,
153}
154
155impl Default for GridPalette {
156 fn default() -> Self {
157 Self::from_theme(&llimphi_theme::Theme::dark())
158 }
159}
160
161impl GridPalette {
162 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
163 Self {
164 bg_panel: t.bg_panel,
165 bg_cell: t.bg_panel_alt,
166 bg_selected: t.bg_selected,
167 fg_text: t.fg_text,
168 fg_muted: t.fg_muted,
169 }
170 }
171}
172
173/// Una celda de la grilla. `content` es el `View` que el caller arma para
174/// el cuerpo (thumb/placeholder/ícono); el widget lo centra y, debajo,
175/// pinta `label` truncable si está. `on_click` se emite al clickear la
176/// celda completa.
177pub struct GridCell<Msg> {
178 pub content: View<Msg>,
179 pub label: Option<String>,
180 pub selected: bool,
181 pub on_click: Msg,
182}
183
184/// Especificación completa de la grilla a renderear. `cells` ya viene
185/// recortada a la ventana visible (ver [`ventana_visible`]); `cols` es el
186/// número de columnas de esa ventana.
187pub struct GridSpec<Msg> {
188 pub cells: Vec<GridCell<Msg>>,
189 pub cols: usize,
190 pub metrics: GridMetrics,
191 pub caption: Option<String>,
192 pub truncated_hint: Option<String>,
193 pub palette: GridPalette,
194}
195
196/// Compone la grilla como un `View<Msg>`. Agrupa `cells` en filas de
197/// `cols` celdas y las apila. El contenedor recorta (`clip`) para que las
198/// celdas no sangren a vecinos cuando el caller subestima el área.
199pub fn grid_view<Msg: Clone + 'static>(spec: GridSpec<Msg>) -> View<Msg> {
200 let GridSpec {
201 cells,
202 cols,
203 metrics,
204 caption,
205 truncated_hint,
206 palette,
207 } = spec;
208 let cols = cols.max(1);
209
210 let mut children: Vec<View<Msg>> = Vec::new();
211
212 if let Some(text) = caption {
213 children.push(barra_texto(text, 11.0, palette.fg_muted, 20.0));
214 }
215
216 // Agrupar en filas de `cols`. La última fila puede quedar incompleta.
217 let mut iter = cells.into_iter();
218 loop {
219 let fila: Vec<GridCell<Msg>> = iter.by_ref().take(cols).collect();
220 if fila.is_empty() {
221 break;
222 }
223 children.push(fila_view(fila, &metrics, &palette));
224 }
225
226 if let Some(text) = truncated_hint {
227 children.push(barra_texto(text, 10.0, palette.fg_muted, 16.0));
228 }
229
230 View::new(Style {
231 flex_direction: FlexDirection::Column,
232 size: Size {
233 width: percent(1.0_f32),
234 height: percent(1.0_f32),
235 },
236 padding: Rect {
237 left: length(metrics.pad),
238 right: length(metrics.pad),
239 top: length(metrics.pad),
240 bottom: length(metrics.pad),
241 },
242 gap: Size {
243 width: length(0.0_f32),
244 height: length(metrics.gap),
245 },
246 ..Default::default()
247 })
248 .fill(palette.bg_panel)
249 .clip(true)
250 .children(children)
251}
252
253fn fila_view<Msg: Clone + 'static>(
254 fila: Vec<GridCell<Msg>>,
255 m: &GridMetrics,
256 palette: &GridPalette,
257) -> View<Msg> {
258 let celdas: Vec<View<Msg>> = fila
259 .into_iter()
260 .map(|c| celda_view(c, m, palette))
261 .collect();
262 View::new(Style {
263 flex_direction: FlexDirection::Row,
264 size: Size {
265 width: percent(1.0_f32),
266 height: length(m.tile_h),
267 },
268 gap: Size {
269 width: length(m.gap),
270 height: length(0.0_f32),
271 },
272 ..Default::default()
273 })
274 .children(celdas)
275}
276
277fn celda_view<Msg: Clone + 'static>(
278 cell: GridCell<Msg>,
279 m: &GridMetrics,
280 palette: &GridPalette,
281) -> View<Msg> {
282 let bg = if cell.selected {
283 palette.bg_selected
284 } else {
285 palette.bg_cell
286 };
287
288 let mut hijos: Vec<View<Msg>> = Vec::with_capacity(2);
289 // Cuerpo de la celda: el content del caller, centrado, ocupa el alto
290 // restante (tile_h menos la franja de label).
291 hijos.push(
292 View::new(Style {
293 flex_grow: 1.0,
294 size: Size {
295 width: percent(1.0_f32),
296 height: length(0.0_f32),
297 },
298 align_items: Some(AlignItems::Center),
299 justify_content: Some(JustifyContent::Center),
300 ..Default::default()
301 })
302 .clip(true)
303 .children(vec![cell.content]),
304 );
305 if let Some(label) = cell.label {
306 hijos.push(
307 View::new(Style {
308 size: Size {
309 width: percent(1.0_f32),
310 height: length(18.0_f32),
311 },
312 padding: Rect {
313 left: length(4.0_f32),
314 right: length(4.0_f32),
315 top: length(0.0_f32),
316 bottom: length(0.0_f32),
317 },
318 ..Default::default()
319 })
320 .clip(true)
321 .text_aligned(label, 10.0, palette.fg_text, Alignment::Center),
322 );
323 }
324
325 View::new(Style {
326 flex_direction: FlexDirection::Column,
327 size: Size {
328 width: length(m.tile_w),
329 height: length(m.tile_h),
330 },
331 align_items: Some(AlignItems::Center),
332 ..Default::default()
333 })
334 .fill(bg)
335 .clip(true)
336 .children(hijos)
337 .on_click(cell.on_click)
338}
339
340fn barra_texto<Msg: Clone + 'static>(
341 text: String,
342 size: f32,
343 color: Color,
344 height: f32,
345) -> View<Msg> {
346 View::new(Style {
347 size: Size {
348 width: percent(1.0_f32),
349 height: length(height),
350 },
351 padding: Rect {
352 left: length(4.0_f32),
353 right: length(4.0_f32),
354 top: length(0.0_f32),
355 bottom: length(0.0_f32),
356 },
357 align_items: Some(AlignItems::Center),
358 ..Default::default()
359 })
360 .text_aligned(text, size, color, Alignment::Start)
361}
362
363#[cfg(test)]
364mod pruebas {
365 use super::*;
366
367 fn metrics() -> GridMetrics {
368 GridMetrics {
369 tile_w: 80.0,
370 tile_h: 96.0,
371 gap: 8.0,
372 pad: 8.0,
373 }
374 }
375
376 #[test]
377 fn cols_se_deriva_del_ancho() {
378 let m = metrics();
379 // util_w = 400 - 16 + 8 = 392; paso = 88; 392/88 = 4.45 → 4.
380 let v = ventana_visible(100, 400.0, 300.0, 0, &m);
381 assert_eq!(v.cols, 4);
382 assert_eq!(v.total_rows, 25);
383 }
384
385 #[test]
386 fn ancho_minimo_da_al_menos_una_columna() {
387 let m = metrics();
388 let v = ventana_visible(10, 50.0, 300.0, 0, &m);
389 assert_eq!(v.cols, 1, "nunca menos de 1 columna");
390 }
391
392 #[test]
393 fn ventana_arriba_monta_filas_visibles_mas_margen() {
394 let m = metrics();
395 // util_h = 300 - 16 + 8 = 292; paso_h = 104; ceil(292/104)=3; +1 = 4.
396 let v = ventana_visible(100, 400.0, 300.0, 0, &m);
397 assert_eq!(v.filas_visibles, 4);
398 assert_eq!(v.first, 0);
399 // 4 filas × 4 cols = 16 items.
400 assert_eq!(v.count, 16);
401 }
402
403 #[test]
404 fn ventana_al_fondo_clampa_y_recorta_la_cola() {
405 let m = metrics();
406 // 100 items, 4 cols → 25 filas. Pedir fila 22 (cerca del fondo).
407 let v = ventana_visible(100, 400.0, 300.0, 22, &m);
408 assert_eq!(v.first_row, 22);
409 assert_eq!(v.first, 88);
410 // last_row = min(22+4, 25) = 25 → count = min(100,100) - 88 = 12.
411 assert_eq!(v.count, 12);
412 }
413
414 #[test]
415 fn scroll_mas_alla_del_fondo_se_clampa() {
416 let m = metrics();
417 let v = ventana_visible(100, 400.0, 300.0, 999, &m);
418 // total_rows 25 → max_first_row 24.
419 assert_eq!(v.first_row, 24);
420 assert_eq!(v.first, 96);
421 // Sólo la última fila: 100 - 96 = 4 items.
422 assert_eq!(v.count, 4);
423 }
424
425 #[test]
426 fn coleccion_vacia_no_monta_nada() {
427 let m = metrics();
428 let v = ventana_visible(0, 400.0, 300.0, 0, &m);
429 assert!(v.cols >= 1);
430 assert_eq!(v.total_rows, 0);
431 assert_eq!(v.count, 0);
432 assert_eq!(v.first, 0);
433 }
434
435 #[test]
436 fn ultima_fila_parcial_cuenta_completa_en_total_rows() {
437 let m = metrics();
438 // 10 items, 4 cols → 3 filas (la última con 2).
439 let v = ventana_visible(10, 400.0, 1000.0, 0, &m);
440 assert_eq!(v.cols, 4);
441 assert_eq!(v.total_rows, 3);
442 // Viewport alto: entran todas.
443 assert_eq!(v.count, 10);
444 }
445
446 #[test]
447 fn grid_view_agrupa_en_filas_sin_panicar() {
448 // Smoke: 7 celdas en 3 columnas → 3 filas (3+3+1). Sólo verifica
449 // que compone sin panicar y devuelve un View.
450 let cells: Vec<GridCell<i32>> = (0..7)
451 .map(|i| GridCell {
452 content: View::new(Style::default()),
453 label: Some(format!("img{i}")),
454 selected: i == 2,
455 on_click: i,
456 })
457 .collect();
458 let _v = grid_view(GridSpec {
459 cells,
460 cols: 3,
461 metrics: metrics(),
462 caption: Some("7 imágenes".into()),
463 truncated_hint: None,
464 palette: GridPalette::default(),
465 });
466 }
467}