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}