1use std::cell::RefCell;
19use std::rc::Rc;
20use std::sync::Arc;
21use std::time::Duration;
22use web_time::Instant;
23
24use crate::color::Color;
25use crate::draw_ctx::DrawCtx;
26use crate::event::{Event, EventResult};
27use crate::geometry::{Point, Rect, Size};
28use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
29use crate::text::Font;
30use crate::widget::{current_mouse_world, Widget};
31
32const TOOLTIP_INITIAL_DELAY: Duration = Duration::from_millis(500);
38const TOOLTIP_FONT_SIZE: f64 = 12.0;
39const TOOLTIP_PAD_X: f64 = 8.0;
40const TOOLTIP_PAD_Y: f64 = 6.0;
41const TOOLTIP_GAP: f64 = 4.0;
42const POINTER_TOOLTIP_EXTRA_DROP: f64 = 10.0;
45const SCREEN_MARGIN: f64 = 4.0;
46
47#[derive(Clone)]
48enum TooltipLineKind {
49 Text,
50 Code,
51 Link,
52}
53
54#[derive(Clone)]
55struct TooltipLine {
56 text: String,
57 kind: TooltipLineKind,
58}
59
60struct TooltipRequest {
61 font: Arc<Font>,
62 lines: Vec<TooltipLine>,
63 anchor: Point,
64 at_pointer: bool,
65}
66
67thread_local! {
68 static TOOLTIP_QUEUE: RefCell<Vec<TooltipRequest>> = const { RefCell::new(Vec::new()) };
69}
70
71pub struct Tooltip {
73 bounds: Rect,
74 children: Vec<Box<dyn Widget>>,
76 base: WidgetBase,
77
78 hover_started_at: Option<Instant>,
82 hovered: bool,
84 tooltip_visible: bool,
88 cursor: Point,
90
91 font: Arc<Font>,
92 lines: Vec<TooltipLine>,
93 disabled_lines: Vec<TooltipLine>,
94 disabled_when: Option<Rc<dyn Fn() -> bool>>,
95 at_pointer: bool,
96}
97
98impl Tooltip {
99 pub fn new(child: Box<dyn Widget>, text: impl Into<String>, font: Arc<Font>) -> Self {
101 Self {
102 bounds: Rect::default(),
103 children: vec![child],
104 base: WidgetBase::new(),
105 hover_started_at: None,
106 hovered: false,
107 tooltip_visible: false,
108 cursor: Point::ORIGIN,
109 font,
110 lines: text_to_lines(text),
111 disabled_lines: Vec::new(),
112 disabled_when: None,
113 at_pointer: true,
114 }
115 }
116
117 pub fn with_text(mut self, text: impl Into<String>) -> Self {
120 self.lines.extend(text_to_lines(text));
121 self
122 }
123
124 pub fn with_code_line(mut self, text: impl Into<String>) -> Self {
126 self.lines.push(TooltipLine {
127 text: text.into(),
128 kind: TooltipLineKind::Code,
129 });
130 self
131 }
132
133 pub fn with_link_line(mut self, text: impl Into<String>) -> Self {
137 self.lines.push(TooltipLine {
138 text: text.into(),
139 kind: TooltipLineKind::Link,
140 });
141 self
142 }
143
144 pub fn at_pointer(mut self) -> Self {
147 self.at_pointer = true;
148 self
149 }
150
151 pub fn at_widget(mut self) -> Self {
154 self.at_pointer = false;
155 self
156 }
157
158 pub fn with_disabled_text(
160 mut self,
161 text: impl Into<String>,
162 disabled_when: impl Fn() -> bool + 'static,
163 ) -> Self {
164 self.disabled_lines = text_to_lines(text);
165 self.disabled_when = Some(Rc::new(disabled_when));
166 self
167 }
168
169 pub fn with_margin(mut self, m: Insets) -> Self {
170 self.base.margin = m;
171 self
172 }
173 pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
174 self.base.h_anchor = h;
175 self
176 }
177 pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
178 self.base.v_anchor = v;
179 self
180 }
181
182 fn show_tip(&self) -> bool {
183 self.hovered
184 && self
185 .hover_started_at
186 .map(|started| started.elapsed() >= TOOLTIP_INITIAL_DELAY)
187 .unwrap_or(false)
188 }
189
190 fn remaining_delay(&self) -> Option<Duration> {
191 if !self.hovered {
192 return None;
193 }
194 let elapsed = self.hover_started_at?.elapsed();
195 Some(TOOLTIP_INITIAL_DELAY.saturating_sub(elapsed))
196 }
197
198 fn active_lines(&self) -> Vec<TooltipLine> {
199 if self.disabled_when.as_ref().map(|f| f()).unwrap_or(false)
200 && !self.disabled_lines.is_empty()
201 {
202 self.disabled_lines.clone()
203 } else {
204 self.lines.clone()
205 }
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::event::MouseButton;
213 use crate::text::Font;
214 use std::sync::atomic::{AtomicUsize, Ordering};
215
216 const FONT_BYTES: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Regular.ttf");
217
218 struct ClickChild {
219 bounds: Rect,
220 children: Vec<Box<dyn Widget>>,
221 clicks: Arc<AtomicUsize>,
222 }
223
224 impl ClickChild {
225 fn new(clicks: Arc<AtomicUsize>) -> Self {
226 Self {
227 bounds: Rect::default(),
228 children: Vec::new(),
229 clicks,
230 }
231 }
232 }
233
234 impl Widget for ClickChild {
235 fn bounds(&self) -> Rect {
236 self.bounds
237 }
238 fn set_bounds(&mut self, bounds: Rect) {
239 self.bounds = bounds;
240 }
241 fn children(&self) -> &[Box<dyn Widget>] {
242 &self.children
243 }
244 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
245 &mut self.children
246 }
247 fn type_name(&self) -> &'static str {
248 "ClickChild"
249 }
250 fn layout(&mut self, available: Size) -> Size {
251 self.bounds = Rect::new(0.0, 0.0, available.width, available.height);
252 available
253 }
254 fn paint(&mut self, _ctx: &mut dyn DrawCtx) {}
255 fn on_event(&mut self, event: &Event) -> EventResult {
256 if let Event::MouseUp {
257 button: MouseButton::Left,
258 ..
259 } = event
260 {
261 self.clicks.fetch_add(1, Ordering::SeqCst);
262 EventResult::Consumed
263 } else {
264 EventResult::Ignored
265 }
266 }
267 }
268
269 #[test]
270 fn tooltip_forwards_clicks_to_wrapped_child() {
271 let clicks = Arc::new(AtomicUsize::new(0));
272 let font = Arc::new(Font::from_bytes(FONT_BYTES.to_vec()).expect("bundled font"));
273 let mut tooltip = Tooltip::new(Box::new(ClickChild::new(clicks.clone())), "tip", font);
274 tooltip.layout(Size::new(20.0, 20.0));
275 let event = Event::MouseUp {
276 pos: Point::new(10.0, 10.0),
277 button: MouseButton::Left,
278 modifiers: Default::default(),
279 };
280 assert_eq!(tooltip.on_event(&event), EventResult::Consumed);
281 assert_eq!(clicks.load(Ordering::SeqCst), 1);
282 }
283
284 #[test]
285 fn tooltip_defaults_to_pointer_anchored() {
286 let clicks = Arc::new(AtomicUsize::new(0));
287 let font = Arc::new(Font::from_bytes(FONT_BYTES.to_vec()).expect("bundled font"));
288 let tooltip = Tooltip::new(Box::new(ClickChild::new(clicks)), "tip", font);
289 assert!(tooltip.at_pointer);
290 }
291
292 #[test]
293 fn tooltip_can_opt_into_widget_anchor() {
294 let clicks = Arc::new(AtomicUsize::new(0));
295 let font = Arc::new(Font::from_bytes(FONT_BYTES.to_vec()).expect("bundled font"));
296 let tooltip = Tooltip::new(Box::new(ClickChild::new(clicks)), "tip", font).at_widget();
297 assert!(!tooltip.at_pointer);
298 }
299}
300
301impl Widget for Tooltip {
302 fn type_name(&self) -> &'static str {
303 "Tooltip"
304 }
305 fn bounds(&self) -> Rect {
306 self.bounds
307 }
308 fn set_bounds(&mut self, b: Rect) {
309 self.bounds = b;
310 }
311 fn children(&self) -> &[Box<dyn Widget>] {
312 &self.children
313 }
314 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
315 &mut self.children
316 }
317
318 fn margin(&self) -> Insets {
319 self.base.margin
320 }
321 fn widget_base(&self) -> Option<&WidgetBase> {
322 Some(&self.base)
323 }
324 fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
325 Some(&mut self.base)
326 }
327 fn h_anchor(&self) -> HAnchor {
328 self.base.h_anchor
329 }
330 fn v_anchor(&self) -> VAnchor {
331 self.base.v_anchor
332 }
333
334 fn is_focusable(&self) -> bool {
335 self.children
336 .first()
337 .map(|c| c.is_focusable())
338 .unwrap_or(false)
339 }
340
341 fn layout(&mut self, available: Size) -> Size {
342 let s = if let Some(child) = self.children.first_mut() {
343 let cs = child.layout(available);
344 child.set_bounds(Rect::new(0.0, 0.0, cs.width, cs.height));
345 cs
346 } else {
347 available
348 };
349 self.bounds = Rect::new(0.0, 0.0, s.width, s.height);
350 s
351 }
352
353 fn paint(&mut self, _: &mut dyn DrawCtx) {}
354
355 fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
356 let should_show = self.show_tip();
357
358 if self.hovered && !should_show {
359 if let Some(remaining) = self.remaining_delay() {
360 if remaining.is_zero() {
361 crate::animation::request_draw();
362 } else {
363 crate::animation::request_draw_after(remaining);
364 }
365 }
366 }
367
368 if should_show != self.tooltip_visible {
369 self.tooltip_visible = should_show;
370 crate::animation::request_draw();
376 }
377
378 if !should_show {
379 return;
380 }
381
382 let anchor = if self.at_pointer {
383 current_mouse_world().unwrap_or(self.cursor)
384 } else {
385 let mut x = self.bounds.width * 0.5;
386 let mut y = 0.0;
392 ctx.root_transform().transform(&mut x, &mut y);
393 Point::new(x, y)
394 };
395 submit_tooltip(TooltipRequest {
396 font: Arc::clone(&self.font),
397 lines: self.active_lines(),
398 anchor,
399 at_pointer: self.at_pointer,
400 });
401 }
402
403 fn on_event(&mut self, event: &Event) -> EventResult {
404 match event {
405 Event::MouseMove { pos } => {
406 let was = self.hovered;
407 self.hovered = self.hit_test(*pos);
408 self.cursor = *pos;
409 if self.hovered && !was {
410 self.hover_started_at = Some(Instant::now());
411 crate::animation::request_draw_after(TOOLTIP_INITIAL_DELAY);
412 } else if !self.hovered {
413 self.hover_started_at = None;
414 if self.tooltip_visible {
415 self.tooltip_visible = false;
416 crate::animation::request_draw();
417 }
418 }
419 if self.hovered != was {
420 crate::animation::request_draw();
421 }
422 self.children
423 .first_mut()
424 .map(|child| child.on_event(event))
425 .unwrap_or(EventResult::Ignored)
426 }
427 Event::MouseWheel { .. } => {
428 self.hovered = false;
429 self.hover_started_at = None;
430 if self.tooltip_visible {
431 self.tooltip_visible = false;
432 crate::animation::request_draw();
433 }
434 self.children
435 .first_mut()
436 .map(|child| child.on_event(event))
437 .unwrap_or(EventResult::Ignored)
438 }
439 _ => self
440 .children
441 .first_mut()
442 .map(|child| child.on_event(event))
443 .unwrap_or(EventResult::Ignored),
444 }
445 }
446
447 fn hit_test(&self, local_pos: Point) -> bool {
448 local_pos.x >= 0.0
449 && local_pos.x <= self.bounds.width
450 && local_pos.y >= 0.0
451 && local_pos.y <= self.bounds.height
452 }
453}
454
455fn text_to_lines(text: impl Into<String>) -> Vec<TooltipLine> {
456 text.into()
457 .lines()
458 .map(|line| TooltipLine {
459 text: line.to_owned(),
460 kind: TooltipLineKind::Text,
461 })
462 .collect()
463}
464
465fn submit_tooltip(request: TooltipRequest) {
466 TOOLTIP_QUEUE.with(|q| q.borrow_mut().push(request));
467}
468
469pub(crate) fn begin_tooltip_frame() {
470 TOOLTIP_QUEUE.with(|q| q.borrow_mut().clear());
471}
472
473pub(crate) fn paint_global_tooltips(ctx: &mut dyn DrawCtx, viewport: Size) {
474 let requests = TOOLTIP_QUEUE.with(|q| q.borrow_mut().drain(..).collect::<Vec<_>>());
475 for request in requests {
476 paint_request(ctx, viewport, request);
477 }
478}
479
480fn paint_request(ctx: &mut dyn DrawCtx, viewport: Size, request: TooltipRequest) {
481 if request.lines.is_empty() {
482 return;
483 }
484
485 let v = ctx.visuals();
486 ctx.set_font(Arc::clone(&request.font));
487 ctx.set_font_size(TOOLTIP_FONT_SIZE);
488
489 let line_h = TOOLTIP_FONT_SIZE * 1.45;
490 let mut max_w = 0.0_f64;
491 for line in &request.lines {
492 if let Some(m) = ctx.measure_text(&line.text) {
493 max_w = max_w.max(m.width);
494 }
495 }
496
497 let panel_w = (max_w + TOOLTIP_PAD_X * 2.0).max(64.0);
498 let panel_h = request.lines.len() as f64 * line_h + TOOLTIP_PAD_Y * 2.0;
499 let mut panel_x = if request.at_pointer {
500 request.anchor.x
501 } else {
502 request.anchor.x - panel_w * 0.5
503 };
504 let mut panel_y = request.anchor.y - panel_h - TOOLTIP_GAP;
505 if request.at_pointer {
506 panel_y -= POINTER_TOOLTIP_EXTRA_DROP;
507 }
508
509 if panel_x + panel_w > viewport.width - SCREEN_MARGIN {
510 panel_x = viewport.width - panel_w - SCREEN_MARGIN;
511 }
512 if panel_y < SCREEN_MARGIN {
513 panel_y = request.anchor.y + TOOLTIP_GAP;
516 }
517 panel_x = panel_x.clamp(
518 SCREEN_MARGIN,
519 (viewport.width - panel_w - SCREEN_MARGIN).max(SCREEN_MARGIN),
520 );
521 panel_y = panel_y.clamp(
522 SCREEN_MARGIN,
523 (viewport.height - panel_h - SCREEN_MARGIN).max(SCREEN_MARGIN),
524 );
525
526 ctx.set_fill_color(Color::rgba(0.0, 0.0, 0.0, 0.20));
527 ctx.begin_path();
528 ctx.rounded_rect(panel_x + 1.0, panel_y - 1.0, panel_w, panel_h, 5.0);
529 ctx.fill();
530
531 ctx.set_fill_color(v.window_fill);
532 ctx.begin_path();
533 ctx.rounded_rect(panel_x, panel_y, panel_w, panel_h, 5.0);
534 ctx.fill();
535
536 ctx.set_stroke_color(v.widget_stroke);
537 ctx.set_line_width(1.0);
538 ctx.begin_path();
539 ctx.rounded_rect(panel_x, panel_y, panel_w, panel_h, 5.0);
540 ctx.stroke();
541
542 for (i, line) in request.lines.iter().enumerate() {
543 let y = panel_y + panel_h - TOOLTIP_PAD_Y - (i as f64 + 1.0) * line_h + 2.0;
544 match line.kind {
545 TooltipLineKind::Text => {
546 ctx.set_fill_color(v.text_color);
547 ctx.fill_text(&line.text, panel_x + TOOLTIP_PAD_X, y);
548 }
549 TooltipLineKind::Code => {
550 if let Some(m) = ctx.measure_text(&line.text) {
551 ctx.set_fill_color(v.track_bg);
552 ctx.begin_path();
553 ctx.rounded_rect(
554 panel_x + TOOLTIP_PAD_X - 3.0,
555 y - 3.0,
556 m.width + 6.0,
557 line_h,
558 3.0,
559 );
560 ctx.fill();
561 }
562 ctx.set_fill_color(v.text_color);
563 ctx.fill_text(&line.text, panel_x + TOOLTIP_PAD_X, y);
564 }
565 TooltipLineKind::Link => {
566 ctx.set_fill_color(v.text_link);
567 ctx.fill_text(&line.text, panel_x + TOOLTIP_PAD_X, y);
568 if let Some(m) = ctx.measure_text(&line.text) {
569 ctx.set_stroke_color(v.text_link);
570 ctx.set_line_width(1.0);
571 ctx.begin_path();
572 ctx.move_to(panel_x + TOOLTIP_PAD_X, y - 2.0);
573 ctx.line_to(panel_x + TOOLTIP_PAD_X + m.width, y - 2.0);
574 ctx.stroke();
575 }
576 }
577 }
578 }
579}