tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Sutichaykuna

⎇ main widgets/grid/src/lib.rs Rust · 783 siq'ikuna · 28.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · 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
46
47// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
48// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
49// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
50#![no_std]
51
52extern crate alloc;
53
54use alloc::string::String;
55use alloc::vec::Vec;
56use alloc::vec;
57use llimphi_compositor::llimphi_layout::taffy::{
58    prelude::{length, percent, FlexDirection, Size, Style},
59    AlignItems, JustifyContent, Rect,
60};
61use llimphi_compositor::peniko::Color;
62use llimphi_compositor::llimphi_text::Alignment;
63use llimphi_compositor::View;
64/// Alto que necesitan **dos** líneas de rótulo a 10 pt, con su interlínea.
65/// Es el default: los nombres de archivo largos son la regla, no la excepción.
66pub const LABEL_DOS_LINEAS: f32 = 30.0;
67
68
69/// Geometría de la grilla en pixels. `tile_h` debe incluir el alto del
70/// label si el caller lo usa — el widget reserva una franja inferior para
71/// él dentro de la celda. `gap` es el espacio entre celdas (y entre filas);
72/// `pad` el margen interno del contenedor (cada lado).
73#[derive(Debug, Clone, Copy)]
74pub struct GridMetrics {
75    pub tile_w: f32,
76    pub tile_h: f32,
77    pub gap: f32,
78    pub pad: f32,
79    /// Alto de la franja del **rótulo**, en px.
80    ///
81    /// Era una constante de 18 px —una línea a 10 pt— con `clip`. Un nombre
82    /// largo envuelve a dos líneas y la segunda quedaba **cortada por la
83    /// mitad**, que es peor que no mostrarla: parece un glitch, no un recorte.
84    /// Ahora entra por acá, el default alcanza para dos líneas, y el texto se
85    /// corta con puntos suspensivos en la segunda (`ellipsis(2)`) — que es un
86    /// recorte que se lee como tal.
87    pub label_h: f32,
88}
89
90impl Default for GridMetrics {
91    fn default() -> Self {
92        // Default ~thumb mediano estilo gThumb.
93        Self {
94            tile_w: 128.0,
95            tile_h: 148.0, // 128 imagen + la franja de rótulo
96            gap: 8.0,
97            pad: 8.0,
98            label_h: LABEL_DOS_LINEAS,
99        }
100    }
101}
102
103/// Resultado del cálculo de virtualización: qué celdas montar. `first` y
104/// `count` delimitan el rango de índices `[first, first + count)` que el
105/// caller debe renderear; `cols` cuántas columnas caben (para `grid_view`).
106/// Los demás campos son informativos (scrollbars, "fila X de Y", clamping).
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct VisibleWindow {
109    /// Columnas que caben en el ancho del viewport (≥ 1).
110    pub cols: usize,
111    /// Índice del primer item a renderear.
112    pub first: usize,
113    /// Cantidad de items a renderear desde `first`.
114    pub count: usize,
115    /// Fila (0-based) del primer item visible — ya clampeada al rango.
116    pub first_row: usize,
117    /// Total de filas que ocupa la colección completa.
118    pub total_rows: usize,
119    /// Filas que entran en el alto del viewport (incluye 1 de margen).
120    pub filas_visibles: usize,
121}
122
123/// Calcula la ventana visible de una grilla virtualizada. **Función pura.**
124///
125/// - `total`: cantidad total de items.
126/// - `viewport_w` / `viewport_h`: dimensiones del área de la grilla en px.
127/// - `scroll_fila`: primera fila que el caller quiere ver arriba (se
128///   clampa al rango válido; el caller no necesita pre-clampar).
129/// - `m`: geometría (tile + gap + pad).
130///
131/// El número de columnas se deriva del ancho: `cols = ⌊(ancho_útil + gap)
132/// / (tile_w + gap)⌋`, mínimo 1. Las filas visibles incluyen una extra de
133/// margen para que una fila parcial al borde no aparezca en blanco al
134/// scrollear.
135pub fn ventana_visible(
136    total: usize,
137    viewport_w: f32,
138    viewport_h: f32,
139    scroll_fila: usize,
140    m: &GridMetrics,
141) -> VisibleWindow {
142    let paso_w = (m.tile_w + m.gap).max(1.0);
143    let paso_h = (m.tile_h + m.gap).max(1.0);
144
145    let util_w = (viewport_w - 2.0 * m.pad + m.gap).max(0.0);
146    let cols = ((util_w / paso_w).floor() as usize).max(1);
147
148    let total_rows = total.div_ceil(cols);
149
150    let util_h = (viewport_h - 2.0 * m.pad + m.gap).max(0.0);
151    let filas_visibles = (util_h / paso_h).ceil() as usize + 1;
152
153    let max_first_row = total_rows.saturating_sub(1);
154    let first_row = scroll_fila.min(max_first_row);
155    let first = first_row * cols;
156    let last_row = (first_row + filas_visibles).min(total_rows);
157    let count = (last_row * cols).min(total).saturating_sub(first);
158
159    VisibleWindow {
160        cols,
161        first,
162        count,
163        first_row,
164        total_rows,
165        filas_visibles,
166    }
167}
168
169/// **El aire de una grilla de miniaturas de celda `tile_w`**, en una sola
170/// cuenta y en un solo lado. Es el `gap` entre celdas, y también el `pad` del
171/// contenedor: el margen del borde y el de entre celdas son el mismo margen.
172///
173/// El aire crece con la celda —una grilla de 360 px con el aire de una de 96
174/// se ve apretada— pero acotado por arriba y por abajo: por debajo de ~12 px
175/// las miniaturas se leen como una plancha continua y no como objetos
176/// separados, y por encima de ~22 el aire compite con el contenido.
177///
178/// Estaba escrita en `nahual-shell` (`tile_w/16`, piso 8) y **no** en
179/// `nahual-module`, que usaba el `GridMetrics::default()` pelado: las dos
180/// grillas de la misma app —la del shell y la que un chasis hospeda— tenían
181/// aires distintos y ninguna de las dos podía cambiarse sin acordarse de la
182/// otra.
183///
184/// **Esto NO abre una canaleta a la derecha**: es el ancho *pretendido*, del
185/// que sale cuántas columnas entran; [`ajustar_al_ancho`] estira después la
186/// celda hasta que esas columnas llenen el panel exacto. Más aire = una celda
187/// un poco más chica o una columna menos, nunca un margen muerto.
188pub fn aire_para_tile(tile_w: f32) -> f32 {
189    (tile_w / 9.0).clamp(12.0, 22.0)
190}
191
192/// Cuántas columnas del ancho `m.tile_w` entran en `viewport_w`. Es el mismo
193/// cálculo que hace [`ventana_visible`], expuesto aparte para quien necesita
194/// las columnas antes de tener la ventana.
195pub fn cols_que_entran(viewport_w: f32, m: &GridMetrics) -> usize {
196    let paso = (m.tile_w + m.gap).max(1.0);
197    let util = (viewport_w - 2.0 * m.pad + m.gap).max(0.0);
198    ((util / paso).floor() as usize).max(1)
199}
200
201/// El ancho de celda con el que `cols` columnas llenan **exactamente** el
202/// ancho útil de `viewport_w`.
203///
204/// Le deja 0,01 px de aire a propósito: con el ancho exacto, el
205/// `floor(util / paso)` de [`ventana_visible`] cae del lado de `cols - 1` en
206/// cuanto el flotante redondea para abajo, y la última columna desaparece.
207pub fn tile_para_cols(viewport_w: f32, cols: usize, m: &GridMetrics) -> f32 {
208    let cols = cols.max(1) as f32;
209    let util = (viewport_w - 2.0 * m.pad + m.gap).max(0.0);
210    (util / cols - m.gap - 0.01).max(1.0)
211}
212
213/// Estira la celda de `ideal` para que las columnas que entran **llenen** el
214/// ancho: las miniaturas ocupan todo el panel en vez de dejar una canaleta
215/// muerta a la derecha, y al cambiar el ancho de la ventana no salta el tamaño,
216/// se reparte el sobrante entre las mismas columnas.
217///
218/// `ideal.tile_w` deja de ser el tamaño que se pinta y pasa a ser el tamaño
219/// *pretendido*, el único que hace falta guardar: las columnas y el ancho real
220/// son función de él y del viewport, así que achicar la ventana y volver al
221/// ancho original devuelve exactamente las mismas columnas.
222///
223/// La franja del rótulo NO escala —es texto: estirarla sólo agranda un hueco—;
224/// lo que crece con la celda es la caja de la imagen.
225pub fn ajustar_al_ancho(viewport_w: f32, ideal: &GridMetrics) -> GridMetrics {
226    let cols = cols_que_entran(viewport_w, ideal);
227    let tile_w = tile_para_cols(viewport_w, cols, ideal);
228    let factor = if ideal.tile_w > 0.0 { tile_w / ideal.tile_w } else { 1.0 };
229    let imagen_h = (ideal.tile_h - ideal.label_h).max(0.0);
230    GridMetrics {
231        tile_w,
232        tile_h: imagen_h * factor + ideal.label_h,
233        ..*ideal
234    }
235}
236
237/// Paleta de la grilla. Defaults dark con selección azulada (calco de
238/// `ListPalette`).
239#[derive(Debug, Clone, Copy)]
240pub struct GridPalette {
241    pub bg_panel: Color,
242    pub bg_cell: Color,
243    pub bg_selected: Color,
244    /// Fondo de una celda **marcada** (ver [`GridCell::marked`]). Por defecto
245    /// el acento a media alpha: se distingue del cursor sin competir con él.
246    pub bg_marked: Color,
247    pub fg_text: Color,
248    pub fg_muted: Color,
249    /// Radio de las esquinas de la celda. `0` = la caja cuadrada de siempre.
250    pub radio: f32,
251    /// Borde `(ancho, color)` de la celda **cuando es cursor o marca**. `None`
252    /// = sin borde, que es el default histórico.
253    ///
254    /// Existe para el modo de celda **sin fondo** ([`GridPalette::sin_fondo`]):
255    /// con `bg_cell` transparente, el relleno deja de ser lo que dice «esta es
256    /// la elegida», y sin borde la selección se leería sólo por un teñido
257    /// suave sobre el fondo del panel.
258    pub borde_sel: Option<(f32, Color)>,
259}
260
261impl Default for GridPalette {
262    fn default() -> Self {
263        Self::from_theme(&llimphi_theme::Theme::dark())
264    }
265}
266
267impl GridPalette {
268    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
269        Self {
270            bg_panel: t.bg_panel,
271            bg_cell: t.bg_panel_alt,
272            bg_selected: t.bg_selected,
273            bg_marked: t.accent.multiply_alpha(0.30),
274            fg_text: t.fg_text,
275            fg_muted: t.fg_muted,
276            radio: 0.0,
277            borde_sel: None,
278        }
279    }
280
281    /// **Celdas sin fondo**: la miniatura se apoya en el fondo del panel y no
282    /// en un cartón gris propio, y lo que marca la elegida es un teñido del
283    /// color de selección más su borde redondeado.
284    ///
285    /// Es el pedido textual del operador sobre la grilla del explorador — *«en
286    /// vista miniaturas y galerías, que no tengan fondo semitransparente, que
287    /// el fondo sea transparente … en selección sí tienen su borde y su fondo
288    /// medio coloreado por el color de selección»*. Va acá y no en el
289    /// consumidor porque la cuenta de los alphas es lo que hace que el cursor
290    /// se distinga de la marca, y con dos copias se despegan.
291    pub fn sin_fondo(t: &llimphi_theme::Theme) -> Self {
292        Self {
293            bg_cell: Color::TRANSPARENT,
294            bg_selected: t.bg_selected.multiply_alpha(0.55),
295            bg_marked: t.accent.multiply_alpha(0.16),
296            radio: 10.0,
297            borde_sel: Some((1.4, t.accent)),
298            ..Self::from_theme(t)
299        }
300    }
301}
302
303/// Una celda de la grilla. `content` es el `View` que el caller arma para
304/// el cuerpo (thumb/placeholder/ícono); el widget lo centra y, debajo,
305/// pinta `label` truncable si está. `on_click` se emite al clickear la
306/// celda completa.
307pub struct GridCell<Msg> {
308    pub content: View<Msg>,
309    pub label: Option<String>,
310    /// El **cursor**: la celda bajo la que está el foco. Una sola.
311    pub selected: bool,
312    /// **Marcada** para operar por lote (Ctrl/Shift+click, banda elástica,
313    /// «seleccionar todo»). Puede haber muchas, y se pintan con fondo propio.
314    ///
315    /// Existe desde 2026-08-12 y no es cosmética. Antes la marca viajaba
316    /// sólo como un `✓` de seis píxeles delante del nombre, así que una
317    /// selección de cuatro archivos se veía **igual** que ninguna — mientras
318    /// el cursor sí tenía caja y borde. El reporte fue textual: *«sólo se
319    /// seleccionó un archivo a la vez, o al menos eso parecía … le di a delete
320    /// y se volaron todos»*. Borrar a ciegas empieza mucho antes del borrado.
321    pub marked: bool,
322    pub on_click: Msg,
323}
324
325/// Especificación completa de la grilla a renderear. `cells` ya viene
326/// recortada a la ventana visible (ver [`ventana_visible`]); `cols` es el
327/// número de columnas de esa ventana.
328pub struct GridSpec<Msg> {
329    pub cells: Vec<GridCell<Msg>>,
330    pub cols: usize,
331    pub metrics: GridMetrics,
332    pub caption: Option<String>,
333    pub truncated_hint: Option<String>,
334    pub palette: GridPalette,
335}
336
337/// Compone la grilla como un `View<Msg>`. Agrupa `cells` en filas de
338/// `cols` celdas y las apila. El contenedor recorta (`clip`) para que las
339/// celdas no sangren a vecinos cuando el caller subestima el área.
340pub fn grid_view<Msg: Clone + 'static>(spec: GridSpec<Msg>) -> View<Msg> {
341    grid_view_con(spec, None)
342}
343
344/// [`grid_view`] con **arrastre y soltado** por celda (mismo `RowDnd` que la
345/// lista: el índice es la posición de la celda dentro de `cells`). Con `dnd =
346/// None` es la grilla de siempre.
347pub fn grid_view_dnd<Msg: Clone + Send + Sync + 'static>(
348    spec: GridSpec<Msg>,
349    dnd: Option<llimphi_widget_list::RowDnd<Msg>>,
350) -> View<Msg> {
351    match dnd {
352        Some(d) => grid_view_con(
353            spec,
354            Some(&move |v, i, click| llimphi_widget_list::con_dnd(v, i, click, &d)),
355        ),
356        None => grid_view_con(spec, None),
357    }
358}
359
360fn grid_view_con<Msg: Clone + 'static>(
361    spec: GridSpec<Msg>,
362    deco: Option<llimphi_widget_list::FilaDeco<'_, Msg>>,
363) -> View<Msg> {
364    let GridSpec {
365        cells,
366        cols,
367        metrics,
368        caption,
369        truncated_hint,
370        palette,
371    } = spec;
372    let cols = cols.max(1);
373
374    let mut children: Vec<View<Msg>> = Vec::new();
375
376    if let Some(text) = caption {
377        children.push(barra_texto(text, 11.0, palette.fg_muted, 20.0));
378    }
379
380    // Agrupar en filas de `cols`. La última fila puede quedar incompleta.
381    let mut iter = cells.into_iter();
382    let mut base = 0usize;
383    loop {
384        let fila: Vec<GridCell<Msg>> = iter.by_ref().take(cols).collect();
385        if fila.is_empty() {
386            break;
387        }
388        let n = fila.len();
389        children.push(fila_view(fila, base, &metrics, &palette, deco));
390        base += n;
391    }
392
393    if let Some(text) = truncated_hint {
394        children.push(barra_texto(text, 10.0, palette.fg_muted, 16.0));
395    }
396
397    View::new(Style {
398        flex_direction: FlexDirection::Column,
399        size: Size {
400            width: percent(1.0_f32),
401            height: percent(1.0_f32),
402        },
403        padding: Rect {
404            left: length(metrics.pad),
405            right: length(metrics.pad),
406            top: length(metrics.pad),
407            bottom: length(metrics.pad),
408        },
409        gap: Size {
410            width: length(0.0_f32),
411            height: length(metrics.gap),
412        },
413        ..Default::default()
414    })
415    .fill(palette.bg_panel)
416    .clip(true)
417    .children(children)
418}
419
420fn fila_view<Msg: Clone + 'static>(
421    fila: Vec<GridCell<Msg>>,
422    base: usize,
423    m: &GridMetrics,
424    palette: &GridPalette,
425    deco: Option<llimphi_widget_list::FilaDeco<'_, Msg>>,
426) -> View<Msg> {
427    let celdas: Vec<View<Msg>> = fila
428        .into_iter()
429        .enumerate()
430        .map(|(k, c)| {
431            let click = c.on_click.clone();
432            let v = celda_view(c, m, palette);
433            match deco {
434                Some(f) => f(v, base + k, click),
435                None => v,
436            }
437        })
438        .collect();
439    View::new(Style {
440        flex_direction: FlexDirection::Row,
441        size: Size {
442            width: percent(1.0_f32),
443            height: length(m.tile_h),
444        },
445        gap: Size {
446            width: length(m.gap),
447            height: length(0.0_f32),
448        },
449        ..Default::default()
450    })
451    .children(celdas)
452}
453
454fn celda_view<Msg: Clone + 'static>(
455    cell: GridCell<Msg>,
456    m: &GridMetrics,
457    palette: &GridPalette,
458) -> View<Msg> {
459    // El cursor manda sobre la marca: una celda que es las dos cosas se pinta
460    // como cursor (es donde está el foco) y la marca se lee del `✓` del label.
461    let bg = if cell.selected {
462        palette.bg_selected
463    } else if cell.marked {
464        palette.bg_marked
465    } else {
466        palette.bg_cell
467    };
468
469    let mut hijos: Vec<View<Msg>> = Vec::with_capacity(2);
470    // Cuerpo de la celda: el content del caller, centrado, ocupa el alto
471    // restante (tile_h menos la franja de label).
472    hijos.push(
473        View::new(Style {
474            flex_grow: 1.0,
475            size: Size {
476                width: percent(1.0_f32),
477                height: length(0.0_f32),
478            },
479            align_items: Some(AlignItems::CENTER),
480            justify_content: Some(JustifyContent::CENTER),
481            ..Default::default()
482        })
483        .clip(true)
484        .children(vec![cell.content]),
485    );
486    if let Some(label) = cell.label {
487        hijos.push(
488            View::new(Style {
489                size: Size {
490                    width: percent(1.0_f32),
491                    height: length(m.label_h),
492                },
493                padding: Rect {
494                    left: length(4.0_f32),
495                    right: length(4.0_f32),
496                    top: length(0.0_f32),
497                    bottom: length(0.0_f32),
498                },
499                flex_shrink: 0.0,
500                ..Default::default()
501            })
502            .clip(true)
503            // Dos líneas y puntos suspensivos: un nombre larguísimo se recorta
504            // de una forma que se LEE como recorte, en vez de dejar media
505            // línea asomando por el borde.
506            .text_aligned(label, 10.0, palette.fg_text, Alignment::Center)
507            .ellipsis(2),
508        );
509    }
510
511    let mut celda = View::new(Style {
512        flex_direction: FlexDirection::Column,
513        size: Size {
514            width: length(m.tile_w),
515            height: length(m.tile_h),
516        },
517        align_items: Some(AlignItems::CENTER),
518        ..Default::default()
519    });
520    // Un relleno con alpha 0 no se pinta: se saltea para no gastar un rect por
521    // celda en el modo sin fondo, que es el de toda una grilla de mil.
522    if bg.components[3] > 0.0 {
523        celda = celda.fill(bg);
524    }
525    if palette.radio > 0.0 {
526        celda = celda.radius(palette.radio as f64);
527    }
528    if let (Some((ancho, color)), true) = (palette.borde_sel, cell.selected || cell.marked) {
529        // El cursor se lleva el borde entero; la marca, uno más tenue — son
530        // dos estados distintos y con el mismo borde volverían a ser uno.
531        let color = if cell.selected { color } else { color.multiply_alpha(0.55) };
532        celda = celda.border(ancho as f64, color);
533    }
534    celda.clip(true).children(hijos).on_click(cell.on_click)
535}
536
537fn barra_texto<Msg: Clone + 'static>(
538    text: String,
539    size: f32,
540    color: Color,
541    height: f32,
542) -> View<Msg> {
543    View::new(Style {
544        size: Size {
545            width: percent(1.0_f32),
546            height: length(height),
547        },
548        padding: Rect {
549            left: length(4.0_f32),
550            right: length(4.0_f32),
551            top: length(0.0_f32),
552            bottom: length(0.0_f32),
553        },
554        align_items: Some(AlignItems::CENTER),
555        ..Default::default()
556    })
557    .text_aligned(text, size, color, Alignment::Start)
558}
559
560#[cfg(test)]
561mod pruebas {
562    // Estos `use` sólo los necesitan los tests: arriba quedaban como imports
563    // muertos. Es deriva de la migración a `no_std`, donde todo lo de `alloc`
564    // se importa a mano.
565    use alloc::format;
566    use super::*;
567
568    fn metrics() -> GridMetrics {
569        GridMetrics {
570            tile_w: 80.0,
571            tile_h: 96.0,
572            gap: 8.0,
573            pad: 8.0,
574            label_h: LABEL_DOS_LINEAS,
575        }
576    }
577
578    #[test]
579    fn cols_se_deriva_del_ancho() {
580        let m = metrics();
581        // util_w = 400 - 16 + 8 = 392; paso = 88; 392/88 = 4.45 → 4.
582        let v = ventana_visible(100, 400.0, 300.0, 0, &m);
583        assert_eq!(v.cols, 4);
584        assert_eq!(v.total_rows, 25);
585    }
586
587    #[test]
588    fn ancho_minimo_da_al_menos_una_columna() {
589        let m = metrics();
590        let v = ventana_visible(10, 50.0, 300.0, 0, &m);
591        assert_eq!(v.cols, 1, "nunca menos de 1 columna");
592    }
593
594    #[test]
595    fn ventana_arriba_monta_filas_visibles_mas_margen() {
596        let m = metrics();
597        // util_h = 300 - 16 + 8 = 292; paso_h = 104; ceil(292/104)=3; +1 = 4.
598        let v = ventana_visible(100, 400.0, 300.0, 0, &m);
599        assert_eq!(v.filas_visibles, 4);
600        assert_eq!(v.first, 0);
601        // 4 filas × 4 cols = 16 items.
602        assert_eq!(v.count, 16);
603    }
604
605    #[test]
606    fn ventana_al_fondo_clampa_y_recorta_la_cola() {
607        let m = metrics();
608        // 100 items, 4 cols → 25 filas. Pedir fila 22 (cerca del fondo).
609        let v = ventana_visible(100, 400.0, 300.0, 22, &m);
610        assert_eq!(v.first_row, 22);
611        assert_eq!(v.first, 88);
612        // last_row = min(22+4, 25) = 25 → count = min(100,100) - 88 = 12.
613        assert_eq!(v.count, 12);
614    }
615
616    #[test]
617    fn scroll_mas_alla_del_fondo_se_clampa() {
618        let m = metrics();
619        let v = ventana_visible(100, 400.0, 300.0, 999, &m);
620        // total_rows 25 → max_first_row 24.
621        assert_eq!(v.first_row, 24);
622        assert_eq!(v.first, 96);
623        // Sólo la última fila: 100 - 96 = 4 items.
624        assert_eq!(v.count, 4);
625    }
626
627    #[test]
628    fn coleccion_vacia_no_monta_nada() {
629        let m = metrics();
630        let v = ventana_visible(0, 400.0, 300.0, 0, &m);
631        assert!(v.cols >= 1);
632        assert_eq!(v.total_rows, 0);
633        assert_eq!(v.count, 0);
634        assert_eq!(v.first, 0);
635    }
636
637    #[test]
638    fn ultima_fila_parcial_cuenta_completa_en_total_rows() {
639        let m = metrics();
640        // 10 items, 4 cols → 3 filas (la última con 2).
641        let v = ventana_visible(10, 400.0, 1000.0, 0, &m);
642        assert_eq!(v.cols, 4);
643        assert_eq!(v.total_rows, 3);
644        // Viewport alto: entran todas.
645        assert_eq!(v.count, 10);
646    }
647
648    #[test]
649    fn ajustar_al_ancho_llena_el_panel_y_conserva_las_columnas() {
650        let ideal = metrics();
651        for w in [300.0_f32, 400.0, 517.0, 1024.0, 1920.0] {
652            let cols = cols_que_entran(w, &ideal);
653            let fit = ajustar_al_ancho(w, &ideal);
654            // 1. La fila entera —celdas + gaps + los dos pads— cubre el ancho
655            //    (con el pelo de aire de `tile_para_cols`).
656            let ocupado = cols as f32 * fit.tile_w + (cols as f32 - 1.0) * fit.gap + 2.0 * fit.pad;
657            assert!(
658                (w - ocupado).abs() < 0.02 * cols as f32 + 0.05,
659                "ancho {w}: {cols} celdas de {} ocupan {ocupado}",
660                fit.tile_w,
661            );
662            // 2. La celda estirada NO cambia el conteo de columnas: si lo
663            //    cambiara, cada pintada movería la grilla otra vez.
664            assert_eq!(
665                ventana_visible(100, w, 600.0, 0, &fit).cols,
666                cols,
667                "ancho {w}: la celda estirada tiene que dar las mismas columnas",
668            );
669            // 3. Estira, no achica (salvo que no entre ni una celda entera).
670            assert!(fit.tile_w >= ideal.tile_w - 0.02 || cols == 1);
671        }
672    }
673
674    #[test]
675    fn el_aire_crece_con_la_celda_y_no_abre_canaleta() {
676        // Las dos mitades del pedido en una sola cuenta: el aire es MÁS que los
677        // 8 px de antes en todo el rango de zoom, y con ese aire la fila sigue
678        // cubriendo el panel entero — el aire sale de la celda, no de un margen
679        // muerto a la derecha.
680        for tile in [72.0_f32, 96.0, 128.0, 220.0, 360.0, 512.0] {
681            let aire = aire_para_tile(tile);
682            assert!(aire >= 12.0, "celda {tile}: aire {aire} < 12");
683            assert!(aire <= 22.0, "celda {tile}: aire {aire} > 22");
684            assert!(aire > 8.0, "celda {tile}: el aire tiene que superar los 8 px de antes");
685            let ideal = GridMetrics {
686                tile_w: tile,
687                tile_h: tile + LABEL_DOS_LINEAS,
688                gap: aire,
689                pad: aire,
690                label_h: LABEL_DOS_LINEAS,
691            };
692            for w in [420.0_f32, 900.0, 1288.0, 1920.0] {
693                let cols = cols_que_entran(w, &ideal);
694                let fit = ajustar_al_ancho(w, &ideal);
695                let ocupado =
696                    cols as f32 * fit.tile_w + (cols as f32 - 1.0) * fit.gap + 2.0 * fit.pad;
697                assert!(
698                    (w - ocupado).abs() < 0.02 * cols as f32 + 0.05,
699                    "celda {tile} en {w}: {cols} columnas ocupan {ocupado}",
700                );
701            }
702        }
703        // Y el aire crece con la celda mientras no toque el techo.
704        assert!(aire_para_tile(180.0) > aire_para_tile(110.0));
705    }
706
707    #[test]
708    fn el_tamano_pretendido_sobrevive_al_resize() {
709        // El invariante del zoom por columnas: como el ancho pretendido no lo
710        // toca nadie al redimensionar, achicar la ventana y volver devuelve
711        // las MISMAS columnas que al principio.
712        let ideal = metrics();
713        let antes = cols_que_entran(1200.0, &ideal);
714        for w in [640.0_f32, 320.0, 1800.0, 900.0] {
715            let _ = ajustar_al_ancho(w, &ideal);
716        }
717        assert_eq!(cols_que_entran(1200.0, &ideal), antes);
718    }
719
720    #[test]
721    fn el_rotulo_no_se_estira_con_la_celda() {
722        let ideal = metrics();
723        let fit = ajustar_al_ancho(1000.0, &ideal);
724        let imagen_ideal = ideal.tile_h - ideal.label_h;
725        let imagen_fit = fit.tile_h - fit.label_h;
726        assert_eq!(fit.label_h, ideal.label_h);
727        assert!(
728            (imagen_fit / imagen_ideal - fit.tile_w / ideal.tile_w).abs() < 1e-3,
729            "la caja de imagen escala con el ancho, la franja del rótulo no",
730        );
731    }
732
733    #[test]
734    fn grid_view_agrupa_en_filas_sin_panicar() {
735        // Smoke: 7 celdas en 3 columnas → 3 filas (3+3+1). Sólo verifica
736        // que compone sin panicar y devuelve un View.
737        let cells: Vec<GridCell<i32>> = (0..7)
738            .map(|i| GridCell {
739                content: View::new(Style::default()),
740                label: Some(format!("img{i}")),
741                selected: i == 2,
742                marked: false,
743                on_click: i,
744            })
745            .collect();
746        let _v = grid_view(GridSpec {
747            cells,
748            cols: 3,
749            metrics: metrics(),
750            caption: Some("7 imágenes".into()),
751            truncated_hint: None,
752            palette: GridPalette::default(),
753        });
754    }
755}
756
757#[cfg(test)]
758mod pruebas_paleta {
759    use super::*;
760
761    /// **La celda sin fondo no pinta cartón y marca con borde.** Es el modo que
762    /// usa la grilla del explorador; lo que se afirma es que `bg_cell` queda
763    /// transparente (o el pedido «que el fondo sea transparente» no se cumple),
764    /// que el cursor y la marca siguen distinguiéndose por el teñido, y que la
765    /// paleta de siempre **no cambió** — la usan el selector de archivos de
766    /// mirada y la grilla de media, que no pidieron nada.
767    #[test]
768    fn sin_fondo_no_pinta_celda_y_marca_con_borde() {
769        let t = llimphi_theme::Theme::dark();
770        let sf = GridPalette::sin_fondo(&t);
771        assert_eq!(sf.bg_cell.components[3], 0.0, "la celda tiene que quedar transparente");
772        assert!(sf.bg_selected.components[3] > sf.bg_marked.components[3], "cursor vs marca");
773        assert!(sf.radio > 0.0, "sin radio el teñido se lee como una caja");
774        let (ancho, _) = sf.borde_sel.expect("la celda elegida lleva borde");
775        assert!(ancho > 0.0);
776
777        // La de siempre, intacta: fondo de panel alternativo y sin borde.
778        let vieja = GridPalette::from_theme(&t);
779        assert_eq!(vieja.bg_cell, t.bg_panel_alt);
780        assert_eq!(vieja.radio, 0.0);
781        assert!(vieja.borde_sel.is_none());
782    }
783}