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-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
20use llimphi_ui::llimphi_layout::taffy::{
21 prelude::{length, percent, Dimension, FlexDirection, Size, Style},
22 AlignItems, JustifyContent, Rect,
23};
24use llimphi_ui::llimphi_raster::peniko::Color;
25use llimphi_ui::llimphi_text::Alignment;
26use llimphi_ui::View;
27use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
28
29/// Alto de una fila editable (px).
30const ROW_H: f32 = 30.0;
31/// Alto del encabezado de tabla (px).
32const HEADER_H: f32 = 24.0;
33/// Alto del botón "agregar" (px).
34const ADD_H: f32 = 32.0;
35
36/// Paleta de la tabla: la del text-input de las celdas + colores del cromo.
37#[derive(Debug, Clone, Copy)]
38pub struct TablePalette {
39 /// Paleta de los inputs de celda.
40 pub input: TextInputPalette,
41 /// Color del texto de los encabezados.
42 pub header_fg: Color,
43 /// Color del glifo de "quitar fila".
44 pub remove_fg: Color,
45 /// Color del texto del botón "agregar".
46 pub add_fg: Color,
47 /// Borde del botón "agregar".
48 pub add_border: Color,
49 /// Relleno de hover de botones.
50 pub hover: Color,
51}
52
53impl Default for TablePalette {
54 fn default() -> Self {
55 Self::from_theme(&llimphi_theme::Theme::dark())
56 }
57}
58
59impl TablePalette {
60 /// Construye la paleta desde un `Theme` semántico.
61 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
62 Self {
63 input: TextInputPalette::from_theme(t),
64 header_fg: t.fg_placeholder,
65 remove_fg: t.fg_muted,
66 add_fg: t.accent,
67 add_border: t.border,
68 hover: t.bg_row_hover,
69 }
70 }
71}
72
73/// Alto total de una tabla/lista de `n_rows` filas (con o sin encabezado), para
74/// que un contenedor con scroll estime el alto del control.
75pub fn table_height(n_rows: usize, has_header: bool) -> f32 {
76 let head = if has_header { HEADER_H } else { 0.0 };
77 head + n_rows as f32 * ROW_H + ADD_H
78}
79
80/// Compone una **tabla** editable.
81///
82/// - `headers`: rótulos de columna. Si está vacío no se pinta encabezado (modo
83/// lista). Su largo fija el número de columnas; si está vacío, se infiere del
84/// ancho de la primera fila (o 1).
85/// - `rows`: el texto de cada celda.
86/// - `focused` / `focused_state`: la celda en edición y su buffer (prestado por
87/// el caller). El resto de las celdas se pintan desde `rows`.
88/// - `on_focus_cell(row, col)`: clic en una celda (el caller arranca a editarla).
89/// - `on_remove_row(row)` / `on_add_row()`: quitar/agregar fila.
90#[allow(clippy::too_many_arguments)]
91pub fn table_view<Msg, FFocus, FRemove, FAdd>(
92 headers: &[String],
93 rows: &[Vec<String>],
94 focused: Option<(usize, usize)>,
95 focused_state: Option<&TextInputState>,
96 add_label: &str,
97 palette: &TablePalette,
98 on_focus_cell: FFocus,
99 on_remove_row: FRemove,
100 on_add_row: FAdd,
101) -> View<Msg>
102where
103 Msg: Clone + Send + Sync + 'static,
104 FFocus: Fn(usize, usize) -> Msg + Clone + Send + Sync + 'static,
105 FRemove: Fn(usize) -> Msg + Clone + Send + Sync + 'static,
106 FAdd: Fn() -> Msg + Clone + Send + Sync + 'static,
107{
108 let ncols = if !headers.is_empty() {
109 headers.len()
110 } else {
111 rows.first().map(Vec::len).unwrap_or(1).max(1)
112 };
113
114 let mut children: Vec<View<Msg>> = Vec::with_capacity(rows.len() + 2);
115
116 // Encabezados (sólo si hay rótulos): una etiqueta por columna + hueco del
117 // botón "quitar".
118 if !headers.is_empty() {
119 let mut head: Vec<View<Msg>> = headers.iter().map(|h| flex_cell(header(h, palette))).collect();
120 head.push(remove_spacer());
121 children.push(row_container(head));
122 }
123
124 for (r, cells) in rows.iter().enumerate() {
125 let mut row_kids: Vec<View<Msg>> = Vec::with_capacity(ncols + 1);
126 for c in 0..ncols {
127 let text = cells.get(c).map(String::as_str).unwrap_or("");
128 let is_focused = focused == Some((r, c));
129 let st = if is_focused { focused_state } else { None };
130 let focus_msg = on_focus_cell(r, c);
131 row_kids.push(flex_cell(cell_input(text, is_focused, st, &palette.input, focus_msg)));
132 }
133 let remove_msg = on_remove_row(r);
134 row_kids.push(remove_button(remove_msg, palette));
135 children.push(row_container(row_kids));
136 }
137
138 children.push(add_button(add_label, on_add_row(), palette));
139 column_container(children)
140}
141
142/// Compone una **lista** editable: una sola columna sin encabezado.
143/// `on_focus_cell(row)` recibe sólo la fila (la columna es siempre 0).
144pub fn list_view<Msg, FFocus, FRemove, FAdd>(
145 items: &[String],
146 focused_row: Option<usize>,
147 focused_state: Option<&TextInputState>,
148 add_label: &str,
149 palette: &TablePalette,
150 on_focus_cell: FFocus,
151 on_remove_row: FRemove,
152 on_add_row: FAdd,
153) -> View<Msg>
154where
155 Msg: Clone + Send + Sync + 'static,
156 FFocus: Fn(usize) -> Msg + Clone + Send + Sync + 'static,
157 FRemove: Fn(usize) -> Msg + Clone + Send + Sync + 'static,
158 FAdd: Fn() -> Msg + Clone + Send + Sync + 'static,
159{
160 let rows: Vec<Vec<String>> = items.iter().map(|s| vec![s.clone()]).collect();
161 let focused = focused_row.map(|r| (r, 0usize));
162 table_view(
163 &[],
164 &rows,
165 focused,
166 focused_state,
167 add_label,
168 palette,
169 move |r, _c| on_focus_cell(r),
170 on_remove_row,
171 on_add_row,
172 )
173}
174
175/// Un input de celda: como [`text_input_view`] pero con el foco provisto por el
176/// caller (no por un `FieldPath`). Si la celda está focada usa el buffer
177/// prestado; si no, pinta un input estático sembrado con su texto.
178fn cell_input<Msg: Clone + 'static>(
179 text: &str,
180 focused: bool,
181 state: Option<&TextInputState>,
182 palette: &TextInputPalette,
183 focus_msg: Msg,
184) -> View<Msg> {
185 if focused {
186 if let Some(st) = state {
187 return text_input_view(st, "", true, palette, focus_msg);
188 }
189 }
190 let mut tmp = TextInputState::new();
191 tmp.set_text(text);
192 text_input_view(&tmp, "", false, palette, focus_msg)
193}
194
195/// Envuelve una celda repartiendo el ancho en partes iguales entre columnas.
196fn flex_cell<Msg: Clone + 'static>(child: View<Msg>) -> View<Msg> {
197 View::new(Style {
198 flex_grow: 1.0,
199 flex_basis: length(0.0_f32),
200 size: Size {
201 width: Dimension::auto(),
202 height: Dimension::auto(),
203 },
204 min_size: Size {
205 width: length(0.0_f32),
206 height: Dimension::auto(),
207 },
208 ..Default::default()
209 })
210 .children(vec![child])
211}
212
213/// Una fila horizontal (celdas + botón quitar).
214fn row_container<Msg: Clone + 'static>(children: Vec<View<Msg>>) -> View<Msg> {
215 View::new(Style {
216 flex_direction: FlexDirection::Row,
217 size: Size {
218 width: percent(1.0_f32),
219 height: Dimension::auto(),
220 },
221 align_items: Some(AlignItems::Center),
222 gap: Size {
223 width: length(6.0_f32),
224 height: length(0.0_f32),
225 },
226 ..Default::default()
227 })
228 .children(children)
229}
230
231/// El contenedor vertical de la tabla/lista.
232fn column_container<Msg: Clone + 'static>(rows: Vec<View<Msg>>) -> View<Msg> {
233 View::new(Style {
234 flex_direction: FlexDirection::Column,
235 size: Size {
236 width: percent(1.0_f32),
237 height: Dimension::auto(),
238 },
239 gap: Size {
240 width: length(0.0_f32),
241 height: length(4.0_f32),
242 },
243 ..Default::default()
244 })
245 .children(rows)
246}
247
248/// El encabezado de una columna.
249fn header<Msg: Clone + 'static>(label: &str, palette: &TablePalette) -> View<Msg> {
250 View::new(Style {
251 size: Size {
252 width: percent(1.0_f32),
253 height: length(16.0_f32),
254 },
255 ..Default::default()
256 })
257 .text_aligned(label.to_string(), 10.5, palette.header_fg, Alignment::Start)
258}
259
260/// El botón cuadrado de "quitar fila" (`×`, U+00D7 — la fuente del SO sí lo
261/// trae, a diferencia de `✕`).
262fn remove_button<Msg: Clone + 'static>(msg: Msg, palette: &TablePalette) -> View<Msg> {
263 View::new(Style {
264 size: Size {
265 width: length(26.0_f32),
266 height: length(26.0_f32),
267 },
268 flex_shrink: 0.0,
269 align_items: Some(AlignItems::Center),
270 justify_content: Some(JustifyContent::Center),
271 ..Default::default()
272 })
273 .radius(5.0)
274 .hover_fill(palette.hover)
275 .on_click(msg)
276 .text_aligned("×".to_string(), 15.0, palette.remove_fg, Alignment::Center)
277}
278
279/// Un hueco del ancho del botón quitar, para alinear el encabezado.
280fn remove_spacer<Msg: Clone + 'static>() -> View<Msg> {
281 View::new(Style {
282 size: Size {
283 width: length(26.0_f32),
284 height: length(16.0_f32),
285 },
286 flex_shrink: 0.0,
287 ..Default::default()
288 })
289}
290
291/// El botón "agregar" al pie.
292fn add_button<Msg: Clone + 'static>(label: &str, msg: Msg, palette: &TablePalette) -> View<Msg> {
293 View::new(Style {
294 size: Size {
295 width: Dimension::auto(),
296 height: length(26.0_f32),
297 },
298 align_self: Some(AlignItems::Start),
299 align_items: Some(AlignItems::Center),
300 justify_content: Some(JustifyContent::Center),
301 padding: Rect {
302 left: length(10.0_f32),
303 right: length(10.0_f32),
304 top: length(0.0_f32),
305 bottom: length(0.0_f32),
306 },
307 margin: Rect {
308 left: length(0.0_f32),
309 right: length(0.0_f32),
310 top: length(2.0_f32),
311 bottom: length(0.0_f32),
312 },
313 ..Default::default()
314 })
315 .radius(6.0)
316 .border(1.0, palette.add_border)
317 .hover_fill(palette.hover)
318 .on_click(msg)
319 .text_aligned(label.to_string(), 11.5, palette.add_fg, Alignment::Center)
320}