Skip to main content

agg_gui/widgets/tooltip/
timings.rs

1//! Tooltip timing model and the cross-widget "reshow" state that all
2//! [`Tooltip`](super::Tooltip) instances share.
3//!
4//! Mirrors the Windows common-controls tooltip conventions and egui's
5//! `Style.interaction` tooltip delay/grace design:
6//!
7//! * **`initial_delay`** — how long the pointer must rest on a control before
8//!   its tip appears the first time (Windows `SPI_GETMOUSEHOVERTIME`, egui's
9//!   `tooltip_delay`).
10//! * **`reshow_delay`** — the much shorter delay used when the pointer moves
11//!   directly from one tipped control to another while the tooltip subsystem is
12//!   still "warm" (Windows `TTDT_RESHOW`, egui's `tooltip_grace_time`). This is
13//!   the move-along-the-toolbar behaviour.
14//! * **`autopop`** — how long a tip stays up before auto-dismissing when the
15//!   pointer just sits there (Windows `TTDT_AUTOPOP`).
16//!
17//! The values live in a thread-local (GUI is single-threaded; mirrors
18//! [`crate::device_scale`] / [`crate::platform`]). Native shells override them
19//! from the OS at startup; the wasm shell keeps the defaults. A `#[doc(hidden)]`
20//! injectable clock lets the timing state machine be unit-tested deterministically
21//! (mirrors `touch_state`'s `clear_last_touch_event_for_testing`).
22
23use std::cell::Cell;
24use std::time::Duration;
25use web_time::Instant;
26
27/// Default initial hover delay. Windows common controls default to roughly
28/// 500ms; egui uses 0.3s. We use 500ms and derive the other two from it.
29pub(super) const DEFAULT_INITIAL_DELAY: Duration = Duration::from_millis(500);
30
31/// Tunable tooltip delays, shared by every [`Tooltip`](super::Tooltip).
32///
33/// `reshow_delay` and `autopop` follow the documented Windows relationships to
34/// the initial delay (reshow = initial / 5, autopop = initial * 10), so a host
35/// only needs the OS hover time to configure all three.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub struct TooltipTimings {
38    /// Delay before a tip first appears on a freshly-hovered control.
39    pub initial_delay: Duration,
40    /// Shorter delay applied when the pointer moves between tipped controls
41    /// while the subsystem is still warm (see [`TooltipTimings::reshow_window`]).
42    pub reshow_delay: Duration,
43    /// How long a continuously-displayed tip stays up before auto-dismissing.
44    pub autopop: Duration,
45}
46
47impl Default for TooltipTimings {
48    fn default() -> Self {
49        Self::from_initial_delay(DEFAULT_INITIAL_DELAY)
50    }
51}
52
53impl TooltipTimings {
54    /// Build a full timing set from just the OS initial hover time, deriving
55    /// `reshow_delay` and `autopop` per the Windows tooltip conventions.
56    pub fn from_initial_delay(initial: Duration) -> Self {
57        Self {
58            initial_delay: initial,
59            reshow_delay: initial / 5,
60            autopop: initial * 10,
61        }
62    }
63
64    /// Window, measured from when a tip was last visible, during which a newly
65    /// hovered control uses [`Self::reshow_delay`] instead of the full initial
66    /// delay. One initial-delay's worth of time keeps the subsystem "warm" long
67    /// enough for a natural move to an adjacent control, then goes cold again.
68    pub(super) fn reshow_window(&self) -> Duration {
69        self.initial_delay
70    }
71}
72
73thread_local! {
74    static TIMINGS: Cell<TooltipTimings> = Cell::new(TooltipTimings::default());
75    /// Wall-clock time a tooltip was last visible anywhere. Frozen the moment
76    /// the pointer leaves, so it measures the gap since the last tip vanished.
77    static LAST_VISIBLE_AT: Cell<Option<Instant>> = const { Cell::new(None) };
78    /// Injectable test clock. When `Some`, all tooltip timing reads it instead
79    /// of the real wall clock so the state machine is deterministic in tests.
80    static TEST_CLOCK: Cell<Option<Instant>> = const { Cell::new(None) };
81}
82
83/// Install the tooltip delays used by every [`Tooltip`](super::Tooltip).
84///
85/// Native shells call this at startup with values derived from the OS hover
86/// time; other hosts keep the defaults.
87pub fn set_tooltip_timings(timings: TooltipTimings) {
88    TIMINGS.with(|c| c.set(timings));
89}
90
91/// Current tooltip delays.
92pub fn tooltip_timings() -> TooltipTimings {
93    TIMINGS.with(|c| c.get())
94}
95
96/// Current time on the tooltip clock — the injected test clock when set,
97/// otherwise the real wall clock.
98pub(super) fn tooltip_now() -> Instant {
99    TEST_CLOCK.with(|c| c.get()).unwrap_or_else(Instant::now)
100}
101
102/// Record that a tip is visible right now, warming the reshow window.
103pub(super) fn note_tooltip_visible() {
104    LAST_VISIBLE_AT.with(|c| c.set(Some(tooltip_now())));
105}
106
107/// When a tip was last visible anywhere, if recently enough to matter.
108pub(super) fn last_tooltip_visible_at() -> Option<Instant> {
109    LAST_VISIBLE_AT.with(|c| c.get())
110}
111
112// --- Test hooks ---------------------------------------------------------------
113
114/// Pin the tooltip clock to `now` (or release it back to the wall clock with
115/// `None`). Test-only: lets a unit test drive the delay/reshow/autopop state
116/// machine without sleeping.
117#[doc(hidden)]
118pub fn set_tooltip_test_clock(now: Option<Instant>) {
119    TEST_CLOCK.with(|c| c.set(now));
120}
121
122/// Advance the pinned test clock by `by`. Panics if no test clock is set.
123#[doc(hidden)]
124pub fn advance_tooltip_test_clock(by: Duration) {
125    TEST_CLOCK.with(|c| {
126        let base = c.get().expect("advance_tooltip_test_clock without a test clock");
127        c.set(Some(base + by));
128    });
129}
130
131/// Reset all tooltip timing globals (clock, reshow window, delays) so tests do
132/// not leak state into one another on a reused harness thread.
133#[doc(hidden)]
134pub fn reset_tooltip_test_state() {
135    TEST_CLOCK.with(|c| c.set(None));
136    LAST_VISIBLE_AT.with(|c| c.set(None));
137    TIMINGS.with(|c| c.set(TooltipTimings::default()));
138}