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-detail-table` — la vista **detalle** de un file manager.
2//!
3//! Una grilla read-only de columnas (nombre · tamaño · fecha · tipo…) con
4//! **encabezados clicables que ordenan**: click en una columna emite
5//! `on_sort(col)`; la columna activa muestra una flecha `▲`/`▼`. Cada fila
6//! es clicable (selección) y opcionalmente lleva un tinte de acento (para
7//! labels/colores, Fase 4.5).
8//!
9//! Como el resto de los widgets Llimphi es **render-only y stateless**: el
10//! orden, el filtro y la selección viven en el `Model` del caller (típicamente
11//! un `nahual_source_core::Navigator`); el widget recibe las filas **ya
12//! ordenadas y ya filtradas** (igual que `widget-list` recibe sólo la ventana
13//! visible) y sólo pinta + avisa.
14//!
15//! Las columnas declaran su ancho como [`ColWidth::Flex`] (reparte el sobrante
16//! proporcionalmente — para la columna nombre) o [`ColWidth::Fixed`] (px
17//! constantes — para tamaño/fecha/tipo). Encabezado y filas usan el MISMO
18//! reparto, así que las columnas quedan alineadas.
19//!
20//! ```ignore
21//! detail_table_view(
22//! DetailSpec {
23//! columns: &[Column::flex("Nombre", 1.0), Column::fixed("Tamaño", 90.0).right(),
24//! Column::fixed("Modificado", 150.0), Column::fixed("Tipo", 80.0)],
25//! rows, // ya ordenadas/filtradas por el caller
26//! sort: Some((1, SortDir::Desc)),
27//! row_height: 22.0,
28//! caption: Some("42 entradas".into()),
29//! palette: DetailPalette::from_theme(&theme),
30//! },
31//! Msg::SortBy, // Fn(usize) -> Msg
32//! )
33//! ```
34
35#![forbid(unsafe_code)]
36
37
38// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
39// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
40// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
41#![no_std]
42
43extern crate alloc;
44
45use alloc::string::{String, ToString};
46use alloc::vec::Vec;
47use alloc::vec;
48use alloc::format;
49use core::cmp::Ordering;
50use llimphi_compositor::llimphi_layout::taffy::{
51 prelude::{length, percent, Dimension, FlexDirection, Size, Style},
52 AlignItems, JustifyContent, Rect,
53};
54use llimphi_compositor::peniko::Color;
55use llimphi_compositor::llimphi_text::Alignment;
56use llimphi_compositor::View;
57
58/// Ancho de una columna.
59#[derive(Debug, Clone, Copy)]
60pub enum ColWidth {
61 /// Reparte el sobrante proporcionalmente al peso (la columna "nombre").
62 Flex(f32),
63 /// Ancho fijo en px (tamaño/fecha/tipo).
64 Fixed(f32),
65 /// Flexible **con piso** `(peso, mínimo_px)`: reparte el sobrante como
66 /// [`Self::Flex`], pero no baja del mínimo mientras haya columnas
67 /// [`Self::Yield`] que puedan ceder. Es la columna que tiene prioridad —el
68 /// nombre de un archivo—: con [`Self::Flex`] a secas, angostar la tabla se
69 /// comía el nombre antes que el tamaño, porque las fijas no ceden nunca.
70 FlexMin(f32, f32),
71 /// Ancho preferido en px que **cede** cuando no hay lugar, hasta cero
72 /// (recortado). Lo que se ve de pasada —el tamaño de un archivo— cuando el
73 /// nombre es lo que importa.
74 Yield(f32),
75 /// Como [`Self::Yield`], **con prioridad**: `(px preferidos, peso)`. Cuando
76 /// falta lugar, las que ceden se angostan en proporción a su peso, así que
77 /// la de peso alto **se esconde entera** antes de que la de peso bajo
78 /// pierda un píxel. Pedido textual (2026-10-12): *«hay un nombre siempre
79 /// que debe tener prioridad de visión, y otras tal vez se esconda toda la
80 /// columna»*. `Yield(px)` es `Cede(px, 1.0)`.
81 Cede(f32, f32),
82}
83
84/// Dirección de orden — sólo para pintar la flecha del encabezado.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum SortDir {
87 Asc,
88 Desc,
89}
90
91impl SortDir {
92 /// La flecha del encabezado activo.
93 fn arrow(self) -> &'static str {
94 match self {
95 SortDir::Asc => " ▲",
96 SortDir::Desc => " ▼",
97 }
98 }
99}
100
101/// Una columna de la grilla: rótulo + ancho + alineación del texto.
102#[derive(Debug, Clone)]
103pub struct Column {
104 pub title: String,
105 pub width: ColWidth,
106 pub align: Alignment,
107 /// **El nombre largo**, cuando el rótulo es corto o un glifo: va al
108 /// tooltip y al nombre accesible del encabezado. Existe porque una columna
109 /// de íconos no se deja angostar si su encabezado dice «Estado» — el
110 /// rótulo pasa a ser un glifo y la palabra queda acá.
111 pub ayuda: Option<String>,
112}
113
114impl Column {
115 /// Columna flexible (reparte sobrante). Alineada a la izquierda.
116 pub fn flex(title: impl Into<String>, weight: f32) -> Self {
117 Self { title: title.into(), width: ColWidth::Flex(weight), align: Alignment::Start, ayuda: None }
118 }
119
120 /// Columna de ancho fijo. Alineada a la izquierda.
121 pub fn fixed(title: impl Into<String>, px: f32) -> Self {
122 Self { title: title.into(), width: ColWidth::Fixed(px), align: Alignment::Start, ayuda: None }
123 }
124
125 /// Columna flexible **con prioridad**: no baja de `min` px mientras las
126 /// [`ColWidth::Yield`] puedan ceder (ver [`ColWidth::FlexMin`]).
127 pub fn flex_min(title: impl Into<String>, weight: f32, min: f32) -> Self {
128 Self { title: title.into(), width: ColWidth::FlexMin(weight, min), align: Alignment::Start, ayuda: None }
129 }
130
131 /// Columna de `px` preferidos que cede cuando falta lugar (ver
132 /// [`ColWidth::Yield`]).
133 pub fn yielding(title: impl Into<String>, px: f32) -> Self {
134 Self { title: title.into(), width: ColWidth::Yield(px), align: Alignment::Start, ayuda: None }
135 }
136
137 /// Columna que cede con prioridad (ver [`ColWidth::Cede`]): `peso` alto =
138 /// se esconde primero.
139 pub fn cede(title: impl Into<String>, px: f32, peso: f32) -> Self {
140 Self { title: title.into(), width: ColWidth::Cede(px, peso), align: Alignment::Start, ayuda: None }
141 }
142
143 /// Variante alineada a la derecha (números: tamaño).
144 pub fn right(mut self) -> Self {
145 self.align = Alignment::End;
146 self
147 }
148
149 /// El nombre largo de la columna (ver [`Column::ayuda`]).
150 pub fn con_ayuda(mut self, ayuda: impl Into<String>) -> Self {
151 self.ayuda = Some(ayuda.into());
152 self
153 }
154}
155
156/// Una fila de datos. `cells` se aparea posicionalmente con las columnas;
157/// celdas de más se ignoran, de menos se pintan vacías.
158pub struct DetailRow<Msg> {
159 pub cells: Vec<String>,
160 pub selected: bool,
161 /// Tinte de fila opcional (labels/colores, Fase 4.5). `None` = sin tinte.
162 pub accent: Option<Color>,
163 /// Ícono vectorial líder opcional para la columna nombre (columna 0). Un
164 /// `View<Msg>` ya construido por el caller (p.ej. `spec_view`); el widget lo
165 /// coloca antes del texto de esa celda y deja el sitio. `None` = sin ícono
166 /// (comportamiento previo intacto). Aditivo.
167 pub icon: Option<View<Msg>>,
168 pub on_click: Msg,
169}
170
171/// Paleta de la grilla detalle.
172#[derive(Debug, Clone, Copy)]
173pub struct DetailPalette {
174 pub bg_panel: Color,
175 /// Fondo de las filas **impares** (zebra). Por defecto igual a `bg_panel`
176 /// → zebra invisible (sin cambio). El caller la enciende con un tinte:
177 /// `let mut p = DetailPalette::from_theme(t); p.bg_row_alt = t.bg_panel_alt;`
178 pub bg_row_alt: Color,
179 pub bg_header: Color,
180 pub bg_selected: Color,
181 pub bg_hover: Color,
182 pub fg_text: Color,
183 pub fg_muted: Color,
184 pub fg_header: Color,
185 pub accent: Color,
186 pub border: Color,
187}
188
189impl Default for DetailPalette {
190 fn default() -> Self {
191 Self::from_theme(&llimphi_theme::Theme::dark())
192 }
193}
194
195impl DetailPalette {
196 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
197 Self {
198 bg_panel: t.bg_panel,
199 // Zebra apagada por defecto (= bg_panel) para no cambiar el look de
200 // los 36 callers; se enciende poniendo un tinte en el campo.
201 bg_row_alt: t.bg_panel,
202 bg_header: t.bg_panel_alt,
203 bg_selected: t.bg_selected,
204 bg_hover: t.bg_row_hover,
205 fg_text: t.fg_text,
206 fg_muted: t.fg_muted,
207 fg_header: t.fg_placeholder,
208 accent: t.accent,
209 border: t.border,
210 }
211 }
212}
213
214/// Especificación de la grilla. Las `rows` vienen YA ordenadas y filtradas
215/// por el caller; `sort` es sólo para la flecha del encabezado.
216pub struct DetailSpec<'a, Msg> {
217 pub columns: &'a [Column],
218 pub rows: Vec<DetailRow<Msg>>,
219 /// Columna activa de orden + su dirección (para la flecha). `None` = sin
220 /// indicador.
221 pub sort: Option<(usize, SortDir)>,
222 pub row_height: f32,
223 pub caption: Option<String>,
224 pub palette: DetailPalette,
225}
226
227/// El publicador del alto medido **es el del crate `scroll`** — este alias
228/// existe sólo para no romper el nombre con el que salió ayer. La pieza vive
229/// allá porque `list` y `tree` la necesitan igual, y tres copias de un átomo
230/// que publica un alto son tres formas de que se desincronicen.
231pub use llimphi_widget_scroll::{offset_efectivo, AltoVisible as AltoCuerpo};
232
233/// Scroll interno del cuerpo, con el encabezado **fijo**.
234pub struct DetailScroll<Msg> {
235 /// Desplazamiento vertical actual (px). El host lo clampea con
236 /// `llimphi_widget_scroll::clamp_offset`.
237 pub offset: f32,
238 /// Alto visible del cuerpo. `0.0` = todavía no se sabe (primer cuadro): se
239 /// pinta sin recortar. Sale de [`AltoCuerpo`] o del propio layout del host.
240 pub viewport_h: f32,
241 /// Rueda / arrastre de la barra: delta en px a sumar al `offset`.
242 pub on_scroll: alloc::sync::Arc<dyn Fn(f32) -> Msg + Send + Sync>,
243}
244
245/// Lo que [`detail_table_view`] no ofrece y [`detail_table_view_ext`] sí.
246/// `Default` es la tabla de siempre.
247pub struct DetailOpciones<Msg> {
248 /// Encabezado fijo + cuerpo scrolleable.
249 pub scroll: Option<DetailScroll<Msg>>,
250 /// Dónde publicar el alto medido del cuerpo.
251 pub medidor: Option<AltoCuerpo>,
252 /// Handles de resize entre columnas: `on_resize(col, delta_px)` mientras se
253 /// arrastra el borde derecho del encabezado de `col`. El host aplica
254 /// [`resize_col`] y guarda el ancho nuevo.
255 pub on_resize: Option<alloc::sync::Arc<dyn Fn(usize, f32) -> Msg + Send + Sync>>,
256 /// **Reordenar columnas por arrastre**: `Some` → cada encabezado se arrastra
257 /// y al soltarlo sobre otro emite `on_reorder(from, to)` (índices dentro del
258 /// `columns` que se le pasó). `None` → columnas fijas.
259 ///
260 /// Mismo patrón que `tabs` y `reorderable_list_view`: el encabezado es a la
261 /// vez fuente de drag (payload = su índice) y destino de drop. El click
262 /// sigue ordenando — el runtime arbitra press-sin-mover = click,
263 /// press-con-mover = drag. El host aplica [`mover_columna`] a su orden y lo
264 /// guarda; el widget no tiene estado.
265 pub on_reorder: Option<alloc::sync::Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>>,
266 /// Qué **posición** ocupa la columna principal (la del nombre): la que
267 /// lleva el ícono líder de [`DetailRow::icon`] y el color pleno/acento del
268 /// texto. Default `0`, que es donde estuvo siempre.
269 ///
270 /// Existe por [`Self::on_reorder`]: si el usuario arrastra el nombre al
271 /// medio, el ícono del archivo se quedaría colgado de la columna «tamaño»
272 /// —el widget no sabe qué significa cada columna, sólo dónde está—. El host,
273 /// que sí lo sabe, dice dónde quedó.
274 pub col_nombre: usize,
275 /// **Una celda pintada por el host**, en lugar del texto de
276 /// [`DetailRow::cells`]: `celda_vista(fila, columna)` devuelve la vista de
277 /// esa celda, o `None` para que vaya el texto de siempre.
278 ///
279 /// Existe porque hay columnas que **no son texto**. El estado de algo que
280 /// se está compilando es un ícono y no la palabra «compilando», y la
281 /// columna que ofrece el gesto de la fila es un botón. Sin esto, el único
282 /// dibujo que una tabla podía tener era el ícono líder de la columna
283 /// nombre ([`DetailRow::icon`]): uno por fila y siempre en el mismo lugar.
284 ///
285 /// Va en las opciones y no en [`DetailRow`] —que sería el lugar natural—
286 /// porque `DetailRow` se construye con literal en los dieciséis llamadores
287 /// de la suite, y un campo más los rompe a los dieciséis. Acá es aditivo:
288 /// [`DetailOpciones`] tiene `Default` y todos la usan con
289 /// `..Default::default()`.
290 ///
291 /// ⚠️ El host devuelve la vista **ya dimensionada**: la celda la centra
292 /// (`align_items: CENTER`) y no le impone tamaño. Un `paint_with` absoluto
293 /// —como el de `llimphi_icons::icon_view`— necesita una caja con lado
294 /// propio alrededor, o se escala al alto entero de la fila.
295 pub celda_vista:
296 Option<alloc::sync::Arc<dyn Fn(usize, usize) -> Option<View<Msg>> + Send + Sync>>,
297 /// **Botón derecho sobre una fila**: `on_context(fila, x, y)` con la
298 /// posición en **pantalla**, que es lo que un menú contextual necesita para
299 /// no salirse del borde.
300 ///
301 /// Una vista detalle sin menú del derecho obliga a poner cada acción como
302 /// un control adentro de una celda, y ahí la tabla deja de ser una tabla.
303 /// `None` —el default— es la tabla de siempre.
304 pub on_context: Option<alloc::sync::Arc<dyn Fn(usize, f32, f32) -> Option<Msg> + Send + Sync>>,
305 /// **Márgenes chicos**: 4 px a los lados y entre columnas en vez de 8.
306 /// Para la tabla que vive en un rail angosto, donde 8 px por hueco son
307 /// un nombre de archivo menos.
308 pub compacta: bool,
309}
310
311impl<Msg> Default for DetailOpciones<Msg> {
312 fn default() -> Self {
313 Self {
314 scroll: None,
315 medidor: None,
316 on_resize: None,
317 on_reorder: None,
318 col_nombre: 0,
319 celda_vista: None,
320 on_context: None,
321 compacta: false,
322 }
323 }
324}
325
326/// Ancho (px) de la zona agarrable del handle de resize. Angosto para no
327/// tapar el título, pero no de un píxel: eso no se puede agarrar.
328const HANDLE_W: f32 = 6.0;
329
330
331/// Compone la grilla detalle. `on_sort(col)` se emite al clickear un
332/// encabezado.
333pub fn detail_table_view<Msg, FSort>(spec: DetailSpec<Msg>, on_sort: FSort) -> View<Msg>
334where
335 Msg: Clone + 'static,
336 FSort: Fn(usize) -> Msg + Clone + 'static,
337{
338 detail_table_view_con(spec, on_sort, None, DetailOpciones::default())
339}
340
341/// [`detail_table_view`] con [`DetailOpciones`]: encabezado fijo con cuerpo
342/// scrolleable y handles de resize entre columnas. Con
343/// `DetailOpciones::default()` es la tabla de siempre.
344pub fn detail_table_view_ext<Msg, FSort>(
345 spec: DetailSpec<Msg>,
346 on_sort: FSort,
347 opciones: DetailOpciones<Msg>,
348) -> View<Msg>
349where
350 Msg: Clone + 'static,
351 FSort: Fn(usize) -> Msg + Clone + 'static,
352{
353 detail_table_view_con(spec, on_sort, None, opciones)
354}
355
356/// [`detail_table_view`] con **arrastre y soltado** por fila (mismo `RowDnd`
357/// que la lista y la grilla). Con `dnd = None` es la tabla de siempre.
358pub fn detail_table_view_dnd<Msg, FSort>(
359 spec: DetailSpec<Msg>,
360 on_sort: FSort,
361 dnd: Option<llimphi_widget_list::RowDnd<Msg>>,
362) -> View<Msg>
363where
364 Msg: Clone + Send + Sync + 'static,
365 FSort: Fn(usize) -> Msg + Clone + 'static,
366{
367 match dnd {
368 Some(d) => detail_table_view_con(
369 spec,
370 on_sort,
371 Some(&move |v, i, click| llimphi_widget_list::con_dnd(v, i, click, &d)),
372 DetailOpciones::default(),
373 ),
374 None => detail_table_view_con(spec, on_sort, None, DetailOpciones::default()),
375 }
376}
377
378/// [`detail_table_view_dnd`] **más las opciones**: arrastre por fila y, por
379/// ejemplo, el menú del botón derecho, que son cosas que una vista detalle de
380/// verdad tiene a la vez. Existe como función aparte y no como un campo de
381/// [`DetailOpciones`] porque el arrastre exige `Msg: Send + Sync` y meterlo
382/// adentro se lo exigiría a toda tabla, incluidas las que no arrastran nada.
383pub fn detail_table_view_dnd_ext<Msg, FSort>(
384 spec: DetailSpec<Msg>,
385 on_sort: FSort,
386 opciones: DetailOpciones<Msg>,
387 dnd: Option<llimphi_widget_list::RowDnd<Msg>>,
388) -> View<Msg>
389where
390 Msg: Clone + Send + Sync + 'static,
391 FSort: Fn(usize) -> Msg + Clone + 'static,
392{
393 match dnd {
394 Some(d) => detail_table_view_con(
395 spec,
396 on_sort,
397 Some(&move |v, i, click| llimphi_widget_list::con_dnd(v, i, click, &d)),
398 opciones,
399 ),
400 None => detail_table_view_con(spec, on_sort, None, opciones),
401 }
402}
403
404fn detail_table_view_con<Msg, FSort>(
405 spec: DetailSpec<Msg>,
406 on_sort: FSort,
407 deco: Option<llimphi_widget_list::FilaDeco<'_, Msg>>,
408 opciones: DetailOpciones<Msg>,
409) -> View<Msg>
410where
411 Msg: Clone + 'static,
412 FSort: Fn(usize) -> Msg + Clone + 'static,
413{
414 let DetailOpciones {
415 scroll,
416 medidor,
417 on_resize,
418 on_reorder,
419 col_nombre,
420 celda_vista,
421 on_context,
422 compacta,
423 } = opciones;
424 // Margen a los lados de la fila y hueco entre columnas: el mismo número,
425 // así el borde de la primera columna queda donde quedaría un hueco.
426 let gap = if compacta { 4.0 } else { 8.0 };
427 let DetailSpec { columns, rows, sort, row_height, caption, palette } = spec;
428
429 let mut children: Vec<View<Msg>> = Vec::with_capacity(rows.len() + 2);
430
431 // Caption opcional (conteo).
432 if let Some(text) = caption {
433 children.push(
434 View::new(Style {
435 size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
436 padding: pad_lr(10.0),
437 align_items: Some(AlignItems::CENTER),
438 ..Default::default()
439 })
440 .text_aligned(text, 10.0, palette.fg_muted, Alignment::Start),
441 );
442 }
443
444 // Encabezado: una celda clicable por columna.
445 let header_cells: Vec<View<Msg>> = columns
446 .iter()
447 .enumerate()
448 .map(|(i, col)| {
449 let activa = sort.map(|(c, _)| c == i).unwrap_or(false);
450 let flecha = match sort {
451 Some((c, dir)) if c == i => dir.arrow(),
452 _ => "",
453 };
454 let label = format!("{}{flecha}", col.title);
455 // La columna ordenada resalta con el fg pleno; el resto queda
456 // apagado. (Antes ambas ramas eran `fg_header` — un `if` no-op
457 // que fingía destacar la columna activa y no lo hacía.)
458 let fg = if activa { palette.fg_text } else { palette.fg_header };
459 let contenido: Vec<View<Msg>> = match &on_resize {
460 // El handle va DENTRO de la celda, pegado a su borde derecho.
461 // La última columna no lleva: arrastrar el borde de la tabla no
462 // redimensiona nada, sólo daría un agarre que no hace nada.
463 Some(f) if i + 1 < columns.len() => vec![
464 View::new(full())
465 .text_aligned(label, 10.5, fg, col.align)
466 .ellipsis(1),
467 handle_resize(i, gap, f.clone(), &palette),
468 ],
469 _ => vec![View::new(full())
470 .text_aligned(label, 10.5, fg, col.align)
471 .ellipsis(1)],
472 };
473 let celda = col_cell(
474 col.width,
475 View::new(Style {
476 size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
477 align_items: Some(AlignItems::CENTER),
478 ..Default::default()
479 })
480 .children(contenido),
481 )
482 .hover_fill(palette.bg_hover)
483 // El label lleva el título limpio: la flecha ▲/▼ es ruido para
484 // un lector, y el sentido del orden viaja en `value`. Si la
485 // columna trae su nombre largo, ése: el rótulo puede ser un glifo.
486 .role(llimphi_compositor::Role::ColumnHeader)
487 .aria_label(col.ayuda.clone().unwrap_or_else(|| col.title.clone()))
488 .aria_value(match sort {
489 Some((c, dir)) if c == i => {
490 if dir.arrow() == "▲" { "ascendente" } else { "descendente" }
491 }
492 _ => "sin ordenar",
493 })
494 .on_click(on_sort(i));
495 let celda = match &col.ayuda {
496 Some(a) => celda.tooltip(a.clone()),
497 None => celda,
498 };
499 // Reordenar por arrastre: el encabezado es fuente (payload = su
500 // índice) y destino de drop. Va DESPUÉS del `on_click` del orden a
501 // propósito: los dos conviven porque el runtime arbitra
502 // press-sin-mover = click, press-con-mover = drag.
503 match &on_reorder {
504 Some(f) => {
505 let f = f.clone();
506 let destino = i;
507 celda
508 .draggable(|_fase: llimphi_compositor::DragPhase, _dx: f32, _dy: f32| None)
509 .drag_payload(i as u64)
510 .drop_hover_fill(palette.bg_hover)
511 .on_drop(move |origen: u64| {
512 let origen = origen as usize;
513 if origen == destino {
514 None
515 } else {
516 Some((f)(origen, destino))
517 }
518 })
519 .cursor(llimphi_compositor::Cursor::Grab)
520 }
521 None => celda,
522 }
523 })
524 .collect();
525 let header = row_box(header_height(row_height), gap)
526 .fill(palette.bg_header)
527 .role(llimphi_compositor::Role::Row)
528 .children(header_cells);
529 // Con scroll el encabezado queda AFUERA del cuerpo desplazado — eso es
530 // todo el «sticky»: no se pega con un truco de posición, simplemente no
531 // viaja. Sin scroll va como una fila más, igual que siempre.
532 let header = if scroll.is_some() {
533 Some(header)
534 } else {
535 children.push(header);
536 None
537 };
538 let filas_desde = children.len();
539
540 // **Con scroll, sólo se montan las filas de la ventana visible.** El cuerpo
541 // conserva el alto de TODAS (la barra y el clamp no cambian) y un separador
542 // arriba ocupa lo que quedó fuera. Sin esto una tabla de cuatrocientas
543 // filas medía cuatrocientas filas de texto con parley en cada cuadro —el
544 // mismo síntoma que el gestor de shuma midió en 12,9 s/cuadro con 310
545 // tarjetas—, aunque se vieran treinta. Con `viewport_h == 0` (primer cuadro,
546 // todavía sin medir) se monta todo, como siempre.
547 let total_filas = rows.len();
548 let ventana = match &scroll {
549 Some(s) => ventana_visible(total_filas, row_height, s.offset, s.viewport_h),
550 None => 0..total_filas,
551 };
552
553 // Filas de datos.
554 for (ri, row) in rows.into_iter().enumerate() {
555 if !ventana.contains(&ri) {
556 continue;
557 }
558 let DetailRow { cells, selected, accent, icon, on_click } = row;
559 // Zebra: filas impares con bg_row_alt (= bg_panel salvo que el caller
560 // lo tiña). La selección gana sobre la zebra.
561 let bg = if selected {
562 palette.bg_selected
563 } else if ri % 2 == 1 {
564 palette.bg_row_alt
565 } else {
566 palette.bg_panel
567 };
568 // Ícono líder de la columna nombre (se consume una sola vez, en i==0).
569 let mut icon = icon;
570 let icon_px = (row_height * 0.72).clamp(13.0, 18.0);
571 // Una vista por celda, si el host la da. El `Arc` se clona por fila
572 // porque el cierre de abajo es `move`.
573 let cv = celda_vista.clone();
574 let cell_views: Vec<View<Msg>> = columns
575 .iter()
576 .enumerate()
577 .map(move |(i, col)| {
578 // Lo que el host pinta gana sobre el texto: es para las
579 // columnas que no son texto (un ícono de estado, un botón).
580 if let Some(f) = cv.as_ref() {
581 if let Some(v) = f(ri, i) {
582 // **Una celda de datos recorta siempre**: un
583 // interruptor o un botón en una columna angostada se
584 // montaba sobre la de al lado (reporte del operador,
585 // 2026-10-12: «que no se sobrepongan»). El encabezado
586 // no recorta: su handle de resize vive en el hueco.
587 return col_cell(col.width, v).clip(true);
588 }
589 }
590 let text = cells.get(i).cloned().unwrap_or_default();
591 // La primera columna (nombre) lleva el acento si hay; el resto
592 // va en fg_muted salvo el nombre que va en fg_text.
593 let fg = if i == col_nombre {
594 accent.unwrap_or(palette.fg_text)
595 } else {
596 palette.fg_muted
597 };
598 let texto = View::new(full())
599 .text_aligned(text, 11.5, fg, col.align)
600 .ellipsis(1);
601 // Columna nombre con ícono: [ícono] etiqueta, en una fila.
602 if i == col_nombre {
603 if let Some(ic) = icon.take() {
604 let caja = View::new(Style {
605 size: Size { width: length(icon_px), height: length(icon_px) },
606 flex_shrink: 0.0,
607 ..Default::default()
608 })
609 .children(vec![ic]);
610 let fila = View::new(Style {
611 flex_direction: FlexDirection::Row,
612 align_items: Some(AlignItems::CENTER),
613 gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
614 size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
615 ..Default::default()
616 })
617 .children(vec![caja, texto]);
618 return col_cell(col.width, fila).clip(true);
619 }
620 }
621 col_cell(col.width, texto).clip(true)
622 })
623 .collect();
624 let mut fila = row_box(row_height, gap)
625 .fill(bg)
626 .hover_fill(palette.bg_hover)
627 .role(llimphi_compositor::Role::Row)
628 .aria_checked(selected)
629 .on_click(on_click.clone())
630 .children(cell_views);
631 // El derecho, si el host lo pidió. Va acá y no en un control adentro de
632 // una celda: una tabla con botones en las celdas deja de ser una tabla.
633 if let Some(cb) = on_context.clone() {
634 fila = fila.on_right_click_screen(move |x, y, _w, _h| cb(ri, x, y));
635 }
636 children.push(match deco {
637 Some(f) => f(fila, ri, on_click),
638 None => fila,
639 });
640 }
641
642 // Cuerpo scrolleable: las filas salen del árbol plano y entran al widget
643 // de scroll canónico (rueda, barra arrastrable, clamp y fling ya viven
644 // ahí — reimplementarlos acá sería un scroll paralelo que se desincroniza).
645 if let Some(DetailScroll { offset, viewport_h, on_scroll }) = scroll {
646 let mut filas: Vec<View<Msg>> = children.split_off(filas_desde);
647 let contenido_h = total_filas as f32 * row_height;
648 if ventana.start > 0 {
649 filas.insert(
650 0,
651 View::new(Style {
652 size: Size {
653 width: percent(1.0_f32),
654 height: length(ventana.start as f32 * row_height),
655 },
656 flex_shrink: 0.0,
657 ..Default::default()
658 }),
659 );
660 }
661 let cuerpo = View::new(Style {
662 flex_direction: FlexDirection::Column,
663 size: Size { width: percent(1.0_f32), height: length(contenido_h) },
664 ..Default::default()
665 })
666 .children(filas);
667 // La barra sigue los colores de LA TABLA, no un tema fijo: una tabla
668 // teñida por el caller con una barra de otro theme se nota.
669 let pal_scroll = llimphi_widget_scroll::ScrollPalette {
670 track: palette.bg_panel,
671 thumb: palette.border,
672 thumb_hover: palette.accent,
673 ..Default::default()
674 };
675 let caja = llimphi_widget_scroll::cuerpo_scrolleable(
676 cuerpo,
677 contenido_h,
678 llimphi_widget_scroll::DesplazamientoV {
679 offset,
680 viewport_len: viewport_h,
681 on_scroll,
682 },
683 medidor,
684 &pal_scroll,
685 );
686 children.extend(header);
687 children.push(caja);
688 }
689
690 View::new(Style {
691 flex_direction: FlexDirection::Column,
692 size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
693 padding: Rect {
694 left: length(0.0_f32),
695 right: length(0.0_f32),
696 top: length(0.0_f32),
697 bottom: length(6.0_f32),
698 },
699 ..Default::default()
700 })
701 .fill(palette.bg_panel)
702 .clip(true)
703 .role(llimphi_compositor::Role::Table)
704 .children(children)
705}
706
707/// Filas de más que se montan a cada lado de la ventana, para que la rueda no
708/// muestre un borde en blanco antes del cuadro siguiente.
709const OVERSCAN_FILAS: usize = 8;
710
711/// Qué filas caen en la ventana visible del cuerpo (más el overscan).
712/// `viewport_h <= 0` = todavía sin medir: todas.
713pub fn ventana_visible(
714 total: usize,
715 row_height: f32,
716 offset: f32,
717 viewport_h: f32,
718) -> core::ops::Range<usize> {
719 if viewport_h <= 0.0 || row_height <= 0.0 {
720 return 0..total;
721 }
722 let primera = (offset.max(0.0) / row_height) as usize;
723 let visibles = (viewport_h / row_height) as usize + 2;
724 let desde = primera.saturating_sub(OVERSCAN_FILAS).min(total);
725 let hasta = (primera + visibles + OVERSCAN_FILAS).min(total);
726 desde..hasta
727}
728
729/// Agarre de resize del borde derecho de un encabezado. Lleva `draggable` y
730/// **no** `on_click`: el hit-test elige el nodo más interno con handler, así
731/// que agarrarlo no dispara el orden de la columna de abajo.
732fn handle_resize<Msg: Clone + 'static>(
733 col: usize,
734 gap: f32,
735 on_resize: alloc::sync::Arc<dyn Fn(usize, f32) -> Msg + Send + Sync>,
736 palette: &DetailPalette,
737) -> View<Msg> {
738 View::new(Style {
739 position: llimphi_compositor::llimphi_layout::taffy::prelude::Position::Absolute,
740 inset: Rect {
741 left: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
742 // Centrado en el HUECO entre las dos columnas, que es donde el ojo
743 // ve el borde: pegado al canto derecho de la celda quedaba medio
744 // hueco más medio handle a la izquierda (reporte del operador:
745 // *«las rayitas de resize columna está saliendo más a la izquierda
746 // de lo que es»*).
747 right: length(-(gap * 0.5 + HANDLE_W * 0.5)),
748 top: length(0.0_f32),
749 bottom: length(0.0_f32),
750 },
751 size: Size { width: length(HANDLE_W), height: percent(1.0_f32) },
752 ..Default::default()
753 })
754 // Sin fill propio: se ve el encabezado. Al hover aparece la línea, que es
755 // la señal de "esto se agarra".
756 .hover_fill(palette.accent)
757 .cursor(llimphi_compositor::Cursor::ColResize)
758 .role(llimphi_compositor::Role::Splitter)
759 .aria_label(format!("Ancho de la columna {}", col + 1))
760 .draggable(move |_phase, dx, _dy| Some((on_resize)(col, dx)))
761}
762
763/// Alto del encabezado: como una fila pero un toque más bajo, con piso.
764fn header_height(row_height: f32) -> f32 {
765 (row_height - 2.0).max(18.0)
766}
767
768/// Una fila horizontal de alto fijo (encabezado o registro). El caller le
769/// agrega `.fill`/`.on_click`/`.children`.
770fn row_box<Msg: Clone + 'static>(height: f32, margen: f32) -> View<Msg> {
771 View::new(Style {
772 flex_direction: FlexDirection::Row,
773 size: Size { width: percent(1.0_f32), height: length(height) },
774 padding: pad_lr(margen),
775 align_items: Some(AlignItems::CENTER),
776 gap: Size { width: length(margen), height: length(0.0_f32) },
777 ..Default::default()
778 })
779}
780
781/// Envuelve el contenido de una celda con el ancho de la columna (flex o
782/// fijo). Encabezado y registro usan esto idéntico → columnas alineadas.
783fn col_cell<Msg: Clone + 'static>(width: ColWidth, child: View<Msg>) -> View<Msg> {
784 let style = match width {
785 ColWidth::Flex(w) => Style {
786 flex_grow: w,
787 flex_basis: length(0.0_f32),
788 min_size: Size { width: length(0.0_f32), height: Dimension::auto() },
789 size: Size { width: Dimension::auto(), height: percent(1.0_f32) },
790 align_items: Some(AlignItems::CENTER),
791 ..Default::default()
792 },
793 ColWidth::Fixed(px) => Style {
794 flex_shrink: 0.0,
795 size: Size { width: length(px), height: percent(1.0_f32) },
796 align_items: Some(AlignItems::CENTER),
797 ..Default::default()
798 },
799 ColWidth::FlexMin(w, min) => Style {
800 flex_grow: w,
801 flex_basis: length(0.0_f32),
802 flex_shrink: 0.0,
803 min_size: Size { width: length(min), height: Dimension::auto() },
804 size: Size { width: Dimension::auto(), height: percent(1.0_f32) },
805 align_items: Some(AlignItems::CENTER),
806 ..Default::default()
807 },
808 ColWidth::Yield(px) | ColWidth::Cede(px, _) => Style {
809 flex_shrink: match width {
810 ColWidth::Cede(_, peso) => peso.max(0.0),
811 _ => 1.0,
812 },
813 flex_basis: length(px),
814 min_size: Size { width: length(0.0_f32), height: Dimension::auto() },
815 size: Size { width: Dimension::auto(), height: percent(1.0_f32) },
816 align_items: Some(AlignItems::CENTER),
817 ..Default::default()
818 },
819 };
820 // Lo que cede se recorta: una cifra a medio ancho no se derrama sobre la
821 // columna de al lado.
822 let cede = matches!(width, ColWidth::Yield(_) | ColWidth::Cede(..));
823 // Celda de tabla. Los encabezados sobrescriben este rol con
824 // `ColumnHeader` (el último `.role()` gana).
825 View::new(style)
826 .clip(cede)
827 .role(llimphi_compositor::Role::Cell)
828 .children(vec![child])
829}
830
831/// Estilo de un hijo que ocupa todo el ancho de su celda.
832fn full() -> Style {
833 Style {
834 size: Size { width: percent(1.0_f32), height: Dimension::auto() },
835 ..Default::default()
836 }
837}
838
839/// Padding horizontal `px` (top/bottom en cero).
840fn pad_lr(px: f32) -> Rect<llimphi_compositor::llimphi_layout::taffy::LengthPercentage> {
841 Rect {
842 left: length(px),
843 right: length(px),
844 top: length(0.0_f32),
845 bottom: length(0.0_f32),
846 }
847}
848
849// ─────────────────────────────────────────────────────────────────────────
850// Navegación por teclado + resize de columnas (helpers puros, lado host)
851// ─────────────────────────────────────────────────────────────────────────
852
853/// Mueve la fila seleccionada con ↑/↓: índice siguiente **clampeado** a
854/// `[0, len-1]` (una DataTable no envuelve). `current == usize::MAX` (nada
855/// seleccionado) arranca por el extremo según la dirección. El host lo cablea
856/// en `update` y dispatcha su `Msg::Select(nuevo)`.
857pub fn step_row(len: usize, current: usize, direction: i32) -> usize {
858 if len == 0 {
859 return 0;
860 }
861 let last = len - 1;
862 if current == usize::MAX {
863 return if direction >= 0 { 0 } else { last };
864 }
865 (current as i64 + direction as i64).clamp(0, last as i64) as usize
866}
867
868/// Extremo para Home/End (PageUp/PageDown grandes): `0` o `len-1`.
869pub fn edge_row(len: usize, to_end: bool) -> usize {
870 if len == 0 {
871 0
872 } else if to_end {
873 len - 1
874 } else {
875 0
876 }
877}
878
879/// Redimensiona la columna `i` sumándole `delta` px a su ancho, con piso
880// ─────────────────────────────────────────────────────────────────────────
881// Orden — la tabla pintaba la flecha; acá está lo que la flecha promete
882// ─────────────────────────────────────────────────────────────────────────
883
884/// Cómo se comparan los valores de una columna.
885///
886/// No hay variante `Fecha` a propósito: una fecha **en ISO** (`2026-08-04`) se
887/// ordena bien como `Texto`, que es justo para lo que ese formato existe; una en
888/// `04/08/2026` no se ordena bien de ninguna forma barata, y para ésa está
889/// [`TipoCol::Propio`]. Inventar un parser de fechas acá sería un segundo
890/// parser distinto del que ya tiene el `datetime-picker`.
891#[derive(Clone)]
892pub enum TipoCol {
893 /// Alfabético, **ignorando mayúsculas** (nadie espera que `Zeta` venga
894 /// antes que `alfa`).
895 Texto,
896 /// Numérico. Lo que no parsea va al final, no al principio: una celda vacía
897 /// o un «—» no debería encabezar el orden.
898 Numero,
899 /// Alfanumérico **natural**: `archivo2` antes que `archivo10`. Es el que
900 /// quiere un explorador de archivos, que es de donde viene esta tabla.
901 Natural,
902 /// Comparador propio (fechas en formato local, unidades, prioridades…).
903 Propio(alloc::sync::Arc<dyn Fn(&str, &str) -> Ordering + Send + Sync>),
904}
905
906impl core::fmt::Debug for TipoCol {
907 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
908 match self {
909 TipoCol::Texto => write!(f, "Texto"),
910 TipoCol::Numero => write!(f, "Numero"),
911 TipoCol::Natural => write!(f, "Natural"),
912 TipoCol::Propio(_) => write!(f, "Propio(..)"),
913 }
914 }
915}
916
917/// Compara dos celdas según el tipo de su columna.
918pub fn comparar(a: &str, b: &str, tipo: &TipoCol) -> Ordering {
919 match tipo {
920 TipoCol::Propio(f) => f(a, b),
921 TipoCol::Texto => cmp_insensible(a, b),
922 TipoCol::Numero => match (a.trim().parse::<f64>(), b.trim().parse::<f64>()) {
923 (Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
924 // Lo que no es número va al FINAL en ascendente: un guion o un
925 // vacío encabezando la lista no es información, es ruido.
926 (Ok(_), Err(_)) => Ordering::Less,
927 (Err(_), Ok(_)) => Ordering::Greater,
928 (Err(_), Err(_)) => cmp_insensible(a, b),
929 },
930 TipoCol::Natural => cmp_natural(a, b),
931 }
932}
933
934/// Compara ignorando mayúsculas sin asignar (nada de `to_lowercase`, que
935/// construiría un `String` por celda y por comparación — en una tabla de mil
936/// filas eso son miles de asignaciones por click).
937fn cmp_insensible(a: &str, b: &str) -> Ordering {
938 let mut ia = a.chars().flat_map(|c| c.to_lowercase());
939 let mut ib = b.chars().flat_map(|c| c.to_lowercase());
940 loop {
941 match (ia.next(), ib.next()) {
942 (None, None) => return Ordering::Equal,
943 (None, Some(_)) => return Ordering::Less,
944 (Some(_), None) => return Ordering::Greater,
945 (Some(x), Some(y)) => match x.cmp(&y) {
946 Ordering::Equal => continue,
947 otro => return otro,
948 },
949 }
950 }
951}
952
953/// Orden natural: los tramos de dígitos se comparan por **valor**, así
954/// `archivo2` viene antes que `archivo10`. Sin asignar y sin convertir a
955/// entero (un tramo de cien dígitos desbordaría cualquier tipo): compara por
956/// largo del tramo sin ceros a la izquierda, y a igual largo, lexicográfico.
957fn cmp_natural(a: &str, b: &str) -> Ordering {
958 let (mut ia, mut ib) = (a.char_indices().peekable(), b.char_indices().peekable());
959 loop {
960 match (ia.peek().copied(), ib.peek().copied()) {
961 (None, None) => return Ordering::Equal,
962 (None, Some(_)) => return Ordering::Less,
963 (Some(_), None) => return Ordering::Greater,
964 (Some((pa, ca)), Some((pb, cb))) => {
965 if ca.is_ascii_digit() && cb.is_ascii_digit() {
966 let ta = tramo_digitos(a, pa);
967 let tb = tramo_digitos(b, pb);
968 let (na, nb) = (ta.trim_start_matches('0'), tb.trim_start_matches('0'));
969 let orden = na.len().cmp(&nb.len()).then_with(|| na.cmp(nb));
970 if orden != Ordering::Equal {
971 return orden;
972 }
973 // Mismo valor: seguir después del tramo en las dos.
974 while ia.peek().is_some_and(|(_, c)| c.is_ascii_digit()) {
975 ia.next();
976 }
977 while ib.peek().is_some_and(|(_, c)| c.is_ascii_digit()) {
978 ib.next();
979 }
980 } else {
981 let (la, lb) = (
982 ca.to_lowercase().next().unwrap_or(ca),
983 cb.to_lowercase().next().unwrap_or(cb),
984 );
985 match la.cmp(&lb) {
986 Ordering::Equal => {
987 ia.next();
988 ib.next();
989 }
990 otro => return otro,
991 }
992 }
993 }
994 }
995 }
996}
997
998/// El tramo de dígitos que arranca en `desde`.
999fn tramo_digitos(s: &str, desde: usize) -> &str {
1000 let fin = s[desde..]
1001 .find(|c: char| !c.is_ascii_digit())
1002 .map(|i| desde + i)
1003 .unwrap_or(s.len());
1004 &s[desde..fin]
1005}
1006
1007/// Qué pasa al clickear el encabezado de `col`: **asc → desc → sin orden**.
1008///
1009/// El tercer estado no es un capricho: sin él, una vez que ordenaste no podés
1010/// volver al orden en que venían los datos —que muchas veces **es** el orden
1011/// bueno (el de inserción, el del servidor)— y hay que recargar para
1012/// recuperarlo.
1013pub fn ciclar_orden(actual: Option<(usize, SortDir)>, col: usize) -> Option<(usize, SortDir)> {
1014 match actual {
1015 Some((c, SortDir::Asc)) if c == col => Some((col, SortDir::Desc)),
1016 Some((c, SortDir::Desc)) if c == col => None,
1017 // Otra columna (o ninguna): arranca ascendente.
1018 _ => Some((col, SortDir::Asc)),
1019 }
1020}
1021
1022/// El orden en que hay que mostrar las filas: una **permutación de índices**,
1023/// no las filas movidas.
1024///
1025/// Devolver índices y no filas es lo que permite que la tabla ordene sin
1026/// conocer el tipo del caller (`DetailRow` lleva un `Msg` y un `View`, que no
1027/// son ordenables ni clonables gratis) y sin que el caller pierda su orden
1028/// original — que es al que vuelve el tercer click.
1029///
1030/// El orden es **estable**: dos filas con la misma celda conservan su posición
1031/// relativa, así ordenar por «tipo» y después por «nombre» hace lo que la gente
1032/// espera.
1033pub fn orden_de_filas(
1034 celdas: &[Vec<String>],
1035 col: usize,
1036 dir: SortDir,
1037 tipo: &TipoCol,
1038) -> Vec<usize> {
1039 let mut idx: Vec<usize> = (0..celdas.len()).collect();
1040 let vacia = String::new();
1041 idx.sort_by(|&a, &b| {
1042 let va = celdas[a].get(col).unwrap_or(&vacia);
1043 let vb = celdas[b].get(col).unwrap_or(&vacia);
1044 let c = comparar(va, vb, tipo);
1045 match dir {
1046 SortDir::Asc => c,
1047 SortDir::Desc => c.reverse(),
1048 }
1049 });
1050 idx
1051}
1052
1053// ─────────────────────────────────────────────────────────────────────────
1054// Paginación
1055// ─────────────────────────────────────────────────────────────────────────
1056
1057/// Qué página se está viendo y de a cuántas filas. Vive en el `Model`.
1058#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1059pub struct Pagina {
1060 /// Índice base 0.
1061 pub indice: usize,
1062 /// Filas por página. `0` se trata como 1 (no hay página de cero filas).
1063 pub tamano: usize,
1064}
1065
1066impl Default for Pagina {
1067 fn default() -> Self {
1068 Self { indice: 0, tamano: 20 }
1069 }
1070}
1071
1072impl Pagina {
1073 pub fn nueva(tamano: usize) -> Self {
1074 Self { indice: 0, tamano: tamano.max(1) }
1075 }
1076
1077 fn tam(&self) -> usize {
1078 self.tamano.max(1)
1079 }
1080
1081 /// Cuántas páginas hay para `total` filas. Con cero filas hay **una**
1082 /// página (vacía), no cero: si no, el pie diría «página 1 de 0».
1083 pub fn total_paginas(&self, total: usize) -> usize {
1084 total.div_ceil(self.tam()).max(1)
1085 }
1086
1087 /// El rango de filas de esta página, ya acotado a `total`.
1088 pub fn rango(&self, total: usize) -> core::ops::Range<usize> {
1089 let ini = (self.indice.min(self.total_paginas(total) - 1)) * self.tam();
1090 let ini = ini.min(total);
1091 ini..(ini + self.tam()).min(total)
1092 }
1093
1094 /// La misma página con el índice acotado — para después de un filtro que
1095 /// dejó menos filas que antes y te dejó parado en la página 9 de 3.
1096 pub fn acotada(self, total: usize) -> Self {
1097 Self { indice: self.indice.min(self.total_paginas(total) - 1), tamano: self.tam() }
1098 }
1099
1100 /// «21–40 de 137» / «Sin filas».
1101 pub fn etiqueta(&self, total: usize) -> String {
1102 if total == 0 {
1103 return "Sin filas".to_string();
1104 }
1105 let r = self.rango(total);
1106 format!("{}–{} de {}", r.start + 1, r.end, total)
1107 }
1108}
1109
1110/// Un botón de la tira del paginador.
1111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1112pub enum BotonPagina {
1113 /// Índice base 0 (se muestra +1).
1114 Numero(usize),
1115 /// El hueco entre tramos: `…`.
1116 Elipsis,
1117}
1118
1119/// La tira de números del paginador, con elipsis: `1 … 4 5 6 … 20`.
1120///
1121/// Siempre muestra la **primera y la última** —que son las dos a las que más se
1122/// salta— y una ventana alrededor de la actual. `max_numeros` es cuántos
1123/// números (sin contar elipsis) se aceptan; por debajo de 5 no hay tira que
1124/// valga la pena y se muestran los primeros.
1125pub fn botones_pagina(actual: usize, total_paginas: usize, max_numeros: usize) -> Vec<BotonPagina> {
1126 let total = total_paginas.max(1);
1127 let max = max_numeros.max(5).min(total);
1128 if total <= max {
1129 return (0..total).map(BotonPagina::Numero).collect();
1130 }
1131 // Reservamos primera + última + dos elipsis en el peor caso.
1132 let ventana = max.saturating_sub(2);
1133 let mitad = ventana / 2;
1134 let mut ini = actual.saturating_sub(mitad);
1135 let mut fin = ini + ventana;
1136 if fin > total - 1 {
1137 fin = total - 1;
1138 ini = fin.saturating_sub(ventana);
1139 }
1140 ini = ini.max(1);
1141
1142 let mut out = vec![BotonPagina::Numero(0)];
1143 if ini > 1 {
1144 out.push(BotonPagina::Elipsis);
1145 }
1146 for i in ini..fin.min(total - 1) {
1147 out.push(BotonPagina::Numero(i));
1148 }
1149 if fin < total - 1 {
1150 out.push(BotonPagina::Elipsis);
1151 }
1152 out.push(BotonPagina::Numero(total - 1));
1153 out
1154}
1155
1156/// Pie de paginación: «21–40 de 137» + `‹ 1 … 4 5 6 … 20 ›`.
1157///
1158/// Las flechas de los extremos se **apagan** en vez de desaparecer, para que el
1159/// pie no cambie de ancho al llegar al final.
1160pub fn pager_view<Msg: Clone + 'static>(
1161 pagina: Pagina,
1162 total_filas: usize,
1163 on_ir: impl Fn(usize) -> Msg,
1164 palette: &DetailPalette,
1165) -> View<Msg> {
1166 let total_pag = pagina.total_paginas(total_filas);
1167 let actual = pagina.indice.min(total_pag - 1);
1168
1169 let mut hijos: Vec<View<Msg>> = Vec::new();
1170 hijos.push(
1171 View::new(Style {
1172 size: Size { width: Dimension::auto(), height: percent(1.0_f32) },
1173 flex_grow: 1.0,
1174 align_items: Some(AlignItems::CENTER),
1175 ..Default::default()
1176 })
1177 .text_aligned(pagina.etiqueta(total_filas), 10.5, palette.fg_muted, Alignment::Start),
1178 );
1179 hijos.push(boton_pager(
1180 "‹",
1181 "Página anterior",
1182 (actual > 0).then(|| actual - 1),
1183 &on_ir,
1184 palette,
1185 ));
1186 for b in botones_pagina(actual, total_pag, 7) {
1187 match b {
1188 BotonPagina::Numero(i) => {
1189 let etiqueta = format!("Página {}", i + 1);
1190 let mut v = boton_pager_txt(&format!("{}", i + 1), &etiqueta, palette);
1191 if i == actual {
1192 v = v.fill(palette.bg_selected).aria_checked(true);
1193 } else {
1194 v = v.hover_fill(palette.bg_hover).on_click(on_ir(i));
1195 }
1196 hijos.push(v);
1197 }
1198 BotonPagina::Elipsis => hijos.push(
1199 View::new(Style {
1200 size: Size { width: length(16.0_f32), height: percent(1.0_f32) },
1201 align_items: Some(AlignItems::CENTER),
1202 justify_content: Some(JustifyContent::CENTER),
1203 ..Default::default()
1204 })
1205 .text("…", 11.0, palette.fg_muted),
1206 ),
1207 }
1208 }
1209 hijos.push(boton_pager(
1210 "›",
1211 "Página siguiente",
1212 (actual + 1 < total_pag).then(|| actual + 1),
1213 &on_ir,
1214 palette,
1215 ));
1216
1217 View::new(Style {
1218 flex_direction: FlexDirection::Row,
1219 size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
1220 flex_shrink: 0.0,
1221 align_items: Some(AlignItems::CENTER),
1222 padding: pad_lr(8.0),
1223 gap: Size { width: length(2.0_f32), height: length(0.0_f32) },
1224 ..Default::default()
1225 })
1226 .fill(palette.bg_header)
1227 .children(hijos)
1228}
1229
1230/// Caja de un botón del paginador, sin comportamiento.
1231fn boton_pager_txt<Msg: Clone + 'static>(
1232 texto: &str,
1233 etiqueta: &str,
1234 palette: &DetailPalette,
1235) -> View<Msg> {
1236 View::new(Style {
1237 size: Size { width: length(22.0_f32), height: length(20.0_f32) },
1238 flex_shrink: 0.0,
1239 align_items: Some(AlignItems::CENTER),
1240 justify_content: Some(JustifyContent::CENTER),
1241 ..Default::default()
1242 })
1243 .text(texto.to_string(), 11.0, palette.fg_text)
1244 .radius(4.0)
1245 .cursor(llimphi_compositor::Cursor::Pointer)
1246 .role(llimphi_compositor::Role::Button)
1247 .aria_label(etiqueta.to_string())
1248}
1249
1250/// Flecha del paginador; `destino = None` la deja apagada.
1251fn boton_pager<Msg: Clone + 'static>(
1252 glifo: &str,
1253 etiqueta: &str,
1254 destino: Option<usize>,
1255 on_ir: &impl Fn(usize) -> Msg,
1256 palette: &DetailPalette,
1257) -> View<Msg> {
1258 let v = boton_pager_txt(glifo, etiqueta, palette);
1259 match destino {
1260 Some(i) => v.hover_fill(palette.bg_hover).on_click(on_ir(i)),
1261 None => v.aria_disabled(true),
1262 }
1263}
1264
1265/// Redimensiona la columna `i` sumándole `delta` px a su ancho, con piso
1266/// `min`. Devuelve el ancho nuevo; el host lo guarda en su `Vec<ColWidth>`
1267/// (columnas `Fixed`). Pensado para cablearse a un drag sobre el borde
1268/// derecho del encabezado. Pura y testeable.
1269pub fn resize_col(current_w: f32, delta: f32, min: f32) -> f32 {
1270 (current_w + delta).max(min)
1271}
1272
1273/// Mueve el elemento `from` de `orden` a la posición `to`, corriendo el resto
1274/// (el gesto de arrastrar un encabezado sobre otro). El host guarda un
1275/// `Vec<usize>` con el orden de sus columnas y lo pasa por acá al recibir el
1276/// `on_reorder` de [`DetailOpciones`]. Índices fuera de rango o iguales dejan
1277/// el orden intacto. Pura y testeable.
1278///
1279/// Es **remove + insert**, no un swap: intercambiar dos columnas separadas
1280/// mueve dos cosas cuando el usuario arrastró una sola, y la que no tocó
1281/// aparece del otro lado de la tabla sin motivo.
1282pub fn mover_columna<T>(orden: &mut Vec<T>, from: usize, to: usize) {
1283 if from == to || from >= orden.len() || to >= orden.len() {
1284 return;
1285 }
1286 let it = orden.remove(from);
1287 orden.insert(to, it);
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292 use super::*;
1293
1294 #[test]
1295 fn mover_columna_corre_el_resto_y_no_intercambia() {
1296 // Arrastrar la primera sobre la tercera: las de en medio corren una
1297 // posición a la izquierda; NINGUNA salta al otro extremo (que es lo
1298 // que haría un swap).
1299 let mut o = vec![0, 1, 2, 3];
1300 mover_columna(&mut o, 0, 2);
1301 assert_eq!(o, vec![1, 2, 0, 3]);
1302 // Y al revés: de la última a la primera.
1303 let mut o = vec![0, 1, 2, 3];
1304 mover_columna(&mut o, 3, 0);
1305 assert_eq!(o, vec![3, 0, 1, 2]);
1306 }
1307
1308 #[test]
1309 fn mover_columna_ignora_lo_imposible() {
1310 let mut o = vec![0, 1, 2];
1311 mover_columna(&mut o, 1, 1);
1312 assert_eq!(o, vec![0, 1, 2]);
1313 mover_columna(&mut o, 9, 0);
1314 assert_eq!(o, vec![0, 1, 2]);
1315 mover_columna(&mut o, 0, 9);
1316 assert_eq!(o, vec![0, 1, 2]);
1317 }
1318
1319 #[test]
1320 fn step_row_clampa_sin_envolver() {
1321 assert_eq!(step_row(5, 0, 1), 1);
1322 assert_eq!(step_row(5, 4, 1), 4); // tope
1323 assert_eq!(step_row(5, 0, -1), 0); // piso
1324 assert_eq!(step_row(5, usize::MAX, 1), 0);
1325 assert_eq!(step_row(5, usize::MAX, -1), 4);
1326 assert_eq!(step_row(0, 0, 1), 0);
1327 }
1328
1329 #[test]
1330 fn edge_row_home_end() {
1331 assert_eq!(edge_row(5, false), 0);
1332 assert_eq!(edge_row(5, true), 4);
1333 assert_eq!(edge_row(0, true), 0);
1334 }
1335
1336 #[test]
1337 fn resize_col_respeta_piso() {
1338 assert_eq!(resize_col(120.0, 30.0, 40.0), 150.0);
1339 assert_eq!(resize_col(50.0, -40.0, 40.0), 40.0); // no baja del min
1340 }
1341
1342 #[test]
1343 fn el_medidor_del_cuerpo_arranca_mudo_y_despues_dice_el_alto() {
1344 let m = AltoCuerpo::new();
1345 assert_eq!(m.px(), 0.0, "antes del primer pintado no hay alto que dar");
1346 m.escribir(240.0);
1347 assert_eq!(m.px(), 240.0);
1348 // Negativo no existe: un rect degenerado no debe volverse un viewport
1349 // negativo que le dé un clamp absurdo al scroll.
1350 m.escribir(-10.0);
1351 assert_eq!(m.px(), 0.0);
1352 }
1353
1354 #[test]
1355 fn sin_alto_medido_el_cuerpo_se_pinta_desde_arriba() {
1356 // El `clamp_offset` del scroll NO alcanza: con viewport 0 le parece que
1357 // el sobrante es el contenido entero y da por bueno cualquier offset.
1358 assert_eq!(llimphi_widget_scroll::clamp_offset(500.0, 1000.0, 0.0), 500.0);
1359 // Por eso la regla vive acá: el primer cuadro se pinta desde arriba en
1360 // vez de saltar a un offset que todavía no se puede validar.
1361 assert_eq!(offset_efectivo(500.0, 0.0), 0.0);
1362 // Ya medido, el offset del host pasa tal cual (el clamp es suyo).
1363 assert_eq!(offset_efectivo(500.0, 300.0), 500.0);
1364 // Y un offset negativo nunca se pinta hacia arriba de la primera fila.
1365 assert_eq!(offset_efectivo(-20.0, 300.0), 0.0);
1366 }
1367 fn celdas(filas: &[[&str; 2]]) -> Vec<Vec<String>> {
1368 filas
1369 .iter()
1370 .map(|f| f.iter().map(|c| c.to_string()).collect())
1371 .collect()
1372 }
1373
1374 #[test]
1375 fn el_click_en_el_encabezado_cicla_asc_desc_y_sin_orden() {
1376 // El tercer estado importa: sin él no se puede volver al orden en que
1377 // vinieron los datos —que muchas veces ES el orden bueno— sin recargar.
1378 let e = ciclar_orden(None, 1);
1379 assert_eq!(e, Some((1, SortDir::Asc)));
1380 let e = ciclar_orden(e, 1);
1381 assert_eq!(e, Some((1, SortDir::Desc)));
1382 assert_eq!(ciclar_orden(e, 1), None);
1383 // Otra columna arranca de cero, ascendente.
1384 assert_eq!(ciclar_orden(Some((1, SortDir::Desc)), 2), Some((2, SortDir::Asc)));
1385 }
1386
1387 #[test]
1388 fn el_texto_se_ordena_ignorando_mayusculas() {
1389 // Nadie espera que «Zeta» venga antes que «alfa».
1390 let c = celdas(&[["Zeta", "1"], ["alfa", "2"], ["Beta", "3"]]);
1391 let orden = orden_de_filas(&c, 0, SortDir::Asc, &TipoCol::Texto);
1392 assert_eq!(orden, vec![1, 2, 0]);
1393 }
1394
1395 #[test]
1396 fn los_numeros_se_ordenan_por_valor_y_lo_que_no_es_numero_va_al_final() {
1397 let c = celdas(&[["a", "100"], ["b", "9"], ["c", "—"], ["d", "20"]]);
1398 let orden = orden_de_filas(&c, 1, SortDir::Asc, &TipoCol::Numero);
1399 // 9, 20, 100, y el guion último: un «—» encabezando no es información.
1400 assert_eq!(orden, vec![1, 3, 0, 2]);
1401 // Como texto darían otra cosa (100 antes que 9), que es el bug clásico.
1402 let como_texto = orden_de_filas(&c, 1, SortDir::Asc, &TipoCol::Texto);
1403 assert_ne!(orden, como_texto);
1404 }
1405
1406 #[test]
1407 fn el_orden_natural_pone_archivo2_antes_que_archivo10() {
1408 let c = celdas(&[
1409 ["archivo10.txt", ""],
1410 ["archivo2.txt", ""],
1411 ["archivo1.txt", ""],
1412 ]);
1413 assert_eq!(
1414 orden_de_filas(&c, 0, SortDir::Asc, &TipoCol::Natural),
1415 vec![2, 1, 0]
1416 );
1417 // Y el alfabético a secas mete el 10 entre el 1 y el 2 — que es
1418 // exactamente el bug por el que existe la variante natural.
1419 assert_eq!(
1420 orden_de_filas(&c, 0, SortDir::Asc, &TipoCol::Texto),
1421 vec![2, 0, 1]
1422 );
1423 // Ceros a la izquierda no cambian el valor.
1424 let z = celdas(&[["x007", ""], ["x8", ""], ["x06", ""]]);
1425 assert_eq!(orden_de_filas(&z, 0, SortDir::Asc, &TipoCol::Natural), vec![2, 0, 1]);
1426 }
1427
1428 #[test]
1429 fn el_orden_es_estable_asi_se_puede_ordenar_por_dos_columnas() {
1430 // Ordenar por «tipo» y después por «nombre» tiene que conservar el
1431 // primer criterio dentro de cada grupo.
1432 let c = celdas(&[["b", "dir"], ["a", "dir"], ["c", "dir"]]);
1433 let orden = orden_de_filas(&c, 1, SortDir::Asc, &TipoCol::Texto);
1434 assert_eq!(orden, vec![0, 1, 2], "todas iguales ⇒ orden original");
1435 }
1436
1437 #[test]
1438 fn descendente_es_exactamente_el_ascendente_al_reves() {
1439 let c = celdas(&[["b", ""], ["a", ""], ["c", ""]]);
1440 let asc = orden_de_filas(&c, 0, SortDir::Asc, &TipoCol::Texto);
1441 let mut desc = orden_de_filas(&c, 0, SortDir::Desc, &TipoCol::Texto);
1442 desc.reverse();
1443 assert_eq!(asc, desc);
1444 }
1445
1446 #[test]
1447 fn un_comparador_propio_cubre_lo_que_no_es_texto_ni_numero() {
1448 // Fechas en formato local, que a propósito no tienen variante propia:
1449 // inventar acá un parser de fechas sería un segundo parser distinto del
1450 // que ya tiene el datetime-picker.
1451 let por_fecha = TipoCol::Propio(alloc::sync::Arc::new(|a: &str, b: &str| {
1452 let clave = |s: &str| {
1453 let p: Vec<&str> = s.split('/').collect();
1454 (p.get(2).copied().unwrap_or("").to_string(),
1455 p.get(1).copied().unwrap_or("").to_string(),
1456 p.first().copied().unwrap_or("").to_string())
1457 };
1458 clave(a).cmp(&clave(b))
1459 }));
1460 let c = celdas(&[["x", "04/08/2026"], ["y", "31/12/2025"], ["z", "01/01/2026"]]);
1461 assert_eq!(orden_de_filas(&c, 1, SortDir::Asc, &por_fecha), vec![1, 2, 0]);
1462 }
1463
1464 #[test]
1465 fn la_pagina_recorta_el_rango_y_nunca_dice_pagina_1_de_0() {
1466 let p = Pagina::nueva(20);
1467 assert_eq!(p.total_paginas(137), 7);
1468 assert_eq!(p.rango(137), 0..20);
1469 assert_eq!(Pagina { indice: 6, tamano: 20 }.rango(137), 120..137, "la última va corta");
1470 assert_eq!(p.etiqueta(137), "1–20 de 137");
1471 assert_eq!(Pagina { indice: 6, tamano: 20 }.etiqueta(137), "121–137 de 137");
1472 // Cero filas: UNA página vacía, no cero.
1473 assert_eq!(p.total_paginas(0), 1);
1474 assert_eq!(p.rango(0), 0..0);
1475 assert_eq!(p.etiqueta(0), "Sin filas");
1476 // Tamaño 0 no divide por cero.
1477 assert_eq!(Pagina { indice: 0, tamano: 0 }.total_paginas(5), 5);
1478 }
1479
1480 #[test]
1481 fn una_pagina_que_quedo_fuera_de_rango_se_acota_sola() {
1482 // Pasa siempre: estabas en la página 9 y un filtro dejó 3 páginas.
1483 let p = Pagina { indice: 8, tamano: 20 };
1484 assert_eq!(p.acotada(50).indice, 2);
1485 // Y el rango tampoco se va aunque no se haya acotado antes.
1486 assert_eq!(p.rango(50), 40..50);
1487 assert_eq!(p.acotada(0).indice, 0);
1488 }
1489
1490 #[test]
1491 fn la_tira_del_paginador_muestra_siempre_la_primera_y_la_ultima() {
1492 // Pocas páginas: todas, sin elipsis.
1493 assert_eq!(
1494 botones_pagina(0, 4, 7),
1495 vec![
1496 BotonPagina::Numero(0),
1497 BotonPagina::Numero(1),
1498 BotonPagina::Numero(2),
1499 BotonPagina::Numero(3)
1500 ]
1501 );
1502 // Muchas y parado al principio: elipsis sólo del lado derecho.
1503 let t = botones_pagina(0, 20, 7);
1504 assert_eq!(t.first(), Some(&BotonPagina::Numero(0)));
1505 assert_eq!(t.last(), Some(&BotonPagina::Numero(19)));
1506 assert_eq!(t.iter().filter(|b| **b == BotonPagina::Elipsis).count(), 1);
1507 // En el medio: elipsis de los dos lados, y la actual adentro.
1508 let t = botones_pagina(10, 20, 7);
1509 assert_eq!(t.iter().filter(|b| **b == BotonPagina::Elipsis).count(), 2);
1510 assert!(t.contains(&BotonPagina::Numero(10)));
1511 assert_eq!(t.first(), Some(&BotonPagina::Numero(0)));
1512 assert_eq!(t.last(), Some(&BotonPagina::Numero(19)));
1513 // Al final: elipsis sólo del izquierdo.
1514 let t = botones_pagina(19, 20, 7);
1515 assert_eq!(t.iter().filter(|b| **b == BotonPagina::Elipsis).count(), 1);
1516 assert!(t.contains(&BotonPagina::Numero(19)));
1517 }
1518
1519 #[test]
1520 fn el_paginador_no_se_rompe_con_una_sola_pagina_ni_con_cero() {
1521 assert_eq!(botones_pagina(0, 1, 7), vec![BotonPagina::Numero(0)]);
1522 assert_eq!(botones_pagina(0, 0, 7), vec![BotonPagina::Numero(0)]);
1523 // Y un `max` absurdo no cuelga ni deja la tira vacía.
1524 assert!(!botones_pagina(3, 40, 1).is_empty());
1525 }
1526
1527 /// La ventana del cuerpo: lo visible más el overscan, nunca fuera de rango,
1528 /// y TODO mientras no hay alto medido (primer cuadro).
1529 #[test]
1530 fn la_ventana_visible_monta_lo_que_se_ve_y_un_margen() {
1531 // 400 filas de 24 px, viewport de 240 (10 filas), arriba de todo.
1532 assert_eq!(ventana_visible(400, 24.0, 0.0, 240.0), 0..20);
1533 // Bajando 100 filas: arranca 8 antes de la primera visible.
1534 assert_eq!(ventana_visible(400, 24.0, 2400.0, 240.0), 92..120);
1535 // Al fondo no se pasa del total.
1536 assert_eq!(ventana_visible(400, 24.0, 9360.0, 240.0), 382..400);
1537 // Sin medir: todas, que es lo que hacía siempre.
1538 assert_eq!(ventana_visible(400, 24.0, 0.0, 0.0), 0..400);
1539 assert_eq!(ventana_visible(0, 24.0, 0.0, 240.0), 0..0);
1540 }
1541
1542 /// Con scroll y alto medido, la tabla NO monta las filas que no se ven —
1543 /// pero el cuerpo conserva el alto de todas, que es lo que lee la barra.
1544 #[test]
1545 fn con_scroll_la_tabla_solo_construye_la_ventana() {
1546 fn contar_filas(v: &View<u8>) -> usize {
1547 let propia = usize::from(
1548 v.semantics.as_ref().and_then(|s| s.role) == Some(llimphi_compositor::Role::Row),
1549 );
1550 propia + v.children.iter().map(contar_filas).sum::<usize>()
1551 }
1552 let columnas = [Column::flex("Nombre", 1.0)];
1553 let filas = || -> Vec<DetailRow<u8>> {
1554 (0..400)
1555 .map(|i| DetailRow {
1556 cells: vec![format!("fila {i}")],
1557 selected: false,
1558 accent: None,
1559 icon: None,
1560 on_click: 0,
1561 })
1562 .collect()
1563 };
1564 let spec = |rows| DetailSpec {
1565 columns: &columnas,
1566 rows,
1567 sort: None,
1568 row_height: 24.0,
1569 caption: None,
1570 palette: DetailPalette::default(),
1571 };
1572 let con = |viewport_h: f32| DetailOpciones {
1573 scroll: Some(DetailScroll {
1574 offset: 2400.0,
1575 viewport_h,
1576 on_scroll: alloc::sync::Arc::new(|_d: f32| 0u8),
1577 }),
1578 ..Default::default()
1579 };
1580 let v = detail_table_view_ext(spec(filas()), |_c| 1u8, con(240.0));
1581 // 28 de datos + el encabezado.
1582 assert_eq!(contar_filas(&v), 28 + 1);
1583 // Sin alto medido se monta todo, como antes.
1584 let v = detail_table_view_ext(spec(filas()), |_c| 1u8, con(0.0));
1585 assert_eq!(contar_filas(&v), 400 + 1);
1586 }
1587}