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::{
54 last_tooltip_visible_at, note_tooltip_visible, tooltip_now, tooltip_timings,
55};
56use super::{TooltipLine, TooltipLineKind, TOOLTIP_FONT_SIZE};
57
58#[derive(Default)]
60struct Controller {
61 target: Option<Vec<usize>>,
64 text: String,
66 anchor: Point,
68 hover_started_at: Option<Instant>,
70 pending_delay: Duration,
73 visible: bool,
75 tip_shown_at: Option<Instant>,
77 suppressed: bool,
80 pointer_down: bool,
82 last_rect: Option<Rect>,
85}
86
87thread_local! {
88 static CONTROLLER: std::cell::RefCell<Controller> =
89 std::cell::RefCell::new(Controller::default());
90}
91
92impl Controller {
93 fn effective_delay(&self) -> Duration {
96 let t = tooltip_timings();
97 match last_tooltip_visible_at() {
98 Some(at) if tooltip_now().saturating_duration_since(at) <= t.reshow_window() => {
99 t.reshow_delay
100 }
101 _ => t.initial_delay,
102 }
103 }
104
105 fn set_target(&mut self, target: Option<(Vec<usize>, String)>, anchor: Option<Point>) {
108 if let Some(a) = anchor {
109 self.anchor = a;
110 }
111 match target {
112 Some((path, text)) => {
113 if self.target.as_deref() != Some(path.as_slice()) {
114 self.target = Some(path);
116 self.hover_started_at = Some(tooltip_now());
117 self.pending_delay = self.effective_delay();
118 self.suppressed = false;
119 if self.visible {
120 self.hide();
121 }
122 crate::animation::request_draw_after_tagged(
123 self.pending_delay,
124 "tooltip.controller.arm_hover",
125 );
126 }
127 self.text = text;
128 }
129 None => {
130 if self.target.is_some() {
131 self.target = None;
132 self.hover_started_at = None;
133 self.suppressed = false;
134 self.hide();
135 }
136 }
137 }
138 }
139
140 fn should_show(&self) -> bool {
141 if self.suppressed || self.pointer_down || self.target.is_none() {
142 return false;
143 }
144 match self.hover_started_at {
145 Some(started) => tooltip_now().saturating_duration_since(started) >= self.pending_delay,
146 None => false,
147 }
148 }
149
150 fn remaining_delay(&self) -> Option<Duration> {
151 if self.target.is_none() || self.suppressed || self.pointer_down {
152 return None;
153 }
154 let elapsed = tooltip_now().saturating_duration_since(self.hover_started_at?);
155 Some(self.pending_delay.saturating_sub(elapsed))
156 }
157
158 fn hide(&mut self) {
159 if self.visible {
160 crate::animation::request_draw_tagged("tooltip.controller.hide");
161 }
162 self.visible = false;
163 self.tip_shown_at = None;
164 self.last_rect = None;
165 }
166
167 fn update_visibility(&mut self) -> bool {
169 if self.visible {
170 if let Some(shown) = self.tip_shown_at {
171 let autopop = tooltip_timings().autopop;
172 let elapsed = tooltip_now().saturating_duration_since(shown);
173 if elapsed >= autopop {
174 self.hide();
175 self.suppressed = true;
176 return false;
177 }
178 crate::animation::request_draw_after_tagged(
179 autopop - elapsed,
180 "tooltip.controller.autopop_rearm",
181 );
182 }
183 }
184
185 if self.should_show() {
186 if !self.visible {
187 self.visible = true;
188 self.tip_shown_at = Some(tooltip_now());
189 crate::animation::request_draw_tagged("tooltip.controller.show");
190 crate::animation::request_draw_after_tagged(
191 tooltip_timings().autopop,
192 "tooltip.controller.autopop_arm",
193 );
194 }
195 note_tooltip_visible();
196 return true;
197 }
198
199 if self.visible {
200 self.hide();
201 } else if let Some(remaining) = self.remaining_delay() {
202 if remaining.is_zero() {
203 crate::animation::request_draw_tagged("tooltip.controller.remaining_zero");
204 } else {
205 crate::animation::request_draw_after_tagged(
206 remaining,
207 "tooltip.controller.remaining",
208 );
209 }
210 }
211 false
212 }
213
214 fn submit(&mut self) {
217 let Some(font) = crate::font_settings::current_system_font() else {
223 self.last_rect = None;
224 return;
225 };
226 let text_w = measure_advance(&font, &self.text, TOOLTIP_FONT_SIZE);
227 let size = panel_size(text_w, 1);
228 let viewport = current_tooltip_viewport();
229 if viewport.width > 0.0 && viewport.height > 0.0 {
230 self.last_rect = Some(place_panel(self.anchor, size, viewport, true));
231 }
232 submit_tooltip(TooltipRequest {
233 font,
234 lines: vec![TooltipLine {
235 text: self.text.clone(),
236 kind: TooltipLineKind::Text,
237 }],
238 anchor: self.anchor,
239 at_pointer: true,
240 });
241 }
242}
243
244pub(crate) fn drive(target: Option<(Vec<usize>, String)>, anchor: Option<Point>) {
249 CONTROLLER.with(|c| {
250 let mut c = c.borrow_mut();
251 c.set_target(target, anchor);
252 if c.update_visibility() {
253 c.submit();
254 }
255 });
256}
257
258pub(crate) fn on_pointer_down() {
261 CONTROLLER.with(|c| {
262 let mut c = c.borrow_mut();
263 c.pointer_down = true;
264 c.suppressed = true;
265 c.hide();
266 });
267}
268
269pub(crate) fn on_pointer_up() {
272 CONTROLLER.with(|c| c.borrow_mut().pointer_down = false);
273}
274
275#[doc(hidden)]
279pub fn is_visible() -> bool {
280 CONTROLLER.with(|c| c.borrow().visible)
281}
282
283#[doc(hidden)]
285pub fn visible_text() -> Option<String> {
286 CONTROLLER.with(|c| {
287 let c = c.borrow();
288 c.visible.then(|| c.text.clone())
289 })
290}
291
292#[doc(hidden)]
294pub fn visible_rect() -> Option<Rect> {
295 CONTROLLER.with(|c| c.borrow().last_rect)
296}
297
298#[doc(hidden)]
301pub fn reset() {
302 CONTROLLER.with(|c| *c.borrow_mut() = Controller::default());
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use crate::draw_ctx::DrawCtx;
309 use crate::event::{Event, EventResult};
310 use crate::geometry::{Rect, Size};
311 use crate::layout_props::WidgetBase;
312 use crate::text::Font;
313 use crate::widget::{App, Widget};
314 use std::sync::Arc;
315
316 use super::super::timings::{
317 advance_tooltip_test_clock, reset_tooltip_test_state, set_tooltip_test_clock,
318 };
319
320 const FONT_BYTES: &[u8] = include_bytes!("../../../assets/fonts/NotoSans-Regular.ttf");
321
322 struct Guard;
325 impl Drop for Guard {
326 fn drop(&mut self) {
327 reset_tooltip_test_state();
328 reset();
329 crate::font_settings::set_system_font(None);
330 }
331 }
332 fn pin() -> Guard {
333 reset_tooltip_test_state();
334 reset();
335 set_tooltip_test_clock(Some(Instant::now()));
336 crate::font_settings::set_system_font(Some(Arc::new(
337 Font::from_bytes(FONT_BYTES.to_vec()).expect("bundled font"),
338 )));
339 Guard
340 }
341
342 struct Tipped {
345 bounds: Rect,
346 children: Vec<Box<dyn Widget>>,
347 base: WidgetBase,
348 }
349 impl Tipped {
350 fn new(tip: &str) -> Self {
351 Self {
352 bounds: Rect::default(),
353 children: Vec::new(),
354 base: WidgetBase::new().with_tooltip(tip),
355 }
356 }
357 }
358 impl Widget for Tipped {
359 fn bounds(&self) -> Rect {
360 self.bounds
361 }
362 fn set_bounds(&mut self, b: Rect) {
363 self.bounds = b;
364 }
365 fn children(&self) -> &[Box<dyn Widget>] {
366 &self.children
367 }
368 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
369 &mut self.children
370 }
371 fn layout(&mut self, available: Size) -> Size {
372 self.bounds = Rect::new(0.0, 0.0, available.width, available.height);
373 available
374 }
375 fn paint(&mut self, _: &mut dyn DrawCtx) {}
376 fn on_event(&mut self, _: &Event) -> EventResult {
377 EventResult::Ignored
378 }
379 fn widget_base(&self) -> Option<&WidgetBase> {
380 Some(&self.base)
381 }
382 fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
383 Some(&mut self.base)
384 }
385 }
386
387 fn hover(app: &mut App, wx: f64, wy: f64) {
389 app.on_mouse_move(wx, 600.0 - wy);
390 }
391
392 #[test]
394 fn tip_shows_after_initial_delay_when_hovered() {
395 let _g = pin();
396 let timings = tooltip_timings();
397 let mut app = App::new(Box::new(Tipped::new("Bold")));
398 app.layout(Size::new(800.0, 600.0));
399
400 hover(&mut app, 400.0, 300.0);
401 app.update_tooltips_for_test();
402 assert!(!is_visible(), "no tip before the initial delay elapses");
403
404 advance_tooltip_test_clock(timings.initial_delay);
405 app.update_tooltips_for_test();
406 assert!(is_visible(), "tip appears once the initial delay elapses");
407 assert_eq!(visible_text().as_deref(), Some("Bold"));
408 }
409
410 #[test]
413 fn moving_between_tipped_widgets_reshows_single_tip() {
414 let _g = pin();
415 let timings = tooltip_timings();
416 let anchor = Some(Point::new(100.0, 100.0));
417
418 drive(Some((vec![0], "A".into())), anchor);
420 advance_tooltip_test_clock(timings.initial_delay);
421 drive(Some((vec![0], "A".into())), anchor);
422 assert_eq!(visible_text().as_deref(), Some("A"));
423
424 drive(None, anchor);
426 assert!(!is_visible());
427 advance_tooltip_test_clock(timings.reshow_delay);
428 drive(Some((vec![1], "B".into())), anchor);
429 assert!(!is_visible(), "B not shown until the (short) reshow delay elapses");
430
431 advance_tooltip_test_clock(timings.reshow_delay);
434 drive(Some((vec![1], "B".into())), anchor);
435 assert!(is_visible());
436 assert_eq!(visible_text().as_deref(), Some("B"), "exactly one tip: B's");
437 }
438
439 #[test]
444 fn visible_tip_follows_live_system_font_swap() {
445 let _g = pin();
446 let timings = tooltip_timings();
447 let anchor = Some(Point::new(100.0, 100.0));
448
449 let font_a = Arc::new(Font::from_bytes(FONT_BYTES.to_vec()).expect("font a"));
451 let font_b = Arc::new(Font::from_bytes(FONT_BYTES.to_vec()).expect("font b"));
452 assert!(!Arc::ptr_eq(&font_a, &font_b));
453
454 crate::font_settings::set_system_font(Some(Arc::clone(&font_a)));
456 drive(Some((vec![0], "Tip".into())), anchor);
457 advance_tooltip_test_clock(timings.initial_delay);
458 drive(Some((vec![0], "Tip".into())), anchor);
459 assert!(is_visible(), "tip visible after the initial delay");
460 let submitted_a =
461 super::super::render::last_submitted_font().expect("tip submitted a request");
462 assert!(
463 Arc::ptr_eq(&submitted_a, &font_a),
464 "tip first paints with the installed font A"
465 );
466
467 crate::font_settings::set_system_font(Some(Arc::clone(&font_b)));
470 drive(Some((vec![0], "Tip".into())), anchor);
471 let submitted_b =
472 super::super::render::last_submitted_font().expect("tip re-submitted after swap");
473 assert!(
474 Arc::ptr_eq(&submitted_b, &font_b),
475 "still-visible tip re-renders with the NEW system font B"
476 );
477 assert!(
478 !Arc::ptr_eq(&submitted_b, &font_a),
479 "the swapped tip must not keep painting with the old font A"
480 );
481 }
482
483 #[test]
486 fn tip_at_viewport_edge_stays_inside() {
487 let _g = pin();
488 let timings = tooltip_timings();
489 let mut app = App::new(Box::new(Tipped::new("Increase indent")));
490 let viewport = Size::new(800.0, 600.0);
491 app.layout(viewport);
492
493 hover(&mut app, 798.0, 300.0);
497 app.update_tooltips_for_test();
498 advance_tooltip_test_clock(timings.initial_delay);
499 app.update_tooltips_for_test();
500
501 assert!(is_visible());
502 let r = visible_rect().expect("visible tip has a placed rect");
503 assert!(r.x >= 0.0, "left edge inside viewport: {r:?}");
504 assert!(
505 r.x + r.width <= viewport.width,
506 "right edge inside viewport: {r:?}"
507 );
508 assert!(r.y >= 0.0 && r.y + r.height <= viewport.height, "vertically inside: {r:?}");
509 }
510
511 #[test]
525 fn armed_hover_delay_surfaces_through_wants_draw() {
526 reset_tooltip_test_state();
527 reset();
528 set_tooltip_test_clock(None); super::super::timings::set_tooltip_timings(
530 super::super::timings::TooltipTimings::from_initial_delay(Duration::from_millis(20)),
531 );
532 let _g = Guard;
533
534 let mut app = App::new(Box::new(Tipped::new("Bold")));
535 app.layout(Size::new(800.0, 600.0));
536
537 hover(&mut app, 400.0, 300.0);
538 crate::animation::clear_draw_request();
541 app.update_tooltips_for_test();
542
543 assert!(
544 crate::animation::peek_next_draw_deadline().is_some(),
545 "the tooltip pass armed a scheduled draw for the hover delay"
546 );
547 assert!(
548 !crate::animation::wants_draw(),
549 "the hover delay has not elapsed yet, so no immediate draw"
550 );
551
552 std::thread::sleep(Duration::from_millis(40));
553
554 assert!(
555 crate::animation::wants_draw(),
556 "once the hover delay is due, wants_draw() surfaces it so a \
557 reactive host draws the frame that shows the tip"
558 );
559 }
560}