tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main widgets/tooltip/src/lib.rs Rust · 266 siq'ikuna · 9.0 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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}