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