tawasuyu

tawasuyu/llimphi

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

Rust 484Markdown 258TOML 131RON 38118 Definiciones

⎇ main widgets/modal/src/lib.rs Rust · 717 líneas · 28.7 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) b575c63f1 · Sergio ·

1//! `llimphi-widget-modal` — diálogo genérico centrado con scrim.
2//!
3//! Distinto del `context-menu` (chico, anclado a un click): el modal
4//! ocupa una región central de tamaño configurable, presenta un título,
5//! un cuerpo arbitrario (lo arma la app) y una barra de botones.
6//!
7//! Uso típico:
8//! 1. La app guarda `Option<ModalState>` en su modelo.
9//! 2. `view_overlay` devuelve `Some(modal_view(spec))` cuando hay
10//!    state, `None` cuando se cerró.
11//! 3. La app captura `Esc` en `on_key` → cierra; `Enter` → primary.
12//!
13//! Tres severidades de botón:
14//! - `Primary` — verde/accent, acción principal.
15//! - `Cancel` — neutral, descarta.
16//! - `Destructive` — rojo, acción irreversible (eliminar, etc).
17
18#![forbid(unsafe_code)]
19
20
21// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
22// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
23// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
24#![no_std]
25
26extern crate alloc;
27
28use alloc::string::String;
29use alloc::vec::Vec;
30use alloc::vec;
31use llimphi_compositor::llimphi_layout::taffy::{
32    prelude::{auto, length, percent, FlexDirection, Position, Size, Style},
33    AlignItems, JustifyContent, Rect,
34};
35use llimphi_compositor::peniko::Color;
36use llimphi_compositor::llimphi_text::Alignment;
37use llimphi_compositor::{Shadow, View};
38use llimphi_theme::{alpha, elevation, motion, radius, Theme};
39use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
40
41/// Paleta del modal.
42#[derive(Debug, Clone, Copy)]
43pub struct ModalPalette {
44    /// Color del scrim. El alpha se usa como **promedio** del vignette
45    /// radial: el centro (debajo del panel) queda ~25% más claro y las
46    /// esquinas ~40% más oscuras, manteniendo la densidad media igual a
47    /// lo que pidió el caller. Esto focaliza al modal sin "encerrarlo".
48    pub scrim: Color,
49    /// Firma visual del panel — gradient sutil + hairline accent en el
50    /// top edge. La que vuelve consistente el "look tawasuyu" en todos
51    /// los modales y overlays.
52    pub panel: PanelStyle,
53    pub border: Color,
54    pub fg_title: Color,
55    pub fg_text: Color,
56    pub bg_btn: Color,
57    pub bg_btn_hover: Color,
58    pub fg_btn: Color,
59    pub bg_primary: Color,
60    pub fg_primary: Color,
61    pub bg_destructive: Color,
62    pub fg_destructive: Color,
63}
64
65impl ModalPalette {
66    /// La letra de un botón apagado. Sale de `fg_text` de la paleta del modal,
67    /// que ya es el tono apagado del tema (`fg_muted`).
68    fn fg_text_apagado(&self) -> Color {
69        self.fg_text
70    }
71}
72
73impl ModalPalette {
74    pub fn from_theme(t: &Theme) -> Self {
75        Self {
76            scrim: Color::from_rgba8(0, 0, 0, alpha::SCRIM),
77            panel: PanelStyle::from_theme_large(t),
78            border: t.border,
79            fg_title: t.fg_text,
80            fg_text: t.fg_muted,
81            bg_btn: t.bg_button,
82            bg_btn_hover: t.bg_button_hover,
83            fg_btn: t.fg_text,
84            bg_primary: t.accent,
85            fg_primary: t.bg_app,
86            bg_destructive: t.fg_destructive,
87            fg_destructive: t.bg_app,
88        }
89    }
90}
91
92/// Severidad de un botón del modal.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum ButtonKind {
95    Primary,
96    Cancel,
97    Destructive,
98}
99
100/// Spec de un botón. `label` se renderiza; `msg` se dispatcha al click.
101#[derive(Clone)]
102pub struct ModalButton<Msg> {
103    pub label: String,
104    pub kind: ButtonKind,
105    pub msg: Msg,
106    /// **Apagado**: se pinta en tono muerto y el click NO se despacha.
107    ///
108    /// Existe porque «el botón no hace nada» y «el botón está apagado» se
109    /// tienen que ver distinto. Un primario con su color de acento que al
110    /// apretarlo no pasa nada es lo que hace desconfiar del diálogo entero; el
111    /// que está apagado se lee de un vistazo, y el rótulo dice qué falta.
112    pub disabled: bool,
113}
114
115impl<Msg> ModalButton<Msg> {
116    pub fn primary(label: impl Into<String>, msg: Msg) -> Self {
117        Self { label: label.into(), kind: ButtonKind::Primary, msg, disabled: false }
118    }
119    pub fn cancel(label: impl Into<String>, msg: Msg) -> Self {
120        Self { label: label.into(), kind: ButtonKind::Cancel, msg, disabled: false }
121    }
122    pub fn destructive(label: impl Into<String>, msg: Msg) -> Self {
123        Self { label: label.into(), kind: ButtonKind::Destructive, msg, disabled: false }
124    }
125    /// Apagalo (o no) según una condición. Encadenable sobre los tres de arriba.
126    pub fn apagado_si(mut self, cond: bool) -> Self {
127        self.disabled = cond;
128        self
129    }
130}
131
132/// Scroll interno del cuerpo del modal, para contenido más alto que el panel.
133/// Sin esto, un `body` alto se recorta (el panel tiene `clip`). El caller
134/// guarda `offset` en su modelo y lo acumula con el delta de rueda; el widget
135/// **clampea** al alto disponible (conoce el área del cuerpo) — el caller sólo
136/// evita que baje de 0.
137pub struct ModalBodyScroll<Msg> {
138    /// Desplazamiento vertical actual (px, ≥ 0).
139    pub offset: f32,
140    /// Alto natural del contenido del `body` (px) — lo mide/estima el caller.
141    pub content_h: f32,
142    /// Rueda sobre el cuerpo → delta (px) a sumar al `offset`.
143    pub on_scroll: alloc::sync::Arc<dyn Fn(f32) -> Msg + Send + Sync>,
144}
145
146/// **El modal como VENTANA: se agarra de la barra de título y se estira de los
147/// cantos.**
148///
149/// Sin esto un diálogo está clavado en el centro y del tamaño que decidió quien
150/// lo escribió: si su cuerpo no entra, el operador no tiene ninguna salida
151/// —moverlo, agrandarlo— y la ventana se vuelve un dibujo. Reporte textual del
152/// operador (2026-09-02) sobre el diálogo de grupo nuevo: *«qué tipo de ventana
153/// es esa, que no se puede dragar ni nada»*, y la regla que dejó: *«odio que
154/// hagas cosas falsas o mal hechas para abaratar programación»*.
155///
156/// La mecánica es la **misma** que la de las ventanas flotantes de shuma
157/// (`floating_pane`): la cabecera lleva `draggable_at`, y ocho asas invisibles
158/// —cuatro bordes y cuatro esquinas, éstas encima— se montan sobre el marco con
159/// el cursor que corresponde. Se copia esa forma a propósito: dos maneras de
160/// agarrar una ventana en el mismo escritorio se sienten como dos escritorios.
161///
162/// **Los cantos no cruzan la frontera.** El asa ya sabe qué borde es, así que
163/// resuelve ella el arrastre y entrega el cambio de geometría ya hecho
164/// (`dx, dy, dw, dh`): el llamador sólo suma y acota con sus mínimos. Un enum
165/// de ocho cantos repetido de los dos lados sería la misma verdad escrita dos
166/// veces, y la que se olvida de actualizar es siempre la de más lejos.
167pub struct ModalVentana<Msg> {
168    /// Cuánto se corrió respecto del centro, px. `(0.0, 0.0)` = donde el modal
169    /// nace, que es centrado como siempre.
170    pub offset: (f32, f32),
171    /// Tamaño ya cambiado por el operador, si lo cambió. `None` = el de
172    /// `ModalSpec::size`.
173    pub size: Option<(f32, f32)>,
174    /// Arrastre de la barra de título → delta en px.
175    pub on_mover: alloc::sync::Arc<dyn Fn(f32, f32) -> Msg + Send + Sync>,
176    /// Arrastre de un canto → `(dx, dy, dw, dh)` ya resueltos.
177    pub on_redimensionar: alloc::sync::Arc<dyn Fn(f32, f32, f32, f32) -> Msg + Send + Sync>,
178}
179
180/// Lo más chico que se deja poner una ventana modal, px. Debajo de esto la
181/// cabecera se come el cuerpo y ya no se puede volver a agrandar por el canto
182/// de abajo, porque coincide con el de arriba.
183pub const VENTANA_MIN_W: f32 = 280.0;
184pub const VENTANA_MIN_H: f32 = 180.0;
185
186/// Zona sensible de un borde, px. Más gorda que la línea que se ve: abajo de
187/// ~6 px hay que cazar el borde con el cursor en vez de apuntarlo.
188const ASA: f32 = 6.0;
189/// La esquina le gana al borde en el rincón, y es la que más se usa.
190const ASA_ESQ: f32 = 14.0;
191
192/// Spec completo del modal.
193pub struct ModalSpec<Msg: Clone + 'static> {
194    pub title: String,
195    /// Cuerpo libre — la app construye un `View` con lo que quiera
196    /// mostrar (texto, form, lista). Se pinta entre título y botones.
197    pub body: View<Msg>,
198    pub buttons: Vec<ModalButton<Msg>>,
199    /// Tamaño del panel (clampea al viewport con margen).
200    pub size: (f32, f32),
201    pub viewport: (f32, f32),
202    /// Msg al hacer click en el scrim o presionar Esc (la app maneja
203    /// Esc en su `on_key`; este Msg es el del click).
204    pub on_dismiss: Msg,
205    pub palette: ModalPalette,
206    /// Scroll del cuerpo si el contenido excede el panel. `None` = sin scroll
207    /// (comportamiento previo: se recorta).
208    pub body_scroll: Option<ModalBodyScroll<Msg>>,
209}
210
211/// Px de scroll por «tick» de rueda en el cuerpo del modal.
212const BODY_WHEEL_LINE: f32 = 40.0;
213
214/// Alto disponible para el contenido del cuerpo dentro de un panel de alto `h`
215/// (descuenta título, botones, separador y el padding del cuerpo). Pública para
216/// que el caller estime cuánto entra sin scroll.
217pub fn body_viewport_height(h: f32) -> f32 {
218    (h - TITLE_H - BUTTONS_H - 1.0 - 2.0 * PAD).max(0.0)
219}
220
221/// La inversa: qué alto de panel hace falta para que un cuerpo de `body_h` px
222/// entre **entero**, con su título, su padding y su fila de botones.
223///
224/// Existe para que el caller no vuelva a estimar el cromo con una constante
225/// propia. La suma a mano es exactamente lo que tenía mal el diálogo de la
226/// pregunta hospedada: `128 + …` se quedaba ~60 px corto, y esos 60 px eran la
227/// fila de botones, que quedaba **fuera del panel**.
228pub fn panel_height_for_body(body_h: f32) -> f32 {
229    body_h.max(0.0) + TITLE_H + BUTTONS_H + 1.0 + 2.0 * PAD
230}
231
232const TITLE_H: f32 = 40.0;
233const BUTTONS_H: f32 = 56.0;
234const TITLE_FONT: f32 = 14.0;
235const BTN_FONT: f32 = 12.5;
236const PAD: f32 = 16.0;
237
238pub fn modal_view<Msg: Clone + 'static>(spec: ModalSpec<Msg>) -> View<Msg> {
239    modal_ventana(spec, None)
240}
241
242/// **El mismo modal, como ventana**: se agarra de la barra de título y se
243/// estira de los cantos. Ver [`ModalVentana`].
244///
245/// Es una puerta aparte y no un campo más de [`ModalSpec`] por una razón
246/// práctica: un campo obligatorio en un struct público obliga a tocar **todos**
247/// los sitios que arman un modal en el repo, y varios están en manos de otra
248/// sesión en este mismo clon. La implementación es una sola; `modal_view` es
249/// esta misma con `None`.
250pub fn modal_ventana<Msg: Clone + 'static>(
251    spec: ModalSpec<Msg>,
252    ventana: Option<ModalVentana<Msg>>,
253) -> View<Msg> {
254    let ModalSpec {
255        title,
256        body,
257        buttons,
258        size,
259        viewport,
260        on_dismiss,
261        palette,
262        body_scroll,
263    } = spec;
264
265    // El tamaño que el operador le dejó puesto manda sobre el pedido; el
266    // viewport sigue siendo el techo (una ventana más grande que la pantalla no
267    // se puede volver a agarrar).
268    let size = ventana.as_ref().and_then(|v| v.size).unwrap_or(size);
269    let (w, h) = (
270        size.0.min(viewport.0 - 32.0).max(200.0),
271        size.1.min(viewport.1 - 32.0).max(140.0),
272    );
273    let (off_x, off_y) = ventana.as_ref().map(|v| v.offset).unwrap_or((0.0, 0.0));
274    // Centrado + lo que el operador la corrió, **acotado a la pantalla**: una
275    // ventana arrastrada afuera del borde no se puede traer de vuelta, y la
276    // barra de título es lo único con lo que se la agarra. Se deja que asome
277    // por el borde, pero nunca del todo.
278    let libre_x = (viewport.0 - w).max(0.0);
279    let libre_y = (viewport.1 - h).max(0.0);
280    let x = (libre_x * 0.5 + off_x).clamp(0.0, libre_x);
281    let y = (libre_y * 0.5 + off_y).clamp(0.0, libre_y);
282
283    // Nombre accesible del diálogo: el lector lo enuncia al abrirse.
284    let aria_title = title.clone();
285    // El scrim lo nombra el título del diálogo que cierra: «Cerrar Máquinas
286    // remotas» dice qué se va, y «Cerrar» a secas no.
287    let aria_scrim = title.clone();
288
289    // Header — título a la izquierda; al borde inferior, una línea
290    // separadora se logra con un nodo de 1px.
291    let header = View::new(Style {
292        size: Size {
293            width: percent(1.0_f32),
294            height: length(TITLE_H),
295        },
296        padding: Rect {
297            left: length(PAD),
298            right: length(PAD),
299            top: length(0.0_f32),
300            bottom: length(0.0_f32),
301        },
302        align_items: Some(AlignItems::CENTER),
303        flex_shrink: 0.0,
304        ..Default::default()
305    })
306    .text_aligned(title, TITLE_FONT, palette.fg_title, Alignment::Start)
307    .role(llimphi_compositor::Role::Heading);
308    // La barra de título arrastra la ventana. Sólo en modo ventana: un diálogo
309    // centrado que se moviera sin poder crecer sería peor que uno quieto.
310    let header = match ventana.as_ref() {
311        Some(v) => {
312            let mover = v.on_mover.clone();
313            header
314                .cursor(llimphi_compositor::Cursor::Grab)
315                .draggable_at(move |_fase, dx, dy, _lx, _ly| Some((mover)(dx, dy)))
316        }
317        None => header,
318    };
319
320    let separator = View::new(Style {
321        size: Size {
322            width: percent(1.0_f32),
323            height: length(1.0_f32),
324        },
325        flex_shrink: 0.0,
326        ..Default::default()
327    })
328    .fill(palette.border);
329
330    // Body — flex_grow para ocupar todo el espacio sobrante.
331    let body_wrap = View::new(Style {
332        size: Size {
333            width: percent(1.0_f32),
334            height: auto(),
335        },
336        flex_grow: 1.0,
337        // **El cuerpo tiene que poder encogerse.** El mínimo automático de un
338        // ítem flex es su min-content, así que un cuerpo más alto que el panel
339        // NO se achicaba: crecía la columna y empujaba la fila de botones fuera
340        // del panel, que la recorta. Con `clip` el cuerpo dejó de pintarse
341        // encima de los botones (2026-09-02), pero los botones seguían sin
342        // estar cuando el que no entraba era el cuerpo entero — que es como
343        // salió el diálogo de la pregunta el 2026-09-06: sin «Aceptar», sin
344        // «Cancelar», y el campo de respuesta pegado al borde de abajo.
345        min_size: Size {
346            width: auto(),
347            height: length(0.0_f32),
348        },
349        padding: Rect {
350            left: length(PAD),
351            right: length(PAD),
352            top: length(PAD),
353            bottom: length(PAD),
354        },
355        ..Default::default()
356    });
357    // **El cuerpo se recorta SIEMPRE.** Sin esto, un cuerpo más alto que el
358    // panel no se recortaba: se salía de su caja y se pintaba **encima de la
359    // fila de botones**, así que el diálogo parecía no tener «Aceptar». Lo
360    // reportó el operador el 2026-09-02 sobre el diálogo de grupo nuevo, con
361    // quince grupos en la lista: *«esa ventana no tiene botón ok, o no se está
362    // mostrando»*. Los botones estaban —tienen `flex_shrink: 0`— y quedaban
363    // debajo del dibujo del cuerpo.
364    //
365    // El doc de `body_scroll` ya decía «None = se recorta»: el código no lo
366    // hacía. Un modal cuyo cuerpo no entra tiene que perder contenido de abajo,
367    // nunca sus botones — el contenido se alcanza con `body_scroll`, los botones
368    // son la única salida del diálogo.
369    let body_wrap = body_wrap.clip(true);
370    let body_wrap = match body_scroll {
371        // Con scroll: desplaza el contenido -offset (clampeado al alto
372        // disponible). La rueda emite el delta para que el host acumule.
373        Some(bs) => {
374            let vp_h = body_viewport_height(h);
375            let max_off = (bs.content_h - vp_h).max(0.0);
376            let off = bs.offset.clamp(0.0, max_off);
377            let on_scroll = bs.on_scroll;
378            let inner = View::new(Style {
379                size: Size { width: percent(1.0_f32), height: auto() },
380                margin: Rect {
381                    left: length(0.0_f32),
382                    right: length(0.0_f32),
383                    top: length(-off),
384                    bottom: length(0.0_f32),
385                },
386                ..Default::default()
387            })
388            .children(vec![body]);
389            body_wrap
390                .on_scroll(move |_dx, dy| Some((on_scroll)(dy * BODY_WHEEL_LINE)))
391                .children(vec![inner])
392        }
393        None => body_wrap.children(vec![body]),
394    };
395
396    // Botones — flex-row justify-end con gap.
397    let btn_children: Vec<View<Msg>> = buttons
398        .into_iter()
399        .map(|b| button_view(b, &palette))
400        .collect();
401    let buttons_row = View::new(Style {
402        size: Size {
403            width: percent(1.0_f32),
404            height: length(BUTTONS_H),
405        },
406        flex_direction: FlexDirection::Row,
407        justify_content: Some(JustifyContent::FLEX_END),
408        align_items: Some(AlignItems::CENTER),
409        padding: Rect {
410            left: length(PAD),
411            right: length(PAD),
412            top: length(0.0_f32),
413            bottom: length(0.0_f32),
414        },
415        gap: Size {
416            width: length(8.0_f32),
417            height: length(0.0_f32),
418        },
419        flex_shrink: 0.0,
420        ..Default::default()
421    })
422    .children(btn_children);
423
424    // Sombra E4 + entrada animada (ease_out_cubic, FAST). El `key`
425    // estable se deriva del puntero a `spec.title` — alcanza para que
426    // una sola firma de modal viva en el frame. Si la app necesita
427    // múltiples modales animados independientemente, pasar un `key`
428    // propio (todavía no expuesto en la API — se agrega cuando aparezca
429    // ese caller).
430    let (sh_a, sh_blur, sh_dy) = elevation::E4;
431    let modal_shadow = Shadow {
432        color: Color::from_rgba8(0, 0, 0, sh_a),
433        blur: sh_blur,
434        dx: 0.0,
435        dy: sh_dy,
436        spread: 0.0,
437    };
438    let panel_key: u64 = 0x4d4f44414c00_u64 ^ (w as u64) ^ ((h as u64) << 8);
439    let panel = View::new(Style {
440        position: Position::Absolute,
441        inset: Rect {
442            left: length(x),
443            top: length(y),
444            right: auto(),
445            bottom: auto(),
446        },
447        size: Size {
448            width: length(w),
449            height: length(h),
450        },
451        flex_direction: FlexDirection::Column,
452        ..Default::default()
453    })
454    .paint_with(panel_signature_painter(palette.panel))
455    .radius(palette.panel.radius)
456    .shadow(modal_shadow)
457    .animated_enter(panel_key, motion::FAST)
458    .clip(true)
459    .role(llimphi_compositor::Role::Dialog)
460    .aria_label(aria_title)
461    .children({
462        let mut hijos = vec![header, separator, body_wrap, buttons_row];
463        hijos.extend(asas_de_tamano(ventana.as_ref()));
464        hijos
465    });
466
467    let scrim_base = palette.scrim;
468    View::new(Style {
469        size: Size {
470            width: percent(1.0_f32),
471            height: percent(1.0_f32),
472        },
473        ..Default::default()
474    })
475    .paint_with(move |scene, _ts, rect| {
476        use llimphi_compositor::peniko::kurbo::{Affine, Point, Rect as KurboRect};
477        use llimphi_compositor::peniko::{color::AlphaColor, Fill, Gradient};
478
479        if rect.w <= 0.0 || rect.h <= 0.0 {
480            return;
481        }
482        // Vignette: el centro toma alpha = base * 0.75 (más translúcido,
483        // deja ver lo que hay detrás del modal); las esquinas alpha =
484        // base * 1.4 (más sólido, oscurece los bordes). El promedio
485        // visual queda cerca de `base` original, así la densidad pedida
486        // por el caller se preserva.
487        let [r, g, b, base_a] = scrim_base.components;
488        let inner: Color =
489            AlphaColor::new([r, g, b, (base_a * 0.75).clamp(0.0, 1.0)]);
490        let outer: Color =
491            AlphaColor::new([r, g, b, (base_a * 1.4).clamp(0.0, 1.0)]);
492
493        let cx = rect.x as f64 + rect.w as f64 * 0.5;
494        let cy = rect.y as f64 + rect.h as f64 * 0.5;
495        let diag_half = (((rect.w as f64).powi(2) + (rect.h as f64).powi(2)).sqrt() * 0.5) as f32;
496        let gradient = Gradient::new_radial(Point::new(cx, cy), diag_half)
497            .with_stops([inner, outer].as_slice());
498        let full = KurboRect::new(
499            rect.x as f64,
500            rect.y as f64,
501            (rect.x + rect.w) as f64,
502            (rect.y + rect.h) as f64,
503        );
504        scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &full);
505    })
506    // El scrim: el click en el vacío descarta. Es lo más grande que se clickea
507    // de un modal (la pantalla entera) y era el nodo mudo que aparecía en TODOS
508    // los modales de TODAS las apps — uno por diálogo abierto.
509    .role(llimphi_compositor::Role::Button)
510    .aria_label(alloc::format!("Cerrar {aria_scrim}"))
511    .on_click(on_dismiss)
512    .children(vec![panel])
513}
514
515fn button_view<Msg: Clone + 'static>(btn: ModalButton<Msg>, palette: &ModalPalette) -> View<Msg> {
516    let (bg, fg, hover) = match btn.kind {
517        ButtonKind::Primary => (palette.bg_primary, palette.fg_primary, brighten(palette.bg_primary, 0.15)),
518        ButtonKind::Cancel => (palette.bg_btn, palette.fg_btn, palette.bg_btn_hover),
519        ButtonKind::Destructive => (palette.bg_destructive, palette.fg_destructive, brighten(palette.bg_destructive, 0.15)),
520    };
521    // Apagado: fondo neutro y letra apagada, como el resto de la suite pinta lo
522    // que no se puede tocar. No es sólo estética — abajo tampoco se le cuelga el
523    // click, así que apretarlo no despacha nada.
524    let (bg, fg, hover) = if btn.disabled {
525        (palette.bg_btn, palette.fg_text_apagado(), palette.bg_btn)
526    } else {
527        (bg, fg, hover)
528    };
529    let label = btn.label.clone();
530    let v = View::new(Style {
531        size: Size {
532            // Ancho por glifos reales (fuente proporcional), con mínimo cómodo.
533            width: length((llimphi_compositor::llimphi_text::text_width(&label, BTN_FONT) + 28.0).max(64.0)),
534            height: length(32.0_f32),
535        },
536        align_items: Some(AlignItems::CENTER),
537        justify_content: Some(JustifyContent::CENTER),
538        padding: Rect {
539            left: length(12.0_f32),
540            right: length(12.0_f32),
541            top: length(0.0_f32),
542            bottom: length(0.0_f32),
543        },
544        flex_shrink: 0.0,
545        ..Default::default()
546    })
547    .fill(bg)
548    .hover_fill(hover)
549    .radius(radius::SM)
550    .text_aligned(label.clone(), BTN_FONT, fg, Alignment::Center)
551    .role(llimphi_compositor::Role::Button)
552    .aria_label(label);
553    if btn.disabled {
554        // Sin `on_click`: un botón apagado que igual despacha su mensaje es la
555        // mitad del arreglo, y la mitad que se olvida.
556        v
557    } else {
558        v.on_click(btn.msg)
559    }
560}
561
562/// Aclara un color sumando `delta` a cada componente RGB. Útil para
563/// derivar un hover state del color base sin tener que definirlo aparte.
564fn brighten(c: Color, delta: f32) -> Color {
565    let [r, g, b, a] = c.components;
566    use llimphi_compositor::peniko::color::AlphaColor;
567    AlphaColor::new([
568        (r + delta).clamp(0.0, 1.0),
569        (g + delta).clamp(0.0, 1.0),
570        (b + delta).clamp(0.0, 1.0),
571        a,
572    ])
573}
574
575/// **Focus trap** para teclado: dado el nº de elementos enfocables del modal
576/// (típicamente los botones de la barra de acciones) y el índice actual,
577/// devuelve el siguiente al pulsar Tab (`forward = true`) o Shift+Tab
578/// (`false`), **ciclando** dentro del modal — el foco nunca escapa al fondo
579/// (esa era la fuga: sin trap, Tab llevaba a widgets detrás del scrim). El
580/// host guarda el índice enfocado y lo actualiza con esto. `current ==
581/// usize::MAX` (nada enfocado) arranca por el primero (Tab) o el último
582/// (Shift+Tab).
583pub fn trap_focus(len: usize, current: usize, forward: bool) -> usize {
584    if len == 0 {
585        return 0;
586    }
587    if current == usize::MAX {
588        return if forward { 0 } else { len - 1 };
589    }
590    let n = len as i64;
591    let dir = if forward { 1 } else { -1 };
592    (current as i64 + dir).rem_euclid(n) as usize
593}
594
595/// **Las ocho asas para cambiarle el tamaño a la ventana**: cuatro bordes y
596/// cuatro esquinas, éstas encima.
597///
598/// Van al final de los hijos del panel a propósito: el hit-test resuelve el z
599/// por orden del árbol, así que lo último gana; y las esquinas después de los
600/// bordes por lo mismo — en el rincón se pisan y ahí manda la esquina, que
601/// mueve los dos ejes. Es la misma disposición que las ventanas flotantes de
602/// shuma, de donde se copió la mecánica entera.
603///
604/// Cada asa resuelve **su** semántica y entrega `(dx, dy, dw, dh)`: agarrar por
605/// la izquierda mueve el borde izquierdo (la ventana se corre y se angosta), por
606/// la derecha sólo la ensancha. Así el llamador no tiene que saber qué canto
607/// tocó nadie.
608fn asas_de_tamano<Msg: Clone + 'static>(ventana: Option<&ModalVentana<Msg>>) -> Vec<View<Msg>> {
609    let Some(v) = ventana else { return Vec::new() };
610    // (izq, der, arr, aba, ancho, alto, cursor)
611    let cantos: [(bool, bool, bool, bool, bool, bool); 8] = [
612        // Los cuatro bordes, a lo largo entero.
613        (true, true, true, false, true, false),   // arriba
614        (true, true, false, true, true, false),   // abajo
615        (true, false, true, true, false, true),   // izquierda
616        (false, true, true, true, false, true),   // derecha
617        // Y las cuatro esquinas, encima.
618        (true, false, true, false, false, false), // arriba-izq
619        (false, true, true, false, false, false), // arriba-der
620        (true, false, false, true, false, false), // abajo-izq
621        (false, true, false, true, false, false), // abajo-der
622    ];
623    let mut out = Vec::with_capacity(8);
624    for (izq, der, arr, aba, ancho_completo, alto_completo) in cantos {
625        // Qué bordes mueve esta asa. Un borde recorre su lado entero, así que
626        // «pegado a los dos lados» no significa que los mueva: lo que manda es
627        // el eje corto.
628        let (mueve_izq, mueve_der) = if ancho_completo { (false, false) } else { (izq, der) };
629        let (mueve_arr, mueve_aba) = if alto_completo { (false, false) } else { (arr, aba) };
630        let esquina = !ancho_completo && !alto_completo;
631        let d = |b: bool| if b { length(0.0_f32) } else { auto() };
632        let (aw, ah) = if ancho_completo {
633            (percent(1.0_f32), length(ASA))
634        } else if alto_completo {
635            (length(ASA), percent(1.0_f32))
636        } else {
637            (length(ASA_ESQ), length(ASA_ESQ))
638        };
639        let cursor = if esquina {
640            if mueve_izq == mueve_arr {
641                llimphi_compositor::Cursor::NwseResize
642            } else {
643                llimphi_compositor::Cursor::NeswResize
644            }
645        } else if ancho_completo {
646            llimphi_compositor::Cursor::NsResize
647        } else {
648            llimphi_compositor::Cursor::EwResize
649        };
650        let redim = v.on_redimensionar.clone();
651        out.push(
652            View::new(Style {
653                position: Position::Absolute,
654                inset: Rect { left: d(izq), right: d(der), top: d(arr), bottom: d(aba) },
655                size: Size { width: aw, height: ah },
656                ..Default::default()
657            })
658            .cursor(cursor)
659            .role(llimphi_compositor::Role::Group)
660            .aria_label("Redimensionar el diálogo")
661            .draggable_at(move |_fase, dx, dy, _lx, _ly| {
662                let dw = if mueve_der { dx } else if mueve_izq { -dx } else { 0.0 };
663                let dh = if mueve_aba { dy } else if mueve_arr { -dy } else { 0.0 };
664                let mx = if mueve_izq { dx } else { 0.0 };
665                let my = if mueve_arr { dy } else { 0.0 };
666                Some((redim)(mx, my, dw, dh))
667            }),
668        );
669    }
670    out
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    #[test]
678    fn trap_focus_cicla_dentro_del_modal() {
679        assert_eq!(trap_focus(3, 0, true), 1);
680        assert_eq!(trap_focus(3, 2, true), 0); // Tab en el último → vuelve al primero
681        assert_eq!(trap_focus(3, 0, false), 2); // Shift+Tab en el primero → al último
682        assert_eq!(trap_focus(3, 1, false), 0);
683    }
684
685    #[test]
686    fn trap_focus_arranca_desde_ninguno() {
687        assert_eq!(trap_focus(3, usize::MAX, true), 0);
688        assert_eq!(trap_focus(3, usize::MAX, false), 2);
689        assert_eq!(trap_focus(0, usize::MAX, true), 0);
690    }
691
692    #[test]
693    fn el_alto_de_panel_para_un_cuerpo_es_la_inversa_del_area_del_cuerpo() {
694        // Es la propiedad que usa el caller para no dejar la fila de botones
695        // afuera: pedir el panel que hace entrar un cuerpo de N px tiene que
696        // devolver exactamente N px de área de cuerpo.
697        for body in [0.0_f32, 12.0, 137.5, 900.0] {
698            let h = panel_height_for_body(body);
699            assert!(
700                (body_viewport_height(h) - body).abs() < 1e-3,
701                "cuerpo {body}: panel {h} deja {} px",
702                body_viewport_height(h)
703            );
704        }
705        // Y siempre hay lugar para el cromo, aunque el cuerpo sea nada.
706        assert!(panel_height_for_body(0.0) >= TITLE_H + BUTTONS_H);
707    }
708
709    #[test]
710    fn body_viewport_descuenta_cromo_y_no_es_negativo() {
711        // Panel alto: el área del cuerpo es el alto menos título/botones/pads.
712        let vp = body_viewport_height(400.0);
713        assert!((vp - (400.0 - TITLE_H - BUTTONS_H - 1.0 - 2.0 * PAD)).abs() < 1e-3);
714        // Panel diminuto: nunca negativo.
715        assert_eq!(body_viewport_height(10.0), 0.0);
716    }
717}