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/tiled/src/lib.rs Rust · 334 líneas · 9.5 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · 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
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}