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