tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! `llimphi-widget-tiled` — grilla auto cols×rows de tiles con title
2//! bar fija arriba.
3//!
4//! Cada tile es un panel rectangular con:
5//! - una franja superior (20 px) con `bg_panel_alt` + label centrado a
6//! la izquierda en `fg_muted`;
7//! - un cuerpo flex que aloja el `View<Msg>` provisto por el caller.
8//!
9//! La grilla se calcula como `cols = ⌈√n⌉`, `rows = ⌈n/cols⌉` — mismo
10//! algoritmo que el `nahual-widget-tiled` GPUI. Las celdas son
11//! equipesos: `flex_grow = 1` sobre ambos ejes.
12//!
13//! ## Variantes
14//!
15//! - [`tiled_view`] — grilla estática, sin reordenamiento.
16//! - [`tiled_view_reorderable`] — drag-to-swap: arrastrar la title bar
17//! de un tile y soltar sobre otro emite `on_reorder(from, to)`. El
18//! tile destino se ilumina (`drop_hover_fill` = `accent`) mientras
19//! el cursor está sobre él durante el drag. Usa los primitives
20//! `drag_payload` + `on_drop` + `drop_hover_fill` de `llimphi-ui`.
21//! - [`tiled_view_cols`] / [`tiled_view_reorderable_cols`] — fuerzan el
22//! número de columnas (útil para sidebars verticales: `cols = 1`).
23
24#![forbid(unsafe_code)]
25
26
27// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
28// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
29// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
30#![no_std]
31
32extern crate alloc;
33
34use alloc::string::String;
35use alloc::vec::Vec;
36use alloc::vec;
37use alloc::string::ToString;
38use alloc::sync::Arc;
39
40use llimphi_compositor::llimphi_layout::taffy::{
41 prelude::{length, percent, Dimension, FlexDirection, Size, Style},
42 Rect,
43};
44use llimphi_compositor::peniko::Color;
45use llimphi_compositor::llimphi_text::Alignment;
46use llimphi_compositor::{DragPhase, View};
47
48const TITLE_BAR_HEIGHT: f32 = 20.0;
49const TITLE_TEXT_SIZE: f32 = 10.0;
50const TILE_GAP: f32 = 4.0;
51const TILE_PADDING: f32 = 4.0;
52
53/// Paleta del tiled.
54#[derive(Debug, Clone, Copy)]
55pub struct TiledPalette {
56 /// Fondo del container outer (visible en los gaps entre tiles).
57 pub bg_outer: Color,
58 /// Fondo del cuerpo del tile.
59 pub bg_tile: Color,
60 /// Fondo de la title bar del tile.
61 pub bg_title: Color,
62 /// Color del label de la title bar.
63 pub fg_title: Color,
64 /// Color del tile destino durante un drag (drop hover).
65 pub bg_drop_hover: Color,
66}
67
68impl Default for TiledPalette {
69 fn default() -> Self {
70 Self::from_theme(&llimphi_theme::Theme::dark())
71 }
72}
73
74impl TiledPalette {
75 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
76 Self {
77 bg_outer: t.bg_app,
78 bg_tile: t.bg_panel,
79 bg_title: t.bg_panel_alt,
80 fg_title: t.fg_muted,
81 bg_drop_hover: t.bg_selected,
82 }
83 }
84}
85
86/// Un tile de la grilla: label que va en la title bar + view del cuerpo.
87pub struct TileSpec<Msg> {
88 pub label: String,
89 pub content: View<Msg>,
90}
91
92type ReorderFn<Msg> = Arc<dyn Fn(usize, usize) -> Option<Msg> + Send + Sync>;
93
94/// Construye una grilla estática (sin drag-to-swap). Equivalente a
95/// [`tiled_view_reorderable`] sin handler de reorder.
96pub fn tiled_view<Msg>(tiles: Vec<TileSpec<Msg>>, palette: &TiledPalette) -> View<Msg>
97where
98 Msg: Clone + Send + Sync + 'static,
99{
100 build(tiles, palette, None, None)
101}
102
103/// Construye una grilla con drag-to-swap. Arrastrar la title bar de un
104/// tile y soltar sobre otro invoca `on_reorder(from_index, to_index)`;
105/// el `Msg` retornado se dispatchea al `update` antes de cerrar el
106/// drag. El caller es responsable de filtrar `from == to`.
107pub fn tiled_view_reorderable<Msg, F>(
108 tiles: Vec<TileSpec<Msg>>,
109 on_reorder: F,
110 palette: &TiledPalette,
111) -> View<Msg>
112where
113 Msg: Clone + Send + Sync + 'static,
114 F: Fn(usize, usize) -> Option<Msg> + Send + Sync + 'static,
115{
116 build(tiles, palette, Some(Arc::new(on_reorder)), None)
117}
118
119/// Como [`tiled_view`] pero con número fijo de columnas. Útil para
120/// sidebars verticales (`cols = 1`) o filas horizontales (`cols = n`)
121/// donde el algoritmo auto-sqrt no sirve. `cols.max(1)` se aplica por
122/// seguridad.
123pub fn tiled_view_cols<Msg>(
124 tiles: Vec<TileSpec<Msg>>,
125 cols: usize,
126 palette: &TiledPalette,
127) -> View<Msg>
128where
129 Msg: Clone + Send + Sync + 'static,
130{
131 build(tiles, palette, None, Some(cols))
132}
133
134/// Como [`tiled_view_reorderable`] pero con número fijo de columnas.
135pub fn tiled_view_reorderable_cols<Msg, F>(
136 tiles: Vec<TileSpec<Msg>>,
137 cols: usize,
138 on_reorder: F,
139 palette: &TiledPalette,
140) -> View<Msg>
141where
142 Msg: Clone + Send + Sync + 'static,
143 F: Fn(usize, usize) -> Option<Msg> + Send + Sync + 'static,
144{
145 build(tiles, palette, Some(Arc::new(on_reorder)), Some(cols))
146}
147
148fn build<Msg>(
149 tiles: Vec<TileSpec<Msg>>,
150 palette: &TiledPalette,
151 on_reorder: Option<ReorderFn<Msg>>,
152 cols_override: Option<usize>,
153) -> View<Msg>
154where
155 Msg: Clone + Send + Sync + 'static,
156{
157 let n = tiles.len();
158 if n == 0 {
159 return View::new(Style {
160 size: Size {
161 width: percent(1.0_f32),
162 height: percent(1.0_f32),
163 },
164 ..Default::default()
165 })
166 .fill(palette.bg_outer)
167 .text(
168 "(tiled vacío)".to_string(),
169 11.0,
170 palette.fg_title,
171 );
172 }
173
174 let cols = cols_override
175 .map(|c| c.max(1))
176 .unwrap_or_else(|| ((n as f32).sqrt().ceil() as usize).max(1));
177 let rows = (n + cols - 1) / cols;
178
179 let mut tiles_iter = tiles.into_iter().enumerate();
180 let mut rows_views: Vec<View<Msg>> = Vec::with_capacity(rows);
181
182 for _r in 0..rows {
183 let mut cells: Vec<View<Msg>> = Vec::with_capacity(cols);
184 for _c in 0..cols {
185 let cell = match tiles_iter.next() {
186 Some((idx, tile)) => tile_view(idx, tile, palette, on_reorder.clone()),
187 None => empty_cell_view(palette),
188 };
189 cells.push(cell);
190 }
191 rows_views.push(row_view(cells));
192 }
193
194 View::new(Style {
195 flex_direction: FlexDirection::Column,
196 size: Size {
197 width: percent(1.0_f32),
198 height: percent(1.0_f32),
199 },
200 gap: Size {
201 width: length(0.0_f32),
202 height: length(TILE_GAP),
203 },
204 padding: Rect {
205 left: length(TILE_PADDING),
206 right: length(TILE_PADDING),
207 top: length(TILE_PADDING),
208 bottom: length(TILE_PADDING),
209 },
210 ..Default::default()
211 })
212 .fill(palette.bg_outer)
213 .children(rows_views)
214}
215
216fn row_view<Msg>(cells: Vec<View<Msg>>) -> View<Msg> {
217 View::new(Style {
218 flex_direction: FlexDirection::Row,
219 size: Size {
220 width: percent(1.0_f32),
221 height: Dimension::auto(),
222 },
223 flex_grow: 1.0,
224 flex_basis: length(0.0_f32),
225 min_size: Size {
226 width: length(0.0_f32),
227 height: length(0.0_f32),
228 },
229 gap: Size {
230 width: length(TILE_GAP),
231 height: length(0.0_f32),
232 },
233 ..Default::default()
234 })
235 .children(cells)
236}
237
238fn tile_view<Msg>(
239 idx: usize,
240 tile: TileSpec<Msg>,
241 palette: &TiledPalette,
242 on_reorder: Option<ReorderFn<Msg>>,
243) -> View<Msg>
244where
245 Msg: Clone + Send + Sync + 'static,
246{
247 let mut title = View::new(Style {
248 size: Size {
249 width: percent(1.0_f32),
250 height: length(TITLE_BAR_HEIGHT),
251 },
252 flex_shrink: 0.0,
253 padding: Rect {
254 left: length(8.0_f32),
255 right: length(8.0_f32),
256 top: length(0.0_f32),
257 bottom: length(0.0_f32),
258 },
259 ..Default::default()
260 })
261 .fill(palette.bg_title)
262 .text_aligned(tile.label, TITLE_TEXT_SIZE, palette.fg_title, Alignment::Start);
263
264 // Si hay reorder, la title bar arrastra con payload = idx.
265 if on_reorder.is_some() {
266 // Handler trivial: tiled no usa dx/dy. Devuelve None.
267 title = title
268 .draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None)
269 .drag_payload(idx as u64);
270 }
271
272 let body = View::new(Style {
273 size: Size {
274 width: percent(1.0_f32),
275 height: Dimension::auto(),
276 },
277 flex_grow: 1.0,
278 min_size: Size {
279 width: length(0.0_f32),
280 height: length(0.0_f32),
281 },
282 ..Default::default()
283 })
284 .children(vec![tile.content]);
285
286 let mut tile_view = View::new(Style {
287 flex_direction: FlexDirection::Column,
288 size: Size {
289 width: Dimension::auto(),
290 height: percent(1.0_f32),
291 },
292 flex_grow: 1.0,
293 flex_basis: length(0.0_f32),
294 min_size: Size {
295 width: length(0.0_f32),
296 height: length(0.0_f32),
297 },
298 ..Default::default()
299 })
300 .fill(palette.bg_tile)
301 .radius(4.0)
302 .clip(true)
303 .children(vec![title, body]);
304
305 // Drop target: si hay reorder, este tile entero recibe drops.
306 if let Some(reorder) = on_reorder {
307 let to_idx = idx;
308 tile_view = tile_view
309 .on_drop(move |from: u64| (reorder)(from as usize, to_idx))
310 .drop_hover_fill(palette.bg_drop_hover);
311 }
312
313 tile_view
314}
315
316fn empty_cell_view<Msg>(palette: &TiledPalette) -> View<Msg>
317where
318 Msg: Clone + 'static,
319{
320 View::new(Style {
321 size: Size {
322 width: Dimension::auto(),
323 height: percent(1.0_f32),
324 },
325 flex_grow: 1.0,
326 flex_basis: length(0.0_f32),
327 min_size: Size {
328 width: length(0.0_f32),
329 height: length(0.0_f32),
330 },
331 ..Default::default()
332 })
333 .fill(palette.bg_outer)
334}