tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
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}