tawasuyu

tawasuyu/llimphi

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

Rust 484Markdown 258TOML 131RON 38118 Definiciones

⎇ main widgets/table/src/lib.rs Rust · 437 líneas · 15.0 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! `llimphi-widget-table` — tabla y lista editables para Llimphi.
2//!
3//! Una grilla de celdas-texto: encabezados opcionales por columna, una fila por
4//! registro con botón **quitar** y un botón **agregar** al pie. La variante
5//! [`list_view`] es el caso de una sola columna sin encabezado.
6//!
7//! **Stateless por diseño.** Como [`text_input_view`], el widget no posee el
8//! foco ni los buffers de edición: el caller le dice cuál celda está focada
9//! (`focused`) y le presta su [`TextInputState`] (`focused_state`); el resto de
10//! las celdas se pintan desde su texto. El tecleo lo enruta el caller a su
11//! `TextInputState` y reconstruye el valor — igual que con un text-input suelto.
12//!
13//! Es agnóstico: no sabe de config. Emite el `Msg` del caller por tres
14//! callbacks (`on_focus_cell`, `on_remove_row`, `on_add_row`). El protocolo de
15//! "qué cambió" (reemplazar una celda, sumar/quitar una fila) lo decide el
16//! caller; el widget sólo dispara el evento con la coordenada.
17
18#![forbid(unsafe_code)]
19
20
21// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
22// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
23// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
24#![no_std]
25
26extern crate alloc;
27
28use alloc::string::String;
29use alloc::vec::Vec;
30use alloc::vec;
31use alloc::string::ToString;
32use llimphi_compositor::llimphi_layout::taffy::{
33    prelude::{length, percent, Dimension, FlexDirection, Size, Style},
34    AlignItems, JustifyContent, Rect,
35};
36use llimphi_compositor::peniko::Color;
37use llimphi_compositor::llimphi_text::Alignment;
38use llimphi_compositor::View;
39use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
40
41/// Alto de una fila editable (px).
42const ROW_H: f32 = 30.0;
43/// Alto del encabezado de tabla (px).
44const HEADER_H: f32 = 24.0;
45/// Alto del botón "agregar" (px).
46const ADD_H: f32 = 32.0;
47
48/// Paleta de la tabla: la del text-input de las celdas + colores del cromo.
49#[derive(Debug, Clone, Copy)]
50pub struct TablePalette {
51    /// Paleta de los inputs de celda.
52    pub input: TextInputPalette,
53    /// Color del texto de los encabezados.
54    pub header_fg: Color,
55    /// Color del glifo de "quitar fila".
56    pub remove_fg: Color,
57    /// Color del texto del botón "agregar".
58    pub add_fg: Color,
59    /// Borde del botón "agregar".
60    pub add_border: Color,
61    /// Relleno de hover de botones.
62    pub hover: Color,
63}
64
65impl Default for TablePalette {
66    fn default() -> Self {
67        Self::from_theme(&llimphi_theme::Theme::dark())
68    }
69}
70
71impl TablePalette {
72    /// Construye la paleta desde un `Theme` semántico.
73    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
74        Self {
75            input: TextInputPalette::from_theme(t),
76            header_fg: t.fg_placeholder,
77            remove_fg: t.fg_muted,
78            add_fg: t.accent,
79            add_border: t.border,
80            hover: t.bg_row_hover,
81        }
82    }
83}
84
85/// Alto total de una tabla/lista de `n_rows` filas (con o sin encabezado), para
86/// que un contenedor con scroll estime el alto del control.
87pub fn table_height(n_rows: usize, has_header: bool) -> f32 {
88    let head = if has_header { HEADER_H } else { 0.0 };
89    head + n_rows as f32 * ROW_H + ADD_H
90}
91
92/// Compone una **tabla** editable.
93///
94/// - `headers`: rótulos de columna. Si está vacío no se pinta encabezado (modo
95///   lista). Su largo fija el número de columnas; si está vacío, se infiere del
96///   ancho de la primera fila (o 1).
97/// - `rows`: el texto de cada celda.
98/// - `focused` / `focused_state`: la celda en edición y su buffer (prestado por
99///   el caller). El resto de las celdas se pintan desde `rows`.
100/// - `on_focus_cell(row, col)`: clic en una celda (el caller arranca a editarla).
101/// - `on_remove_row(row)` / `on_add_row()`: quitar/agregar fila.
102#[allow(clippy::too_many_arguments)]
103pub fn table_view<Msg, FFocus, FRemove, FAdd>(
104    headers: &[String],
105    rows: &[Vec<String>],
106    focused: Option<(usize, usize)>,
107    focused_state: Option<&TextInputState>,
108    add_label: &str,
109    palette: &TablePalette,
110    on_focus_cell: FFocus,
111    on_remove_row: FRemove,
112    on_add_row: FAdd,
113) -> View<Msg>
114where
115    Msg: Clone + Send + Sync + 'static,
116    FFocus: Fn(usize, usize) -> Msg + Clone + Send + Sync + 'static,
117    FRemove: Fn(usize) -> Msg + Clone + Send + Sync + 'static,
118    FAdd: Fn() -> Msg + Clone + Send + Sync + 'static,
119{
120    let ncols = if !headers.is_empty() {
121        headers.len()
122    } else {
123        rows.first().map(Vec::len).unwrap_or(1).max(1)
124    };
125
126    let mut children: Vec<View<Msg>> = Vec::with_capacity(rows.len() + 2);
127
128    // Encabezados (sólo si hay rótulos): una etiqueta por columna + hueco del
129    // botón "quitar".
130    if !headers.is_empty() {
131        let mut head: Vec<View<Msg>> = headers.iter().map(|h| flex_cell(header(h, palette))).collect();
132        head.push(remove_spacer());
133        children.push(row_container(head));
134    }
135
136    for (r, cells) in rows.iter().enumerate() {
137        let mut row_kids: Vec<View<Msg>> = Vec::with_capacity(ncols + 1);
138        for c in 0..ncols {
139            let text = cells.get(c).map(String::as_str).unwrap_or("");
140            let is_focused = focused == Some((r, c));
141            let st = if is_focused { focused_state } else { None };
142            let focus_msg = on_focus_cell(r, c);
143            row_kids.push(flex_cell(cell_input(text, is_focused, st, &palette.input, focus_msg)));
144        }
145        let remove_msg = on_remove_row(r);
146        row_kids.push(remove_button(remove_msg, palette));
147        children.push(row_container(row_kids));
148    }
149
150    children.push(add_button(add_label, on_add_row(), palette));
151    column_container(children)
152}
153
154/// Compone una **lista** editable: una sola columna sin encabezado.
155/// `on_focus_cell(row)` recibe sólo la fila (la columna es siempre 0).
156pub fn list_view<Msg, FFocus, FRemove, FAdd>(
157    items: &[String],
158    focused_row: Option<usize>,
159    focused_state: Option<&TextInputState>,
160    add_label: &str,
161    palette: &TablePalette,
162    on_focus_cell: FFocus,
163    on_remove_row: FRemove,
164    on_add_row: FAdd,
165) -> View<Msg>
166where
167    Msg: Clone + Send + Sync + 'static,
168    FFocus: Fn(usize) -> Msg + Clone + Send + Sync + 'static,
169    FRemove: Fn(usize) -> Msg + Clone + Send + Sync + 'static,
170    FAdd: Fn() -> Msg + Clone + Send + Sync + 'static,
171{
172    let rows: Vec<Vec<String>> = items.iter().map(|s| vec![s.clone()]).collect();
173    let focused = focused_row.map(|r| (r, 0usize));
174    table_view(
175        &[],
176        &rows,
177        focused,
178        focused_state,
179        add_label,
180        palette,
181        move |r, _c| on_focus_cell(r),
182        on_remove_row,
183        on_add_row,
184    )
185}
186
187/// Un input de celda: como [`text_input_view`] pero con el foco provisto por el
188/// caller (no por un `FieldPath`). Si la celda está focada usa el buffer
189/// prestado; si no, pinta un input estático sembrado con su texto.
190fn cell_input<Msg: Clone + 'static>(
191    text: &str,
192    focused: bool,
193    state: Option<&TextInputState>,
194    palette: &TextInputPalette,
195    focus_msg: Msg,
196) -> View<Msg> {
197    if focused {
198        if let Some(st) = state {
199            return text_input_view(st, "", true, palette, focus_msg);
200        }
201    }
202    let mut tmp = TextInputState::new();
203    tmp.set_text(text);
204    text_input_view(&tmp, "", false, palette, focus_msg)
205}
206
207/// Envuelve una celda repartiendo el ancho en partes iguales entre columnas.
208fn flex_cell<Msg: Clone + 'static>(child: View<Msg>) -> View<Msg> {
209    View::new(Style {
210        flex_grow: 1.0,
211        flex_basis: length(0.0_f32),
212        size: Size {
213            width: Dimension::auto(),
214            height: Dimension::auto(),
215        },
216        min_size: Size {
217            width: length(0.0_f32),
218            height: Dimension::auto(),
219        },
220        ..Default::default()
221    })
222    .role(llimphi_compositor::Role::Cell)
223    .children(vec![child])
224}
225
226/// Una fila horizontal (celdas + botón quitar).
227fn row_container<Msg: Clone + 'static>(children: Vec<View<Msg>>) -> View<Msg> {
228    View::new(Style {
229        flex_direction: FlexDirection::Row,
230        size: Size {
231            width: percent(1.0_f32),
232            height: Dimension::auto(),
233        },
234        align_items: Some(AlignItems::CENTER),
235        gap: Size {
236            width: length(6.0_f32),
237            height: length(0.0_f32),
238        },
239        ..Default::default()
240    })
241    .role(llimphi_compositor::Role::Row)
242    .children(children)
243}
244
245/// El contenedor vertical de la tabla/lista.
246fn column_container<Msg: Clone + 'static>(rows: Vec<View<Msg>>) -> View<Msg> {
247    View::new(Style {
248        flex_direction: FlexDirection::Column,
249        size: Size {
250            width: percent(1.0_f32),
251            height: Dimension::auto(),
252        },
253        gap: Size {
254            width: length(0.0_f32),
255            height: length(4.0_f32),
256        },
257        ..Default::default()
258    })
259    .role(llimphi_compositor::Role::Table)
260    .children(rows)
261}
262
263/// El encabezado de una columna.
264fn header<Msg: Clone + 'static>(label: &str, palette: &TablePalette) -> View<Msg> {
265    View::new(Style {
266        size: Size {
267            width: percent(1.0_f32),
268            height: length(16.0_f32),
269        },
270        ..Default::default()
271    })
272    .text_aligned(label.to_string(), 10.5, palette.header_fg, Alignment::Start)
273    .role(llimphi_compositor::Role::ColumnHeader)
274}
275
276/// El botón cuadrado de "quitar fila" (`×`, U+00D7 — la fuente del SO sí lo
277/// trae, a diferencia de `✕`).
278fn remove_button<Msg: Clone + 'static>(msg: Msg, palette: &TablePalette) -> View<Msg> {
279    View::new(Style {
280        size: Size {
281            width: length(26.0_f32),
282            height: length(26.0_f32),
283        },
284        flex_shrink: 0.0,
285        align_items: Some(AlignItems::CENTER),
286        justify_content: Some(JustifyContent::CENTER),
287        ..Default::default()
288    })
289    .radius(5.0)
290    .hover_fill(palette.hover)
291    .on_click(msg)
292    .role(llimphi_compositor::Role::Button)
293    .aria_label("Quitar fila")
294    .text_aligned("×".to_string(), 15.0, palette.remove_fg, Alignment::Center)
295}
296
297/// Un hueco del ancho del botón quitar, para alinear el encabezado.
298fn remove_spacer<Msg: Clone + 'static>() -> View<Msg> {
299    View::new(Style {
300        size: Size {
301            width: length(26.0_f32),
302            height: length(16.0_f32),
303        },
304        flex_shrink: 0.0,
305        ..Default::default()
306    })
307}
308
309/// El botón "agregar" al pie.
310fn add_button<Msg: Clone + 'static>(label: &str, msg: Msg, palette: &TablePalette) -> View<Msg> {
311    View::new(Style {
312        size: Size {
313            width: Dimension::auto(),
314            height: length(26.0_f32),
315        },
316        align_self: Some(AlignItems::START),
317        align_items: Some(AlignItems::CENTER),
318        justify_content: Some(JustifyContent::CENTER),
319        padding: Rect {
320            left: length(10.0_f32),
321            right: length(10.0_f32),
322            top: length(0.0_f32),
323            bottom: length(0.0_f32),
324        },
325        margin: Rect {
326            left: length(0.0_f32),
327            right: length(0.0_f32),
328            top: length(2.0_f32),
329            bottom: length(0.0_f32),
330        },
331        ..Default::default()
332    })
333    .radius(6.0)
334    .border(1.0, palette.add_border)
335    .hover_fill(palette.hover)
336    .on_click(msg)
337    .text_aligned(label.to_string(), 11.5, palette.add_fg, Alignment::Center)
338}
339
340// ─────────────────────────────────────────────────────────────────────────
341// Navegación de celda estilo planilla (helper puro, lado host)
342// ─────────────────────────────────────────────────────────────────────────
343
344/// Un movimiento del foco de celda en la grilla editable, como en una planilla.
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub enum CellMove {
347    /// Flechas: mueve una celda, clampeado a los bordes.
348    Up,
349    Down,
350    Left,
351    Right,
352    /// Tab: siguiente celda; al final de la fila salta al inicio de la
353    /// siguiente; en la última celda se queda.
354    Tab,
355    /// Shift+Tab: celda anterior; al inicio de la fila salta al final de la
356    /// anterior; en la primera celda se queda.
357    ShiftTab,
358    /// Enter: baja una fila (misma columna), clampeado.
359    Enter,
360}
361
362/// Mueve el foco `(row, col)` según `mv` dentro de una grilla de `nrows`×`ncols`.
363/// Las flechas y Enter clampean a los bordes; Tab/Shift+Tab avanzan en orden de
364/// lectura sin envolver más allá del último/primer registro. Pura y testeable —
365/// el host la cablea en `on_key` y dispatcha `on_focus_cell(r, c)` con el
366/// resultado.
367pub fn next_cell(row: usize, col: usize, nrows: usize, ncols: usize, mv: CellMove) -> (usize, usize) {
368    if nrows == 0 || ncols == 0 {
369        return (0, 0);
370    }
371    let last_r = nrows - 1;
372    let last_c = ncols - 1;
373    match mv {
374        CellMove::Up => (row.saturating_sub(1), col.min(last_c)),
375        CellMove::Down => ((row + 1).min(last_r), col.min(last_c)),
376        CellMove::Left => (row.min(last_r), col.saturating_sub(1)),
377        CellMove::Right => (row.min(last_r), (col + 1).min(last_c)),
378        CellMove::Enter => ((row + 1).min(last_r), col.min(last_c)),
379        CellMove::Tab => {
380            if col < last_c {
381                (row, col + 1)
382            } else if row < last_r {
383                (row + 1, 0)
384            } else {
385                (last_r, last_c) // fin de la grilla: se queda
386            }
387        }
388        CellMove::ShiftTab => {
389            if col > 0 {
390                (row.min(last_r), col - 1)
391            } else if row > 0 {
392                (row - 1, last_c)
393            } else {
394                (0, 0) // inicio de la grilla: se queda
395            }
396        }
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn flechas_clampan_en_los_bordes() {
406        // grilla 3 filas × 2 cols
407        assert_eq!(next_cell(0, 0, 3, 2, CellMove::Up), (0, 0));
408        assert_eq!(next_cell(0, 0, 3, 2, CellMove::Down), (1, 0));
409        assert_eq!(next_cell(2, 1, 3, 2, CellMove::Down), (2, 1)); // tope
410        assert_eq!(next_cell(1, 0, 3, 2, CellMove::Left), (1, 0)); // piso col
411        assert_eq!(next_cell(1, 0, 3, 2, CellMove::Right), (1, 1));
412    }
413
414    #[test]
415    fn tab_avanza_en_orden_de_lectura() {
416        assert_eq!(next_cell(0, 0, 3, 2, CellMove::Tab), (0, 1));
417        assert_eq!(next_cell(0, 1, 3, 2, CellMove::Tab), (1, 0)); // salta de fila
418        assert_eq!(next_cell(2, 1, 3, 2, CellMove::Tab), (2, 1)); // fin: se queda
419    }
420
421    #[test]
422    fn shift_tab_retrocede() {
423        assert_eq!(next_cell(1, 0, 3, 2, CellMove::ShiftTab), (0, 1)); // sube de fila
424        assert_eq!(next_cell(0, 0, 3, 2, CellMove::ShiftTab), (0, 0)); // inicio: se queda
425    }
426
427    #[test]
428    fn enter_baja_una_fila() {
429        assert_eq!(next_cell(0, 1, 3, 2, CellMove::Enter), (1, 1));
430        assert_eq!(next_cell(2, 1, 3, 2, CellMove::Enter), (2, 1)); // tope
431    }
432
433    #[test]
434    fn grilla_vacia_es_origen() {
435        assert_eq!(next_cell(0, 0, 0, 0, CellMove::Tab), (0, 0));
436    }
437}