tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ 92dd0e5bfb2ca4456006da99437f4100e156d856 widgets/tiled/src/lib.rs Rust · 322 siq'ikuna · 9.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat: llimphi standalone — framework UI soberano extraído del monorepo e65e9cc62 · Sergio ·

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}