tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Sutichaykuna

⎇ dba855e446ca2bd4e7d146bdc168fb232cad15a4 widgets/list/src/lib.rs Rust · 396 siq'ikuna · 12.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · 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
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}