tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
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}