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}