Another project
1use core::time::Duration;
2
3use serde::Serialize;
4
5use crate::a11y::{AccessNode, Role};
6use crate::frame::FrameCtx;
7use crate::hit_test::Interaction;
8use crate::input::FrameInstant;
9use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
10use crate::strings::StringKey;
11use crate::widget_id::{WidgetId, WidgetKey};
12
13use super::paint::{LabelText, WidgetPaint};
14
15#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)]
16pub enum TooltipPlacement {
17 Below,
18 Above,
19 Right,
20 Left,
21}
22
23#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
24pub struct TooltipState {
25 pub showing: bool,
26 pub hover_began: Option<FrameInstant>,
27 pub focus_began: Option<FrameInstant>,
28}
29
30#[derive(Copy, Clone, Debug, PartialEq)]
31pub struct Tooltip {
32 pub anchor: WidgetId,
33 pub anchor_rect: LayoutRect,
34 pub label: StringKey,
35 pub placement: TooltipPlacement,
36 pub gap: LayoutPx,
37 pub size: LayoutSize,
38 pub delay: Duration,
39}
40
41impl Tooltip {
42 #[must_use]
43 pub const fn new(
44 anchor: WidgetId,
45 anchor_rect: LayoutRect,
46 label: StringKey,
47 placement: TooltipPlacement,
48 size: LayoutSize,
49 ) -> Self {
50 Self {
51 anchor,
52 anchor_rect,
53 label,
54 placement,
55 gap: LayoutPx::new(4.0),
56 size,
57 delay: Duration::from_millis(500),
58 }
59 }
60
61 #[must_use]
62 pub const fn with_delay(self, delay: Duration) -> Self {
63 Self { delay, ..self }
64 }
65}
66
67#[must_use]
68pub fn show_tooltip(
69 ctx: &mut FrameCtx<'_>,
70 tooltip: Tooltip,
71 state: &mut TooltipState,
72) -> Vec<WidgetPaint> {
73 let interaction = ctx.previous.interaction(tooltip.anchor);
74 let now = ctx.input.frame;
75 update_state(state, &interaction, now);
76 state.showing = should_show(state, now, tooltip.delay);
77 if !state.showing {
78 return Vec::new();
79 }
80 let rect = popup_rect(
81 tooltip.anchor_rect,
82 tooltip.placement,
83 tooltip.gap,
84 tooltip.size,
85 );
86 ctx.a11y.push(
87 tooltip.anchor.child(WidgetKey::new("tooltip")),
88 rect,
89 AccessNode::new(Role::Tooltip).with_label(tooltip.label),
90 );
91 vec![WidgetPaint::Tooltip {
92 rect,
93 text: LabelText::Key(tooltip.label),
94 anchor: tooltip.anchor,
95 elevation: ctx.theme().elevation.level2,
96 }]
97}
98
99fn update_state(state: &mut TooltipState, interaction: &Interaction, now: FrameInstant) {
100 if interaction.hover() {
101 if state.hover_began.is_none() {
102 state.hover_began = Some(now);
103 }
104 } else {
105 state.hover_began = None;
106 }
107 if interaction.focused() {
108 if state.focus_began.is_none() {
109 state.focus_began = Some(now);
110 }
111 } else {
112 state.focus_began = None;
113 }
114}
115
116fn should_show(state: &TooltipState, now: FrameInstant, delay: Duration) -> bool {
117 let qualifies = |start: FrameInstant| now.since(start) >= delay;
118 state.hover_began.is_some_and(qualifies) || state.focus_began.is_some_and(qualifies)
119}
120
121fn popup_rect(
122 anchor: LayoutRect,
123 placement: TooltipPlacement,
124 gap: LayoutPx,
125 size: LayoutSize,
126) -> LayoutRect {
127 let centered_x =
128 anchor.origin.x.value() + anchor.size.width.value() / 2.0 - size.width.value() / 2.0;
129 let centered_y =
130 anchor.origin.y.value() + anchor.size.height.value() / 2.0 - size.height.value() / 2.0;
131 let origin = match placement {
132 TooltipPlacement::Below => LayoutPos::new(
133 LayoutPx::new(centered_x),
134 LayoutPx::new(anchor.origin.y.value() + anchor.size.height.value() + gap.value()),
135 ),
136 TooltipPlacement::Above => LayoutPos::new(
137 LayoutPx::new(centered_x),
138 LayoutPx::new(anchor.origin.y.value() - size.height.value() - gap.value()),
139 ),
140 TooltipPlacement::Right => LayoutPos::new(
141 LayoutPx::new(anchor.origin.x.value() + anchor.size.width.value() + gap.value()),
142 LayoutPx::new(centered_y),
143 ),
144 TooltipPlacement::Left => LayoutPos::new(
145 LayoutPx::new(anchor.origin.x.value() - size.width.value() - gap.value()),
146 LayoutPx::new(centered_y),
147 ),
148 };
149 LayoutRect::new(origin, size)
150}
151
152#[cfg(test)]
153mod tests {
154 use core::time::Duration;
155 use std::sync::Arc;
156
157 use super::{Tooltip, TooltipPlacement, TooltipState, show_tooltip};
158 use crate::focus::FocusManager;
159 use crate::frame::FrameCtx;
160 use crate::hit_test::{HitFrame, HitState, Interaction, InteractionState};
161 use crate::hotkey::HotkeyTable;
162 use crate::input::{FrameInstant, InputSnapshot};
163 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
164 use crate::strings::StringKey;
165 use crate::strings::StringTable;
166 use crate::theme::Theme;
167 use crate::widget_id::{WidgetId, WidgetKey};
168 use crate::widgets::WidgetPaint;
169
170 const LABEL: StringKey = StringKey::new("tooltip.label");
171
172 fn anchor_id() -> WidgetId {
173 WidgetId::ROOT.child(WidgetKey::new("anchor"))
174 }
175
176 fn anchor_rect() -> LayoutRect {
177 LayoutRect::new(
178 LayoutPos::new(LayoutPx::new(50.0), LayoutPx::new(40.0)),
179 LayoutSize::new(LayoutPx::new(60.0), LayoutPx::new(20.0)),
180 )
181 }
182
183 fn tooltip() -> Tooltip {
184 Tooltip::new(
185 anchor_id(),
186 anchor_rect(),
187 LABEL,
188 TooltipPlacement::Below,
189 LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(18.0)),
190 )
191 }
192
193 fn run(
194 prev: &HitState,
195 frame: FrameInstant,
196 state: &mut TooltipState,
197 tooltip: Tooltip,
198 ) -> Vec<WidgetPaint> {
199 let theme = Arc::new(Theme::light());
200 let mut focus = FocusManager::new();
201 let table = HotkeyTable::new();
202 let mut hits = HitFrame::new();
203 let mut input = InputSnapshot::idle(frame);
204 let mut shaper = bone_text::Shaper::new();
205 let mut a11y = crate::a11y::AccessTreeBuilder::new();
206 let mut ctx = FrameCtx::new(
207 theme,
208 &mut input,
209 &mut focus,
210 &table,
211 StringTable::empty(),
212 &mut hits,
213 prev,
214 &mut a11y,
215 &mut shaper,
216 );
217 show_tooltip(&mut ctx, tooltip, state)
218 }
219
220 fn prev_with_hover() -> HitState {
221 let mut state = HitState::new();
222 state.interactions.insert(
223 anchor_id(),
224 Interaction {
225 state: InteractionState::HOVER,
226 ..Interaction::idle()
227 },
228 );
229 state
230 }
231
232 fn prev_with_focus() -> HitState {
233 let mut state = HitState::new();
234 state.interactions.insert(
235 anchor_id(),
236 Interaction {
237 state: InteractionState::FOCUSED,
238 ..Interaction::idle()
239 },
240 );
241 state
242 }
243
244 fn prev_idle() -> HitState {
245 HitState::new()
246 }
247
248 #[test]
249 fn hover_for_less_than_delay_does_not_show() {
250 let mut state = TooltipState::default();
251 let prev = prev_with_hover();
252 let _ = run(
253 &prev,
254 FrameInstant::from_duration(Duration::from_millis(100)),
255 &mut state,
256 tooltip(),
257 );
258 assert!(!state.showing);
259 }
260
261 #[test]
262 fn hover_past_delay_shows_tooltip() {
263 let mut state = TooltipState::default();
264 let prev = prev_with_hover();
265 let _ = run(
266 &prev,
267 FrameInstant::from_duration(Duration::from_millis(10)),
268 &mut state,
269 tooltip(),
270 );
271 let paint = run(
272 &prev,
273 FrameInstant::from_duration(Duration::from_millis(800)),
274 &mut state,
275 tooltip(),
276 );
277 assert!(state.showing);
278 assert_eq!(paint.len(), 1);
279 }
280
281 #[test]
282 fn losing_hover_resets_timer() {
283 let mut state = TooltipState::default();
284 let _ = run(
285 &prev_with_hover(),
286 FrameInstant::from_duration(Duration::from_millis(10)),
287 &mut state,
288 tooltip(),
289 );
290 let _ = run(
291 &prev_idle(),
292 FrameInstant::from_duration(Duration::from_millis(100)),
293 &mut state,
294 tooltip(),
295 );
296 assert!(state.hover_began.is_none());
297 assert!(!state.showing);
298 }
299
300 #[test]
301 fn focus_satisfies_show_condition() {
302 let mut state = TooltipState::default();
303 let _ = run(
304 &prev_with_focus(),
305 FrameInstant::from_duration(Duration::from_millis(10)),
306 &mut state,
307 tooltip(),
308 );
309 let paint = run(
310 &prev_with_focus(),
311 FrameInstant::from_duration(Duration::from_millis(700)),
312 &mut state,
313 tooltip(),
314 );
315 assert!(state.showing);
316 assert_eq!(paint.len(), 1);
317 }
318
319 #[test]
320 fn placement_below_positions_under_anchor() {
321 let mut state = TooltipState::default();
322 let _ = run(
323 &prev_with_hover(),
324 FrameInstant::from_duration(Duration::from_millis(10)),
325 &mut state,
326 tooltip(),
327 );
328 let paint = run(
329 &prev_with_hover(),
330 FrameInstant::from_duration(Duration::from_millis(900)),
331 &mut state,
332 tooltip(),
333 );
334 let WidgetPaint::Tooltip { rect, .. } = &paint[0] else {
335 panic!("expected tooltip paint")
336 };
337 assert!(
338 rect.origin.y.value()
339 > anchor_rect().origin.y.value() + anchor_rect().size.height.value()
340 );
341 }
342
343 #[test]
344 fn placement_above_inverts_y() {
345 let mut state = TooltipState::default();
346 let above = Tooltip::new(
347 anchor_id(),
348 anchor_rect(),
349 LABEL,
350 TooltipPlacement::Above,
351 LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(18.0)),
352 );
353 let _ = run(
354 &prev_with_hover(),
355 FrameInstant::from_duration(Duration::from_millis(10)),
356 &mut state,
357 above,
358 );
359 let paint = run(
360 &prev_with_hover(),
361 FrameInstant::from_duration(Duration::from_millis(900)),
362 &mut state,
363 above,
364 );
365 let WidgetPaint::Tooltip { rect, .. } = &paint[0] else {
366 panic!("expected tooltip paint")
367 };
368 assert!(rect.origin.y.value() < anchor_rect().origin.y.value());
369 }
370}