tawasuyu

tawasuyu/llimphi

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

Rust 484Markdown 258TOML 131RON 38118 Definitions

⎇ main widgets/splitter/src/lib.rs Rust · 478 lines · 17.6 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! `llimphi-widget-splitter` — split container con divisor draggable.
2//!
3//! Análogo Llimphi al `nahual-widget-splitter` GPUI: dos panes con un
4//! divisor entre medio que el usuario arrastra para reasignar el tamaño.
5//! El widget no mantiene estado: el caller acumula el tamaño de un pane
6//! en su `Model` y le pasa el valor actual + un handler `Fn(DragPhase,
7//! f32) -> Option<Msg>` que materializa el delta en un Msg de update.
8//!
9//! Uso típico (dos panes, izquierdo fijo y derecho flex):
10//!
11//! ```ignore
12//! splitter_two(
13//!     Direction::Row,
14//!     left_view,
15//!     PaneSize::Fixed(model.left_size),
16//!     right_view,
17//!     PaneSize::Flex,
18//!     |phase, dx| match phase {
19//!         DragPhase::Move => Some(Msg::ResizeLeft(dx)),
20//!         DragPhase::End => Some(Msg::PersistLayout),
21//!     },
22//!     &SplitterPalette::default(),
23//! )
24//! ```
25
26#![forbid(unsafe_code)]
27
28
29// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
30// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
31// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
32#![no_std]
33
34extern crate alloc;
35
36use alloc::vec;
37use alloc::sync::Arc;
38
39use llimphi_compositor::llimphi_layout::taffy::{
40    prelude::{length, percent, FlexDirection, Size, Style},
41    Rect,
42};
43use llimphi_compositor::peniko::Color;
44use llimphi_compositor::{DragPhase, View};
45
46/// Dirección del split. `Row` apila los panes horizontalmente
47/// (divisor vertical, drag horizontal); `Column` los apila verticalmente.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Direction {
50    Row,
51    Column,
52}
53
54/// Tamaño de un pane sobre el eje principal del split.
55#[derive(Debug, Clone, Copy)]
56pub enum PaneSize {
57    /// Ancho/alto fijo en pixels. El otro pane se ajusta con `flex_grow`.
58    Fixed(f32),
59    /// Toma todo el espacio sobrante (`flex_grow = 1`).
60    Flex,
61}
62
63/// Paleta del divisor. Cambia de color al hover para señalar
64/// "agarrame y arrastra".
65#[derive(Debug, Clone, Copy)]
66pub struct SplitterPalette {
67    pub divider: Color,
68    pub divider_hover: Color,
69    pub thickness: f32,
70}
71
72impl Default for SplitterPalette {
73    fn default() -> Self {
74        Self::from_theme(&llimphi_theme::Theme::dark())
75    }
76}
77
78impl SplitterPalette {
79    /// Construye la paleta desde un `Theme` semántico.
80    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
81        Self {
82            divider: t.border,
83            divider_hover: t.accent,
84            thickness: 6.0,
85        }
86    }
87}
88
89/// Split de dos panes con divisor draggable entre medio. `on_resize`
90/// se invoca con el delta del eje principal (positivo → divisor se
91/// mueve a la derecha/abajo).
92pub fn splitter_two<Msg, F>(
93    direction: Direction,
94    a: View<Msg>,
95    a_size: PaneSize,
96    b: View<Msg>,
97    b_size: PaneSize,
98    on_resize: F,
99    palette: &SplitterPalette,
100) -> View<Msg>
101where
102    Msg: Clone + Send + Sync + 'static,
103    F: Fn(DragPhase, f32) -> Option<Msg> + Send + Sync + 'static,
104{
105    splitter_two_ext(
106        direction,
107        a,
108        a_size,
109        b,
110        b_size,
111        on_resize,
112        palette,
113        SplitterOpciones::default(),
114    )
115}
116
117/// [`splitter_two`] con [`SplitterOpciones`]: doble-click sobre el divisor y
118/// divisor enfocable (la puerta del resize por teclado). Con
119/// `SplitterOpciones::default()` produce exactamente el mismo árbol que
120/// [`splitter_two`].
121#[allow(clippy::too_many_arguments)]
122pub fn splitter_two_ext<Msg, F>(
123    direction: Direction,
124    a: View<Msg>,
125    a_size: PaneSize,
126    b: View<Msg>,
127    b_size: PaneSize,
128    on_resize: F,
129    palette: &SplitterPalette,
130    opciones: SplitterOpciones<Msg>,
131) -> View<Msg>
132where
133    Msg: Clone + Send + Sync + 'static,
134    F: Fn(DragPhase, f32) -> Option<Msg> + Send + Sync + 'static,
135{
136    let flex_dir = match direction {
137        Direction::Row => FlexDirection::Row,
138        Direction::Column => FlexDirection::Column,
139    };
140
141    // El divisor sólo necesita Msg en el eje principal — escondemos el
142    // otro detrás del closure.
143    let on_resize = Arc::new(on_resize);
144    let cb_dir = direction;
145    let cb = on_resize.clone();
146    let mut divider = divider_view::<Msg>(direction, palette, move |phase, dx, dy| {
147        let main = match cb_dir {
148            Direction::Row => dx,
149            Direction::Column => dy,
150        };
151        (cb)(phase, main)
152    });
153    if let Some(msg) = opciones.on_toggle {
154        divider = divider.on_double_tap(msg);
155    }
156    if let Some(id) = opciones.focus_id {
157        divider = divider.focusable(id);
158    }
159
160    let pane_a = wrap_pane(a, direction, a_size);
161    let pane_b = wrap_pane(b, direction, b_size);
162
163    View::new(Style {
164        flex_direction: flex_dir,
165        size: Size {
166            width: percent(1.0_f32),
167            height: percent(1.0_f32),
168        },
169        ..Default::default()
170    })
171    .children(vec![pane_a, divider, pane_b])
172}
173
174/// Lo que [`splitter_two`] no ofrece y [`splitter_two_ext`] sí. El `Default`
175/// es el splitter de siempre: sin doble-click y sin foco, ni un nodo distinto
176/// en el árbol.
177pub struct SplitterOpciones<Msg> {
178    /// `Msg` del **doble-click sobre el divisor**: el gesto estándar para
179    /// colapsar el pane y, repetido, devolverlo a donde estaba. El widget no
180    /// guarda el tamaño previo — lo hace el host con [`alternar_colapso`].
181    pub on_toggle: Option<Msg>,
182    /// Id de foco del divisor. Con `Some`, el divisor entra en el orden de Tab
183    /// y el host puede redimensionar por teclado ([`ajuste_desde_tecla`]); sin
184    /// él, el divisor sólo se arrastra con el mouse — que es lo que un teclado
185    /// solo no puede hacer, y por eso esto existe.
186    pub focus_id: Option<u64>,
187}
188
189impl<Msg> Default for SplitterOpciones<Msg> {
190    fn default() -> Self {
191        Self { on_toggle: None, focus_id: None }
192    }
193}
194
195/// Un ajuste del divisor pedido por teclado. Lo produce
196/// [`ajuste_desde_tecla`] y lo consume [`aplicar_ajuste`]; en el medio el host
197/// decide si lo aplica (p. ej. sólo si el divisor tiene el foco).
198#[derive(Debug, Clone, Copy, PartialEq)]
199pub enum AjusteSplitter {
200    /// Mover el divisor tantos px (signo = hacia dónde).
201    Delta(f32),
202    /// Al mínimo (Home) — el pane se cierra hasta su tope chico.
203    AlMinimo,
204    /// Al máximo (End).
205    AlMaximo,
206    /// Alternar colapsado/restaurado (Enter o Espacio sobre el divisor): el
207    /// equivalente por teclado del doble-click.
208    Alternar,
209}
210
211/// Paso (px) de un ajuste por teclado, y el paso grande con Shift. Son los
212/// valores de un `<div role=separator>` accesible: chico para afinar, grande
213/// para recorrer.
214pub const PASO_TECLADO: f32 = 8.0;
215/// Paso con Shift — diez veces el chico, como el `Shift+flecha` de un slider.
216pub const PASO_TECLADO_GRANDE: f32 = 80.0;
217
218/// Traduce una tecla al ajuste del divisor, según su **dirección**: un divisor
219/// vertical (`Row`, separa izquierda de derecha) se mueve con ←/→, y uno
220/// horizontal (`Column`) con ↑/↓. Devuelve `None` para todo lo demás.
221///
222/// Que la dirección decida qué flechas valen no es un detalle: con las cuatro
223/// siempre activas, ↑ sobre un divisor vertical movería algo que visualmente no
224/// se mueve en ese eje, y encima le robaría la tecla al pane enfocado.
225pub fn ajuste_desde_tecla(
226    ev: &llimphi_compositor::KeyEvent,
227    direction: Direction,
228) -> Option<AjusteSplitter> {
229    use llimphi_compositor::{Key, KeyState, NamedKey};
230    if ev.state != KeyState::Pressed {
231        return None;
232    }
233    let paso = if ev.modifiers.shift { PASO_TECLADO_GRANDE } else { PASO_TECLADO };
234    let Key::Named(k) = ev.key else { return None };
235    let (menos, mas) = match direction {
236        Direction::Row => (NamedKey::ArrowLeft, NamedKey::ArrowRight),
237        Direction::Column => (NamedKey::ArrowUp, NamedKey::ArrowDown),
238    };
239    Some(match k {
240        _ if k == menos => AjusteSplitter::Delta(-paso),
241        _ if k == mas => AjusteSplitter::Delta(paso),
242        NamedKey::Home => AjusteSplitter::AlMinimo,
243        NamedKey::End => AjusteSplitter::AlMaximo,
244        NamedKey::Enter | NamedKey::Space => AjusteSplitter::Alternar,
245        _ => return None,
246    })
247}
248
249/// Aplica un [`AjusteSplitter`] al tamaño actual, con el mismo clamp que
250/// [`apply_resize`]. `Alternar` necesita el tamaño recordado, así que se
251/// resuelve con [`alternar_colapso`] y acá devuelve el tamaño sin tocar.
252pub fn aplicar_ajuste(current: f32, ajuste: AjusteSplitter, min: f32, max: f32) -> f32 {
253    let (lo, hi) = (min.min(max), min.max(max));
254    match ajuste {
255        AjusteSplitter::Delta(d) => apply_resize(current, d, min, max),
256        AjusteSplitter::AlMinimo => lo,
257        AjusteSplitter::AlMaximo => hi,
258        AjusteSplitter::Alternar => current,
259    }
260}
261
262/// Alterna colapsado ↔ restaurado, que es lo que hace el doble-click sobre el
263/// divisor. Devuelve `(tamaño nuevo, tamaño a recordar)`.
264///
265/// El host guarda ese segundo valor en su `Model` y lo devuelve en la próxima
266/// llamada. Se separa así —y no con un `bool colapsado`— porque lo que hay que
267/// recordar no es *que* estaba abierto sino **cuánto**: sin eso, restaurar
268/// devuelve un tamaño de fábrica y el usuario pierde su layout, que es
269/// justamente el reproche clásico a los splitters que colapsan mal.
270///
271/// `restaurar_por_defecto` es el ancho al que se abre cuando no hay nada
272/// recordado (primer doble-click estando ya colapsado).
273pub fn alternar_colapso(
274    actual: f32,
275    recordado: Option<f32>,
276    restaurar_por_defecto: f32,
277) -> (f32, Option<f32>) {
278    if esta_colapsado(actual) {
279        // Abrir: al tamaño recordado, o al de fábrica si no hay memoria. Nunca
280        // a cero, que dejaría el doble-click sin efecto visible.
281        let destino = recordado.filter(|v| !esta_colapsado(*v)).unwrap_or(restaurar_por_defecto);
282        (destino, None)
283    } else {
284        (0.0, Some(actual))
285    }
286}
287
288/// ¿Este tamaño cuenta como colapsado? Con tolerancia: un pane de medio píxel
289/// es un pane cerrado que el redondeo dejó abierto.
290pub fn esta_colapsado(tamano: f32) -> bool {
291    tamano <= 0.5
292}
293
294/// Aplica un delta de drag al tamaño de un pane con **clamp min/max** — la
295/// pieza que el widget no ofrecía y cada caller tenía que replicar (o se
296/// olvidaba, dejando que un pane se comiera al otro). El caller la usa en su
297/// `update` al recibir `Msg::Resize(delta)`: `self.size = apply_resize(
298/// self.size, delta, MIN, MAX)`. Como el delta llega por-evento y el caller
299/// acumula en su modelo (`size += …`), el clamp por-paso mantiene el rango
300/// aun con la vista congelada durante el drag.
301pub fn apply_resize(current: f32, delta: f32, min: f32, max: f32) -> f32 {
302    (current + delta).clamp(min.min(max), min.max(max))
303}
304
305fn wrap_pane<Msg>(view: View<Msg>, direction: Direction, size: PaneSize) -> View<Msg> {
306    // El panel Flex usa flex-basis 0 en el eje principal (no `auto`): así su
307    // tamaño es la porción de espacio libre que le toca por `flex_grow`, en
308    // vez del tamaño de su contenido. Con `auto` un hijo que desborda (p.ej.
309    // una grilla con flex-wrap o contenido ancho) volvía el panel
310    // content-sized y rompía el wrap / desbordaba horizontalmente.
311    let (width, height, flex_grow) = match (direction, size) {
312        (Direction::Row, PaneSize::Fixed(px)) => (length(px), percent(1.0_f32), 0.0),
313        (Direction::Row, PaneSize::Flex) => (length(0.0_f32), percent(1.0_f32), 1.0),
314        (Direction::Column, PaneSize::Fixed(px)) => (percent(1.0_f32), length(px), 0.0),
315        (Direction::Column, PaneSize::Flex) => (percent(1.0_f32), length(0.0_f32), 1.0),
316    };
317    View::new(Style {
318        size: Size { width, height },
319        flex_grow,
320        flex_shrink: 0.0,
321        min_size: Size {
322            width: length(0.0_f32),
323            height: length(0.0_f32),
324        },
325        ..Default::default()
326    })
327    .children(vec![view])
328}
329
330fn divider_view<Msg>(
331    direction: Direction,
332    palette: &SplitterPalette,
333    handler: impl Fn(DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
334) -> View<Msg>
335where
336    Msg: Clone + Send + Sync + 'static,
337{
338    let (width, height) = match direction {
339        Direction::Row => (length(palette.thickness), percent(1.0_f32)),
340        Direction::Column => (percent(1.0_f32), length(palette.thickness)),
341    };
342    // Divisor vertical (Row, separa izq/der) → resize este-oeste; divisor
343    // horizontal (Column, separa arriba/abajo) → resize norte-sur.
344    let cursor = match direction {
345        Direction::Row => llimphi_compositor::Cursor::ColResize,
346        Direction::Column => llimphi_compositor::Cursor::RowResize,
347    };
348    View::new(Style {
349        size: Size { width, height },
350        flex_shrink: 0.0,
351        padding: Rect {
352            left: length(0.0_f32),
353            right: length(0.0_f32),
354            top: length(0.0_f32),
355            bottom: length(0.0_f32),
356        },
357        ..Default::default()
358    })
359    .fill(palette.divider)
360    .hover_fill(palette.divider_hover)
361    .cursor(cursor)
362    // El divisor es un control, no una línea decorativa: con este rol el
363    // lector lo expone como redimensionable en vez de saltearlo.
364    .role(llimphi_compositor::Role::Splitter)
365    .aria_label("Divisor de paneles")
366    .draggable(handler)
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn apply_resize_respeta_min_max() {
375        assert_eq!(apply_resize(200.0, 30.0, 100.0, 400.0), 230.0);
376        assert_eq!(apply_resize(120.0, -50.0, 100.0, 400.0), 100.0); // clamp min
377        assert_eq!(apply_resize(380.0, 50.0, 100.0, 400.0), 400.0); // clamp max
378    }
379
380    #[test]
381    fn apply_resize_tolera_min_max_invertidos() {
382        // Si el caller pasa (max, min) por error, no explota.
383        assert_eq!(apply_resize(200.0, 0.0, 400.0, 100.0), 200.0);
384        assert_eq!(apply_resize(50.0, 0.0, 400.0, 100.0), 100.0);
385    }
386
387    fn tecla(k: llimphi_compositor::NamedKey, shift: bool) -> llimphi_compositor::KeyEvent {
388        llimphi_compositor::KeyEvent {
389            key: llimphi_compositor::Key::Named(k),
390            state: llimphi_compositor::KeyState::Pressed,
391            text: None,
392            modifiers: llimphi_compositor::Modifiers { shift, ..Default::default() },
393            repeat: false,
394        }
395    }
396
397    #[test]
398    fn las_flechas_que_valen_dependen_del_eje_del_divisor() {
399        use llimphi_compositor::NamedKey::*;
400        // Divisor vertical: ←/→ mueven, ↑/↓ no son suyas (son del pane).
401        assert_eq!(
402            ajuste_desde_tecla(&tecla(ArrowRight, false), Direction::Row),
403            Some(AjusteSplitter::Delta(PASO_TECLADO))
404        );
405        assert_eq!(
406            ajuste_desde_tecla(&tecla(ArrowLeft, false), Direction::Row),
407            Some(AjusteSplitter::Delta(-PASO_TECLADO))
408        );
409        assert_eq!(ajuste_desde_tecla(&tecla(ArrowDown, false), Direction::Row), None);
410        // Divisor horizontal: al revés.
411        assert_eq!(
412            ajuste_desde_tecla(&tecla(ArrowDown, false), Direction::Column),
413            Some(AjusteSplitter::Delta(PASO_TECLADO))
414        );
415        assert_eq!(ajuste_desde_tecla(&tecla(ArrowRight, false), Direction::Column), None);
416        // Shift agranda el paso, sin cambiar el signo.
417        assert_eq!(
418            ajuste_desde_tecla(&tecla(ArrowLeft, true), Direction::Row),
419            Some(AjusteSplitter::Delta(-PASO_TECLADO_GRANDE))
420        );
421        // Bordes y alternancia.
422        assert_eq!(
423            ajuste_desde_tecla(&tecla(Home, false), Direction::Row),
424            Some(AjusteSplitter::AlMinimo)
425        );
426        assert_eq!(
427            ajuste_desde_tecla(&tecla(End, false), Direction::Row),
428            Some(AjusteSplitter::AlMaximo)
429        );
430        assert_eq!(
431            ajuste_desde_tecla(&tecla(Space, false), Direction::Row),
432            Some(AjusteSplitter::Alternar)
433        );
434        // Lo ajeno no dispara, y soltar tampoco.
435        assert_eq!(ajuste_desde_tecla(&tecla(Escape, false), Direction::Row), None);
436        let suelta = llimphi_compositor::KeyEvent {
437            state: llimphi_compositor::KeyState::Released,
438            ..tecla(ArrowLeft, false)
439        };
440        assert_eq!(ajuste_desde_tecla(&suelta, Direction::Row), None);
441    }
442
443    #[test]
444    fn aplicar_ajuste_respeta_los_topes() {
445        assert_eq!(aplicar_ajuste(200.0, AjusteSplitter::Delta(30.0), 100.0, 400.0), 230.0);
446        assert_eq!(aplicar_ajuste(120.0, AjusteSplitter::Delta(-50.0), 100.0, 400.0), 100.0);
447        assert_eq!(aplicar_ajuste(200.0, AjusteSplitter::AlMinimo, 100.0, 400.0), 100.0);
448        assert_eq!(aplicar_ajuste(200.0, AjusteSplitter::AlMaximo, 100.0, 400.0), 400.0);
449        // `Alternar` no se resuelve acá: necesita el tamaño recordado.
450        assert_eq!(aplicar_ajuste(200.0, AjusteSplitter::Alternar, 100.0, 400.0), 200.0);
451    }
452
453    #[test]
454    fn el_colapso_recuerda_cuanto_media_y_lo_devuelve() {
455        // Colapsar guarda el tamaño exacto que tenía, no un "estaba abierto".
456        let (t1, memoria) = alternar_colapso(237.0, None, 200.0);
457        assert_eq!(t1, 0.0);
458        assert_eq!(memoria, Some(237.0));
459        assert!(esta_colapsado(t1));
460        // Restaurar devuelve ESE tamaño, no el de fábrica: el layout del
461        // usuario sobrevive al doble-click.
462        let (t2, memoria) = alternar_colapso(t1, memoria, 200.0);
463        assert_eq!(t2, 237.0);
464        assert_eq!(memoria, None);
465    }
466
467    #[test]
468    fn sin_memoria_se_abre_al_tamano_de_fabrica_y_nunca_a_cero() {
469        // Primer doble-click sobre un pane que ya arrancó colapsado.
470        assert_eq!(alternar_colapso(0.0, None, 200.0), (200.0, None));
471        // Y una memoria envenenada (un cero guardado) no deja el doble-click
472        // sin efecto visible: cae al de fábrica igual.
473        assert_eq!(alternar_colapso(0.0, Some(0.0), 200.0), (200.0, None));
474        // Medio píxel es colapsado: el redondeo no debería abrir un pane.
475        assert!(esta_colapsado(0.4));
476        assert!(!esta_colapsado(1.0));
477    }
478}