//! `llimphi-widget-tiled` — grilla auto cols×rows de tiles con title //! bar fija arriba. //! //! Cada tile es un panel rectangular con: //! - una franja superior (20 px) con `bg_panel_alt` + label centrado a //! la izquierda en `fg_muted`; //! - un cuerpo flex que aloja el `View` provisto por el caller. //! //! La grilla se calcula como `cols = ⌈√n⌉`, `rows = ⌈n/cols⌉` — mismo //! algoritmo que el `nahual-widget-tiled` GPUI. Las celdas son //! equipesos: `flex_grow = 1` sobre ambos ejes. //! //! ## Variantes //! //! - [`tiled_view`] — grilla estática, sin reordenamiento. //! - [`tiled_view_reorderable`] — drag-to-swap: arrastrar la title bar //! de un tile y soltar sobre otro emite `on_reorder(from, to)`. El //! tile destino se ilumina (`drop_hover_fill` = `accent`) mientras //! el cursor está sobre él durante el drag. Usa los primitives //! `drag_payload` + `on_drop` + `drop_hover_fill` de `llimphi-ui`. //! - [`tiled_view_cols`] / [`tiled_view_reorderable_cols`] — fuerzan el //! número de columnas (útil para sidebars verticales: `cols = 1`). #![forbid(unsafe_code)] // Sin `std`: este widget no usa nada del sistema —sólo `alloc` para // `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula // WASM o al kernel. Antes enlazaba `std` por omisión (W4.6). #![no_std] extern crate alloc; use alloc::string::String; use alloc::vec::Vec; use alloc::vec; use alloc::string::ToString; use alloc::sync::Arc; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{length, percent, Dimension, FlexDirection, Size, Style}, Rect, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::llimphi_text::Alignment; use llimphi_compositor::{DragPhase, View}; const TITLE_BAR_HEIGHT: f32 = 20.0; const TITLE_TEXT_SIZE: f32 = 10.0; const TILE_GAP: f32 = 4.0; const TILE_PADDING: f32 = 4.0; /// Paleta del tiled. #[derive(Debug, Clone, Copy)] pub struct TiledPalette { /// Fondo del container outer (visible en los gaps entre tiles). pub bg_outer: Color, /// Fondo del cuerpo del tile. pub bg_tile: Color, /// Fondo de la title bar del tile. pub bg_title: Color, /// Color del label de la title bar. pub fg_title: Color, /// Color del tile destino durante un drag (drop hover). pub bg_drop_hover: Color, } impl Default for TiledPalette { fn default() -> Self { Self::from_theme(&llimphi_theme::Theme::dark()) } } impl TiledPalette { pub fn from_theme(t: &llimphi_theme::Theme) -> Self { Self { bg_outer: t.bg_app, bg_tile: t.bg_panel, bg_title: t.bg_panel_alt, fg_title: t.fg_muted, bg_drop_hover: t.bg_selected, } } } /// Un tile de la grilla: label que va en la title bar + view del cuerpo. pub struct TileSpec { pub label: String, pub content: View, } type ReorderFn = Arc Option + Send + Sync>; /// Construye una grilla estática (sin drag-to-swap). Equivalente a /// [`tiled_view_reorderable`] sin handler de reorder. pub fn tiled_view(tiles: Vec>, palette: &TiledPalette) -> View where Msg: Clone + Send + Sync + 'static, { build(tiles, palette, None, None) } /// Construye una grilla con drag-to-swap. Arrastrar la title bar de un /// tile y soltar sobre otro invoca `on_reorder(from_index, to_index)`; /// el `Msg` retornado se dispatchea al `update` antes de cerrar el /// drag. El caller es responsable de filtrar `from == to`. pub fn tiled_view_reorderable( tiles: Vec>, on_reorder: F, palette: &TiledPalette, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(usize, usize) -> Option + Send + Sync + 'static, { build(tiles, palette, Some(Arc::new(on_reorder)), None) } /// Como [`tiled_view`] pero con número fijo de columnas. Útil para /// sidebars verticales (`cols = 1`) o filas horizontales (`cols = n`) /// donde el algoritmo auto-sqrt no sirve. `cols.max(1)` se aplica por /// seguridad. pub fn tiled_view_cols( tiles: Vec>, cols: usize, palette: &TiledPalette, ) -> View where Msg: Clone + Send + Sync + 'static, { build(tiles, palette, None, Some(cols)) } /// Como [`tiled_view_reorderable`] pero con número fijo de columnas. pub fn tiled_view_reorderable_cols( tiles: Vec>, cols: usize, on_reorder: F, palette: &TiledPalette, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(usize, usize) -> Option + Send + Sync + 'static, { build(tiles, palette, Some(Arc::new(on_reorder)), Some(cols)) } fn build( tiles: Vec>, palette: &TiledPalette, on_reorder: Option>, cols_override: Option, ) -> View where Msg: Clone + Send + Sync + 'static, { let n = tiles.len(); if n == 0 { return View::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32), }, ..Default::default() }) .fill(palette.bg_outer) .text( "(tiled vacío)".to_string(), 11.0, palette.fg_title, ); } let cols = cols_override .map(|c| c.max(1)) .unwrap_or_else(|| ((n as f32).sqrt().ceil() as usize).max(1)); let rows = (n + cols - 1) / cols; let mut tiles_iter = tiles.into_iter().enumerate(); let mut rows_views: Vec> = Vec::with_capacity(rows); for _r in 0..rows { let mut cells: Vec> = Vec::with_capacity(cols); for _c in 0..cols { let cell = match tiles_iter.next() { Some((idx, tile)) => tile_view(idx, tile, palette, on_reorder.clone()), None => empty_cell_view(palette), }; cells.push(cell); } rows_views.push(row_view(cells)); } View::new(Style { flex_direction: FlexDirection::Column, size: Size { width: percent(1.0_f32), height: percent(1.0_f32), }, gap: Size { width: length(0.0_f32), height: length(TILE_GAP), }, padding: Rect { left: length(TILE_PADDING), right: length(TILE_PADDING), top: length(TILE_PADDING), bottom: length(TILE_PADDING), }, ..Default::default() }) .fill(palette.bg_outer) .children(rows_views) } fn row_view(cells: Vec>) -> View { View::new(Style { flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: Dimension::auto(), }, flex_grow: 1.0, flex_basis: length(0.0_f32), min_size: Size { width: length(0.0_f32), height: length(0.0_f32), }, gap: Size { width: length(TILE_GAP), height: length(0.0_f32), }, ..Default::default() }) .children(cells) } fn tile_view( idx: usize, tile: TileSpec, palette: &TiledPalette, on_reorder: Option>, ) -> View where Msg: Clone + Send + Sync + 'static, { let mut title = View::new(Style { size: Size { width: percent(1.0_f32), height: length(TITLE_BAR_HEIGHT), }, flex_shrink: 0.0, padding: Rect { left: length(8.0_f32), right: length(8.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, ..Default::default() }) .fill(palette.bg_title) .text_aligned(tile.label, TITLE_TEXT_SIZE, palette.fg_title, Alignment::Start); // Si hay reorder, la title bar arrastra con payload = idx. if on_reorder.is_some() { // Handler trivial: tiled no usa dx/dy. Devuelve None. title = title .draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None) .drag_payload(idx as u64); } let body = View::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto(), }, flex_grow: 1.0, min_size: Size { width: length(0.0_f32), height: length(0.0_f32), }, ..Default::default() }) .children(vec![tile.content]); let mut tile_view = View::new(Style { flex_direction: FlexDirection::Column, size: Size { width: Dimension::auto(), height: percent(1.0_f32), }, flex_grow: 1.0, flex_basis: length(0.0_f32), min_size: Size { width: length(0.0_f32), height: length(0.0_f32), }, ..Default::default() }) .fill(palette.bg_tile) .radius(4.0) .clip(true) .children(vec![title, body]); // Drop target: si hay reorder, este tile entero recibe drops. if let Some(reorder) = on_reorder { let to_idx = idx; tile_view = tile_view .on_drop(move |from: u64| (reorder)(from as usize, to_idx)) .drop_hover_fill(palette.bg_drop_hover); } tile_view } fn empty_cell_view(palette: &TiledPalette) -> View where Msg: Clone + 'static, { View::new(Style { size: Size { width: Dimension::auto(), height: percent(1.0_f32), }, flex_grow: 1.0, flex_basis: length(0.0_f32), min_size: Size { width: length(0.0_f32), height: length(0.0_f32), }, ..Default::default() }) .fill(palette.bg_outer) }