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