gioser

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ e74800d9dab0bd8fcc82d36f776628ad45cedfd3 widgets/panes/src/lib.rs Rust · 505 líneas · 15.4 KB Historia Quién tocó qué crudo

feat: llimphi standalone — framework UI soberano extraído del monorepo e65e9cc62 · Sergio ·

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}