tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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}