tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
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
40use std::sync::Arc;
41
42use llimphi_ui::llimphi_layout::taffy::{
43 prelude::{length, percent, FlexDirection, Size, Style},
44 AlignItems, Rect,
45};
46use llimphi_ui::llimphi_raster::peniko::Color;
47use llimphi_ui::llimphi_text::Alignment;
48use llimphi_ui::{DragPhase, View};
49
50/// Paleta de la lista. Los defaults son una variante dark con selección
51/// azulada — equivalente conceptual a `nahual_theme` en su tema oscuro.
52#[derive(Debug, Clone, Copy)]
53pub struct ListPalette {
54 pub bg_panel: Color,
55 pub bg_selected: Color,
56 pub fg_text: Color,
57 pub fg_muted: Color,
58 /// Resalte de la fila destino mientras un drag de reorder pasa por
59 /// encima. Sólo se usa en [`reorderable_list_view`]. Default = accent
60 /// translúcido (40 %) sobre `bg_selected` — distinguible del hover y
61 /// la selección estable.
62 pub bg_drop_hover: Color,
63}
64
65impl Default for ListPalette {
66 fn default() -> Self {
67 Self::from_theme(&llimphi_theme::Theme::dark())
68 }
69}
70
71impl ListPalette {
72 /// Construye la paleta desde un `Theme` semántico.
73 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
74 // Resalte de drop = accent del theme con 40 % de opacidad
75 // multiplicada — gana sobre `bg_selected` por luminancia para que
76 // un drag sobre una fila ya seleccionada se note.
77 let mut drop = t.accent;
78 drop.components[3] *= 0.40;
79 Self {
80 bg_panel: t.bg_panel,
81 bg_selected: t.bg_selected,
82 fg_text: t.fg_text,
83 fg_muted: t.fg_muted,
84 bg_drop_hover: drop,
85 }
86 }
87}
88
89/// Una fila a renderear. `selected` cambia el fondo; `on_click` se emite al
90/// hacer click sobre cualquier parte de la fila.
91pub struct ListRow<Msg> {
92 pub label: String,
93 pub selected: bool,
94 pub on_click: Msg,
95}
96
97/// Especificación completa de la lista a renderear.
98pub struct ListSpec<Msg> {
99 /// Filas a renderear, ya filtradas a la ventana visible.
100 pub rows: Vec<ListRow<Msg>>,
101 /// Total de items del modelo (usado para el caption — la lista
102 /// mostrada puede ser un subconjunto virtualizado).
103 pub total: usize,
104 /// Caption opcional arriba de las filas (p. ej. "120 entradas").
105 pub caption: Option<String>,
106 /// Mensaje opcional al pie ("… y 12 más") cuando hay items fuera de
107 /// la ventana visible. El caller decide qué texto usar.
108 pub truncated_hint: Option<String>,
109 /// Altura de cada fila en pixels.
110 pub row_height: f32,
111 pub palette: ListPalette,
112}
113
114/// Compone la lista como un `View<Msg>`. El contenedor tiene `clip = true`
115/// para evitar overflow visual cuando el llamador subestima el tamaño
116/// disponible — las filas que excedan el área del panel se recortan.
117pub fn list_view<Msg: Clone + 'static>(spec: ListSpec<Msg>) -> View<Msg> {
118 let ListSpec {
119 rows,
120 total: _,
121 caption,
122 truncated_hint,
123 row_height,
124 palette,
125 } = spec;
126
127 let mut children: Vec<View<Msg>> = Vec::with_capacity(rows.len() + 2);
128
129 if let Some(text) = caption {
130 children.push(
131 View::new(Style {
132 size: Size {
133 width: percent(1.0_f32),
134 height: length(20.0_f32),
135 },
136 padding: Rect {
137 left: length(10.0_f32),
138 right: length(10.0_f32),
139 top: length(0.0_f32),
140 bottom: length(0.0_f32),
141 },
142 align_items: Some(AlignItems::Center),
143 ..Default::default()
144 })
145 .text_aligned(text, 10.0, palette.fg_muted, Alignment::Start),
146 );
147 }
148
149 for row in rows {
150 children.push(row_view(row, row_height, &palette));
151 }
152
153 if let Some(text) = truncated_hint {
154 children.push(
155 View::new(Style {
156 size: Size {
157 width: percent(1.0_f32),
158 height: length(16.0_f32),
159 },
160 padding: Rect {
161 left: length(10.0_f32),
162 right: length(10.0_f32),
163 top: length(0.0_f32),
164 bottom: length(0.0_f32),
165 },
166 ..Default::default()
167 })
168 .text_aligned(text, 10.0, palette.fg_muted, Alignment::Start),
169 );
170 }
171
172 View::new(Style {
173 flex_direction: FlexDirection::Column,
174 size: Size {
175 width: percent(1.0_f32),
176 height: percent(1.0_f32),
177 },
178 padding: Rect {
179 left: length(0.0_f32),
180 right: length(0.0_f32),
181 top: length(6.0_f32),
182 bottom: length(6.0_f32),
183 },
184 ..Default::default()
185 })
186 .fill(palette.bg_panel)
187 .clip(true)
188 .children(children)
189}
190
191fn row_view<Msg: Clone + 'static>(row: ListRow<Msg>, height: f32, palette: &ListPalette) -> View<Msg> {
192 let bg = if row.selected {
193 palette.bg_selected
194 } else {
195 palette.bg_panel
196 };
197 View::new(Style {
198 size: Size {
199 width: percent(1.0_f32),
200 height: length(height),
201 },
202 padding: Rect {
203 left: length(10.0_f32),
204 right: length(10.0_f32),
205 top: length(0.0_f32),
206 bottom: length(0.0_f32),
207 },
208 align_items: Some(AlignItems::Center),
209 ..Default::default()
210 })
211 .fill(bg)
212 .text_aligned(row.label, 12.0, palette.fg_text, Alignment::Start)
213 // Labels largos terminan en `…` (single-line) en vez de cortarse seco.
214 .ellipsis(1)
215 .on_click(row.on_click)
216}
217
218/// Función que el caller usa para reaccionar a un reorder. Recibe `(from,
219/// to)` — índices en `rows` — y devuelve el `Msg` a despachar (o `None`
220/// para ignorar el drop, p. ej. si `from == to`).
221pub type ReorderFn<Msg> = Arc<dyn Fn(usize, usize) -> Option<Msg> + Send + Sync>;
222
223/// Una fila para [`reorderable_list_view`]. Pesa más que [`ListRow`]
224/// porque cada fila acepta `on_click` opcional y siempre lleva drag
225/// handle al borde izquierdo (gripper `⋮⋮` en `fg_muted`) — convención
226/// kanban/Trello/Flutter `ReorderableListView`.
227pub struct ReorderableListRow<Msg> {
228 pub label: String,
229 pub selected: bool,
230 pub on_click: Option<Msg>,
231}
232
233/// Especificación de una lista reordenable por drag&drop (Bloque 14 de
234/// PARIDAD-FLUTTER, sigue Tier 5). Cada fila lleva un gripper a la
235/// izquierda; arrastrar una fila y soltarla sobre otra emite
236/// `on_reorder(from, to)`. La fila destino se ilumina con
237/// `palette.bg_drop_hover` mientras el cursor está sobre ella durante el
238/// drag.
239pub struct ReorderableListSpec<Msg> {
240 pub rows: Vec<ReorderableListRow<Msg>>,
241 pub caption: Option<String>,
242 pub row_height: f32,
243 pub palette: ListPalette,
244 pub on_reorder: ReorderFn<Msg>,
245}
246
247/// Compone una lista reordenable (Bloque 14). Patrón: cada fila exhibe
248/// un gripper al borde izquierdo y es `draggable` con `payload = idx`;
249/// la **fila entera** (no sólo el gripper) recibe drops con `on_drop` y
250/// `drop_hover_fill`. El handler `on_reorder(from, to)` cae al caller
251/// que decide qué `Msg` despachar — el widget no muta nada por sí solo.
252///
253/// Composición pura sobre los primitives `drag_payload` / `on_drop` /
254/// `drop_hover_fill` / `draggable` de `llimphi-ui` (ver `tiled` que
255/// reordena paneles bajo el mismo idiom).
256pub fn reorderable_list_view<Msg>(spec: ReorderableListSpec<Msg>) -> View<Msg>
257where
258 Msg: Clone + Send + Sync + 'static,
259{
260 let ReorderableListSpec {
261 rows,
262 caption,
263 row_height,
264 palette,
265 on_reorder,
266 } = spec;
267
268 let mut children: Vec<View<Msg>> = Vec::with_capacity(rows.len() + 1);
269
270 if let Some(text) = caption {
271 children.push(
272 View::new(Style {
273 size: Size {
274 width: percent(1.0_f32),
275 height: length(20.0_f32),
276 },
277 padding: Rect {
278 left: length(10.0_f32),
279 right: length(10.0_f32),
280 top: length(0.0_f32),
281 bottom: length(0.0_f32),
282 },
283 align_items: Some(AlignItems::Center),
284 ..Default::default()
285 })
286 .text_aligned(text, 10.0, palette.fg_muted, Alignment::Start),
287 );
288 }
289
290 for (idx, row) in rows.into_iter().enumerate() {
291 children.push(reorderable_row_view(idx, row, row_height, &palette, on_reorder.clone()));
292 }
293
294 View::new(Style {
295 flex_direction: FlexDirection::Column,
296 size: Size {
297 width: percent(1.0_f32),
298 height: percent(1.0_f32),
299 },
300 padding: Rect {
301 left: length(0.0_f32),
302 right: length(0.0_f32),
303 top: length(6.0_f32),
304 bottom: length(6.0_f32),
305 },
306 ..Default::default()
307 })
308 .fill(palette.bg_panel)
309 .clip(true)
310 .children(children)
311}
312
313fn reorderable_row_view<Msg>(
314 idx: usize,
315 row: ReorderableListRow<Msg>,
316 height: f32,
317 palette: &ListPalette,
318 on_reorder: ReorderFn<Msg>,
319) -> View<Msg>
320where
321 Msg: Clone + Send + Sync + 'static,
322{
323 let bg = if row.selected {
324 palette.bg_selected
325 } else {
326 palette.bg_panel
327 };
328
329 // Gripper `⋮⋮` al borde izquierdo, en `fg_muted` y arrastrable. El
330 // drag entrega `payload = idx`. Devolvemos `None` por evento de drag
331 // (no usamos dx/dy aquí — el destino se decide en el `on_drop` del
332 // otro nodo).
333 let gripper = View::new(Style {
334 size: Size {
335 width: length(20.0_f32),
336 height: percent(1.0_f32),
337 },
338 align_items: Some(AlignItems::Center),
339 ..Default::default()
340 })
341 .text_aligned("⋮⋮", 14.0, palette.fg_muted, Alignment::Center)
342 .draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None)
343 .drag_payload(idx as u64)
344 .cursor(llimphi_ui::Cursor::Grab);
345
346 // Etiqueta: ocupa el resto de la fila, con ellipsis y click opcional.
347 let mut label = View::new(Style {
348 size: Size {
349 width: percent(1.0_f32),
350 height: percent(1.0_f32),
351 },
352 align_items: Some(AlignItems::Center),
353 ..Default::default()
354 })
355 .text_aligned(row.label, 12.0, palette.fg_text, Alignment::Start)
356 .ellipsis(1);
357 if let Some(msg) = row.on_click {
358 label = label.on_click(msg);
359 }
360
361 // El **row entero** es target de drop. Cuando el cursor pasa por
362 // encima durante un drag, `drop_hover_fill` lo ilumina. Al soltar,
363 // emitimos el reorder si from != to.
364 let to_idx = idx;
365 let reorder = on_reorder.clone();
366 View::new(Style {
367 flex_direction: FlexDirection::Row,
368 size: Size {
369 width: percent(1.0_f32),
370 height: length(height),
371 },
372 padding: Rect {
373 left: length(4.0_f32),
374 right: length(10.0_f32),
375 top: length(0.0_f32),
376 bottom: length(0.0_f32),
377 },
378 align_items: Some(AlignItems::Center),
379 gap: Size {
380 width: length(6.0_f32),
381 height: length(0.0_f32),
382 },
383 ..Default::default()
384 })
385 .fill(bg)
386 .on_drop(move |from: u64| {
387 let from = from as usize;
388 if from == to_idx {
389 None
390 } else {
391 (reorder)(from, to_idx)
392 }
393 })
394 .drop_hover_fill(palette.bg_drop_hover)
395 .children(vec![gripper, label])
396}