tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-workspace/src/lib.rs Rust · 378 líneas · 10.5 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! `llimphi-workspace` — chasis genérico estilo tmux.
2//!
3//! Paso 2 de la visión "montar cualquier componente de tawasuyu en un layout
4//! intercambiable con splits resizables". Donde [`llimphi_widget_panes`]
5//! aporta el **árbol** (estructura + render + drag), este crate aporta la
6//! **máquina de estados** (qué panel está enfocado, cómo se parte/cierra,
7//! el contador de ids) + el **chrome estándar** (toolbar split/cerrar).
8//!
9//! ## Cómo lo usa una app
10//!
11//! La app guarda un [`Workspace`] en su `Model` y un `HashMap<PaneId, …>`
12//! con el estado de cada panel. Su `Msg` envuelve dos cosas:
13//!
14//! ```ignore
15//! enum Msg {
16//!     Ws(WsMsg),                 // mensajes del chasis (focus/split/…)
17//!     Panel(PaneId, PanelMsg),   // mensajes de un panel concreto
18//! }
19//! ```
20//!
21//! En `update`, los `Ws` se aplican con [`Workspace::apply`], que devuelve
22//! un [`WsEffect`] indicando si hay que **crear** el estado de un panel
23//! nuevo o **borrar** el de uno cerrado. En `view`, [`workspace_view`] arma
24//! el chrome + el árbol; la app sólo provee el contenido de cada hoja (ya
25//! lifteado a su propio `Msg` — el chasis no toca los `PanelMsg`).
26//!
27//! El lift se hace al construir la vista (igual que `shuma-module`), así
28//! sorteamos la falta de `View::map` sin `Box<dyn Any>`: el chasis es
29//! genérico sobre el `Msg` del host y nunca downcastea.
30
31#![forbid(unsafe_code)]
32
33use llimphi_ui::llimphi_layout::taffy::{
34    prelude::{length, percent, Dimension, FlexDirection, Size, Style},
35    Rect,
36};
37use llimphi_ui::llimphi_raster::peniko::Color;
38use llimphi_ui::View;
39use llimphi_widget_panes::{panes_view, Layout, PanesPalette};
40
41pub use llimphi_widget_panes::{Axis, PaneId, Side};
42
43/// Estado del workspace: el árbol de paneles + cuál está enfocado + el
44/// contador para asignar ids nuevos. Agnóstico del contenido — el host
45/// guarda el estado real de cada panel por su `PaneId`.
46#[derive(Debug, Clone)]
47pub struct Workspace {
48    layout: Layout,
49    focused: PaneId,
50    next_id: PaneId,
51}
52
53impl Workspace {
54    /// Workspace con un único panel (id `0`).
55    pub fn new() -> Self {
56        Self {
57            layout: Layout::single(0),
58            focused: 0,
59            next_id: 1,
60        }
61    }
62
63    /// Id del panel enfocado.
64    pub fn focused(&self) -> PaneId {
65        self.focused
66    }
67
68    /// Cantidad de paneles.
69    pub fn count(&self) -> usize {
70        self.layout.count()
71    }
72
73    /// Ids de todos los paneles, en orden espacial.
74    pub fn leaves(&self) -> Vec<PaneId> {
75        self.layout.leaves()
76    }
77
78    /// El árbol crudo (para casos avanzados; lo normal es [`workspace_view`]).
79    pub fn layout(&self) -> &Layout {
80        &self.layout
81    }
82
83    /// Enfoca un panel (no-op si no existe).
84    pub fn focus(&mut self, id: PaneId) {
85        if self.layout.contains(id) {
86            self.focused = id;
87        }
88    }
89
90    /// Parte el panel enfocado en `axis`; el nuevo queda enfocado. Devuelve
91    /// el `PaneId` nuevo para que el host cree su estado.
92    pub fn split(&mut self, axis: Axis) -> PaneId {
93        let id = self.next_id;
94        self.next_id += 1;
95        self.layout.split(self.focused, id, axis);
96        self.focused = id;
97        id
98    }
99
100    /// Cierra el panel enfocado (no cierra el último). Devuelve el id
101    /// removido para que el host libere su estado, o `None` si no removió.
102    pub fn close(&mut self) -> Option<PaneId> {
103        if self.count() <= 1 {
104            return None;
105        }
106        let target = self.focused;
107        let (nl, removed) = self.layout.clone().without(target);
108        if removed {
109            self.layout = nl;
110            self.focused = self.layout.first_leaf();
111            Some(target)
112        } else {
113            None
114        }
115    }
116
117    /// Ajusta el ratio del split direccionado por `path`.
118    pub fn resize(&mut self, path: &[Side], delta: f32) {
119        self.layout.resize(path, delta);
120    }
121
122    /// Aplica un mensaje del chasis y reporta el efecto a atender.
123    pub fn apply(&mut self, msg: WsMsg) -> WsEffect {
124        match msg {
125            WsMsg::Focus(id) => {
126                self.focus(id);
127                WsEffect::None
128            }
129            WsMsg::Split(axis) => WsEffect::Created(self.split(axis)),
130            WsMsg::Close => match self.close() {
131                Some(id) => WsEffect::Closed(id),
132                None => WsEffect::None,
133            },
134            WsMsg::Resize(path, d) => {
135                self.resize(&path, d);
136                WsEffect::None
137            }
138        }
139    }
140}
141
142impl Default for Workspace {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148/// Mensajes del chasis. El host los envuelve en su propio `Msg` y los rutea
149/// a [`Workspace::apply`].
150#[derive(Debug, Clone, PartialEq)]
151pub enum WsMsg {
152    Focus(PaneId),
153    Split(Axis),
154    Close,
155    Resize(Vec<Side>, f32),
156}
157
158/// Resultado de [`Workspace::apply`] — qué tiene que hacer el host con su
159/// mapa de estados de panel.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum WsEffect {
162    /// Nada que hacer.
163    None,
164    /// Se creó un panel nuevo con este id: inicializa su estado.
165    Created(PaneId),
166    /// Se cerró este panel: borra su estado.
167    Closed(PaneId),
168}
169
170/// Paleta del chasis.
171#[derive(Debug, Clone, Copy)]
172pub struct WorkspacePalette {
173    pub panes: PanesPalette,
174    pub bar_bg: Color,
175    pub btn_bg: Color,
176    pub btn_hover: Color,
177    pub label: Color,
178    pub muted: Color,
179}
180
181impl Default for WorkspacePalette {
182    fn default() -> Self {
183        Self::from_theme(&llimphi_theme::Theme::dark())
184    }
185}
186
187impl WorkspacePalette {
188    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
189        Self {
190            panes: PanesPalette::from_theme(t),
191            bar_bg: t.bg_panel,
192            btn_bg: t.bg_button,
193            btn_hover: t.bg_button_hover,
194            label: t.fg_text,
195            muted: t.fg_muted,
196        }
197    }
198}
199
200/// Arma el chasis completo: toolbar (Split →/↓, Cerrar, estado) + el árbol
201/// de paneles.
202///
203/// - `leaf` materializa el contenido de cada panel — **ya lifteado al `Msg`
204///   del host** (el host hace el lift internamente con su `Panel(id, …)`).
205/// - `lift` mapea los [`WsMsg`] del chasis al `Msg` del host.
206pub fn workspace_view<Host>(
207    ws: &Workspace,
208    palette: &WorkspacePalette,
209    mut leaf: impl FnMut(PaneId) -> View<Host>,
210    lift: impl Fn(WsMsg) -> Host + Clone + Send + Sync + 'static,
211) -> View<Host>
212where
213    Host: Clone + Send + Sync + 'static,
214{
215    let toolbar = View::new(Style {
216        flex_direction: FlexDirection::Row,
217        gap: Size {
218            width: length(8.0_f32),
219            height: length(8.0_f32),
220        },
221        padding: uniform(8.0),
222        flex_shrink: 0.0,
223        ..Default::default()
224    })
225    .fill(palette.bar_bg)
226    .children(vec![
227        button("Split →", lift(WsMsg::Split(Axis::Horizontal)), palette),
228        button("Split ↓", lift(WsMsg::Split(Axis::Vertical)), palette),
229        button("Cerrar", lift(WsMsg::Close), palette),
230        View::new(Style {
231            flex_grow: 1.0,
232            ..Default::default()
233        }),
234        text(
235            format!("foco #{}  ·  {} paneles", ws.focused(), ws.count()),
236            13.0,
237            palette.muted,
238        ),
239    ]);
240
241    let lift_resize = lift.clone();
242    let lift_focus = lift.clone();
243    let area = panes_view(
244        ws.layout(),
245        ws.focused(),
246        |id| leaf(id),
247        move |path, phase, d| {
248            let _ = phase;
249            Some((lift_resize)(WsMsg::Resize(path, d)))
250        },
251        move |id| (lift_focus)(WsMsg::Focus(id)),
252        &palette.panes,
253    );
254
255    let area_wrap = View::new(Style {
256        flex_grow: 1.0,
257        size: full(),
258        min_size: zero(),
259        ..Default::default()
260    })
261    .children(vec![area]);
262
263    View::new(Style {
264        flex_direction: FlexDirection::Column,
265        size: full(),
266        ..Default::default()
267    })
268    .children(vec![toolbar, area_wrap])
269}
270
271fn button<Host>(label: &str, msg: Host, palette: &WorkspacePalette) -> View<Host>
272where
273    Host: Clone + Send + Sync + 'static,
274{
275    View::new(Style {
276        padding: Rect {
277            left: length(12.0_f32),
278            right: length(12.0_f32),
279            top: length(6.0_f32),
280            bottom: length(6.0_f32),
281        },
282        flex_shrink: 0.0,
283        ..Default::default()
284    })
285    .fill(palette.btn_bg)
286    .hover_fill(palette.btn_hover)
287    .radius(6.0)
288    .on_click(msg)
289    .children(vec![text(label.to_string(), 14.0, palette.label)])
290}
291
292fn text<Host>(content: String, size: f32, color: Color) -> View<Host>
293where
294    Host: Clone + Send + Sync + 'static,
295{
296    View::new(Style::default()).text(content, size, color)
297}
298
299fn full() -> Size<Dimension> {
300    Size {
301        width: percent(1.0_f32),
302        height: percent(1.0_f32),
303    }
304}
305
306fn zero() -> Size<Dimension> {
307    Size {
308        width: length(0.0_f32),
309        height: length(0.0_f32),
310    }
311}
312
313fn uniform(px: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::prelude::LengthPercentage> {
314    Rect {
315        left: length(px),
316        right: length(px),
317        top: length(px),
318        bottom: length(px),
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn starts_with_one_pane() {
328        let ws = Workspace::new();
329        assert_eq!(ws.count(), 1);
330        assert_eq!(ws.focused(), 0);
331    }
332
333    #[test]
334    fn split_creates_and_focuses_new() {
335        let mut ws = Workspace::new();
336        let id = ws.split(Axis::Horizontal);
337        assert_eq!(ws.count(), 2);
338        assert_eq!(ws.focused(), id);
339        assert_ne!(id, 0);
340    }
341
342    #[test]
343    fn apply_split_reports_created() {
344        let mut ws = Workspace::new();
345        match ws.apply(WsMsg::Split(Axis::Vertical)) {
346            WsEffect::Created(id) => assert_eq!(id, ws.focused()),
347            other => panic!("esperaba Created, fue {other:?}"),
348        }
349    }
350
351    #[test]
352    fn close_reports_closed_and_refocuses() {
353        let mut ws = Workspace::new();
354        let id = ws.split(Axis::Horizontal); // foco en el nuevo
355        match ws.apply(WsMsg::Close) {
356            WsEffect::Closed(closed) => {
357                assert_eq!(closed, id);
358                assert_eq!(ws.count(), 1);
359                assert_eq!(ws.focused(), 0);
360            }
361            other => panic!("esperaba Closed, fue {other:?}"),
362        }
363    }
364
365    #[test]
366    fn cannot_close_last_pane() {
367        let mut ws = Workspace::new();
368        assert_eq!(ws.apply(WsMsg::Close), WsEffect::None);
369        assert_eq!(ws.count(), 1);
370    }
371
372    #[test]
373    fn focus_ignores_unknown() {
374        let mut ws = Workspace::new();
375        ws.focus(999);
376        assert_eq!(ws.focused(), 0);
377    }
378}