//! `llimphi-widget-modal` — diálogo genérico centrado con scrim. //! //! Distinto del `context-menu` (chico, anclado a un click): el modal //! ocupa una región central de tamaño configurable, presenta un título, //! un cuerpo arbitrario (lo arma la app) y una barra de botones. //! //! Uso típico: //! 1. La app guarda `Option` en su modelo. //! 2. `view_overlay` devuelve `Some(modal_view(spec))` cuando hay //! state, `None` cuando se cerró. //! 3. La app captura `Esc` en `on_key` → cierra; `Enter` → primary. //! //! Tres severidades de botón: //! - `Primary` — verde/accent, acción principal. //! - `Cancel` — neutral, descarta. //! - `Destructive` — rojo, acción irreversible (eliminar, etc). #![forbid(unsafe_code)] // Sin `std`: este widget no usa nada del sistema —sólo `alloc` para // `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula // WASM o al kernel. Antes enlazaba `std` por omisión (W4.6). #![no_std] extern crate alloc; use alloc::string::String; use alloc::vec::Vec; use alloc::vec; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{auto, length, percent, FlexDirection, Position, Size, Style}, AlignItems, JustifyContent, Rect, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::llimphi_text::Alignment; use llimphi_compositor::{Shadow, View}; use llimphi_theme::{alpha, elevation, motion, radius, Theme}; use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; /// Paleta del modal. #[derive(Debug, Clone, Copy)] pub struct ModalPalette { /// Color del scrim. El alpha se usa como **promedio** del vignette /// radial: el centro (debajo del panel) queda ~25% más claro y las /// esquinas ~40% más oscuras, manteniendo la densidad media igual a /// lo que pidió el caller. Esto focaliza al modal sin "encerrarlo". pub scrim: Color, /// Firma visual del panel — gradient sutil + hairline accent en el /// top edge. La que vuelve consistente el "look tawasuyu" en todos /// los modales y overlays. pub panel: PanelStyle, pub border: Color, pub fg_title: Color, pub fg_text: Color, pub bg_btn: Color, pub bg_btn_hover: Color, pub fg_btn: Color, pub bg_primary: Color, pub fg_primary: Color, pub bg_destructive: Color, pub fg_destructive: Color, } impl ModalPalette { /// La letra de un botón apagado. Sale de `fg_text` de la paleta del modal, /// que ya es el tono apagado del tema (`fg_muted`). fn fg_text_apagado(&self) -> Color { self.fg_text } } impl ModalPalette { pub fn from_theme(t: &Theme) -> Self { Self { scrim: Color::from_rgba8(0, 0, 0, alpha::SCRIM), panel: PanelStyle::from_theme_large(t), border: t.border, fg_title: t.fg_text, fg_text: t.fg_muted, bg_btn: t.bg_button, bg_btn_hover: t.bg_button_hover, fg_btn: t.fg_text, bg_primary: t.accent, fg_primary: t.bg_app, bg_destructive: t.fg_destructive, fg_destructive: t.bg_app, } } } /// Severidad de un botón del modal. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ButtonKind { Primary, Cancel, Destructive, } /// Spec de un botón. `label` se renderiza; `msg` se dispatcha al click. #[derive(Clone)] pub struct ModalButton { pub label: String, pub kind: ButtonKind, pub msg: Msg, /// **Apagado**: se pinta en tono muerto y el click NO se despacha. /// /// Existe porque «el botón no hace nada» y «el botón está apagado» se /// tienen que ver distinto. Un primario con su color de acento que al /// apretarlo no pasa nada es lo que hace desconfiar del diálogo entero; el /// que está apagado se lee de un vistazo, y el rótulo dice qué falta. pub disabled: bool, } impl ModalButton { pub fn primary(label: impl Into, msg: Msg) -> Self { Self { label: label.into(), kind: ButtonKind::Primary, msg, disabled: false } } pub fn cancel(label: impl Into, msg: Msg) -> Self { Self { label: label.into(), kind: ButtonKind::Cancel, msg, disabled: false } } pub fn destructive(label: impl Into, msg: Msg) -> Self { Self { label: label.into(), kind: ButtonKind::Destructive, msg, disabled: false } } /// Apagalo (o no) según una condición. Encadenable sobre los tres de arriba. pub fn apagado_si(mut self, cond: bool) -> Self { self.disabled = cond; self } } /// Scroll interno del cuerpo del modal, para contenido más alto que el panel. /// Sin esto, un `body` alto se recorta (el panel tiene `clip`). El caller /// guarda `offset` en su modelo y lo acumula con el delta de rueda; el widget /// **clampea** al alto disponible (conoce el área del cuerpo) — el caller sólo /// evita que baje de 0. pub struct ModalBodyScroll { /// Desplazamiento vertical actual (px, ≥ 0). pub offset: f32, /// Alto natural del contenido del `body` (px) — lo mide/estima el caller. pub content_h: f32, /// Rueda sobre el cuerpo → delta (px) a sumar al `offset`. pub on_scroll: alloc::sync::Arc Msg + Send + Sync>, } /// **El modal como VENTANA: se agarra de la barra de título y se estira de los /// cantos.** /// /// Sin esto un diálogo está clavado en el centro y del tamaño que decidió quien /// lo escribió: si su cuerpo no entra, el operador no tiene ninguna salida /// —moverlo, agrandarlo— y la ventana se vuelve un dibujo. Reporte textual del /// operador (2026-09-02) sobre el diálogo de grupo nuevo: *«qué tipo de ventana /// es esa, que no se puede dragar ni nada»*, y la regla que dejó: *«odio que /// hagas cosas falsas o mal hechas para abaratar programación»*. /// /// La mecánica es la **misma** que la de las ventanas flotantes de shuma /// (`floating_pane`): la cabecera lleva `draggable_at`, y ocho asas invisibles /// —cuatro bordes y cuatro esquinas, éstas encima— se montan sobre el marco con /// el cursor que corresponde. Se copia esa forma a propósito: dos maneras de /// agarrar una ventana en el mismo escritorio se sienten como dos escritorios. /// /// **Los cantos no cruzan la frontera.** El asa ya sabe qué borde es, así que /// resuelve ella el arrastre y entrega el cambio de geometría ya hecho /// (`dx, dy, dw, dh`): el llamador sólo suma y acota con sus mínimos. Un enum /// de ocho cantos repetido de los dos lados sería la misma verdad escrita dos /// veces, y la que se olvida de actualizar es siempre la de más lejos. pub struct ModalVentana { /// Cuánto se corrió respecto del centro, px. `(0.0, 0.0)` = donde el modal /// nace, que es centrado como siempre. pub offset: (f32, f32), /// Tamaño ya cambiado por el operador, si lo cambió. `None` = el de /// `ModalSpec::size`. pub size: Option<(f32, f32)>, /// Arrastre de la barra de título → delta en px. pub on_mover: alloc::sync::Arc Msg + Send + Sync>, /// Arrastre de un canto → `(dx, dy, dw, dh)` ya resueltos. pub on_redimensionar: alloc::sync::Arc Msg + Send + Sync>, } /// Lo más chico que se deja poner una ventana modal, px. Debajo de esto la /// cabecera se come el cuerpo y ya no se puede volver a agrandar por el canto /// de abajo, porque coincide con el de arriba. pub const VENTANA_MIN_W: f32 = 280.0; pub const VENTANA_MIN_H: f32 = 180.0; /// Zona sensible de un borde, px. Más gorda que la línea que se ve: abajo de /// ~6 px hay que cazar el borde con el cursor en vez de apuntarlo. const ASA: f32 = 6.0; /// La esquina le gana al borde en el rincón, y es la que más se usa. const ASA_ESQ: f32 = 14.0; /// Spec completo del modal. pub struct ModalSpec { pub title: String, /// Cuerpo libre — la app construye un `View` con lo que quiera /// mostrar (texto, form, lista). Se pinta entre título y botones. pub body: View, pub buttons: Vec>, /// Tamaño del panel (clampea al viewport con margen). pub size: (f32, f32), pub viewport: (f32, f32), /// Msg al hacer click en el scrim o presionar Esc (la app maneja /// Esc en su `on_key`; este Msg es el del click). pub on_dismiss: Msg, pub palette: ModalPalette, /// Scroll del cuerpo si el contenido excede el panel. `None` = sin scroll /// (comportamiento previo: se recorta). pub body_scroll: Option>, } /// Px de scroll por «tick» de rueda en el cuerpo del modal. const BODY_WHEEL_LINE: f32 = 40.0; /// Alto disponible para el contenido del cuerpo dentro de un panel de alto `h` /// (descuenta título, botones, separador y el padding del cuerpo). Pública para /// que el caller estime cuánto entra sin scroll. pub fn body_viewport_height(h: f32) -> f32 { (h - TITLE_H - BUTTONS_H - 1.0 - 2.0 * PAD).max(0.0) } /// La inversa: qué alto de panel hace falta para que un cuerpo de `body_h` px /// entre **entero**, con su título, su padding y su fila de botones. /// /// Existe para que el caller no vuelva a estimar el cromo con una constante /// propia. La suma a mano es exactamente lo que tenía mal el diálogo de la /// pregunta hospedada: `128 + …` se quedaba ~60 px corto, y esos 60 px eran la /// fila de botones, que quedaba **fuera del panel**. pub fn panel_height_for_body(body_h: f32) -> f32 { body_h.max(0.0) + TITLE_H + BUTTONS_H + 1.0 + 2.0 * PAD } const TITLE_H: f32 = 40.0; const BUTTONS_H: f32 = 56.0; const TITLE_FONT: f32 = 14.0; const BTN_FONT: f32 = 12.5; const PAD: f32 = 16.0; pub fn modal_view(spec: ModalSpec) -> View { modal_ventana(spec, None) } /// **El mismo modal, como ventana**: se agarra de la barra de título y se /// estira de los cantos. Ver [`ModalVentana`]. /// /// Es una puerta aparte y no un campo más de [`ModalSpec`] por una razón /// práctica: un campo obligatorio en un struct público obliga a tocar **todos** /// los sitios que arman un modal en el repo, y varios están en manos de otra /// sesión en este mismo clon. La implementación es una sola; `modal_view` es /// esta misma con `None`. pub fn modal_ventana( spec: ModalSpec, ventana: Option>, ) -> View { let ModalSpec { title, body, buttons, size, viewport, on_dismiss, palette, body_scroll, } = spec; // El tamaño que el operador le dejó puesto manda sobre el pedido; el // viewport sigue siendo el techo (una ventana más grande que la pantalla no // se puede volver a agarrar). let size = ventana.as_ref().and_then(|v| v.size).unwrap_or(size); let (w, h) = ( size.0.min(viewport.0 - 32.0).max(200.0), size.1.min(viewport.1 - 32.0).max(140.0), ); let (off_x, off_y) = ventana.as_ref().map(|v| v.offset).unwrap_or((0.0, 0.0)); // Centrado + lo que el operador la corrió, **acotado a la pantalla**: una // ventana arrastrada afuera del borde no se puede traer de vuelta, y la // barra de título es lo único con lo que se la agarra. Se deja que asome // por el borde, pero nunca del todo. let libre_x = (viewport.0 - w).max(0.0); let libre_y = (viewport.1 - h).max(0.0); let x = (libre_x * 0.5 + off_x).clamp(0.0, libre_x); let y = (libre_y * 0.5 + off_y).clamp(0.0, libre_y); // Nombre accesible del diálogo: el lector lo enuncia al abrirse. let aria_title = title.clone(); // El scrim lo nombra el título del diálogo que cierra: «Cerrar Máquinas // remotas» dice qué se va, y «Cerrar» a secas no. let aria_scrim = title.clone(); // Header — título a la izquierda; al borde inferior, una línea // separadora se logra con un nodo de 1px. let header = View::new(Style { size: Size { width: percent(1.0_f32), height: length(TITLE_H), }, padding: Rect { left: length(PAD), right: length(PAD), top: length(0.0_f32), bottom: length(0.0_f32), }, align_items: Some(AlignItems::CENTER), flex_shrink: 0.0, ..Default::default() }) .text_aligned(title, TITLE_FONT, palette.fg_title, Alignment::Start) .role(llimphi_compositor::Role::Heading); // La barra de título arrastra la ventana. Sólo en modo ventana: un diálogo // centrado que se moviera sin poder crecer sería peor que uno quieto. let header = match ventana.as_ref() { Some(v) => { let mover = v.on_mover.clone(); header .cursor(llimphi_compositor::Cursor::Grab) .draggable_at(move |_fase, dx, dy, _lx, _ly| Some((mover)(dx, dy))) } None => header, }; let separator = View::new(Style { size: Size { width: percent(1.0_f32), height: length(1.0_f32), }, flex_shrink: 0.0, ..Default::default() }) .fill(palette.border); // Body — flex_grow para ocupar todo el espacio sobrante. let body_wrap = View::new(Style { size: Size { width: percent(1.0_f32), height: auto(), }, flex_grow: 1.0, // **El cuerpo tiene que poder encogerse.** El mínimo automático de un // ítem flex es su min-content, así que un cuerpo más alto que el panel // NO se achicaba: crecía la columna y empujaba la fila de botones fuera // del panel, que la recorta. Con `clip` el cuerpo dejó de pintarse // encima de los botones (2026-09-02), pero los botones seguían sin // estar cuando el que no entraba era el cuerpo entero — que es como // salió el diálogo de la pregunta el 2026-09-06: sin «Aceptar», sin // «Cancelar», y el campo de respuesta pegado al borde de abajo. min_size: Size { width: auto(), height: length(0.0_f32), }, padding: Rect { left: length(PAD), right: length(PAD), top: length(PAD), bottom: length(PAD), }, ..Default::default() }); // **El cuerpo se recorta SIEMPRE.** Sin esto, un cuerpo más alto que el // panel no se recortaba: se salía de su caja y se pintaba **encima de la // fila de botones**, así que el diálogo parecía no tener «Aceptar». Lo // reportó el operador el 2026-09-02 sobre el diálogo de grupo nuevo, con // quince grupos en la lista: *«esa ventana no tiene botón ok, o no se está // mostrando»*. Los botones estaban —tienen `flex_shrink: 0`— y quedaban // debajo del dibujo del cuerpo. // // El doc de `body_scroll` ya decía «None = se recorta»: el código no lo // hacía. Un modal cuyo cuerpo no entra tiene que perder contenido de abajo, // nunca sus botones — el contenido se alcanza con `body_scroll`, los botones // son la única salida del diálogo. let body_wrap = body_wrap.clip(true); let body_wrap = match body_scroll { // Con scroll: desplaza el contenido -offset (clampeado al alto // disponible). La rueda emite el delta para que el host acumule. Some(bs) => { let vp_h = body_viewport_height(h); let max_off = (bs.content_h - vp_h).max(0.0); let off = bs.offset.clamp(0.0, max_off); let on_scroll = bs.on_scroll; let inner = View::new(Style { size: Size { width: percent(1.0_f32), height: auto() }, margin: Rect { left: length(0.0_f32), right: length(0.0_f32), top: length(-off), bottom: length(0.0_f32), }, ..Default::default() }) .children(vec![body]); body_wrap .on_scroll(move |_dx, dy| Some((on_scroll)(dy * BODY_WHEEL_LINE))) .children(vec![inner]) } None => body_wrap.children(vec![body]), }; // Botones — flex-row justify-end con gap. let btn_children: Vec> = buttons .into_iter() .map(|b| button_view(b, &palette)) .collect(); let buttons_row = View::new(Style { size: Size { width: percent(1.0_f32), height: length(BUTTONS_H), }, flex_direction: FlexDirection::Row, justify_content: Some(JustifyContent::FLEX_END), align_items: Some(AlignItems::CENTER), padding: Rect { left: length(PAD), right: length(PAD), top: length(0.0_f32), bottom: length(0.0_f32), }, gap: Size { width: length(8.0_f32), height: length(0.0_f32), }, flex_shrink: 0.0, ..Default::default() }) .children(btn_children); // Sombra E4 + entrada animada (ease_out_cubic, FAST). El `key` // estable se deriva del puntero a `spec.title` — alcanza para que // una sola firma de modal viva en el frame. Si la app necesita // múltiples modales animados independientemente, pasar un `key` // propio (todavía no expuesto en la API — se agrega cuando aparezca // ese caller). let (sh_a, sh_blur, sh_dy) = elevation::E4; let modal_shadow = Shadow { color: Color::from_rgba8(0, 0, 0, sh_a), blur: sh_blur, dx: 0.0, dy: sh_dy, spread: 0.0, }; let panel_key: u64 = 0x4d4f44414c00_u64 ^ (w as u64) ^ ((h as u64) << 8); let panel = View::new(Style { position: Position::Absolute, inset: Rect { left: length(x), top: length(y), right: auto(), bottom: auto(), }, size: Size { width: length(w), height: length(h), }, flex_direction: FlexDirection::Column, ..Default::default() }) .paint_with(panel_signature_painter(palette.panel)) .radius(palette.panel.radius) .shadow(modal_shadow) .animated_enter(panel_key, motion::FAST) .clip(true) .role(llimphi_compositor::Role::Dialog) .aria_label(aria_title) .children({ let mut hijos = vec![header, separator, body_wrap, buttons_row]; hijos.extend(asas_de_tamano(ventana.as_ref())); hijos }); let scrim_base = palette.scrim; View::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32), }, ..Default::default() }) .paint_with(move |scene, _ts, rect| { use llimphi_compositor::peniko::kurbo::{Affine, Point, Rect as KurboRect}; use llimphi_compositor::peniko::{color::AlphaColor, Fill, Gradient}; if rect.w <= 0.0 || rect.h <= 0.0 { return; } // Vignette: el centro toma alpha = base * 0.75 (más translúcido, // deja ver lo que hay detrás del modal); las esquinas alpha = // base * 1.4 (más sólido, oscurece los bordes). El promedio // visual queda cerca de `base` original, así la densidad pedida // por el caller se preserva. let [r, g, b, base_a] = scrim_base.components; let inner: Color = AlphaColor::new([r, g, b, (base_a * 0.75).clamp(0.0, 1.0)]); let outer: Color = AlphaColor::new([r, g, b, (base_a * 1.4).clamp(0.0, 1.0)]); let cx = rect.x as f64 + rect.w as f64 * 0.5; let cy = rect.y as f64 + rect.h as f64 * 0.5; let diag_half = (((rect.w as f64).powi(2) + (rect.h as f64).powi(2)).sqrt() * 0.5) as f32; let gradient = Gradient::new_radial(Point::new(cx, cy), diag_half) .with_stops([inner, outer].as_slice()); let full = KurboRect::new( rect.x as f64, rect.y as f64, (rect.x + rect.w) as f64, (rect.y + rect.h) as f64, ); scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &full); }) // El scrim: el click en el vacío descarta. Es lo más grande que se clickea // de un modal (la pantalla entera) y era el nodo mudo que aparecía en TODOS // los modales de TODAS las apps — uno por diálogo abierto. .role(llimphi_compositor::Role::Button) .aria_label(alloc::format!("Cerrar {aria_scrim}")) .on_click(on_dismiss) .children(vec![panel]) } fn button_view(btn: ModalButton, palette: &ModalPalette) -> View { let (bg, fg, hover) = match btn.kind { ButtonKind::Primary => (palette.bg_primary, palette.fg_primary, brighten(palette.bg_primary, 0.15)), ButtonKind::Cancel => (palette.bg_btn, palette.fg_btn, palette.bg_btn_hover), ButtonKind::Destructive => (palette.bg_destructive, palette.fg_destructive, brighten(palette.bg_destructive, 0.15)), }; // Apagado: fondo neutro y letra apagada, como el resto de la suite pinta lo // que no se puede tocar. No es sólo estética — abajo tampoco se le cuelga el // click, así que apretarlo no despacha nada. let (bg, fg, hover) = if btn.disabled { (palette.bg_btn, palette.fg_text_apagado(), palette.bg_btn) } else { (bg, fg, hover) }; let label = btn.label.clone(); let v = View::new(Style { size: Size { // Ancho por glifos reales (fuente proporcional), con mínimo cómodo. width: length((llimphi_compositor::llimphi_text::text_width(&label, BTN_FONT) + 28.0).max(64.0)), height: length(32.0_f32), }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), padding: Rect { left: length(12.0_f32), right: length(12.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, flex_shrink: 0.0, ..Default::default() }) .fill(bg) .hover_fill(hover) .radius(radius::SM) .text_aligned(label.clone(), BTN_FONT, fg, Alignment::Center) .role(llimphi_compositor::Role::Button) .aria_label(label); if btn.disabled { // Sin `on_click`: un botón apagado que igual despacha su mensaje es la // mitad del arreglo, y la mitad que se olvida. v } else { v.on_click(btn.msg) } } /// Aclara un color sumando `delta` a cada componente RGB. Útil para /// derivar un hover state del color base sin tener que definirlo aparte. fn brighten(c: Color, delta: f32) -> Color { let [r, g, b, a] = c.components; use llimphi_compositor::peniko::color::AlphaColor; AlphaColor::new([ (r + delta).clamp(0.0, 1.0), (g + delta).clamp(0.0, 1.0), (b + delta).clamp(0.0, 1.0), a, ]) } /// **Focus trap** para teclado: dado el nº de elementos enfocables del modal /// (típicamente los botones de la barra de acciones) y el índice actual, /// devuelve el siguiente al pulsar Tab (`forward = true`) o Shift+Tab /// (`false`), **ciclando** dentro del modal — el foco nunca escapa al fondo /// (esa era la fuga: sin trap, Tab llevaba a widgets detrás del scrim). El /// host guarda el índice enfocado y lo actualiza con esto. `current == /// usize::MAX` (nada enfocado) arranca por el primero (Tab) o el último /// (Shift+Tab). pub fn trap_focus(len: usize, current: usize, forward: bool) -> usize { if len == 0 { return 0; } if current == usize::MAX { return if forward { 0 } else { len - 1 }; } let n = len as i64; let dir = if forward { 1 } else { -1 }; (current as i64 + dir).rem_euclid(n) as usize } /// **Las ocho asas para cambiarle el tamaño a la ventana**: cuatro bordes y /// cuatro esquinas, éstas encima. /// /// Van al final de los hijos del panel a propósito: el hit-test resuelve el z /// por orden del árbol, así que lo último gana; y las esquinas después de los /// bordes por lo mismo — en el rincón se pisan y ahí manda la esquina, que /// mueve los dos ejes. Es la misma disposición que las ventanas flotantes de /// shuma, de donde se copió la mecánica entera. /// /// Cada asa resuelve **su** semántica y entrega `(dx, dy, dw, dh)`: agarrar por /// la izquierda mueve el borde izquierdo (la ventana se corre y se angosta), por /// la derecha sólo la ensancha. Así el llamador no tiene que saber qué canto /// tocó nadie. fn asas_de_tamano(ventana: Option<&ModalVentana>) -> Vec> { let Some(v) = ventana else { return Vec::new() }; // (izq, der, arr, aba, ancho, alto, cursor) let cantos: [(bool, bool, bool, bool, bool, bool); 8] = [ // Los cuatro bordes, a lo largo entero. (true, true, true, false, true, false), // arriba (true, true, false, true, true, false), // abajo (true, false, true, true, false, true), // izquierda (false, true, true, true, false, true), // derecha // Y las cuatro esquinas, encima. (true, false, true, false, false, false), // arriba-izq (false, true, true, false, false, false), // arriba-der (true, false, false, true, false, false), // abajo-izq (false, true, false, true, false, false), // abajo-der ]; let mut out = Vec::with_capacity(8); for (izq, der, arr, aba, ancho_completo, alto_completo) in cantos { // Qué bordes mueve esta asa. Un borde recorre su lado entero, así que // «pegado a los dos lados» no significa que los mueva: lo que manda es // el eje corto. let (mueve_izq, mueve_der) = if ancho_completo { (false, false) } else { (izq, der) }; let (mueve_arr, mueve_aba) = if alto_completo { (false, false) } else { (arr, aba) }; let esquina = !ancho_completo && !alto_completo; let d = |b: bool| if b { length(0.0_f32) } else { auto() }; let (aw, ah) = if ancho_completo { (percent(1.0_f32), length(ASA)) } else if alto_completo { (length(ASA), percent(1.0_f32)) } else { (length(ASA_ESQ), length(ASA_ESQ)) }; let cursor = if esquina { if mueve_izq == mueve_arr { llimphi_compositor::Cursor::NwseResize } else { llimphi_compositor::Cursor::NeswResize } } else if ancho_completo { llimphi_compositor::Cursor::NsResize } else { llimphi_compositor::Cursor::EwResize }; let redim = v.on_redimensionar.clone(); out.push( View::new(Style { position: Position::Absolute, inset: Rect { left: d(izq), right: d(der), top: d(arr), bottom: d(aba) }, size: Size { width: aw, height: ah }, ..Default::default() }) .cursor(cursor) .role(llimphi_compositor::Role::Group) .aria_label("Redimensionar el diálogo") .draggable_at(move |_fase, dx, dy, _lx, _ly| { let dw = if mueve_der { dx } else if mueve_izq { -dx } else { 0.0 }; let dh = if mueve_aba { dy } else if mueve_arr { -dy } else { 0.0 }; let mx = if mueve_izq { dx } else { 0.0 }; let my = if mueve_arr { dy } else { 0.0 }; Some((redim)(mx, my, dw, dh)) }), ); } out } #[cfg(test)] mod tests { use super::*; #[test] fn trap_focus_cicla_dentro_del_modal() { assert_eq!(trap_focus(3, 0, true), 1); assert_eq!(trap_focus(3, 2, true), 0); // Tab en el último → vuelve al primero assert_eq!(trap_focus(3, 0, false), 2); // Shift+Tab en el primero → al último assert_eq!(trap_focus(3, 1, false), 0); } #[test] fn trap_focus_arranca_desde_ninguno() { assert_eq!(trap_focus(3, usize::MAX, true), 0); assert_eq!(trap_focus(3, usize::MAX, false), 2); assert_eq!(trap_focus(0, usize::MAX, true), 0); } #[test] fn el_alto_de_panel_para_un_cuerpo_es_la_inversa_del_area_del_cuerpo() { // Es la propiedad que usa el caller para no dejar la fila de botones // afuera: pedir el panel que hace entrar un cuerpo de N px tiene que // devolver exactamente N px de área de cuerpo. for body in [0.0_f32, 12.0, 137.5, 900.0] { let h = panel_height_for_body(body); assert!( (body_viewport_height(h) - body).abs() < 1e-3, "cuerpo {body}: panel {h} deja {} px", body_viewport_height(h) ); } // Y siempre hay lugar para el cromo, aunque el cuerpo sea nada. assert!(panel_height_for_body(0.0) >= TITLE_H + BUTTONS_H); } #[test] fn body_viewport_descuenta_cromo_y_no_es_negativo() { // Panel alto: el área del cuerpo es el alto menos título/botones/pads. let vp = body_viewport_height(400.0); assert!((vp - (400.0 - TITLE_H - BUTTONS_H - 1.0 - 2.0 * PAD)).abs() < 1e-3); // Panel diminuto: nunca negativo. assert_eq!(body_viewport_height(10.0), 0.0); } }