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/list/src/lib.rs Rust · 902 lines · 33.4 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-list` — lista vertical virtualizada.
2//!
3//! Compone una pila de filas con foco visual en la seleccionada y un Msg
4//! por click. Pensado como bloque reusable para file explorers, árboles
5//! lineales, paneles de log, listados de items, etc.
6//!
7//! El widget **no** maneja virtualización por sí mismo: el caller pasa
8//! únicamente las filas que deberían renderearse (las visibles según su
9//! propio `offset`/`scroll`). El widget se ocupa del resto: caption
10//! opcional con el conteo, fondo de selección, hint "… y N más" cuando
11//! `total > rows.len()`, y `clip` en el contenedor para que las filas no
12//! sangren a vecinos.
13//!
14//! Ejemplo:
15//!
16//! ```ignore
17//! let rows: Vec<ListRow<Msg>> = entries[offset..(offset + visible).min(entries.len())]
18//!     .iter()
19//!     .enumerate()
20//!     .map(|(i, e)| ListRow {
21//!         label: e.name.clone(),
22//!         selected: offset + i == selected,
23//!         on_click: Msg::Select(offset + i),
24//!     })
25//!     .collect();
26//!
27//! let panel = list_view(ListSpec {
28//!     rows,
29//!     total: entries.len(),
30//!     caption: Some(format!("{} entradas", entries.len())),
31//!     truncated_hint: (entries.len() > offset + rows.len())
32//!         .then(|| format!("… y {} más", entries.len() - offset - rows.len())),
33//!     row_height: 22.0,
34//!     palette: ListPalette::default(),
35//! });
36//! ```
37
38#![forbid(unsafe_code)]
39
40
41// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
42// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
43// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
44#![no_std]
45
46extern crate alloc;
47
48use alloc::string::String;
49use alloc::vec::Vec;
50use alloc::vec;
51use alloc::sync::Arc;
52
53use llimphi_compositor::llimphi_layout::taffy::{
54    prelude::{length, percent, FlexDirection, Size, Style},
55    AlignItems, Rect,
56};
57use llimphi_compositor::peniko::Color;
58use llimphi_compositor::llimphi_text::Alignment;
59use llimphi_compositor::{CargaArrastre, DragPhase, View};
60
61/// Paleta de la lista. Los defaults son una variante dark con selección
62/// azulada — equivalente conceptual a `nahual_theme` en su tema oscuro.
63#[derive(Debug, Clone, Copy)]
64pub struct ListPalette {
65    pub bg_panel: Color,
66    pub bg_selected: Color,
67    /// Fondo de una fila **marcada** (ver [`IconRow::marked`]). Por defecto el
68    /// acento a alpha baja: se distingue del cursor sin competir con él.
69    pub bg_marked: Color,
70    pub fg_text: Color,
71    pub fg_muted: Color,
72    /// Resalte de hover sobre una fila (feedback al pasar el cursor). Antes
73    /// las filas normales NO tenían hover (inconsistente con `tree`, que sí).
74    pub bg_hover: Color,
75    /// Resalte de la fila destino mientras un drag de reorder pasa por
76    /// encima. Sólo se usa en [`reorderable_list_view`]. Default = accent
77    /// translúcido (40 %) sobre `bg_selected` — distinguible del hover y
78    /// la selección estable.
79    pub bg_drop_hover: Color,
80}
81
82impl Default for ListPalette {
83    fn default() -> Self {
84        Self::from_theme(&llimphi_theme::Theme::dark())
85    }
86}
87
88impl ListPalette {
89    /// Construye la paleta desde un `Theme` semántico.
90    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
91        // Resalte de drop = accent del theme con 40 % de opacidad
92        // multiplicada — gana sobre `bg_selected` por luminancia para que
93        // un drag sobre una fila ya seleccionada se note.
94        let mut drop = t.accent;
95        drop.components[3] *= 0.40;
96        Self {
97            bg_panel: t.bg_panel,
98            bg_selected: t.bg_selected,
99            bg_marked: t.accent.multiply_alpha(0.22),
100            fg_text: t.fg_text,
101            fg_muted: t.fg_muted,
102            bg_hover: t.bg_row_hover,
103            bg_drop_hover: drop,
104        }
105    }
106}
107
108/// Una fila a renderear. `selected` cambia el fondo; `on_click` se emite al
109/// hacer click sobre cualquier parte de la fila.
110pub struct ListRow<Msg> {
111    pub label: String,
112    pub selected: bool,
113    pub on_click: Msg,
114}
115
116/// Especificación completa de la lista a renderear.
117pub struct ListSpec<Msg> {
118    /// Filas a renderear, ya filtradas a la ventana visible.
119    pub rows: Vec<ListRow<Msg>>,
120    /// Total de items del modelo (usado para el caption — la lista
121    /// mostrada puede ser un subconjunto virtualizado).
122    pub total: usize,
123    /// Caption opcional arriba de las filas (p. ej. "120 entradas").
124    pub caption: Option<String>,
125    /// Mensaje opcional al pie ("… y 12 más") cuando hay items fuera de
126    /// la ventana visible. El caller decide qué texto usar.
127    pub truncated_hint: Option<String>,
128    /// Altura de cada fila en pixels.
129    pub row_height: f32,
130    pub palette: ListPalette,
131}
132
133/// Lo que [`list_view`] no ofrece y [`list_view_ext`] sí. `Default` es la
134/// lista de siempre (recorte y nada más).
135pub struct ListOpciones<Msg> {
136    /// Scroll real del cuerpo: las filas se desplazan y hay barra arrastrable.
137    /// El `caption` y el aviso de truncado quedan **fijos** — son rótulos de la
138    /// lista, no contenido de ella.
139    pub scroll: Option<llimphi_widget_scroll::DesplazamientoV<Msg>>,
140    /// Dónde publicar el alto medido del cuerpo, para el `viewport_len` del
141    /// cuadro siguiente.
142    pub medidor: Option<llimphi_widget_scroll::AltoVisible>,
143}
144
145impl<Msg> Default for ListOpciones<Msg> {
146    fn default() -> Self {
147        Self { scroll: None, medidor: None }
148    }
149}
150
151/// Compone la lista como un `View<Msg>`. El contenedor tiene `clip = true`
152/// para evitar overflow visual cuando el llamador subestima el tamaño
153/// disponible — las filas que excedan el área del panel se recortan.
154pub fn list_view<Msg: Clone + 'static>(spec: ListSpec<Msg>) -> View<Msg> {
155    list_view_ext(spec, ListOpciones::default())
156}
157
158/// [`list_view`] con **scroll real** en vez de sólo recorte: hasta acá, una
159/// lista más larga que su panel se cortaba y no había forma de ver el resto sin
160/// que el host virtualizara por su cuenta. Con `ListOpciones::default()` es
161/// idéntica a [`list_view`].
162pub fn list_view_ext<Msg: Clone + 'static>(
163    spec: ListSpec<Msg>,
164    opciones: ListOpciones<Msg>,
165) -> View<Msg> {
166    let ListOpciones { scroll, medidor } = opciones;
167    let ListSpec {
168        rows,
169        total: _,
170        caption,
171        truncated_hint,
172        row_height,
173        palette,
174    } = spec;
175
176    let mut children: Vec<View<Msg>> = Vec::with_capacity(rows.len() + 2);
177
178    if let Some(text) = caption {
179        children.push(
180            View::new(Style {
181                size: Size {
182                    width: percent(1.0_f32),
183                    height: length(20.0_f32),
184                },
185                padding: Rect {
186                    left: length(10.0_f32),
187                    right: length(10.0_f32),
188                    top: length(0.0_f32),
189                    bottom: length(0.0_f32),
190                },
191                align_items: Some(AlignItems::CENTER),
192                ..Default::default()
193            })
194            .text_aligned(text, 10.0, palette.fg_muted, Alignment::Start),
195        );
196    }
197
198    let filas: Vec<View<Msg>> = rows
199        .into_iter()
200        .map(|row| row_view(row, row_height, &palette))
201        .collect();
202    let alto_filas = filas.len() as f32 * row_height;
203    match &scroll {
204        // Con scroll el cuerpo va aparte, después del caption: así el rótulo
205        // no se va con las filas.
206        Some(_) => {
207            let cuerpo = View::new(Style {
208                flex_direction: FlexDirection::Column,
209                size: Size { width: percent(1.0_f32), height: length(alto_filas) },
210                ..Default::default()
211            })
212            .children(filas);
213            let d = scroll.expect("recién comprobado");
214            children.push(llimphi_widget_scroll::cuerpo_scrolleable(
215                cuerpo,
216                alto_filas,
217                d,
218                medidor,
219                &llimphi_widget_scroll::ScrollPalette {
220                    track: palette.bg_panel,
221                    thumb: palette.fg_muted,
222                    thumb_hover: palette.bg_selected,
223                    ..Default::default()
224                },
225            ));
226        }
227        None => children.extend(filas),
228    }
229
230    if let Some(text) = truncated_hint {
231        children.push(
232            View::new(Style {
233                size: Size {
234                    width: percent(1.0_f32),
235                    height: length(16.0_f32),
236                },
237                padding: Rect {
238                    left: length(10.0_f32),
239                    right: length(10.0_f32),
240                    top: length(0.0_f32),
241                    bottom: length(0.0_f32),
242                },
243                ..Default::default()
244            })
245            .text_aligned(text, 10.0, palette.fg_muted, Alignment::Start),
246        );
247    }
248
249    View::new(Style {
250        flex_direction: FlexDirection::Column,
251        size: Size {
252            width: percent(1.0_f32),
253            height: percent(1.0_f32),
254        },
255        padding: Rect {
256            left: length(0.0_f32),
257            right: length(0.0_f32),
258            top: length(6.0_f32),
259            bottom: length(6.0_f32),
260        },
261        ..Default::default()
262    })
263    .fill(palette.bg_panel)
264    .clip(true)
265    .role(llimphi_compositor::Role::List)
266    .children(children)
267}
268
269fn row_view<Msg: Clone + 'static>(row: ListRow<Msg>, height: f32, palette: &ListPalette) -> View<Msg> {
270    let selected = row.selected;
271    let bg = if selected {
272        palette.bg_selected
273    } else {
274        palette.bg_panel
275    };
276    let mut v = View::new(Style {
277        size: Size {
278            width: percent(1.0_f32),
279            height: length(height),
280        },
281        // Sin esto, una lista más larga que su panel NO se recorta como dice el
282        // doc: el flex reparte el faltante y **aplasta** todas las filas (60
283        // filas de 22 px declarados se pintaban a 14). El alto de fila es un
284        // dato del caller, no una sugerencia.
285        flex_shrink: 0.0,
286        padding: Rect {
287            left: length(10.0_f32),
288            right: length(10.0_f32),
289            top: length(0.0_f32),
290            bottom: length(0.0_f32),
291        },
292        align_items: Some(AlignItems::CENTER),
293        ..Default::default()
294    })
295    .fill(bg)
296    .text_aligned(row.label, 12.0, palette.fg_text, Alignment::Start)
297    // Labels largos terminan en `…` (single-line) en vez de cortarse seco.
298    .ellipsis(1)
299    // El nombre accesible sale del texto de la fila; `selected` viaja como
300    // `checked` (es lo que ARIA usa para el ítem de una lista de selección).
301    .role(llimphi_compositor::Role::ListItem)
302    .aria_checked(selected)
303    .on_click(row.on_click);
304    // Hover sólo en filas no seleccionadas (para no tapar el tinte de selección).
305    if !selected {
306        v = v.hover_fill(palette.bg_hover);
307    }
308    v
309}
310
311// =============================================================================
312//  Variante con ícono líder
313// =============================================================================
314
315/// Como [`ListRow`] pero con un **ícono vectorial** opcional al inicio de la
316/// fila. El ícono es un `View<Msg>` ya construido por el caller (p.ej. con
317/// `tullpu_icon_llimphi::spec_view`) — el widget no conoce tullpu ni el motor de
318/// íconos, sólo lo coloca y deja sitio. Pensado para listas de módulos/apps que
319/// quieren un emblema por fila sin caer en glifos de fuente.
320pub struct IconRow<Msg> {
321    pub icon: Option<View<Msg>>,
322    pub label: String,
323    /// El **cursor**: la fila con el foco. Una sola.
324    pub selected: bool,
325    /// **Marcada** para operar por lote. Puede haber muchas, y se pintan con
326    /// fondo propio. Ver la nota de `GridCell::marked`: hasta 2026-08-12 la
327    /// marca era un `✓` en el texto y una selección múltiple se veía igual que
328    /// ninguna, con Supr esperando del otro lado.
329    pub marked: bool,
330    pub on_click: Msg,
331}
332
333/// Especificación de una lista con íconos líderes. Misma forma que [`ListSpec`].
334pub struct IconListSpec<Msg> {
335    pub rows: Vec<IconRow<Msg>>,
336    pub total: usize,
337    pub caption: Option<String>,
338    pub truncated_hint: Option<String>,
339    pub row_height: f32,
340    pub palette: ListPalette,
341}
342
343/// Compone una lista con un ícono opcional por fila. Equivalente a [`list_view`]
344/// pero cada fila es `[icono] etiqueta`. Aditivo: no afecta a [`list_view`].
345/// Arrastre y soltado **por fila**, aditivo sobre los widgets de lista/grilla:
346/// el widget sigue componiendo la fila igual, sólo le cuelga el drag/drop.
347///
348/// - `payload(i)` — el `u64` que viaja al arrastrar la fila `i`; `None` = fila
349///   no arrastrable.
350/// - `on_drop(i, payload)` — el `Msg` al soltar sobre la fila `i`; `None` = la
351///   fila no es destino (p. ej. un archivo: sólo las carpetas reciben).
352/// - `hover` — tinte de la fila mientras un arrastre la sobrevuela.
353///
354/// Ojo de diseño: una fila arrastrable emite su click por `on_click_at` (en el
355/// **press**), porque `draggable` a secas se come el `on_click`. Es la única
356/// combinación que el runtime deja coexistir (ver `draggable_at`).
357pub struct RowDnd<Msg> {
358    pub payload: Arc<dyn Fn(usize) -> Option<u64> + Send + Sync>,
359    pub on_drop: Arc<dyn Fn(usize, u64) -> Option<Msg> + Send + Sync>,
360    pub hover: Color,
361    /// **Qué se ve viajando** cuando se arrastra la fila `i`: el fantasma que
362    /// el runtime pinta pegado al cursor
363    /// ([`llimphi_compositor::CargaArrastre`]). `None` —el default— deja el
364    /// arrastre invisible, que es como se comportaba antes de que esto
365    /// existiera: ninguna lista pierde nada por no declararlo.
366    pub carga: Option<Arc<dyn Fn(usize) -> Option<CargaArrastre> + Send + Sync>>,
367}
368
369impl<Msg> Clone for RowDnd<Msg> {
370    fn clone(&self) -> Self {
371        Self {
372            payload: self.payload.clone(),
373            on_drop: self.on_drop.clone(),
374            hover: self.hover,
375            carga: self.carga.clone(),
376        }
377    }
378}
379
380/// Aplica a una fila ya compuesta el arrastre/soltado de `dnd` para el índice
381/// `i`. `click` es el `Msg` que la fila emitía por `on_click` — se re-emite por
382/// `on_click_at` cuando la fila pasa a ser arrastrable.
383///
384/// **El drag va por `draggable_at`, no por `draggable`, y no es cosmético.** La
385/// escalera de prioridad del press en `llimphi-ui` es
386/// `drag_velocity > drag_at + on_click_at > drag > on_click_at > on_click`: la
387/// rama de `drag` pelado gana **exclusiva** y ni siquiera arma el click, así que
388/// una fila con `draggable(..) + on_click_at(..)` quedaba **muda al click** —
389/// seleccionar con el mouse no funcionaba en ninguna lista ni grilla con DnD, y
390/// se leía como "los clicks sobre los iconos no andan". Sólo `drag_at` coexiste
391/// con `on_click_at` (es la rama que arma el `pending_click` y lo dispara en el
392/// release si el cursor no se alejó), que es justo la semántica que queremos:
393/// un toque selecciona, un arrastre de verdad no activa nada.
394pub fn con_dnd<Msg: Clone + Send + Sync + 'static>(
395    fila: View<Msg>,
396    i: usize,
397    click: Msg,
398    dnd: &RowDnd<Msg>,
399) -> View<Msg> {
400    let mut fila = fila;
401    if let Some(p) = (dnd.payload)(i) {
402        fila = fila
403            .draggable_at(move |_phase, _dx, _dy, _lx0, _ly0| None)
404            .drag_payload(p)
405            .on_click_at(move |_lx, _ly, _w, _h| Some(click.clone()));
406        if let Some(c) = dnd.carga.as_ref().and_then(|f| f(i)) {
407            fila = fila.drag_carga(c);
408        }
409    }
410    let on_drop = dnd.on_drop.clone();
411    fila.on_drop(move |payload| (on_drop)(i, payload)).drop_hover_fill(dnd.hover)
412}
413
414pub fn icon_list_view<Msg: Clone + 'static>(spec: IconListSpec<Msg>) -> View<Msg> {
415    icon_list_view_con(spec, None)
416}
417
418/// [`icon_list_view`] con **arrastre y soltado** opcional por fila. Con `dnd =
419/// None` es exactamente la lista de siempre.
420///
421/// El `Send + Sync` extra lo pide el runtime para los handlers de drag/drop; la
422/// variante sin DnD conserva el bound histórico (`Clone`), así los callers
423/// genéricos que no arrastran nada no pagan el peaje.
424pub fn icon_list_view_dnd<Msg: Clone + Send + Sync + 'static>(
425    spec: IconListSpec<Msg>,
426    dnd: Option<RowDnd<Msg>>,
427) -> View<Msg> {
428    match dnd {
429        Some(d) => icon_list_view_con(spec, Some(&move |v, i, click| con_dnd(v, i, click, &d))),
430        None => icon_list_view_con(spec, None),
431    }
432}
433
434/// Decorador de fila: recibe la fila ya compuesta, su índice y el `Msg` que
435/// emitía por click, y devuelve la fila decorada. Es el punto de extensión que
436/// usa el DnD sin contagiar sus bounds al camino común.
437pub type FilaDeco<'a, Msg> = &'a dyn Fn(View<Msg>, usize, Msg) -> View<Msg>;
438
439fn icon_list_view_con<Msg: Clone + 'static>(
440    spec: IconListSpec<Msg>,
441    deco: Option<FilaDeco<'_, Msg>>,
442) -> View<Msg> {
443    let IconListSpec { rows, total: _, caption, truncated_hint, row_height, palette } = spec;
444    let mut children: Vec<View<Msg>> = Vec::with_capacity(rows.len() + 2);
445
446    if let Some(text) = caption {
447        children.push(
448            View::new(Style {
449                size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
450                padding: Rect { left: length(10.0_f32), right: length(10.0_f32), top: length(0.0_f32), bottom: length(0.0_f32) },
451                align_items: Some(AlignItems::CENTER),
452                ..Default::default()
453            })
454            .text_aligned(text, 10.0, palette.fg_muted, Alignment::Start),
455        );
456    }
457
458    for (i, row) in rows.into_iter().enumerate() {
459        let click = row.on_click.clone();
460        let fila = icon_row_view(row, row_height, &palette);
461        children.push(match deco {
462            Some(f) => f(fila, i, click),
463            None => fila,
464        });
465    }
466
467    if let Some(text) = truncated_hint {
468        children.push(
469            View::new(Style {
470                size: Size { width: percent(1.0_f32), height: length(16.0_f32) },
471                padding: Rect { left: length(10.0_f32), right: length(10.0_f32), top: length(0.0_f32), bottom: length(0.0_f32) },
472                ..Default::default()
473            })
474            .text_aligned(text, 10.0, palette.fg_muted, Alignment::Start),
475        );
476    }
477
478    View::new(Style {
479        flex_direction: FlexDirection::Column,
480        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
481        padding: Rect { left: length(0.0_f32), right: length(0.0_f32), top: length(6.0_f32), bottom: length(6.0_f32) },
482        ..Default::default()
483    })
484    .fill(palette.bg_panel)
485    .clip(true)
486    .role(llimphi_compositor::Role::List)
487    .children(children)
488}
489
490fn icon_row_view<Msg: Clone + 'static>(row: IconRow<Msg>, height: f32, palette: &ListPalette) -> View<Msg> {
491    let selected = row.selected;
492    // El cursor manda sobre la marca (es donde está el foco); una fila marcada
493    // sin cursor lleva el tinte del acento.
494    let bg = if selected {
495        palette.bg_selected
496    } else if row.marked {
497        palette.bg_marked
498    } else {
499        palette.bg_panel
500    };
501    let icon_px = (height * 0.66).min(20.0).max(12.0);
502    // Acá el texto vive en un hijo, no en el nodo clickeable — sin label
503    // explícito el lector anunciaría la fila sin nombre.
504    let aria = row.label.clone();
505
506    let label = View::new(Style {
507        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
508        align_items: Some(AlignItems::CENTER),
509        ..Default::default()
510    })
511    .text_aligned(row.label, 12.0, palette.fg_text, Alignment::Start)
512    .ellipsis(1);
513
514    let mut children: Vec<View<Msg>> = Vec::with_capacity(2);
515    if let Some(icon) = row.icon {
516        children.push(
517            View::new(Style {
518                size: Size { width: length(icon_px), height: length(icon_px) },
519                flex_shrink: 0.0,
520                ..Default::default()
521            })
522            .children(vec![icon]),
523        );
524    }
525    children.push(label);
526
527    View::new(Style {
528        flex_direction: FlexDirection::Row,
529        size: Size { width: percent(1.0_f32), height: length(height) },
530        padding: Rect { left: length(10.0_f32), right: length(10.0_f32), top: length(0.0_f32), bottom: length(0.0_f32) },
531        align_items: Some(AlignItems::CENTER),
532        gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
533        ..Default::default()
534    })
535    .fill(bg)
536    .children(children)
537    .role(llimphi_compositor::Role::ListItem)
538    .aria_label(aria)
539    .aria_checked(selected)
540    .on_click(row.on_click)
541    .hover_fill(if selected { palette.bg_selected } else { palette.bg_hover })
542}
543
544/// Función que el caller usa para reaccionar a un reorder. Recibe `(from,
545/// to)` — índices en `rows` — y devuelve el `Msg` a despachar (o `None`
546/// para ignorar el drop, p. ej. si `from == to`).
547pub type ReorderFn<Msg> = Arc<dyn Fn(usize, usize) -> Option<Msg> + Send + Sync>;
548
549/// Una fila para [`reorderable_list_view`]. Pesa más que [`ListRow`]
550/// porque cada fila acepta `on_click` opcional y siempre lleva drag
551/// handle al borde izquierdo (gripper `⋮⋮` en `fg_muted`) — convención
552/// kanban/Trello/Flutter `ReorderableListView`.
553pub struct ReorderableListRow<Msg> {
554    pub label: String,
555    pub selected: bool,
556    pub on_click: Option<Msg>,
557}
558
559/// Especificación de una lista reordenable por drag&drop (Bloque 14 de
560/// PARIDAD-FLUTTER, sigue Tier 5). Cada fila lleva un gripper a la
561/// izquierda; arrastrar una fila y soltarla sobre otra emite
562/// `on_reorder(from, to)`. La fila destino se ilumina con
563/// `palette.bg_drop_hover` mientras el cursor está sobre ella durante el
564/// drag.
565pub struct ReorderableListSpec<Msg> {
566    pub rows: Vec<ReorderableListRow<Msg>>,
567    pub caption: Option<String>,
568    pub row_height: f32,
569    pub palette: ListPalette,
570    pub on_reorder: ReorderFn<Msg>,
571}
572
573/// Compone una lista reordenable (Bloque 14). Patrón: cada fila exhibe
574/// un gripper al borde izquierdo y es `draggable` con `payload = idx`;
575/// la **fila entera** (no sólo el gripper) recibe drops con `on_drop` y
576/// `drop_hover_fill`. El handler `on_reorder(from, to)` cae al caller
577/// que decide qué `Msg` despachar — el widget no muta nada por sí solo.
578///
579/// Composición pura sobre los primitives `drag_payload` / `on_drop` /
580/// `drop_hover_fill` / `draggable` de `llimphi-ui` (ver `tiled` que
581/// reordena paneles bajo el mismo idiom).
582pub fn reorderable_list_view<Msg>(spec: ReorderableListSpec<Msg>) -> View<Msg>
583where
584    Msg: Clone + Send + Sync + 'static,
585{
586    let ReorderableListSpec {
587        rows,
588        caption,
589        row_height,
590        palette,
591        on_reorder,
592    } = spec;
593
594    let mut children: Vec<View<Msg>> = Vec::with_capacity(rows.len() + 1);
595
596    if let Some(text) = caption {
597        children.push(
598            View::new(Style {
599                size: Size {
600                    width: percent(1.0_f32),
601                    height: length(20.0_f32),
602                },
603                padding: Rect {
604                    left: length(10.0_f32),
605                    right: length(10.0_f32),
606                    top: length(0.0_f32),
607                    bottom: length(0.0_f32),
608                },
609                align_items: Some(AlignItems::CENTER),
610                ..Default::default()
611            })
612            .text_aligned(text, 10.0, palette.fg_muted, Alignment::Start),
613        );
614    }
615
616    for (idx, row) in rows.into_iter().enumerate() {
617        children.push(reorderable_row_view(idx, row, row_height, &palette, on_reorder.clone()));
618    }
619
620    View::new(Style {
621        flex_direction: FlexDirection::Column,
622        size: Size {
623            width: percent(1.0_f32),
624            height: percent(1.0_f32),
625        },
626        padding: Rect {
627            left: length(0.0_f32),
628            right: length(0.0_f32),
629            top: length(6.0_f32),
630            bottom: length(6.0_f32),
631        },
632        ..Default::default()
633    })
634    .fill(palette.bg_panel)
635    .clip(true)
636    .role(llimphi_compositor::Role::List)
637    .children(children)
638}
639
640fn reorderable_row_view<Msg>(
641    idx: usize,
642    row: ReorderableListRow<Msg>,
643    height: f32,
644    palette: &ListPalette,
645    on_reorder: ReorderFn<Msg>,
646) -> View<Msg>
647where
648    Msg: Clone + Send + Sync + 'static,
649{
650    let bg = if row.selected {
651        palette.bg_selected
652    } else {
653        palette.bg_panel
654    };
655
656    // Gripper `⋮⋮` al borde izquierdo, en `fg_muted` y arrastrable. El
657    // drag entrega `payload = idx`. Devolvemos `None` por evento de drag
658    // (no usamos dx/dy aquí — el destino se decide en el `on_drop` del
659    // otro nodo).
660    let gripper = View::new(Style {
661        size: Size {
662            width: length(20.0_f32),
663            height: percent(1.0_f32),
664        },
665        align_items: Some(AlignItems::CENTER),
666        ..Default::default()
667    })
668    .text_aligned("⋮⋮", 14.0, palette.fg_muted, Alignment::Center)
669    .draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None)
670    .drag_payload(idx as u64)
671    .cursor(llimphi_compositor::Cursor::Grab);
672
673    // Etiqueta: ocupa el resto de la fila, con ellipsis y click opcional.
674    let mut label = View::new(Style {
675        size: Size {
676            width: percent(1.0_f32),
677            height: percent(1.0_f32),
678        },
679        align_items: Some(AlignItems::CENTER),
680        ..Default::default()
681    })
682    .text_aligned(row.label, 12.0, palette.fg_text, Alignment::Start)
683    .ellipsis(1);
684    if let Some(msg) = row.on_click {
685        label = label.on_click(msg);
686    }
687
688    // El **row entero** es target de drop. Cuando el cursor pasa por
689    // encima durante un drag, `drop_hover_fill` lo ilumina. Al soltar,
690    // emitimos el reorder si from != to.
691    let to_idx = idx;
692    let reorder = on_reorder.clone();
693    View::new(Style {
694        flex_direction: FlexDirection::Row,
695        size: Size {
696            width: percent(1.0_f32),
697            height: length(height),
698        },
699        padding: Rect {
700            left: length(4.0_f32),
701            right: length(10.0_f32),
702            top: length(0.0_f32),
703            bottom: length(0.0_f32),
704        },
705        align_items: Some(AlignItems::CENTER),
706        gap: Size {
707            width: length(6.0_f32),
708            height: length(0.0_f32),
709        },
710        ..Default::default()
711    })
712    .fill(bg)
713    .on_drop(move |from: u64| {
714        let from = from as usize;
715        if from == to_idx {
716            None
717        } else {
718            (reorder)(from, to_idx)
719        }
720    })
721    .drop_hover_fill(palette.bg_drop_hover)
722    .children(vec![gripper, label])
723}
724
725/// Mueve la selección/foco con teclado (↑/↓) sobre una lista de `len`
726/// filas: índice siguiente **clampeado** a `[0, len-1]` (una lista no
727/// envuelve, a diferencia de tabs/segmented). `current == usize::MAX`
728/// (nada seleccionado) arranca por el extremo según la dirección. El host
729/// lo cablea en `update` y dispatcha su `Msg::Select(nuevo)`.
730pub fn step_visible(len: usize, current: usize, direction: i32) -> usize {
731    if len == 0 {
732        return 0;
733    }
734    let last = len - 1;
735    if current == usize::MAX {
736        return if direction >= 0 { 0 } else { last };
737    }
738    let next = current as i64 + direction as i64;
739    next.clamp(0, last as i64) as usize
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    #[test]
747    fn step_visible_clampa_sin_envolver() {
748        assert_eq!(step_visible(5, 0, 1), 1);
749        assert_eq!(step_visible(5, 4, 1), 4); // tope, no envuelve
750        assert_eq!(step_visible(5, 0, -1), 0); // piso, no envuelve
751        assert_eq!(step_visible(5, 2, -1), 1);
752    }
753
754    #[test]
755    fn step_visible_arranca_desde_ninguno() {
756        assert_eq!(step_visible(5, usize::MAX, 1), 0);
757        assert_eq!(step_visible(5, usize::MAX, -1), 4);
758        assert_eq!(step_visible(0, usize::MAX, 1), 0);
759    }
760
761    /// Una fila con DnD tiene que quedar en la **única** combinación que el
762    /// runtime deja coexistir: `drag_at` + `on_click_at` (+ payload). Con `drag`
763    /// pelado, la rama de drag del press gana exclusiva y el click no se arma
764    /// nunca — la fila queda muda al mouse. Este test es el candado de eso.
765    #[test]
766    fn la_fila_arrastrable_conserva_su_click() {
767        let dnd: RowDnd<u8> = RowDnd {
768            payload: Arc::new(|i| Some(i as u64 + 1)),
769            on_drop: Arc::new(|_i, _p| None),
770            hover: Color::from_rgba8(0, 0, 0, 0),
771            carga: None,
772        };
773        let fila = con_dnd(View::new(Style::default()), 3, 7u8, &dnd);
774        let rare = fila.rare.as_ref().expect("props raras");
775        assert!(rare.drag_at.is_some(), "el drag va por draggable_at");
776        assert!(
777            rare.drag.is_none(),
778            "drag pelado gana exclusivo en el press y se come el click"
779        );
780        assert!(rare.on_click_at.is_some(), "la fila sigue emitiendo su click");
781        assert_eq!(rare.drag_payload, Some(4));
782    }
783
784    /// Sin payload la fila no es arrastrable: ni drag ni on_click_at. Conserva
785    /// el `on_click` que traía (el camino de las filas no-arrastrables).
786    #[test]
787    fn la_fila_sin_payload_no_toca_el_click() {
788        let dnd: RowDnd<u8> = RowDnd {
789            payload: Arc::new(|_i| None),
790            on_drop: Arc::new(|_i, _p| None),
791            hover: Color::from_rgba8(0, 0, 0, 0),
792            carga: None,
793        };
794        let fila = con_dnd(View::new(Style::default()).on_click(9u8), 0, 7u8, &dnd);
795        assert_eq!(fila.on_click, Some(9));
796        let rare = fila.rare.as_ref().expect("props raras");
797        assert!(rare.drag_at.is_none() && rare.drag.is_none());
798        assert!(rare.on_click_at.is_none());
799    }
800}
801
802// ─────────────────────────────────────────────────────────────────────────
803// Multi-selección estilo file-manager (helpers puros, lado host)
804// ─────────────────────────────────────────────────────────────────────────
805
806/// Ctrl/Cmd-click: **alterna** `idx` en el conjunto de índices seleccionados
807/// (lo agrega si falta, lo quita si está). El host guarda su `Vec<usize>` de
808/// selección y lo muta con esto. Pura y testeable.
809pub fn toggle_selection(selected: &mut Vec<usize>, idx: usize) {
810    if let Some(pos) = selected.iter().position(|&i| i == idx) {
811        selected.remove(pos);
812    } else {
813        selected.push(idx);
814    }
815}
816
817/// Shift-click: rango inclusivo `[anchor, idx]` **ordenado** (funciona en
818/// cualquier dirección). El host reemplaza (o une) su selección con estos
819/// índices — `selection_range(a, b).collect::<Vec<_>>()`.
820pub fn selection_range(anchor: usize, idx: usize) -> core::ops::RangeInclusive<usize> {
821    if anchor <= idx {
822        anchor..=idx
823    } else {
824        idx..=anchor
825    }
826}
827
828#[cfg(test)]
829mod multiselect_tests {
830    use super::*;
831
832    #[test]
833    fn toggle_agrega_y_quita() {
834        let mut sel = vec![1usize, 3];
835        toggle_selection(&mut sel, 2);
836        assert!(sel.contains(&2));
837        toggle_selection(&mut sel, 1);
838        assert!(!sel.contains(&1)); // estaba → se quita
839    }
840
841    #[test]
842    fn range_ordena_en_cualquier_direccion() {
843        assert_eq!(selection_range(2, 5).collect::<Vec<_>>(), vec![2, 3, 4, 5]);
844        assert_eq!(selection_range(5, 2).collect::<Vec<_>>(), vec![2, 3, 4, 5]);
845        assert_eq!(selection_range(3, 3).collect::<Vec<_>>(), vec![3]);
846    }
847
848    /// **La trampa de `con_dnd`, documentada donde vive.**
849    ///
850    /// `con_dnd` cablea el click **sólo si la fila tiene payload**, porque su
851    /// razón de ser es que `draggable` se come el `on_click` y hay que
852    /// re-emitirlo por `on_click_at`. Consecuencia que no es obvia leyendo la
853    /// firma: una fila que **recibe** arrastres pero no los **origina**
854    /// (`payload` → `None`) pasa por `con_dnd` y sale **muda**.
855    ///
856    /// Costó las «Colecciones» de nahual, que se pintaban enteras —nombre,
857    /// ícono, conteo, resaltado— y no respondían al click. Un pantallazo no lo
858    /// muestra: el píxel certifica el dibujo, no la respuesta.
859    ///
860    /// La forma correcta para una fila sin arrastre es poner su `on_click`
861    /// **en la fila** y usar `con_dnd` sólo por el `on_drop`.
862    #[test]
863    fn con_dnd_sin_payload_no_cablea_el_click() {
864        #[derive(Clone, PartialEq, Debug)]
865        enum M {
866            Click,
867            Drop,
868        }
869        let sin_payload = RowDnd::<M> {
870            payload: Arc::new(|_| None),
871            on_drop: Arc::new(|_, _| Some(M::Drop)),
872            hover: Color::TRANSPARENT,
873            carga: None,
874        };
875        // Se monta para poder mirar los handlers: `View` es un builder y sus
876        // campos no son públicos; el `MountedNode` es la forma que ve el runtime.
877        let montar = |v: View<M>| {
878            let mut t = llimphi_compositor::llimphi_layout::LayoutTree::new();
879            let m = llimphi_compositor::mount(&mut t, v);
880            m.nodes.into_iter().next().expect("un nodo")
881        };
882
883        let n = montar(con_dnd(View::new(Style::default()), 0, M::Click, &sin_payload));
884        assert!(
885            n.on_click_at.is_none() && n.on_click.is_none(),
886            "con payload None, `con_dnd` NO cablea el click — poné el on_click en la fila"
887        );
888        assert!(n.on_drop.is_some(), "pero sí recibe el drop, que es para lo que sirve acá");
889
890        // Con payload sí lo cablea: es la mitad de control, sin la cual el
891        // assert de arriba pasaría aunque `con_dnd` no hiciera nada nunca.
892        let con_payload = RowDnd::<M> {
893            payload: Arc::new(|_| Some(7)),
894            on_drop: Arc::new(|_, _| Some(M::Drop)),
895            hover: Color::TRANSPARENT,
896            carga: None,
897        };
898        let n = montar(con_dnd(View::new(Style::default()), 0, M::Click, &con_payload));
899        assert!(n.on_click_at.is_some(), "con payload, el click se re-emite por on_click_at");
900        assert_eq!(n.drag_payload, Some(7));
901    }
902}