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