Skip to main content

agg_gui/widgets/tooltip/
controller.rs

1//! Central tooltip controller — the first-class, wrapper-free tooltip path.
2//!
3//! Any widget can declare hover help by storing a string in its
4//! [`WidgetBase::tooltip`](crate::layout_props::WidgetBase::tooltip) (via
5//! [`Widget::with_tooltip`](crate::widget::Widget::with_tooltip)). Once per
6//! frame the [`App`](crate::widget::App) finds the deepest hovered widget whose
7//! [`tooltip_text`](crate::widget::Widget::tooltip_text) is `Some` and feeds it
8//! to [`drive`]. This module owns the single, app-wide state machine that
9//! decides *when* a tip is visible and, while it is, submits it to the shared
10//! [`render`](super::render) queue so it paints in the global-overlay pass
11//! above all clips.
12//!
13//! # One tip, app-wide
14//!
15//! There is exactly one controller (a thread-local — the GUI is single
16//! threaded, matching [`super::timings`] / [`super::render`]). Because it keys
17//! off the deepest *tipped* widget, only one tip is ever live. It shares the
18//! reshow window in [`super::timings`] with the legacy [`Tooltip`](super::Tooltip)
19//! wrapper, so moving between a wrapped control and a `with_tooltip` control
20//! still gets the fast "reshow" delay and never shows two tips at once.
21//!
22//! # Timing
23//!
24//! The state machine mirrors [`Tooltip::update_visibility`](super::Tooltip):
25//! initial delay → reshow delay when warm → autopop dismiss → hide-on-press /
26//! hide-on-leave. It reuses [`super::timings`] wholesale rather than
27//! duplicating the delays.
28//!
29//! # Font — host contract (required)
30//!
31//! The tip text is painted with the crate-wide font override
32//! ([`font_settings::current_system_font`](crate::font_settings::current_system_font)),
33//! read live on every paint so a tip re-renders in the new face the instant the
34//! host swaps the system font. **This library is deliberately font-free: it
35//! ships no bundled fallback.** Every host MUST call
36//! [`font_settings::set_system_font`](crate::font_settings::set_system_font) with
37//! a real font at startup, before any tip can appear. When no font is installed
38//! the state machine still runs (timing, visibility) but [`Controller::submit`]
39//! paints *nothing* — the tip is silently invisible. That is a host-setup bug,
40//! not a headless-only edge case: the demos install their default font in
41//! `demo-ui::font_init::init` precisely so tips are never blank. Tests install a
42//! font explicitly via the same API.
43
44use std::time::Duration;
45use web_time::Instant;
46
47use crate::geometry::{Point, Rect};
48use crate::text::measure_advance;
49
50use super::render::{
51    current_tooltip_viewport, panel_size, place_panel, submit_tooltip, TooltipRequest,
52};
53use super::timings::{
54    last_tooltip_visible_at, note_tooltip_visible, tooltip_now, tooltip_timings,
55};
56use super::{TooltipLine, TooltipLineKind, TOOLTIP_FONT_SIZE};
57
58/// The single app-wide tip state machine. See the module docs.
59#[derive(Default)]
60struct Controller {
61    /// Identity (child-index path) of the widget currently supplying the tip,
62    /// used to detect target changes (move to another control ⇒ reshow).
63    target: Option<Vec<usize>>,
64    /// Tip text for the current target.
65    text: String,
66    /// Latest pointer position in world coords (the tip anchor).
67    anchor: Point,
68    /// When the pointer entered the current target.
69    hover_started_at: Option<Instant>,
70    /// Delay captured at target-enter: full initial, or the shorter reshow
71    /// delay when the subsystem was warm.
72    pending_delay: Duration,
73    /// Whether the tip is currently visible.
74    visible: bool,
75    /// When the visible tip appeared, for autopop dismissal.
76    tip_shown_at: Option<Instant>,
77    /// Suppresses (re)showing until the pointer leaves and re-enters — set on a
78    /// press and on autopop, mirroring Windows.
79    suppressed: bool,
80    /// Whether a mouse button is currently held (tips never show while pressed).
81    pointer_down: bool,
82    /// Panel rect from the last visible frame, in world coords — exposed to
83    /// tests to assert edge clamping without paint introspection.
84    last_rect: Option<Rect>,
85}
86
87thread_local! {
88    static CONTROLLER: std::cell::RefCell<Controller> =
89        std::cell::RefCell::new(Controller::default());
90}
91
92impl Controller {
93    /// Delay for a hover starting now: the shorter reshow delay when a tip was
94    /// visible within the reshow window, otherwise the full initial delay.
95    fn effective_delay(&self) -> Duration {
96        let t = tooltip_timings();
97        match last_tooltip_visible_at() {
98            Some(at) if tooltip_now().saturating_duration_since(at) <= t.reshow_window() => {
99                t.reshow_delay
100            }
101            _ => t.initial_delay,
102        }
103    }
104
105    /// Adopt `target` as the hovered tipped widget for this frame (or clear it),
106    /// re-arming the hover timer on a target change.
107    fn set_target(&mut self, target: Option<(Vec<usize>, String)>, anchor: Option<Point>) {
108        if let Some(a) = anchor {
109            self.anchor = a;
110        }
111        match target {
112            Some((path, text)) => {
113                if self.target.as_deref() != Some(path.as_slice()) {
114                    // Entered a different tipped widget: re-arm.
115                    self.target = Some(path);
116                    self.hover_started_at = Some(tooltip_now());
117                    self.pending_delay = self.effective_delay();
118                    self.suppressed = false;
119                    if self.visible {
120                        self.hide();
121                    }
122                    crate::animation::request_draw_after_tagged(
123                        self.pending_delay,
124                        "tooltip.controller.arm_hover",
125                    );
126                }
127                self.text = text;
128            }
129            None => {
130                if self.target.is_some() {
131                    self.target = None;
132                    self.hover_started_at = None;
133                    self.suppressed = false;
134                    self.hide();
135                }
136            }
137        }
138    }
139
140    fn should_show(&self) -> bool {
141        if self.suppressed || self.pointer_down || self.target.is_none() {
142            return false;
143        }
144        match self.hover_started_at {
145            Some(started) => tooltip_now().saturating_duration_since(started) >= self.pending_delay,
146            None => false,
147        }
148    }
149
150    fn remaining_delay(&self) -> Option<Duration> {
151        if self.target.is_none() || self.suppressed || self.pointer_down {
152            return None;
153        }
154        let elapsed = tooltip_now().saturating_duration_since(self.hover_started_at?);
155        Some(self.pending_delay.saturating_sub(elapsed))
156    }
157
158    fn hide(&mut self) {
159        if self.visible {
160            crate::animation::request_draw_tagged("tooltip.controller.hide");
161        }
162        self.visible = false;
163        self.tip_shown_at = None;
164        self.last_rect = None;
165    }
166
167    /// Advance the state machine; returns whether the tip is visible now.
168    fn update_visibility(&mut self) -> bool {
169        if self.visible {
170            if let Some(shown) = self.tip_shown_at {
171                let autopop = tooltip_timings().autopop;
172                let elapsed = tooltip_now().saturating_duration_since(shown);
173                if elapsed >= autopop {
174                    self.hide();
175                    self.suppressed = true;
176                    return false;
177                }
178                crate::animation::request_draw_after_tagged(
179                    autopop - elapsed,
180                    "tooltip.controller.autopop_rearm",
181                );
182            }
183        }
184
185        if self.should_show() {
186            if !self.visible {
187                self.visible = true;
188                self.tip_shown_at = Some(tooltip_now());
189                crate::animation::request_draw_tagged("tooltip.controller.show");
190                crate::animation::request_draw_after_tagged(
191                    tooltip_timings().autopop,
192                    "tooltip.controller.autopop_arm",
193                );
194            }
195            note_tooltip_visible();
196            return true;
197        }
198
199        if self.visible {
200            self.hide();
201        } else if let Some(remaining) = self.remaining_delay() {
202            if remaining.is_zero() {
203                crate::animation::request_draw_tagged("tooltip.controller.remaining_zero");
204            } else {
205                crate::animation::request_draw_after_tagged(
206                    remaining,
207                    "tooltip.controller.remaining",
208                );
209            }
210        }
211        false
212    }
213
214    /// When visible, compute the placed panel rect (for tests) and submit the
215    /// tip to the shared render queue so it paints in the global-overlay pass.
216    fn submit(&mut self) {
217        // Host contract: a system font MUST be installed via
218        // `font_settings::set_system_font` before a tip can paint (this library
219        // ships no bundled fallback — see the module docs). Re-read live every
220        // paint so a tip follows a mid-session font swap. When none is present
221        // there is nothing to paint; keep the state machine state and bail.
222        let Some(font) = crate::font_settings::current_system_font() else {
223            self.last_rect = None;
224            return;
225        };
226        let text_w = measure_advance(&font, &self.text, TOOLTIP_FONT_SIZE);
227        let size = panel_size(text_w, 1);
228        let viewport = current_tooltip_viewport();
229        if viewport.width > 0.0 && viewport.height > 0.0 {
230            self.last_rect = Some(place_panel(self.anchor, size, viewport, true));
231        }
232        submit_tooltip(TooltipRequest {
233            font,
234            lines: vec![TooltipLine {
235                text: self.text.clone(),
236                kind: TooltipLineKind::Text,
237            }],
238            anchor: self.anchor,
239            at_pointer: true,
240        });
241    }
242}
243
244/// Per-frame entry point called by the App's tooltip pass. `target` is the
245/// deepest hovered widget carrying a tip (its index-path identity + text), or
246/// `None` when nothing tipped is hovered. `anchor` is the pointer world
247/// position. Runs the state machine and, if a tip is visible, submits it.
248pub(crate) fn drive(target: Option<(Vec<usize>, String)>, anchor: Option<Point>) {
249    CONTROLLER.with(|c| {
250        let mut c = c.borrow_mut();
251        c.set_target(target, anchor);
252        if c.update_visibility() {
253            c.submit();
254        }
255    });
256}
257
258/// A press hides any visible tip and keeps it hidden until the pointer leaves
259/// and re-enters a tipped widget (Windows press-hide behaviour).
260pub(crate) fn on_pointer_down() {
261    CONTROLLER.with(|c| {
262        let mut c = c.borrow_mut();
263        c.pointer_down = true;
264        c.suppressed = true;
265        c.hide();
266    });
267}
268
269/// Release clears the held-button flag (re-showing still waits on re-entry
270/// because `suppressed` stays set until the target changes).
271pub(crate) fn on_pointer_up() {
272    CONTROLLER.with(|c| c.borrow_mut().pointer_down = false);
273}
274
275// --- Test / introspection hooks ------------------------------------------------
276
277/// Whether a tip is currently visible.
278#[doc(hidden)]
279pub fn is_visible() -> bool {
280    CONTROLLER.with(|c| c.borrow().visible)
281}
282
283/// The visible tip's text, or `None` when hidden.
284#[doc(hidden)]
285pub fn visible_text() -> Option<String> {
286    CONTROLLER.with(|c| {
287        let c = c.borrow();
288        c.visible.then(|| c.text.clone())
289    })
290}
291
292/// The visible tip's placed panel rect (world coords), or `None`.
293#[doc(hidden)]
294pub fn visible_rect() -> Option<Rect> {
295    CONTROLLER.with(|c| c.borrow().last_rect)
296}
297
298/// Reset the controller to its initial state (tests: avoid leaking state across
299/// cases on a reused harness thread).
300#[doc(hidden)]
301pub fn reset() {
302    CONTROLLER.with(|c| *c.borrow_mut() = Controller::default());
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::draw_ctx::DrawCtx;
309    use crate::event::{Event, EventResult};
310    use crate::geometry::{Rect, Size};
311    use crate::layout_props::WidgetBase;
312    use crate::text::Font;
313    use crate::widget::{App, Widget};
314    use std::sync::Arc;
315
316    use super::super::timings::{
317        advance_tooltip_test_clock, reset_tooltip_test_state, set_tooltip_test_clock,
318    };
319
320    const FONT_BYTES: &[u8] = include_bytes!("../../../assets/fonts/NotoSans-Regular.ttf");
321
322    /// Pin the tooltip clock and clear both the shared timing state and the
323    /// controller so cases do not leak into one another; restores on drop.
324    struct Guard;
325    impl Drop for Guard {
326        fn drop(&mut self) {
327            reset_tooltip_test_state();
328            reset();
329            crate::font_settings::set_system_font(None);
330        }
331    }
332    fn pin() -> Guard {
333        reset_tooltip_test_state();
334        reset();
335        set_tooltip_test_clock(Some(Instant::now()));
336        crate::font_settings::set_system_font(Some(Arc::new(
337            Font::from_bytes(FONT_BYTES.to_vec()).expect("bundled font"),
338        )));
339        Guard
340    }
341
342    /// A leaf that fills whatever bounds it is given and carries a tip on its
343    /// `WidgetBase`, so hovering anywhere in the viewport targets it.
344    struct Tipped {
345        bounds: Rect,
346        children: Vec<Box<dyn Widget>>,
347        base: WidgetBase,
348    }
349    impl Tipped {
350        fn new(tip: &str) -> Self {
351            Self {
352                bounds: Rect::default(),
353                children: Vec::new(),
354                base: WidgetBase::new().with_tooltip(tip),
355            }
356        }
357    }
358    impl Widget for Tipped {
359        fn bounds(&self) -> Rect {
360            self.bounds
361        }
362        fn set_bounds(&mut self, b: Rect) {
363            self.bounds = b;
364        }
365        fn children(&self) -> &[Box<dyn Widget>] {
366            &self.children
367        }
368        fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
369            &mut self.children
370        }
371        fn layout(&mut self, available: Size) -> Size {
372            self.bounds = Rect::new(0.0, 0.0, available.width, available.height);
373            available
374        }
375        fn paint(&mut self, _: &mut dyn DrawCtx) {}
376        fn on_event(&mut self, _: &Event) -> EventResult {
377            EventResult::Ignored
378        }
379        fn widget_base(&self) -> Option<&WidgetBase> {
380            Some(&self.base)
381        }
382        fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
383            Some(&mut self.base)
384        }
385    }
386
387    /// Move the pointer to world `(wx, wy)` in an 800×600 viewport (scale 1).
388    fn hover(app: &mut App, wx: f64, wy: f64) {
389        app.on_mouse_move(wx, 600.0 - wy);
390    }
391
392    /// (a) A `with_tooltip` widget shows its tip after the initial delay.
393    #[test]
394    fn tip_shows_after_initial_delay_when_hovered() {
395        let _g = pin();
396        let timings = tooltip_timings();
397        let mut app = App::new(Box::new(Tipped::new("Bold")));
398        app.layout(Size::new(800.0, 600.0));
399
400        hover(&mut app, 400.0, 300.0);
401        app.update_tooltips_for_test();
402        assert!(!is_visible(), "no tip before the initial delay elapses");
403
404        advance_tooltip_test_clock(timings.initial_delay);
405        app.update_tooltips_for_test();
406        assert!(is_visible(), "tip appears once the initial delay elapses");
407        assert_eq!(visible_text().as_deref(), Some("Bold"));
408    }
409
410    /// (b) Moving between two tipped widgets uses the reshow delay and never
411    /// shows more than one tip: only the second widget's tip is live.
412    #[test]
413    fn moving_between_tipped_widgets_reshows_single_tip() {
414        let _g = pin();
415        let timings = tooltip_timings();
416        let anchor = Some(Point::new(100.0, 100.0));
417
418        // Enter A and show its tip, warming the reshow window.
419        drive(Some((vec![0], "A".into())), anchor);
420        advance_tooltip_test_clock(timings.initial_delay);
421        drive(Some((vec![0], "A".into())), anchor);
422        assert_eq!(visible_text().as_deref(), Some("A"));
423
424        // Leave A (tip hides, window stays warm), then a short move to B.
425        drive(None, anchor);
426        assert!(!is_visible());
427        advance_tooltip_test_clock(timings.reshow_delay);
428        drive(Some((vec![1], "B".into())), anchor);
429        assert!(!is_visible(), "B not shown until the (short) reshow delay elapses");
430
431        // After only the reshow delay — well short of a full initial delay — B's
432        // tip appears, and it is the only tip.
433        advance_tooltip_test_clock(timings.reshow_delay);
434        drive(Some((vec![1], "B".into())), anchor);
435        assert!(is_visible());
436        assert_eq!(visible_text().as_deref(), Some("B"), "exactly one tip: B's");
437    }
438
439    /// (c) A visible tip re-renders with the CURRENT system font: swapping the
440    /// crate-wide font mid-hover makes the very next paint submit the new face.
441    /// Pins the "font read live every paint" contract the module docs promise —
442    /// the controller must never cache the font it first painted with.
443    #[test]
444    fn visible_tip_follows_live_system_font_swap() {
445        let _g = pin();
446        let timings = tooltip_timings();
447        let anchor = Some(Point::new(100.0, 100.0));
448
449        // Two distinct fonts (same bytes) tell apart by Arc pointer identity.
450        let font_a = Arc::new(Font::from_bytes(FONT_BYTES.to_vec()).expect("font a"));
451        let font_b = Arc::new(Font::from_bytes(FONT_BYTES.to_vec()).expect("font b"));
452        assert!(!Arc::ptr_eq(&font_a, &font_b));
453
454        // Install A, hover, and let the tip appear: it submits with font A.
455        crate::font_settings::set_system_font(Some(Arc::clone(&font_a)));
456        drive(Some((vec![0], "Tip".into())), anchor);
457        advance_tooltip_test_clock(timings.initial_delay);
458        drive(Some((vec![0], "Tip".into())), anchor);
459        assert!(is_visible(), "tip visible after the initial delay");
460        let submitted_a =
461            super::super::render::last_submitted_font().expect("tip submitted a request");
462        assert!(
463            Arc::ptr_eq(&submitted_a, &font_a),
464            "tip first paints with the installed font A"
465        );
466
467        // Swap the system font live and paint again WITHOUT re-hovering: the
468        // controller must re-read the font and submit B, not the cached A.
469        crate::font_settings::set_system_font(Some(Arc::clone(&font_b)));
470        drive(Some((vec![0], "Tip".into())), anchor);
471        let submitted_b =
472            super::super::render::last_submitted_font().expect("tip re-submitted after swap");
473        assert!(
474            Arc::ptr_eq(&submitted_b, &font_b),
475            "still-visible tip re-renders with the NEW system font B"
476        );
477        assert!(
478            !Arc::ptr_eq(&submitted_b, &font_a),
479            "the swapped tip must not keep painting with the old font A"
480        );
481    }
482
483    /// (d) A tipped widget hovered at the right viewport edge paints its tip
484    /// fully inside the viewport safe area (edge clamping).
485    #[test]
486    fn tip_at_viewport_edge_stays_inside() {
487        let _g = pin();
488        let timings = tooltip_timings();
489        let mut app = App::new(Box::new(Tipped::new("Increase indent")));
490        let viewport = Size::new(800.0, 600.0);
491        app.layout(viewport);
492
493        // Hover hard against the right edge; the pointer-anchored panel would
494        // overflow to the right if it were not clamped. The first pass arms the
495        // hover timer; the tip shows on the pass after the initial delay.
496        hover(&mut app, 798.0, 300.0);
497        app.update_tooltips_for_test();
498        advance_tooltip_test_clock(timings.initial_delay);
499        app.update_tooltips_for_test();
500
501        assert!(is_visible());
502        let r = visible_rect().expect("visible tip has a placed rect");
503        assert!(r.x >= 0.0, "left edge inside viewport: {r:?}");
504        assert!(
505            r.x + r.width <= viewport.width,
506            "right edge inside viewport: {r:?}"
507        );
508        assert!(r.y >= 0.0 && r.y + r.height <= viewport.height, "vertically inside: {r:?}");
509    }
510
511    /// Reactive-host regression for the scheduled-draw lost-wakeup fix. After
512    /// the controller arms its hover delay in one tooltip pass, the deadline
513    /// must surface through [`crate::animation::wants_draw`] on its own once it
514    /// comes due — with no second explicit paint request. That is exactly the
515    /// signal a reactive host needs to draw the frame that reveals the tip.
516    ///
517    /// Deliberately uses the REAL wall clock (no test clock) with a short
518    /// initial delay: the controller's `should_show` reads `tooltip_now()`
519    /// while the arm goes through `animation::request_draw_after`
520    /// (`Instant::now`). Those are independent clocks, so pinning the test
521    /// clock would let real time drift past the animation deadline while the
522    /// controller thinks no time passed. Sharing one wall clock keeps the two
523    /// machines consistent; the sleep is kept short.
524    #[test]
525    fn armed_hover_delay_surfaces_through_wants_draw() {
526        reset_tooltip_test_state();
527        reset();
528        set_tooltip_test_clock(None); // real wall clock drives both machines
529        super::super::timings::set_tooltip_timings(
530            super::super::timings::TooltipTimings::from_initial_delay(Duration::from_millis(20)),
531        );
532        let _g = Guard;
533
534        let mut app = App::new(Box::new(Tipped::new("Bold")));
535        app.layout(Size::new(800.0, 600.0));
536
537        hover(&mut app, 400.0, 300.0);
538        // Simulate the start-of-frame clear, then run the tooltip pass that
539        // arms the hover delay via `request_draw_after`.
540        crate::animation::clear_draw_request();
541        app.update_tooltips_for_test();
542
543        assert!(
544            crate::animation::peek_next_draw_deadline().is_some(),
545            "the tooltip pass armed a scheduled draw for the hover delay"
546        );
547        assert!(
548            !crate::animation::wants_draw(),
549            "the hover delay has not elapsed yet, so no immediate draw"
550        );
551
552        std::thread::sleep(Duration::from_millis(40));
553
554        assert!(
555            crate::animation::wants_draw(),
556            "once the hover delay is due, wants_draw() surfaces it so a \
557             reactive host draws the frame that shows the tip"
558        );
559    }
560}