//! `llimphi-widget-tooltip` — tooltip flotante con anchor + clamping. //! //! Render puro: el widget recibe el anchor (típicamente bottom-center //! del elemento que lo dispara), el viewport y el texto, y devuelve un //! `View` posicionado en absolute para colgarlo de `view_overlay`. //! La app es responsable de: //! 1. Detectar el hover sobre el elemento via `View::on_pointer_enter` //! + un `Tween`/delay para evitar tooltips que parpadean al pasar. //! 2. Guardar el `Option` en su modelo. //! 3. Devolverlo desde `view_overlay`. //! 4. Cerrarlo con `View::on_pointer_leave` sobre el mismo elemento. //! //! No se incluye scrim — el tooltip es informativo, no modal: los //! clicks atraviesan al árbol principal. (Para popovers con //! interacción, usar `llimphi-widget-modal` o el `context-menu`). #![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; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{auto, length, FlexDirection, Position, Size, Style}, AlignItems, Rect, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::llimphi_text::Alignment; use llimphi_compositor::View; use llimphi_theme::{radius, Theme}; /// Paleta del tooltip — fondo "glass panel" oscuro, texto claro. #[derive(Debug, Clone, Copy)] pub struct TooltipPalette { pub bg: Color, pub fg: Color, pub border: Color, } impl TooltipPalette { pub fn from_theme(t: &Theme) -> Self { Self { bg: t.bg_app, fg: t.fg_text, border: t.border, } } } /// Lado preferido al que se coloca el tooltip respecto del anchor. /// Si no entra en el viewport por ese lado, el clamping lo empuja al /// lado contrario (no recortado). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Side { Top, #[default] Bottom, Left, Right, } /// Spec para [`tooltip_view`]. #[derive(Debug, Clone)] pub struct TooltipSpec { /// Punto de origen — típicamente el centro del elemento que dispara. pub anchor: (f32, f32), /// Tamaño actual de la ventana, para clamping. pub viewport: (f32, f32), /// Lado preferido respecto del anchor. pub side: Side, pub text: String, pub palette: TooltipPalette, } const PAD_X: f32 = 8.0; const PAD_Y: f32 = 5.0; const GAP: f32 = 6.0; const FONT_SIZE: f32 = 11.5; const LINE_H: f32 = FONT_SIZE * 1.35; const MAX_W: f32 = 280.0; const MARGIN: f32 = 4.0; /// Mide el tooltip con la fuente real y decide su tamaño. Devuelve /// `(w, h)` incluyendo padding. El ancho de contenido se clampea a /// `MAX_W`; si el texto no entra en una línea, se estima el nº de /// renglones (el nodo de texto envuelve al ancho del panel). fn tooltip_size(text: &str) -> (f32, f32) { let content_max = MAX_W - PAD_X * 2.0; let full = llimphi_compositor::llimphi_text::text_width(text, FONT_SIZE); let inner = full.min(content_max); let lines = if full <= content_max { 1.0 } else { (full / content_max).ceil() }; (inner + PAD_X * 2.0, lines * LINE_H + PAD_Y * 2.0) } /// Ubica el panel: elige el lado efectivo (FLIP real al contrario si no /// entra por el preferido) y devuelve `(x, y, side_efectivo)` ya /// clampeado al viewport. Pura y testeable — sin esto el "flip" del doc /// era en realidad un `clamp` que solapaba el tooltip con el anchor. fn place( anchor: (f32, f32), viewport: (f32, f32), side: Side, w: f32, h: f32, ) -> (f32, f32, Side) { // ¿Entra por el lado preferido sin salirse del viewport? let fits = |s: Side| -> bool { match s { Side::Bottom => anchor.1 + GAP + h <= viewport.1 - MARGIN, Side::Top => anchor.1 - GAP - h >= MARGIN, Side::Right => anchor.0 + GAP + w <= viewport.0 - MARGIN, Side::Left => anchor.0 - GAP - w >= MARGIN, } }; let opposite = |s: Side| match s { Side::Bottom => Side::Top, Side::Top => Side::Bottom, Side::Right => Side::Left, Side::Left => Side::Right, }; // Flip al contrario sólo si el preferido no entra y el opuesto sí. let eff = if fits(side) || !fits(opposite(side)) { side } else { opposite(side) }; let (raw_x, raw_y) = match eff { Side::Bottom => (anchor.0 - w * 0.5, anchor.1 + GAP), Side::Top => (anchor.0 - w * 0.5, anchor.1 - GAP - h), Side::Right => (anchor.0 + GAP, anchor.1 - h * 0.5), Side::Left => (anchor.0 - GAP - w, anchor.1 - h * 0.5), }; // Clamp del eje cruzado (y del principal como red de seguridad). let x = raw_x.min((viewport.0 - w - MARGIN).max(MARGIN)).max(MARGIN); let y = raw_y.min((viewport.1 - h - MARGIN).max(MARGIN)).max(MARGIN); (x, y, eff) } pub fn tooltip_view(spec: TooltipSpec) -> View { let TooltipSpec { anchor, viewport, side, text, palette } = spec; let (est_w, est_h) = tooltip_size(&text); let (x, y, _eff) = place(anchor, viewport, side, est_w, est_h); let panel = View::new(Style { position: Position::Absolute, inset: Rect { left: length(x), top: length(y), right: auto(), bottom: auto(), }, size: Size { width: length(est_w), height: length(est_h), }, flex_direction: FlexDirection::Column, align_items: Some(AlignItems::FLEX_START), padding: Rect { left: length(PAD_X), right: length(PAD_X), top: length(PAD_Y), bottom: length(PAD_Y), }, ..Default::default() }) .fill(palette.bg) .radius(radius::SM) .text_aligned(text, FONT_SIZE, palette.fg, Alignment::Start) // Rol propio: los lectores tratan un Tooltip distinto de un texto suelto // (lo anuncian como ayuda del control que lo disparó, no como contenido). .role(llimphi_compositor::Role::Tooltip); // Wrapper invisible que ocupa toda la pantalla — el panel ya está // posicionado en absolute, pero `view_overlay` espera un único root // que cubre la ventana. Sin scrim ni intercept de clicks. View::new(Style { size: Size { width: llimphi_compositor::llimphi_layout::taffy::prelude::percent(1.0_f32), height: llimphi_compositor::llimphi_layout::taffy::prelude::percent(1.0_f32), }, ..Default::default() }) // Borde sutil pintado vía un nodo separado: pintamos el panel sobre // un rect 1px más grande coloreado con `border` — barato y consistente // con cómo el context-menu hace su borde. .children(vec![ View::new(Style { position: Position::Absolute, inset: Rect { left: length(x - 1.0), top: length(y - 1.0), right: auto(), bottom: auto(), }, size: Size { width: length(est_w + 2.0), height: length(est_h + 2.0), }, ..Default::default() }) .fill(palette.border) .radius(radius::SM + 1.0), panel, ]) } #[cfg(test)] mod tests { use super::*; const VP: (f32, f32) = (1000.0, 800.0); #[test] fn flip_bottom_a_top_cuando_no_entra_abajo() { // Anchor pegado al borde inferior: Bottom no entra → flipea a Top. let (_, y, eff) = place((500.0, 790.0), VP, Side::Bottom, 100.0, 40.0); assert_eq!(eff, Side::Top); // El panel queda ARRIBA del anchor (y + h <= anchor.y). assert!(y + 40.0 <= 790.0, "el panel flipeado no debe solapar el anchor"); } #[test] fn flip_right_a_left_cuando_no_entra_a_la_derecha() { let (x, _, eff) = place((990.0, 400.0), VP, Side::Right, 120.0, 40.0); assert_eq!(eff, Side::Left); assert!(x + 120.0 <= 990.0, "el panel debe quedar a la izquierda del anchor"); } #[test] fn no_flipea_si_entra_por_el_lado_preferido() { let (_, y, eff) = place((500.0, 400.0), VP, Side::Bottom, 100.0, 40.0); assert_eq!(eff, Side::Bottom); assert!(y >= 400.0, "Bottom queda debajo del anchor"); } #[test] fn no_flipea_si_ninguno_de_los_dos_lados_entra() { // Viewport minúsculo: ni Top ni Bottom entran → conserva el preferido. let (_, _, eff) = place((25.0, 25.0), (50.0, 50.0), Side::Bottom, 100.0, 100.0); assert_eq!(eff, Side::Bottom); } #[test] fn size_multilinea_crece_en_alto() { let corto = tooltip_size("ok"); let largo = tooltip_size( "un texto bastante largo que sin duda excede el ancho máximo del \ tooltip y por lo tanto tiene que envolver a varios renglones seguidos", ); assert!(largo.0 <= MAX_W, "el ancho se clampea a MAX_W"); assert!(largo.1 > corto.1, "el texto largo ocupa más alto (multilínea)"); } }