tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ main widgets/detail-table/src/lib.rs Rust · 1587 lines · 63.6 KB History Who touched what raw

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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}