Skip to main content

lumen_primitives/
tooltip.rs

1//! Hover-delay tooltip widget driven by markup `<tooltip text="Save" delay="500">...trigger...</tooltip>`.
2//!
3//! - The single child is the trigger; the tooltip remains invisible until the trigger has carried [`Hovered`] for `delay` milliseconds (default 500, `--lumen-tooltip-delay` token).
4//! - The trigger entity carries [`TooltipSource`]; the first tick of [`Hovered`] records [`HoverStartedAt`].
5//! - Once dwell exceeds the configured delay, a [`TooltipPopup`] entity is spawned below-right of the CURSOR (Qt `QToolTip` placement), flipping above/left near viewport edges - see [`cursor_tooltip_origin`].
6//! - The popup despawns one tick after [`Hovered`] clears.
7//!
8//! ## Canonical `#[derive(Widget)]` port
9//!
10//! Tooltip is the reference port to the new
11//! [`lumen_widget_macros::Widget`] derive. `TooltipSource` carries
12//! `#[derive(Widget)]` with `tag = "tooltip"`, which emits a
13//! [`lumen_widget::Widget`] impl (parser-tag accessor + spawn glue) so
14//! a runtime registry can wire `<tooltip text="...">` markup to this
15//! component without the previous hand-coded
16//! `if tag == "tooltip" { ... }` branch in `lumenc::parse::html`.
17//!
18//! The four per-tick systems remain hand-written (the `#[widget(prop)]`
19//! /`#[widget(state)]` attributes only cover marker-component
20//! authoring; system bodies stay author-supplied for v1). The plugin
21//! struct is also hand-written (via `#[widget(plugin =
22//! "TooltipPlugin")]`) because the four systems need to be installed
23//! in a specific tick stage - the canned no-op plugin emitted by the
24//! derive can't do that yet.
25
26use bevy_ecs::prelude::*;
27use bevy_ecs::query::Added;
28use glam::Vec2;
29use lumen_core::components::{
30    Color, Fill, LumenClasses, Position, Style, TextContent, TextStyle, Transform, Visuals,
31};
32use lumen_core::input::Hovered;
33use lumen_core::prelude::*;
34use lumen_core::render_world::Viewport;
35use lumen_core::tick::WakeDeadline;
36use lumen_core::time::{Duration, Instant};
37use lumen_widget_macros::Widget;
38
39/// Authored on the trigger via `<tooltip text="..." delay="...">`. The
40/// markup parser attaches this component on the SINGLE child the
41/// `<tooltip>` wraps - `<tooltip>` itself is a no-op container in the
42/// layout tree.
43///
44/// Carries `#[derive(Widget)]` (see crate docs) so the runtime widget
45/// registry can spawn it from a parsed `<tooltip>` tag.
46#[derive(Component, Clone, Debug, Widget)]
47#[widget(tag = "tooltip", plugin = "TooltipPlugin")]
48pub struct TooltipSource {
49    /// Tooltip body text. Multi-line strings are honoured (the text
50    /// renderer wraps according to the popup's container width).
51    #[widget(prop)]
52    pub text: String,
53    /// Hover dwell before the popup appears. Default 500 ms - the
54    /// single Rust-side fallback; the built-in skins route it through
55    /// the `--lumen-tooltip-delay` token and the markup `delay=` attr
56    /// overrides both (resolution happens in `lumenc`'s cascade).
57    #[widget(prop)]
58    pub delay_ms: u32,
59    /// Gap between the cursor hotspot and the popup's top-left corner,
60    /// in logical pixels. Default 12 px (~ Qt's cursor-to-tip offset);
61    /// token-reachable via `--lumen-tooltip-offset`, markup `offset=`
62    /// wins.
63    #[widget(prop)]
64    pub offset: f32,
65}
66
67impl Default for TooltipSource {
68    fn default() -> Self {
69        Self {
70            text: String::new(),
71            delay_ms: 500,
72            offset: 12.0,
73        }
74    }
75}
76
77/// Per-entity timestamp of when [`Hovered`] was first inserted.
78/// Recorded on `Added<Hovered>` and used to compute dwell time before
79/// the tooltip popup is allowed to appear.
80#[derive(Component, Clone, Copy, Debug)]
81pub struct HoverStartedAt(pub Instant);
82
83/// Marker on the spawned popup entity. Tracks which trigger the
84/// popup belongs to so the cleanup pass knows what to despawn when
85/// the trigger un-hovers.
86#[derive(Component, Clone, Copy, Debug)]
87pub struct TooltipPopup {
88    /// The trigger entity this popup is paired with.
89    pub trigger: Entity,
90}
91
92/// Plugin: registers the four tooltip systems and a default `class`
93/// rule via the LSP completion tables. No render path of its own -
94/// the popup is just an `<overlay>`-shaped entity with a `Visuals`
95/// fill + `TextContent`, so all standard styling (CSS `.tooltip`
96/// selector, hover-bg, opacity) works.
97pub struct TooltipPlugin;
98
99impl Plugin for TooltipPlugin {
100    fn build(self, app: &mut App) {
101        app.add_systems(
102            TickStage::Systems,
103            (
104                record_hover_started,
105                spawn_tooltip_popups,
106                apply_tooltip_defaults,
107                despawn_tooltip_popups,
108            ),
109        );
110    }
111}
112
113/// Stamp `HoverStartedAt` on any entity that just gained `Hovered`.
114///
115/// A tooltip trigger also asks the loop to wake when its delay runs out:
116/// with the pointer resting on the trigger nothing else may tick, and the
117/// popup only spawns on a tick.
118fn record_hover_started(
119    mut commands: Commands,
120    new_hovers: Query<(Entity, Option<&TooltipSource>), Added<Hovered>>,
121    wake: Option<Res<WakeDeadline>>,
122) {
123    let now = Instant::now();
124    for (e, src) in &new_hovers {
125        commands.entity(e).insert(HoverStartedAt(now));
126        if let (Some(src), Some(wake)) = (src, wake.as_deref()) {
127            wake.request(now + Duration::from_millis(u64::from(src.delay_ms)));
128        }
129    }
130}
131
132/// Conservative pre-layout estimate of the tooltip body size used by
133/// the edge-flip heuristic. Real text shaping happens during the
134/// layout pass *after* this spawn, so we approximate from the source
135/// string length. Each line uses ~7 px / char average at the default
136/// 13 px font and 18 px line height - close enough to choose left-vs-
137/// right and top-vs-bottom anchoring; a 1-tick mis-flip on the very
138/// first display of a borderline tooltip is acceptable.
139fn estimated_tooltip_size(text: &str) -> Vec2 {
140    let lines = text.lines().collect::<Vec<_>>();
141    let max_chars = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
142    let row_count = lines.len().max(1) as f32;
143    Vec2::new(
144        (max_chars as f32 * 7.0 + 16.0).max(48.0),
145        row_count * 18.0 + 16.0,
146    )
147}
148
149/// Cursor-relative tooltip origin (Qt `QToolTip` placement): the popup
150/// sits below-right of the cursor hotspot, `offset` px away on each
151/// axis. When the preferred spot would overflow the viewport, each axis
152/// flips independently to the cursor's other side (above / left),
153/// saturating at 0 so the popup never leaves the window.
154pub fn cursor_tooltip_origin(cursor: Vec2, popup: Vec2, viewport: Vec2, offset: f32) -> Vec2 {
155    let x = if cursor.x + offset + popup.x <= viewport.x {
156        cursor.x + offset
157    } else {
158        (cursor.x - offset - popup.x).max(0.0)
159    };
160    let y = if cursor.y + offset + popup.y <= viewport.y {
161        cursor.y + offset
162    } else {
163        (cursor.y - offset - popup.y).max(0.0)
164    };
165    Vec2::new(x, y)
166}
167
168/// Spawn a popup for any hovered `<tooltip>` trigger whose dwell
169/// exceeds the configured delay and whose popup doesn't already
170/// exist.
171///
172/// Placement is CURSOR-relative (Qt style): below-right of the pointer
173/// hotspot by [`TooltipSource::offset`] px, flipping above / left of
174/// the cursor near the viewport's bottom / right edges - see
175/// [`cursor_tooltip_origin`]. Falls back to the trigger's top-left
176/// when the backend reports no pointer position (keyboard-only /
177/// synthetic hover).
178fn spawn_tooltip_popups(
179    mut commands: Commands,
180    triggers: Query<(Entity, &TooltipSource, &Transform, &HoverStartedAt), With<Hovered>>,
181    existing: Query<&TooltipPopup>,
182    viewport: Option<Res<Viewport>>,
183    pointer: Option<Res<lumen_core::input::PointerState>>,
184    wake: Option<Res<WakeDeadline>>,
185) {
186    let already: std::collections::HashSet<Entity> = existing.iter().map(|p| p.trigger).collect();
187    let now = Instant::now();
188    let viewport_size = viewport
189        .map(|v| v.size)
190        .unwrap_or(Vec2::new(f32::INFINITY, f32::INFINITY));
191    let cursor = pointer.as_ref().and_then(|p| p.position);
192    for (trigger, src, t, started) in &triggers {
193        if already.contains(&trigger) {
194            continue;
195        }
196        let due = started.0 + Duration::from_millis(u64::from(src.delay_ms));
197        if now < due {
198            // Still dwelling: keep the loop's wake-up booked for the moment
199            // the delay ends, since the deadline is cleared every tick.
200            if let Some(wake) = wake.as_deref() {
201                wake.request(due);
202            }
203            continue;
204        }
205        let est = estimated_tooltip_size(&src.text);
206        let anchor = cursor.unwrap_or(t.absolute);
207        let origin = cursor_tooltip_origin(anchor, est, viewport_size, src.offset);
208        // Default Visuals / TextStyle are not spawned inline: they
209        // would beat any author CSS `.tooltip { background: ... }`
210        // rule (inline-attribute styling has the highest cascade
211        // priority). `apply_tooltip_defaults` fills them in only when
212        // CSS-or-other-systems haven't already attached a Visuals
213        // component - preserving the unstyled-tooltip default look
214        // while letting `.tooltip` author rules win when present.
215        commands.spawn((
216            TooltipPopup { trigger },
217            // Top-layer paint band: the tooltip stacks with other popups
218            // by open order (later-opened on top) instead of the orphan
219            // fallback band.
220            lumen_core::render_world::OverlayLayer,
221            LumenClasses(vec!["tooltip".into()]),
222            Style {
223                width: lumen_core::components::Length::Auto,
224                height: lumen_core::components::Length::Auto,
225                position: Position::Absolute,
226                // Pinned by its top-left corner; `right` / `bottom` stay
227                // `auto` so the popup keeps its content size.
228                inset: lumen_core::components::Edges {
229                    left: origin.x,
230                    top: origin.y,
231                    ..lumen_core::components::Edges::auto()
232                },
233                padding: lumen_core::components::Edges::all(8.0),
234                ..Default::default()
235            },
236            TextContent(src.text.clone()),
237        ));
238    }
239}
240
241/// Fill in default `Visuals` (dark panel + rounded corners) and
242/// `TextStyle` (near-white body) on any tooltip popup that doesn't
243/// already carry them - covers the no-CSS case while leaving an
244/// author-defined `.tooltip { background: ...; color: ...; }` rule
245/// authoritative when one applies.
246///
247/// Runs after `spawn_tooltip_popups`. Idempotent: once a popup has a
248/// `Visuals` or `TextStyle` component (whether from this system or
249/// elsewhere), this pass leaves it alone.
250fn apply_tooltip_defaults(
251    mut commands: Commands,
252    needs_visuals: Query<Entity, (With<TooltipPopup>, Without<Visuals>)>,
253    needs_text_style: Query<Entity, (With<TooltipPopup>, Without<TextStyle>)>,
254) {
255    for e in &needs_visuals {
256        commands.entity(e).insert(Visuals {
257            fill: Some(Fill::Solid(Color::rgba(0.10, 0.12, 0.16, 0.95))),
258            radius: 6.0,
259            ..Default::default()
260        });
261    }
262    for e in &needs_text_style {
263        commands.entity(e).insert(TextStyle {
264            color: Color::rgb(0.94, 0.95, 0.97),
265            size_px: 13.0,
266            ..Default::default()
267        });
268    }
269}
270
271/// Drop popups whose trigger is no longer hovered (or no longer
272/// exists).
273fn despawn_tooltip_popups(
274    mut commands: Commands,
275    popups: Query<(Entity, &TooltipPopup)>,
276    hovered: Query<(), With<Hovered>>,
277) {
278    for (popup, info) in &popups {
279        if hovered.get(info.trigger).is_err() {
280            commands.entity(popup).despawn();
281        }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use lumen_widget::{Attributes, Widget as _};
289
290    #[test]
291    fn derive_widget_tag_matches_markup() {
292        assert_eq!(TooltipSource::parser_tag(), "tooltip");
293    }
294
295    #[test]
296    fn derive_widget_name_matches_type() {
297        assert_eq!(TooltipSource::name(), "TooltipSource");
298    }
299
300    #[test]
301    fn widget_spawn_inserts_component_with_attrs() {
302        let mut app = App::new();
303        let e = app.world.spawn_empty().id();
304        let attrs: Attributes = [("text", "Save"), ("delay_ms", "750")].into();
305        TooltipSource::spawn(e, &attrs, &mut app.world);
306        let src = app
307            .world
308            .entity(e)
309            .get::<TooltipSource>()
310            .expect("TooltipSource present");
311        assert_eq!(src.text, "Save");
312        assert_eq!(src.delay_ms, 750);
313    }
314
315    #[test]
316    fn widget_spawn_keeps_defaults_on_missing_attrs() {
317        let mut app = App::new();
318        let e = app.world.spawn_empty().id();
319        let attrs = Attributes::new();
320        TooltipSource::spawn(e, &attrs, &mut app.world);
321        let src = app.world.entity(e).get::<TooltipSource>().unwrap();
322        assert_eq!(src.text, "");
323        assert_eq!(src.delay_ms, 500);
324        assert_eq!(src.offset, 12.0);
325    }
326
327    #[test]
328    fn cursor_origin_prefers_below_right() {
329        let o = cursor_tooltip_origin(
330            Vec2::new(100.0, 100.0),
331            Vec2::new(80.0, 30.0),
332            Vec2::new(800.0, 600.0),
333            12.0,
334        );
335        assert_eq!(o, Vec2::new(112.0, 112.0), "cursor + offset on both axes");
336    }
337
338    #[test]
339    fn cursor_origin_flips_left_near_right_edge() {
340        let o = cursor_tooltip_origin(
341            Vec2::new(780.0, 100.0),
342            Vec2::new(80.0, 30.0),
343            Vec2::new(800.0, 600.0),
344            12.0,
345        );
346        assert_eq!(
347            o.x,
348            780.0 - 12.0 - 80.0,
349            "popup right edge at cursor - offset"
350        );
351        assert_eq!(o.y, 112.0, "y axis unaffected");
352    }
353
354    #[test]
355    fn cursor_origin_flips_above_near_bottom_edge() {
356        let o = cursor_tooltip_origin(
357            Vec2::new(100.0, 590.0),
358            Vec2::new(80.0, 30.0),
359            Vec2::new(800.0, 600.0),
360            12.0,
361        );
362        assert_eq!(
363            o.y,
364            590.0 - 12.0 - 30.0,
365            "popup bottom edge at cursor - offset"
366        );
367        assert_eq!(o.x, 112.0);
368    }
369
370    #[test]
371    fn cursor_origin_saturates_at_zero() {
372        // Cursor near the corner with a popup bigger than the room on
373        // both flip sides: never negative.
374        let o = cursor_tooltip_origin(
375            Vec2::new(5.0, 5.0),
376            Vec2::new(2000.0, 2000.0),
377            Vec2::new(800.0, 600.0),
378            12.0,
379        );
380        assert_eq!(o, Vec2::ZERO);
381    }
382
383    /// A trigger resting under the pointer books a wake-up for when its delay
384    /// ends, on the first hovered tick and on every tick until the popup
385    /// spawns, so an idle loop still shows the tooltip.
386    #[test]
387    fn a_dwelling_trigger_wakes_the_loop_when_its_delay_ends() {
388        let mut app = App::new();
389        app.add_plugin(TooltipPlugin);
390        let before = Instant::now();
391        app.world.spawn((
392            TooltipSource {
393                text: "Save".into(),
394                delay_ms: 10_000,
395                ..Default::default()
396            },
397            Transform::default(),
398            Hovered,
399        ));
400        for _ in 0..2 {
401            app.tick();
402            let at = lumen_core::tick::wake_deadline(&app.world).expect("a wake-up is booked");
403            assert!(at >= before + Duration::from_millis(10_000));
404            assert!(at <= Instant::now() + Duration::from_millis(10_000));
405        }
406    }
407}