tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
feat: llimphi standalone — framework UI soberano extraído del monorepo
1//! `llimphi-widget-panes` — árbol de paneles BSP estilo tmux.
2//!
3//! La pieza que faltaba para "montar cualquier componente de gioser en un
4//! layout intercambiable con splits resizables". El widget NO conoce los
5//! dominios: hospeda hojas opacas (`View<Msg>`) en un árbol binario que el
6//! usuario parte (horizontal/vertical), cierra, enfoca (click) y
7//! redimensiona (arrastrando los divisores). tmux, pero in-process y sobre
8//! el bucle Elm de Llimphi.
9//!
10//! No confundir con `llimphi-widget-panel` (el chrome de UN panel con
11//! título): esto es el árbol de N panes.
12//!
13//! ## Modelo
14//!
15//! - [`Layout`] es la **estructura** del árbol (qué hoja vive dónde, con
16//! qué ratio cada split). Vive en el `Model` del host y se manipula con
17//! [`Layout::split`], [`Layout::without`] y [`Layout::resize`].
18//! - El **contenido** de cada hoja lo provee el host vía un closure
19//! `FnMut(PaneId) -> View<Msg>` que se invoca al construir la vista —
20//! por eso puede tomar prestado el `Model` (no necesita ser `'static`).
21//! - El handler de resize sí se guarda en el árbol de vistas (lo agarra el
22//! divisor draggable), así que ése debe ser `'static + Send + Sync`. El
23//! de focus se evalúa al construir (porque `on_click` toma el `Msg` por
24//! valor), así que no tiene esa restricción.
25//!
26//! ## Por qué no `Box<dyn Any>`
27//!
28//! Igual que el resto del repo: el host mantiene un `enum` de sus tipos de
29//! panel y hace dispatch estático. El widget es genérico sobre `Msg`; el
30//! host decide cómo materializar cada hoja. Cero downcasting.
31
32#![forbid(unsafe_code)]
33
34use std::sync::Arc;
35
36use llimphi_ui::llimphi_layout::taffy::{
37 prelude::{length, percent, Dimension, FlexDirection, Size, Style},
38 Rect,
39};
40use llimphi_ui::llimphi_raster::peniko::Color;
41use llimphi_ui::{DragPhase, View};
42
43/// Identificador estable de un panel. El host lo asigna (un contador
44/// monótono basta) y lo usa como llave hacia su propio estado.
45pub type PaneId = u64;
46
47/// Eje del split. `Horizontal` pone los panes lado a lado (divisor
48/// vertical, se arrastra en X); `Vertical` los apila (divisor horizontal,
49/// se arrastra en Y).
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Axis {
52 Horizontal,
53 Vertical,
54}
55
56/// Rama de un split, usada para direccionar un nodo dentro del árbol.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Side {
59 First,
60 Second,
61}
62
63/// Árbol binario de paneles. `Leaf` es un panel; `Split` divide el espacio
64/// entre dos subárboles con un `ratio` (fracción que ocupa el primero).
65#[derive(Debug, Clone, PartialEq)]
66pub enum Layout {
67 Leaf(PaneId),
68 Split {
69 axis: Axis,
70 /// Fracción del eje que ocupa el subárbol `first` (0..1).
71 ratio: f32,
72 first: Box<Layout>,
73 second: Box<Layout>,
74 },
75}
76
77impl Layout {
78 /// Árbol de un solo panel.
79 pub fn single(id: PaneId) -> Self {
80 Layout::Leaf(id)
81 }
82
83 /// Cantidad de hojas (paneles) en el árbol.
84 pub fn count(&self) -> usize {
85 match self {
86 Layout::Leaf(_) => 1,
87 Layout::Split { first, second, .. } => first.count() + second.count(),
88 }
89 }
90
91 /// Lista de todas las hojas, en orden de aparición (izq→der / arr→ab).
92 pub fn leaves(&self) -> Vec<PaneId> {
93 let mut out = Vec::new();
94 self.collect_leaves(&mut out);
95 out
96 }
97
98 fn collect_leaves(&self, out: &mut Vec<PaneId>) {
99 match self {
100 Layout::Leaf(id) => out.push(*id),
101 Layout::Split { first, second, .. } => {
102 first.collect_leaves(out);
103 second.collect_leaves(out);
104 }
105 }
106 }
107
108 /// `true` si la hoja existe en el árbol.
109 pub fn contains(&self, id: PaneId) -> bool {
110 match self {
111 Layout::Leaf(x) => *x == id,
112 Layout::Split { first, second, .. } => first.contains(id) || second.contains(id),
113 }
114 }
115
116 /// Primera hoja (la de más arriba/izquierda). Útil para reenfocar tras
117 /// cerrar un panel.
118 pub fn first_leaf(&self) -> PaneId {
119 match self {
120 Layout::Leaf(id) => *id,
121 Layout::Split { first, .. } => first.first_leaf(),
122 }
123 }
124
125 /// Parte la hoja `target` en dos: `target` queda en `Side::First` y la
126 /// nueva hoja `new` en `Side::Second`, con ratio 0.5. Devuelve `true`
127 /// si encontró el target.
128 pub fn split(&mut self, target: PaneId, new: PaneId, axis: Axis) -> bool {
129 match self {
130 Layout::Leaf(id) if *id == target => {
131 *self = Layout::Split {
132 axis,
133 ratio: 0.5,
134 first: Box::new(Layout::Leaf(target)),
135 second: Box::new(Layout::Leaf(new)),
136 };
137 true
138 }
139 Layout::Leaf(_) => false,
140 Layout::Split { first, second, .. } => {
141 first.split(target, new, axis) || second.split(target, new, axis)
142 }
143 }
144 }
145
146 /// Devuelve el árbol sin la hoja `target`, colapsando el split padre en
147 /// el hermano sobreviviente. El `bool` indica si removió algo. Quitar la
148 /// única hoja raíz es no-op (devuelve el árbol intacto, `false`).
149 pub fn without(self, target: PaneId) -> (Layout, bool) {
150 match self {
151 Layout::Leaf(id) => (Layout::Leaf(id), false),
152 Layout::Split {
153 axis,
154 ratio,
155 first,
156 second,
157 } => {
158 if matches!(*first, Layout::Leaf(t) if t == target) {
159 return (*second, true);
160 }
161 if matches!(*second, Layout::Leaf(t) if t == target) {
162 return (*first, true);
163 }
164 let (nf, rf) = first.without(target);
165 if rf {
166 return (
167 Layout::Split {
168 axis,
169 ratio,
170 first: Box::new(nf),
171 second,
172 },
173 true,
174 );
175 }
176 let (ns, rs) = second.without(target);
177 (
178 Layout::Split {
179 axis,
180 ratio,
181 first: Box::new(nf),
182 second: Box::new(ns),
183 },
184 rs,
185 )
186 }
187 }
188 }
189
190 /// Ajusta el ratio del split direccionado por `path` (camino de raíz a
191 /// ese nodo). `delta` se suma al ratio, clamp a [0.05, 0.95].
192 pub fn resize(&mut self, path: &[Side], delta: f32) {
193 match self {
194 Layout::Split {
195 ratio,
196 first,
197 second,
198 ..
199 } => match path.split_first() {
200 None => *ratio = (*ratio + delta).clamp(0.05, 0.95),
201 Some((Side::First, rest)) => first.resize(rest, delta),
202 Some((Side::Second, rest)) => second.resize(rest, delta),
203 },
204 Layout::Leaf(_) => {}
205 }
206 }
207}
208
209/// Ratio movido por píxel arrastrado. No conocemos el tamaño en px del
210/// contenedor en tiempo de `view` (limitación conocida de Llimphi, la
211/// misma raíz por la que no hay `View::map`), así que aproximamos con una
212/// sensibilidad fija. El clamp en [`Layout::resize`] evita degenerar.
213const RESIZE_SENSITIVITY: f32 = 1.0 / 600.0;
214
215/// Paleta del árbol de paneles.
216#[derive(Debug, Clone, Copy)]
217pub struct PanesPalette {
218 pub bg: Color,
219 pub border: Color,
220 pub focus_border: Color,
221 pub divider: Color,
222 pub divider_hover: Color,
223 pub thickness: f32,
224}
225
226impl Default for PanesPalette {
227 fn default() -> Self {
228 Self::from_theme(&llimphi_theme::Theme::dark())
229 }
230}
231
232impl PanesPalette {
233 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
234 Self {
235 bg: t.bg_app,
236 border: t.border,
237 focus_border: t.accent,
238 divider: t.border,
239 divider_hover: t.accent,
240 thickness: 6.0,
241 }
242 }
243}
244
245/// Renderiza el árbol de paneles.
246///
247/// - `leaf` materializa el contenido de cada hoja; se llama una vez por
248/// panel mientras se construye la vista (puede tomar prestado el host).
249/// - `on_resize` recibe el camino al split, la fase del drag y el delta de
250/// ratio; devolver `Some(msg)` dispara el `update` (el host llama
251/// [`Layout::resize`]).
252/// - `on_focus` produce el msg al hacer click en un panel.
253pub fn panes_view<Msg>(
254 layout: &Layout,
255 focused: PaneId,
256 mut leaf: impl FnMut(PaneId) -> View<Msg>,
257 on_resize: impl Fn(Vec<Side>, DragPhase, f32) -> Option<Msg> + Send + Sync + 'static,
258 on_focus: impl Fn(PaneId) -> Msg,
259 palette: &PanesPalette,
260) -> View<Msg>
261where
262 Msg: Clone + Send + Sync + 'static,
263{
264 let on_resize: Arc<dyn Fn(Vec<Side>, DragPhase, f32) -> Option<Msg> + Send + Sync> =
265 Arc::new(on_resize);
266 render(
267 layout,
268 focused,
269 &mut leaf,
270 &on_resize,
271 &on_focus,
272 Vec::new(),
273 palette,
274 )
275}
276
277#[allow(clippy::too_many_arguments)]
278fn render<Msg>(
279 layout: &Layout,
280 focused: PaneId,
281 leaf: &mut dyn FnMut(PaneId) -> View<Msg>,
282 on_resize: &Arc<dyn Fn(Vec<Side>, DragPhase, f32) -> Option<Msg> + Send + Sync>,
283 on_focus: &dyn Fn(PaneId) -> Msg,
284 path: Vec<Side>,
285 palette: &PanesPalette,
286) -> View<Msg>
287where
288 Msg: Clone + Send + Sync + 'static,
289{
290 match layout {
291 Layout::Leaf(id) => {
292 let id = *id;
293 let content = leaf(id);
294 let is_focused = id == focused;
295 let border_col = if is_focused {
296 palette.focus_border
297 } else {
298 palette.border
299 };
300 let border_w = if is_focused { 2.0 } else { 1.0 };
301
302 // Caja interior (fondo del panel) con el contenido del host.
303 let inner = View::new(Style {
304 flex_grow: 1.0,
305 flex_direction: FlexDirection::Column,
306 size: full(),
307 min_size: zero(),
308 ..Default::default()
309 })
310 .fill(palette.bg)
311 .children(vec![content]);
312
313 // Marco: no hay `stroke`, así que el borde es un contenedor
314 // relleno con un padding del grosor → simula el trazo.
315 View::new(Style {
316 flex_direction: FlexDirection::Column,
317 size: full(),
318 min_size: zero(),
319 padding: uniform(border_w),
320 ..Default::default()
321 })
322 .fill(border_col)
323 .on_click(on_focus(id))
324 .children(vec![inner])
325 }
326 Layout::Split {
327 axis,
328 ratio,
329 first,
330 second,
331 } => {
332 let flex_dir = match axis {
333 Axis::Horizontal => FlexDirection::Row,
334 Axis::Vertical => FlexDirection::Column,
335 };
336
337 let mut p1 = path.clone();
338 p1.push(Side::First);
339 let mut p2 = path.clone();
340 p2.push(Side::Second);
341
342 let a = render(first, focused, leaf, on_resize, on_focus, p1, palette);
343 let b = render(second, focused, leaf, on_resize, on_focus, p2, palette);
344
345 let pane_a = grow_pane(a, *ratio);
346 let pane_b = grow_pane(b, 1.0 - *ratio);
347 let divider = divider_view(*axis, palette, on_resize.clone(), path.clone());
348
349 View::new(Style {
350 flex_direction: flex_dir,
351 size: full(),
352 min_size: zero(),
353 ..Default::default()
354 })
355 .children(vec![pane_a, divider, pane_b])
356 }
357 }
358}
359
360fn grow_pane<Msg>(view: View<Msg>, grow: f32) -> View<Msg>
361where
362 Msg: Clone + Send + Sync + 'static,
363{
364 View::new(Style {
365 flex_grow: grow.max(0.01),
366 flex_shrink: 1.0,
367 flex_basis: length(0.0),
368 size: full(),
369 min_size: zero(),
370 ..Default::default()
371 })
372 .children(vec![view])
373}
374
375fn divider_view<Msg>(
376 axis: Axis,
377 palette: &PanesPalette,
378 on_resize: Arc<dyn Fn(Vec<Side>, DragPhase, f32) -> Option<Msg> + Send + Sync>,
379 path: Vec<Side>,
380) -> View<Msg>
381where
382 Msg: Clone + Send + Sync + 'static,
383{
384 let (width, height) = match axis {
385 Axis::Horizontal => (length(palette.thickness), percent(1.0_f32)),
386 Axis::Vertical => (percent(1.0_f32), length(palette.thickness)),
387 };
388 View::new(Style {
389 size: Size { width, height },
390 flex_shrink: 0.0,
391 ..Default::default()
392 })
393 .fill(palette.divider)
394 .hover_fill(palette.divider_hover)
395 .draggable(move |phase, dx, dy| {
396 let main = match axis {
397 Axis::Horizontal => dx,
398 Axis::Vertical => dy,
399 };
400 (on_resize)(path.clone(), phase, main * RESIZE_SENSITIVITY)
401 })
402}
403
404fn full() -> Size<Dimension> {
405 Size {
406 width: percent(1.0_f32),
407 height: percent(1.0_f32),
408 }
409}
410
411fn zero() -> Size<Dimension> {
412 Size {
413 width: length(0.0_f32),
414 height: length(0.0_f32),
415 }
416}
417
418fn uniform(px: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::prelude::LengthPercentage> {
419 Rect {
420 left: length(px),
421 right: length(px),
422 top: length(px),
423 bottom: length(px),
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn single_has_one_leaf() {
433 let l = Layout::single(1);
434 assert_eq!(l.count(), 1);
435 assert_eq!(l.leaves(), vec![1]);
436 assert_eq!(l.first_leaf(), 1);
437 }
438
439 #[test]
440 fn split_creates_two_leaves() {
441 let mut l = Layout::single(1);
442 assert!(l.split(1, 2, Axis::Horizontal));
443 assert_eq!(l.count(), 2);
444 assert_eq!(l.leaves(), vec![1, 2]);
445 assert!(l.contains(2));
446 }
447
448 #[test]
449 fn split_missing_target_is_noop() {
450 let mut l = Layout::single(1);
451 assert!(!l.split(99, 2, Axis::Vertical));
452 assert_eq!(l.count(), 1);
453 }
454
455 #[test]
456 fn nested_split_then_close_collapses() {
457 let mut l = Layout::single(1);
458 l.split(1, 2, Axis::Horizontal);
459 l.split(2, 3, Axis::Vertical); // 2 se parte en [2 / 3]
460 assert_eq!(l.leaves(), vec![1, 2, 3]);
461
462 let (l, removed) = l.without(3);
463 assert!(removed);
464 assert_eq!(l.leaves(), vec![1, 2]);
465
466 let (l, removed) = l.without(1);
467 assert!(removed);
468 assert_eq!(l.leaves(), vec![2]);
469
470 let (l, removed) = l.without(2);
471 assert!(!removed);
472 assert_eq!(l.leaves(), vec![2]);
473 }
474
475 #[test]
476 fn resize_adjusts_ratio_with_clamp() {
477 let mut l = Layout::single(1);
478 l.split(1, 2, Axis::Horizontal);
479 l.resize(&[], 0.2);
480 if let Layout::Split { ratio, .. } = &l {
481 assert!((ratio - 0.7).abs() < 1e-6);
482 } else {
483 panic!("esperaba split");
484 }
485 l.resize(&[], -10.0);
486 if let Layout::Split { ratio, .. } = &l {
487 assert!((ratio - 0.05).abs() < 1e-6);
488 }
489 }
490
491 #[test]
492 fn resize_nested_path() {
493 let mut l = Layout::single(1);
494 l.split(1, 2, Axis::Horizontal);
495 l.split(2, 3, Axis::Vertical);
496 l.resize(&[Side::Second], 0.1);
497 if let Layout::Split { second, .. } = &l {
498 if let Layout::Split { ratio, .. } = second.as_ref() {
499 assert!((ratio - 0.6).abs() < 1e-6);
500 return;
501 }
502 }
503 panic!("estructura inesperada");
504 }
505}