1use 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#[derive(Default)]
58struct Controller {
59 target: Option<Vec<usize>>,
62 text: String,
64 anchor: Point,
66 hover_started_at: Option<Instant>,
68 pending_delay: Duration,
71 visible: bool,
73 tip_shown_at: Option<Instant>,
75 suppressed: bool,
78 pointer_down: bool,
80 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 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 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 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 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 fn submit(&mut self) {
215 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
242pub(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
256pub(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
267pub(crate) fn on_pointer_up() {
270 CONTROLLER.with(|c| c.borrow_mut().pointer_down = false);
271}
272
273#[doc(hidden)]
277pub fn is_visible() -> bool {
278 CONTROLLER.with(|c| c.borrow().visible)
279}
280
281#[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#[doc(hidden)]
292pub fn visible_rect() -> Option<Rect> {
293 CONTROLLER.with(|c| c.borrow().last_rect)
294}
295
296#[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 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 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 fn hover(app: &mut App, wx: f64, wy: f64) {
387 app.on_mouse_move(wx, 600.0 - wy);
388 }
389
390 #[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 #[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 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 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 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 #[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 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 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 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 #[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(&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 #[test]
529 fn armed_hover_delay_surfaces_through_wants_draw() {
530 reset_tooltip_test_state();
531 reset();
532 set_tooltip_test_clock(None); 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 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}