tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! `llimphi-widget-tooltip` — tooltip flotante con anchor + clamping.
2//!
3//! Render puro: el widget recibe el anchor (típicamente bottom-center
4//! del elemento que lo dispara), el viewport y el texto, y devuelve un
5//! `View<Msg>` posicionado en absolute para colgarlo de `view_overlay`.
6//! La app es responsable de:
7//! 1. Detectar el hover sobre el elemento via `View::on_pointer_enter`
8//! + un `Tween`/delay para evitar tooltips que parpadean al pasar.
9//! 2. Guardar el `Option<TooltipSpec>` en su modelo.
10//! 3. Devolverlo desde `view_overlay`.
11//! 4. Cerrarlo con `View::on_pointer_leave` sobre el mismo elemento.
12//!
13//! No se incluye scrim — el tooltip es informativo, no modal: los
14//! clicks atraviesan al árbol principal. (Para popovers con
15//! interacción, usar `llimphi-widget-modal` o el `context-menu`).
16
17#![forbid(unsafe_code)]
18
19
20// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
21// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
22// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
23#![no_std]
24
25extern crate alloc;
26
27use alloc::string::String;
28use alloc::vec;
29use llimphi_compositor::llimphi_layout::taffy::{
30 prelude::{auto, length, FlexDirection, Position, Size, Style},
31 AlignItems, Rect,
32};
33use llimphi_compositor::peniko::Color;
34use llimphi_compositor::llimphi_text::Alignment;
35use llimphi_compositor::View;
36use llimphi_theme::{radius, Theme};
37
38/// Paleta del tooltip — fondo "glass panel" oscuro, texto claro.
39#[derive(Debug, Clone, Copy)]
40pub struct TooltipPalette {
41 pub bg: Color,
42 pub fg: Color,
43 pub border: Color,
44}
45
46impl TooltipPalette {
47 pub fn from_theme(t: &Theme) -> Self {
48 Self {
49 bg: t.bg_app,
50 fg: t.fg_text,
51 border: t.border,
52 }
53 }
54}
55
56/// Lado preferido al que se coloca el tooltip respecto del anchor.
57/// Si no entra en el viewport por ese lado, el clamping lo empuja al
58/// lado contrario (no recortado).
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum Side {
61 Top,
62 #[default]
63 Bottom,
64 Left,
65 Right,
66}
67
68/// Spec para [`tooltip_view`].
69#[derive(Debug, Clone)]
70pub struct TooltipSpec {
71 /// Punto de origen — típicamente el centro del elemento que dispara.
72 pub anchor: (f32, f32),
73 /// Tamaño actual de la ventana, para clamping.
74 pub viewport: (f32, f32),
75 /// Lado preferido respecto del anchor.
76 pub side: Side,
77 pub text: String,
78 pub palette: TooltipPalette,
79}
80
81const PAD_X: f32 = 8.0;
82const PAD_Y: f32 = 5.0;
83const GAP: f32 = 6.0;
84const FONT_SIZE: f32 = 11.5;
85const LINE_H: f32 = FONT_SIZE * 1.35;
86const MAX_W: f32 = 280.0;
87const MARGIN: f32 = 4.0;
88
89/// Mide el tooltip con la fuente real y decide su tamaño. Devuelve
90/// `(w, h)` incluyendo padding. El ancho de contenido se clampea a
91/// `MAX_W`; si el texto no entra en una línea, se estima el nº de
92/// renglones (el nodo de texto envuelve al ancho del panel).
93fn tooltip_size(text: &str) -> (f32, f32) {
94 let content_max = MAX_W - PAD_X * 2.0;
95 let full = llimphi_compositor::llimphi_text::text_width(text, FONT_SIZE);
96 let inner = full.min(content_max);
97 let lines = if full <= content_max {
98 1.0
99 } else {
100 (full / content_max).ceil()
101 };
102 (inner + PAD_X * 2.0, lines * LINE_H + PAD_Y * 2.0)
103}
104
105/// Ubica el panel: elige el lado efectivo (FLIP real al contrario si no
106/// entra por el preferido) y devuelve `(x, y, side_efectivo)` ya
107/// clampeado al viewport. Pura y testeable — sin esto el "flip" del doc
108/// era en realidad un `clamp` que solapaba el tooltip con el anchor.
109fn place(
110 anchor: (f32, f32),
111 viewport: (f32, f32),
112 side: Side,
113 w: f32,
114 h: f32,
115) -> (f32, f32, Side) {
116 // ¿Entra por el lado preferido sin salirse del viewport?
117 let fits = |s: Side| -> bool {
118 match s {
119 Side::Bottom => anchor.1 + GAP + h <= viewport.1 - MARGIN,
120 Side::Top => anchor.1 - GAP - h >= MARGIN,
121 Side::Right => anchor.0 + GAP + w <= viewport.0 - MARGIN,
122 Side::Left => anchor.0 - GAP - w >= MARGIN,
123 }
124 };
125 let opposite = |s: Side| match s {
126 Side::Bottom => Side::Top,
127 Side::Top => Side::Bottom,
128 Side::Right => Side::Left,
129 Side::Left => Side::Right,
130 };
131 // Flip al contrario sólo si el preferido no entra y el opuesto sí.
132 let eff = if fits(side) || !fits(opposite(side)) {
133 side
134 } else {
135 opposite(side)
136 };
137
138 let (raw_x, raw_y) = match eff {
139 Side::Bottom => (anchor.0 - w * 0.5, anchor.1 + GAP),
140 Side::Top => (anchor.0 - w * 0.5, anchor.1 - GAP - h),
141 Side::Right => (anchor.0 + GAP, anchor.1 - h * 0.5),
142 Side::Left => (anchor.0 - GAP - w, anchor.1 - h * 0.5),
143 };
144
145 // Clamp del eje cruzado (y del principal como red de seguridad).
146 let x = raw_x.min((viewport.0 - w - MARGIN).max(MARGIN)).max(MARGIN);
147 let y = raw_y.min((viewport.1 - h - MARGIN).max(MARGIN)).max(MARGIN);
148 (x, y, eff)
149}
150
151pub fn tooltip_view<Msg: Clone + 'static>(spec: TooltipSpec) -> View<Msg> {
152 let TooltipSpec { anchor, viewport, side, text, palette } = spec;
153
154 let (est_w, est_h) = tooltip_size(&text);
155 let (x, y, _eff) = place(anchor, viewport, side, est_w, est_h);
156
157 let panel = View::new(Style {
158 position: Position::Absolute,
159 inset: Rect {
160 left: length(x),
161 top: length(y),
162 right: auto(),
163 bottom: auto(),
164 },
165 size: Size {
166 width: length(est_w),
167 height: length(est_h),
168 },
169 flex_direction: FlexDirection::Column,
170 align_items: Some(AlignItems::FLEX_START),
171 padding: Rect {
172 left: length(PAD_X),
173 right: length(PAD_X),
174 top: length(PAD_Y),
175 bottom: length(PAD_Y),
176 },
177 ..Default::default()
178 })
179 .fill(palette.bg)
180 .radius(radius::SM)
181 .text_aligned(text, FONT_SIZE, palette.fg, Alignment::Start)
182 // Rol propio: los lectores tratan un Tooltip distinto de un texto suelto
183 // (lo anuncian como ayuda del control que lo disparó, no como contenido).
184 .role(llimphi_compositor::Role::Tooltip);
185
186 // Wrapper invisible que ocupa toda la pantalla — el panel ya está
187 // posicionado en absolute, pero `view_overlay` espera un único root
188 // que cubre la ventana. Sin scrim ni intercept de clicks.
189 View::new(Style {
190 size: Size {
191 width: llimphi_compositor::llimphi_layout::taffy::prelude::percent(1.0_f32),
192 height: llimphi_compositor::llimphi_layout::taffy::prelude::percent(1.0_f32),
193 },
194 ..Default::default()
195 })
196 // Borde sutil pintado vía un nodo separado: pintamos el panel sobre
197 // un rect 1px más grande coloreado con `border` — barato y consistente
198 // con cómo el context-menu hace su borde.
199 .children(vec![
200 View::new(Style {
201 position: Position::Absolute,
202 inset: Rect {
203 left: length(x - 1.0),
204 top: length(y - 1.0),
205 right: auto(),
206 bottom: auto(),
207 },
208 size: Size {
209 width: length(est_w + 2.0),
210 height: length(est_h + 2.0),
211 },
212 ..Default::default()
213 })
214 .fill(palette.border)
215 .radius(radius::SM + 1.0),
216 panel,
217 ])
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 const VP: (f32, f32) = (1000.0, 800.0);
225
226 #[test]
227 fn flip_bottom_a_top_cuando_no_entra_abajo() {
228 // Anchor pegado al borde inferior: Bottom no entra → flipea a Top.
229 let (_, y, eff) = place((500.0, 790.0), VP, Side::Bottom, 100.0, 40.0);
230 assert_eq!(eff, Side::Top);
231 // El panel queda ARRIBA del anchor (y + h <= anchor.y).
232 assert!(y + 40.0 <= 790.0, "el panel flipeado no debe solapar el anchor");
233 }
234
235 #[test]
236 fn flip_right_a_left_cuando_no_entra_a_la_derecha() {
237 let (x, _, eff) = place((990.0, 400.0), VP, Side::Right, 120.0, 40.0);
238 assert_eq!(eff, Side::Left);
239 assert!(x + 120.0 <= 990.0, "el panel debe quedar a la izquierda del anchor");
240 }
241
242 #[test]
243 fn no_flipea_si_entra_por_el_lado_preferido() {
244 let (_, y, eff) = place((500.0, 400.0), VP, Side::Bottom, 100.0, 40.0);
245 assert_eq!(eff, Side::Bottom);
246 assert!(y >= 400.0, "Bottom queda debajo del anchor");
247 }
248
249 #[test]
250 fn no_flipea_si_ninguno_de_los_dos_lados_entra() {
251 // Viewport minúsculo: ni Top ni Bottom entran → conserva el preferido.
252 let (_, _, eff) = place((25.0, 25.0), (50.0, 50.0), Side::Bottom, 100.0, 100.0);
253 assert_eq!(eff, Side::Bottom);
254 }
255
256 #[test]
257 fn size_multilinea_crece_en_alto() {
258 let corto = tooltip_size("ok");
259 let largo = tooltip_size(
260 "un texto bastante largo que sin duda excede el ancho máximo del \
261 tooltip y por lo tanto tiene que envolver a varios renglones seguidos",
262 );
263 assert!(largo.0 <= MAX_W, "el ancho se clampea a MAX_W");
264 assert!(largo.1 > corto.1, "el texto largo ocupa más alto (multilínea)");
265 }
266}