gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ 14d76bd4af13b219f0b162a54f09e784dea9aa8d widgets/grid/src/lib.rs Rust · 467 líneas · 14.4 KB Historia Quién tocó qué crudo

feat: llimphi standalone — framework UI soberano extraído del monorepo e65e9cc62 · Sergio ·

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}