agg_gui/widgets/tooltip/mod.rs
1//! `Tooltip` — a wrapper widget that shows egui-style hover help.
2//!
3//! Tooltips come in two flavours:
4//!
5//! * **Lightweight** (the default, used by dozens of call sites): text lines
6//! submitted during the widget paint pass and drawn at the end of the frame
7//! by [`crate::widget::App`] via a global queue ([`render`]). Fire-and-forget,
8//! no hit-testing.
9//!
10//! * **Interactive** (opt-in via [`Tooltip::with_interactive_content`]): the tip
11//! is a real floating UI surface hosting a child widget tree (labels,
12//! hyperlinks, a nested tooltip). It participates in the global-overlay
13//! hit-test so the pointer can move *into* it without dismissing it, and it
14//! supports one level of nesting — see [`interactive`]. This mirrors egui's
15//! `on_hover_ui` tooltips.
16//!
17//! # Usage
18//!
19//! ```ignore
20//! Tooltip::new(
21//! Box::new(Button::new("Hover me", font.clone()).on_click(|| {})),
22//! "This is a tooltip",
23//! font.clone(),
24//! )
25//! ```
26
27pub mod controller;
28mod interactive;
29mod render;
30mod timings;
31
32pub(crate) use render::{begin_tooltip_frame, paint_global_tooltips};
33#[doc(hidden)]
34pub use timings::{advance_tooltip_test_clock, reset_tooltip_test_state, set_tooltip_test_clock};
35pub use timings::{set_tooltip_timings, tooltip_timings, TooltipTimings};
36
37use timings::{last_tooltip_visible_at, note_tooltip_visible, tooltip_now};
38
39use std::rc::Rc;
40use std::sync::Arc;
41use std::time::Duration;
42use web_time::Instant;
43
44use crate::draw_ctx::DrawCtx;
45use crate::event::{Event, EventResult};
46use crate::geometry::{Point, Rect, Size};
47use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
48use crate::text::Font;
49use crate::widget::{current_mouse_world, Widget};
50
51use render::{submit_tooltip, TooltipRequest};
52
53/// Compile-time default initial hover delay, kept as a stable reference for
54/// tests and for interactive-tip code paths. Runtime code reads the (possibly
55/// OS-overridden) value via [`tooltip_timings`]; this equals the default there.
56const TOOLTIP_INITIAL_DELAY: Duration = timings::DEFAULT_INITIAL_DELAY;
57pub(super) const TOOLTIP_FONT_SIZE: f64 = 12.0;
58pub(super) const TOOLTIP_PAD_X: f64 = 8.0;
59pub(super) const TOOLTIP_PAD_Y: f64 = 6.0;
60pub(super) const TOOLTIP_GAP: f64 = 4.0;
61/// Extra vertical offset for pointer-anchored tooltips. They should
62/// read as attached below the cursor rather than hugging it.
63pub(super) const POINTER_TOOLTIP_EXTRA_DROP: f64 = 10.0;
64pub(super) const SCREEN_MARGIN: f64 = 4.0;
65
66#[derive(Clone)]
67pub(super) enum TooltipLineKind {
68 Text,
69 Code,
70 Link,
71}
72
73#[derive(Clone)]
74pub(super) struct TooltipLine {
75 pub text: String,
76 pub kind: TooltipLineKind,
77}
78
79/// A wrapper widget that shows a text tooltip on hover.
80pub struct Tooltip {
81 bounds: Rect,
82 /// The wrapped child widget is stored in `children[0]`.
83 children: Vec<Box<dyn Widget>>,
84 base: WidgetBase,
85
86 /// Time when the pointer entered the widget. `None` when the
87 /// pointer is outside. Wall-clock timing gives consistent tooltip
88 /// latency even when the app is not repainting continuously.
89 hover_started_at: Option<Instant>,
90 /// Whether the cursor is currently inside the widget bounds.
91 hovered: bool,
92 /// Whether this tooltip was visible on the previous paint. Used
93 /// to invalidate when the delayed tooltip appears or disappears,
94 /// not just when hover state changes.
95 tooltip_visible: bool,
96 /// Delay to apply for the current hover, captured when the pointer entered:
97 /// the full initial delay, or the shorter reshow delay if another tip was
98 /// visible within the reshow window (move-along-the-toolbar).
99 pending_delay: Duration,
100 /// When the current tip became visible, used to auto-dismiss after
101 /// [`TooltipTimings::autopop`].
102 tip_shown_at: Option<Instant>,
103 /// Suppresses (re)showing until the pointer leaves and re-enters. Set on any
104 /// mouse press over the child and on autopop, mirroring Windows: a click or a
105 /// timed-out tip stays hidden until you move off and back onto the control.
106 suppressed: bool,
107 /// Whether a mouse button is currently held; tips never show while pressed.
108 pointer_down: bool,
109 /// Last known cursor position in local coordinates.
110 cursor: Point,
111
112 font: Arc<Font>,
113 lines: Vec<TooltipLine>,
114 disabled_lines: Vec<TooltipLine>,
115 disabled_when: Option<Rc<dyn Fn() -> bool>>,
116 at_pointer: bool,
117
118 // --- Interactive-mode state (see `interactive`) ---------------------
119 /// When `true`, the tip is a hit-testable surface hosting `content`
120 /// instead of the lightweight text queue.
121 interactive: bool,
122 /// The interactive tip's child widget tree. Not part of `children`, so
123 /// it is not laid out / painted / hit-tested by the normal tree walk —
124 /// [`interactive`] manages it manually at the floating tip position.
125 content: Option<Box<dyn Widget>>,
126 /// Natural size of `content` from its last layout.
127 content_size: Size,
128 /// Latched open-state for the interactive tip. Stays open while the
129 /// pointer is over the anchor OR the tip; closes after a grace period
130 /// once it leaves both (or on Escape).
131 tip_open: bool,
132 /// Whether the pointer is currently over the interactive tip surface.
133 tip_hovered: bool,
134 /// Panel rectangle in this widget's LOCAL coordinate space, captured
135 /// during the last overlay paint and reused for hit-testing.
136 tip_panel_local: Option<Rect>,
137 /// Where `content` is painted within the panel (local coords).
138 content_origin_local: Point,
139 /// When the pointer left both anchor and tip; the tip closes once this
140 /// exceeds [`interactive::TOOLTIP_CLOSE_GRACE`].
141 close_requested_at: Option<Instant>,
142 /// Path of the content descendant the pointer last hovered, so we can
143 /// clear its hover (and any nested tooltip) when the pointer moves off.
144 last_content_path: Option<Vec<usize>>,
145}
146
147impl Tooltip {
148 /// Create a new `Tooltip` wrapping `child` with `text` as the tip message.
149 pub fn new(child: Box<dyn Widget>, text: impl Into<String>, font: Arc<Font>) -> Self {
150 Self {
151 bounds: Rect::default(),
152 children: vec![child],
153 base: WidgetBase::new(),
154 hover_started_at: None,
155 hovered: false,
156 tooltip_visible: false,
157 pending_delay: TOOLTIP_INITIAL_DELAY,
158 tip_shown_at: None,
159 suppressed: false,
160 pointer_down: false,
161 cursor: Point::ORIGIN,
162 font,
163 lines: text_to_lines(text),
164 disabled_lines: Vec::new(),
165 disabled_when: None,
166 at_pointer: true,
167 interactive: false,
168 content: None,
169 content_size: Size::new(0.0, 0.0),
170 tip_open: false,
171 tip_hovered: false,
172 tip_panel_local: None,
173 content_origin_local: Point::ORIGIN,
174 close_requested_at: None,
175 last_content_path: None,
176 }
177 }
178
179 /// Add another hover text block, matching egui's ability to chain
180 /// `.on_hover_text(...)` calls.
181 pub fn with_text(mut self, text: impl Into<String>) -> Self {
182 self.lines.extend(text_to_lines(text));
183 self
184 }
185
186 /// Add a code-styled line to the tooltip.
187 pub fn with_code_line(mut self, text: impl Into<String>) -> Self {
188 self.lines.push(TooltipLine {
189 text: text.into(),
190 kind: TooltipLineKind::Code,
191 });
192 self
193 }
194
195 /// Add a link-styled line to the tooltip. Tooltip overlays are
196 /// informational; the line is styled like a link but does not receive
197 /// pointer events.
198 pub fn with_link_line(mut self, text: impl Into<String>) -> Self {
199 self.lines.push(TooltipLine {
200 text: text.into(),
201 kind: TooltipLineKind::Link,
202 });
203 self
204 }
205
206 /// Turn this tooltip into an **interactive** surface hosting `content`
207 /// (a small widget tree of labels / hyperlinks). The lightweight text
208 /// lines are ignored while interactive. The pointer can enter the tip
209 /// without dismissing it, and a tooltip-bearing widget inside `content`
210 /// shows its own (nested) tip. Mirrors egui's `on_hover_ui`.
211 pub fn with_interactive_content(mut self, content: Box<dyn Widget>) -> Self {
212 self.interactive = true;
213 self.content = Some(content);
214 self.at_pointer = false;
215 self
216 }
217
218 /// Place the tooltip relative to the mouse cursor instead of the widget.
219 /// This is the default; kept for call-site clarity.
220 pub fn at_pointer(mut self) -> Self {
221 self.at_pointer = true;
222 self
223 }
224
225 /// Place the tooltip relative to the wrapped widget instead of the
226 /// mouse cursor.
227 pub fn at_widget(mut self) -> Self {
228 self.at_pointer = false;
229 self
230 }
231
232 /// Use alternate tooltip text while `disabled_when` returns true.
233 pub fn with_disabled_text(
234 mut self,
235 text: impl Into<String>,
236 disabled_when: impl Fn() -> bool + 'static,
237 ) -> Self {
238 self.disabled_lines = text_to_lines(text);
239 self.disabled_when = Some(Rc::new(disabled_when));
240 self
241 }
242
243 pub fn with_margin(mut self, m: Insets) -> Self {
244 self.base.margin = m;
245 self
246 }
247 pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
248 self.base.h_anchor = h;
249 self
250 }
251 pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
252 self.base.v_anchor = v;
253 self
254 }
255
256 /// Delay to apply for a hover starting now: the shorter reshow delay when a
257 /// tip was visible within the reshow window, otherwise the full initial
258 /// delay. Captured at pointer-enter into [`Self::pending_delay`].
259 fn effective_delay(&self) -> Duration {
260 let t = tooltip_timings();
261 match last_tooltip_visible_at() {
262 Some(at) if tooltip_now().saturating_duration_since(at) <= t.reshow_window() => {
263 t.reshow_delay
264 }
265 _ => t.initial_delay,
266 }
267 }
268
269 /// Whether the tip should be visible right now, ignoring autopop (handled
270 /// separately in [`Self::update_visibility`]).
271 fn should_show_tip(&self) -> bool {
272 if self.suppressed || self.pointer_down || !self.hovered {
273 return false;
274 }
275 match self.hover_started_at {
276 Some(started) => tooltip_now().saturating_duration_since(started) >= self.pending_delay,
277 None => false,
278 }
279 }
280
281 /// Advance the lightweight tip's visibility state machine. Applies autopop,
282 /// flips [`Self::tooltip_visible`], warms the shared reshow window, and
283 /// schedules the wall-clock wake that drives the delayed show / auto-dismiss
284 /// without continuous repainting. Returns whether the tip is now visible.
285 ///
286 /// Split out from `paint_overlay` so the state machine is unit-testable
287 /// without a `DrawCtx`.
288 fn update_visibility(&mut self) -> bool {
289 // Auto-dismiss a tip that has sat open too long, and keep it hidden
290 // until the pointer leaves and re-enters (Windows AUTOPOP behaviour).
291 if self.tooltip_visible {
292 if let Some(shown) = self.tip_shown_at {
293 let autopop = tooltip_timings().autopop;
294 let elapsed = tooltip_now().saturating_duration_since(shown);
295 if elapsed >= autopop {
296 self.hide_tip();
297 self.suppressed = true;
298 return false;
299 }
300 // Re-arm the autopop wake every visible frame: the deadline is
301 // read-and-cleared by the host each frame, so an intervening
302 // repaint would otherwise drop it and the dismiss never fires.
303 crate::animation::request_draw_after_tagged(
304 autopop - elapsed,
305 "tooltip.wrapper.autopop_rearm",
306 );
307 }
308 }
309
310 if self.should_show_tip() {
311 if !self.tooltip_visible {
312 self.tooltip_visible = true;
313 self.tip_shown_at = Some(tooltip_now());
314 crate::animation::request_draw_tagged("tooltip.wrapper.show");
315 // Wake at the autopop deadline so a still pointer still dismisses.
316 crate::animation::request_draw_after_tagged(
317 tooltip_timings().autopop,
318 "tooltip.wrapper.autopop_arm",
319 );
320 }
321 note_tooltip_visible();
322 return true;
323 }
324
325 if self.tooltip_visible {
326 self.hide_tip();
327 } else if let Some(remaining) = self.remaining_delay() {
328 // Not yet time to show: schedule the wake at the show deadline.
329 if remaining.is_zero() {
330 crate::animation::request_draw_tagged("tooltip.wrapper.remaining_zero");
331 } else {
332 crate::animation::request_draw_after_tagged(remaining, "tooltip.wrapper.remaining");
333 }
334 }
335 false
336 }
337
338 /// Time left before the pending show fires, or `None` when no show is
339 /// pending (not hovered, suppressed, or a button is held).
340 fn remaining_delay(&self) -> Option<Duration> {
341 if !self.hovered || self.suppressed || self.pointer_down {
342 return None;
343 }
344 let elapsed = tooltip_now().saturating_duration_since(self.hover_started_at?);
345 Some(self.pending_delay.saturating_sub(elapsed))
346 }
347
348 /// Hide the tip and clear its display timer, invalidating so the overlay
349 /// clears on the next paint.
350 fn hide_tip(&mut self) {
351 if self.tooltip_visible {
352 crate::animation::request_draw();
353 }
354 self.tooltip_visible = false;
355 self.tip_shown_at = None;
356 }
357
358 fn active_lines(&self) -> Vec<TooltipLine> {
359 if self.disabled_when.as_ref().map(|f| f()).unwrap_or(false)
360 && !self.disabled_lines.is_empty()
361 {
362 self.disabled_lines.clone()
363 } else {
364 self.lines.clone()
365 }
366 }
367}
368
369impl Widget for Tooltip {
370 fn type_name(&self) -> &'static str {
371 "Tooltip"
372 }
373 fn bounds(&self) -> Rect {
374 self.bounds
375 }
376 fn set_bounds(&mut self, b: Rect) {
377 self.bounds = b;
378 }
379 fn children(&self) -> &[Box<dyn Widget>] {
380 &self.children
381 }
382 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
383 &mut self.children
384 }
385
386 fn margin(&self) -> Insets {
387 self.base.margin
388 }
389 fn widget_base(&self) -> Option<&WidgetBase> {
390 Some(&self.base)
391 }
392 fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
393 Some(&mut self.base)
394 }
395 fn h_anchor(&self) -> HAnchor {
396 self.base.h_anchor
397 }
398 fn v_anchor(&self) -> VAnchor {
399 self.base.v_anchor
400 }
401
402 /// Forward the wrapped child's size constraints so the tooltip is a
403 /// transparent layout wrapper. Without this a constrained child — e.g. a
404 /// [`ComboBox`](crate::widgets::combo_box::ComboBox) whose `max_size` caps its
405 /// width — would lose that cap when wrapped and placed in a `FlexRow`, because
406 /// the row reads the *wrapper's* constraints, not the child's, and would let
407 /// the combo stretch across the whole row.
408 fn min_size(&self) -> Size {
409 self.children
410 .first()
411 .map(|c| c.min_size())
412 .unwrap_or(Size::ZERO)
413 }
414 fn max_size(&self) -> Size {
415 self.children
416 .first()
417 .map(|c| c.max_size())
418 .unwrap_or(Size::MAX)
419 }
420
421 /// Expose the tip text to the inspector and to tests, so a tooltip's presence
422 /// and message can be asserted by walking the widget tree (there is no public
423 /// downcast for `dyn Widget`).
424 fn properties(&self) -> Vec<(&'static str, String)> {
425 vec![(
426 "tooltip",
427 self.lines
428 .iter()
429 .map(|l| l.text.as_str())
430 .collect::<Vec<_>>()
431 .join("\n"),
432 )]
433 }
434
435 fn is_focusable(&self) -> bool {
436 self.children
437 .first()
438 .map(|c| c.is_focusable())
439 .unwrap_or(false)
440 }
441
442 fn layout(&mut self, available: Size) -> Size {
443 let s = if let Some(child) = self.children.first_mut() {
444 let cs = child.layout(available);
445 child.set_bounds(Rect::new(0.0, 0.0, cs.width, cs.height));
446 cs
447 } else {
448 available
449 };
450 self.bounds = Rect::new(0.0, 0.0, s.width, s.height);
451 s
452 }
453
454 fn paint(&mut self, _: &mut dyn DrawCtx) {}
455
456 fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
457 // Interactive tips paint their surface in `paint_global_overlay`,
458 // not through the lightweight text queue.
459 if self.interactive {
460 return;
461 }
462
463 // Advance the delay/reshow/autopop state machine. It handles its own
464 // invalidation and schedules the wall-clock wake for the delayed show
465 // or auto-dismiss, so no busy-repaint loop is needed.
466 if !self.update_visibility() {
467 return;
468 }
469
470 let anchor = if self.at_pointer {
471 current_mouse_world().unwrap_or(self.cursor)
472 } else {
473 let mut x = self.bounds.width * 0.5;
474 // Widget-anchored tooltips should appear below the
475 // hovered widget by default (MatterCAD-style). In
476 // agg-gui's Y-up coords, the bottom edge is y=0; the
477 // global paint step will offset the panel by
478 // `TOOLTIP_GAP` from this anchor.
479 let mut y = 0.0;
480 ctx.root_transform().transform(&mut x, &mut y);
481 Point::new(x, y)
482 };
483 submit_tooltip(TooltipRequest {
484 font: Arc::clone(&self.font),
485 lines: self.active_lines(),
486 anchor,
487 at_pointer: self.at_pointer,
488 });
489 }
490
491 fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
492 if self.interactive {
493 self.paint_interactive_tip(ctx);
494 }
495 }
496
497 fn hit_test_global_overlay(&self, local_pos: Point) -> bool {
498 self.interactive && self.interactive_hit(local_pos)
499 }
500
501 fn on_unconsumed_key(
502 &mut self,
503 key: &crate::event::Key,
504 _modifiers: crate::event::Modifiers,
505 ) -> EventResult {
506 if self.interactive {
507 self.interactive_unconsumed_key(key)
508 } else {
509 EventResult::Ignored
510 }
511 }
512
513 fn on_event(&mut self, event: &Event) -> EventResult {
514 if self.interactive {
515 return self.on_interactive_event(event);
516 }
517 match event {
518 Event::MouseMove { pos } => {
519 let was = self.hovered;
520 self.hovered = self.hit_test(*pos);
521 self.cursor = *pos;
522 if self.hovered && !was {
523 // Entering: start the hover timer, picking the reshow delay
524 // when the subsystem is still warm from a recent tip.
525 self.hover_started_at = Some(tooltip_now());
526 self.pending_delay = self.effective_delay();
527 self.suppressed = false;
528 crate::animation::request_draw_after_tagged(
529 self.pending_delay,
530 "tooltip.wrapper.enter_arm",
531 );
532 } else if !self.hovered && was {
533 // Leaving: reset so the next entry re-arms cleanly and any
534 // press/autopop suppression clears.
535 self.hover_started_at = None;
536 self.suppressed = false;
537 self.hide_tip();
538 }
539 if self.hovered != was {
540 crate::animation::request_draw();
541 }
542 self.children
543 .first_mut()
544 .map(|child| child.on_event(event))
545 .unwrap_or(EventResult::Ignored)
546 }
547 Event::MouseDown { .. } => {
548 // A press over the control hides the tip and keeps it hidden
549 // until the pointer leaves and re-enters.
550 self.pointer_down = true;
551 self.suppressed = true;
552 self.hide_tip();
553 self.children
554 .first_mut()
555 .map(|child| child.on_event(event))
556 .unwrap_or(EventResult::Ignored)
557 }
558 Event::MouseUp { .. } => {
559 self.pointer_down = false;
560 self.children
561 .first_mut()
562 .map(|child| child.on_event(event))
563 .unwrap_or(EventResult::Ignored)
564 }
565 Event::MouseWheel { .. } => {
566 self.hovered = false;
567 self.hover_started_at = None;
568 self.suppressed = false;
569 self.hide_tip();
570 self.children
571 .first_mut()
572 .map(|child| child.on_event(event))
573 .unwrap_or(EventResult::Ignored)
574 }
575 _ => self
576 .children
577 .first_mut()
578 .map(|child| child.on_event(event))
579 .unwrap_or(EventResult::Ignored),
580 }
581 }
582
583 fn hit_test(&self, local_pos: Point) -> bool {
584 local_pos.x >= 0.0
585 && local_pos.x <= self.bounds.width
586 && local_pos.y >= 0.0
587 && local_pos.y <= self.bounds.height
588 }
589}
590
591fn text_to_lines(text: impl Into<String>) -> Vec<TooltipLine> {
592 text.into()
593 .lines()
594 .map(|line| TooltipLine {
595 text: line.to_owned(),
596 kind: TooltipLineKind::Text,
597 })
598 .collect()
599}
600
601#[cfg(test)]
602mod tests;