1use std::{cell::Cell, rc::Rc, time::Duration};
2
3use rgpui::{
4 Action, AnyElement, AnyView, App, AppContext, Bounds, Context, Display, Element, ElementId,
5 GlobalElementId, Half, InspectorElementId, IntoElement, LayoutId, MouseButton, ParentElement,
6 Pixels, Point, Position, Render, SharedString, Size, StatefulInteractiveElement, Style,
7 StyleRefinement, Styled, Task, Window, deferred, div, point, prelude::FluentBuilder, px,
8};
9
10use crate::{
11 ActiveTheme, StyledExt,
12 animation::{Transition, ease_in_out_cubic, ease_out_cubic},
13 h_flex,
14 kbd::Kbd,
15 root::Root,
16 text::Text,
17};
18
19pub(crate) fn init(_cx: &mut App) {
20 }
22
23enum TooltipContext {
26 Text(Text),
27 Element(Box<dyn Fn(&mut Window, &mut App) -> AnyElement>),
28}
29
30pub struct Tooltip {
33 style: StyleRefinement,
34 content: TooltipContext,
35 key_binding: Option<Kbd>,
36 action: Option<(Box<dyn Action>, Option<SharedString>)>,
37}
38
39impl Tooltip {
40 pub fn new(text: impl Into<Text>) -> Self {
42 Self {
43 style: StyleRefinement::default(),
44 content: TooltipContext::Text(text.into()),
45 key_binding: None,
46 action: None,
47 }
48 }
49
50 pub fn element<E, F>(builder: F) -> Self
52 where
53 E: IntoElement,
54 F: Fn(&mut Window, &mut App) -> E + 'static,
55 {
56 Self {
57 style: StyleRefinement::default(),
58 key_binding: None,
59 action: None,
60 content: TooltipContext::Element(Box::new(move |window, cx| {
61 builder(window, cx).into_any_element()
62 })),
63 }
64 }
65
66 pub fn action(mut self, action: &dyn Action, context: Option<&str>) -> Self {
68 self.action = Some((action.boxed_clone(), context.map(SharedString::new)));
69 self
70 }
71
72 pub fn key_binding(mut self, key_binding: Option<Kbd>) -> Self {
74 self.key_binding = key_binding;
75 self
76 }
77
78 pub fn build(self, _: &mut Window, cx: &mut App) -> AnyView {
80 cx.new(|_| self).into()
81 }
82}
83
84impl FluentBuilder for Tooltip {}
85impl Styled for Tooltip {
86 fn style(&mut self) -> &mut StyleRefinement {
87 &mut self.style
88 }
89}
90impl Render for Tooltip {
91 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
92 let key_binding = if let Some(key_binding) = &self.key_binding {
93 Some(key_binding.clone())
94 } else {
95 if let Some((action, context)) = &self.action {
96 Kbd::binding_for_action(
97 action.as_ref(),
98 context.as_ref().map(|s| s.as_ref()),
99 window,
100 )
101 } else {
102 None
103 }
104 };
105
106 div().child(
107 h_flex()
109 .font_family(cx.theme().font_family.clone())
110 .m_3()
111 .bg(cx.theme().popover)
112 .text_color(cx.theme().popover_foreground)
113 .bg(cx.theme().popover)
114 .border_1()
115 .border_color(cx.theme().border)
116 .shadow_md()
117 .rounded(px(6.))
118 .justify_between()
119 .py_0p5()
120 .px_2()
121 .text_sm()
122 .gap_3()
123 .refine_style(&self.style)
124 .map(|this| {
125 this.child(div().map(|this| match self.content {
126 TooltipContext::Text(ref text) => this.child(text.clone()),
127 TooltipContext::Element(ref builder) => this.child(builder(window, cx)),
128 }))
129 })
130 .when_some(key_binding, |this, kbd| {
131 this.child(
132 div()
133 .text_xs()
134 .flex_shrink_0()
135 .text_color(cx.theme().muted_foreground)
136 .child(kbd.appearance(false)),
137 )
138 }),
139 )
140 }
141}
142
143const GRACE_PERIOD: Duration = Duration::from_millis(300);
147const SHOW_DELAY: Duration = Duration::from_millis(500);
149const ENTER_DURATION: Duration = Duration::from_millis(150);
151const SLIDE_DURATION: Duration = Duration::from_millis(200);
153const TOOLTIP_WINDOW_MARGIN: Pixels = px(4.);
154
155#[derive(Clone, Copy, Debug, PartialEq)]
156enum TooltipPlacement {
157 Above,
158 Below,
159}
160
161#[derive(Clone, Copy, Debug, PartialEq)]
162struct TooltipOverlayPosition {
163 bounds: Bounds<Pixels>,
164 placement: TooltipPlacement,
165}
166
167fn tooltip_overlay_position(
168 trigger_bounds: Bounds<Pixels>,
169 tooltip_size: Size<Pixels>,
170 viewport_size: Size<Pixels>,
171 margin: Pixels,
172) -> TooltipOverlayPosition {
173 let centered_x = trigger_bounds.center().x - tooltip_size.width.half();
174 let above_bounds = Bounds::new(
175 point(centered_x, trigger_bounds.top() - tooltip_size.height),
176 tooltip_size,
177 );
178 let below_bounds = Bounds::new(point(centered_x, trigger_bounds.bottom()), tooltip_size);
179
180 let bottom_limit = (viewport_size.height - margin).max(margin);
181 let available_above = (trigger_bounds.top() - margin).max(px(0.));
182 let available_below = (bottom_limit - trigger_bounds.bottom()).max(px(0.));
183
184 let (bounds, placement) = if above_bounds.top() >= margin {
185 (above_bounds, TooltipPlacement::Above)
186 } else if below_bounds.bottom() <= bottom_limit {
187 (below_bounds, TooltipPlacement::Below)
188 } else if available_below >= available_above {
189 (below_bounds, TooltipPlacement::Below)
190 } else {
191 (above_bounds, TooltipPlacement::Above)
192 };
193
194 TooltipOverlayPosition {
195 bounds: clamp_tooltip_bounds(bounds, viewport_size, margin),
196 placement,
197 }
198}
199
200fn clamp_tooltip_bounds(
201 mut bounds: Bounds<Pixels>,
202 viewport_size: Size<Pixels>,
203 margin: Pixels,
204) -> Bounds<Pixels> {
205 let right_limit = (viewport_size.width - margin).max(margin);
206 let bottom_limit = (viewport_size.height - margin).max(margin);
207
208 if bounds.right() > right_limit {
209 bounds.origin.x -= bounds.right() - right_limit;
210 }
211 if bounds.left() < margin {
212 bounds.origin.x = margin;
213 }
214
215 if bounds.bottom() > bottom_limit {
216 bounds.origin.y -= bounds.bottom() - bottom_limit;
217 }
218 if bounds.top() < margin {
219 bounds.origin.y = margin;
220 }
221
222 bounds
223}
224
225struct TooltipOverlayPositioner {
226 trigger_bounds: Bounds<Pixels>,
227 children: Vec<AnyElement>,
228}
229
230struct TooltipOverlayPositionerState {
231 child_layout_ids: Vec<LayoutId>,
232}
233
234fn tooltip_overlay_positioner(trigger_bounds: Bounds<Pixels>) -> TooltipOverlayPositioner {
235 TooltipOverlayPositioner {
236 trigger_bounds,
237 children: Vec::new(),
238 }
239}
240
241impl ParentElement for TooltipOverlayPositioner {
242 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
243 self.children.extend(elements);
244 }
245}
246
247impl Element for TooltipOverlayPositioner {
248 type RequestLayoutState = TooltipOverlayPositionerState;
249 type PrepaintState = ();
250
251 fn id(&self) -> Option<ElementId> {
252 None
253 }
254
255 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
256 None
257 }
258
259 fn request_layout(
260 &mut self,
261 _id: Option<&GlobalElementId>,
262 _inspector_id: Option<&InspectorElementId>,
263 window: &mut Window,
264 cx: &mut App,
265 ) -> (LayoutId, Self::RequestLayoutState) {
266 let child_layout_ids = self
267 .children
268 .iter_mut()
269 .map(|child| child.request_layout(window, cx))
270 .collect::<Vec<_>>();
271
272 let layout_id = window.request_layout(
273 Style {
274 position: Position::Absolute,
275 display: Display::Flex,
276 ..Style::default()
277 },
278 child_layout_ids.iter().copied(),
279 cx,
280 );
281
282 (
283 layout_id,
284 TooltipOverlayPositionerState { child_layout_ids },
285 )
286 }
287
288 fn prepaint(
289 &mut self,
290 _id: Option<&GlobalElementId>,
291 _inspector_id: Option<&InspectorElementId>,
292 bounds: Bounds<Pixels>,
293 request_layout: &mut Self::RequestLayoutState,
294 window: &mut Window,
295 cx: &mut App,
296 ) {
297 if request_layout.child_layout_ids.is_empty() {
298 return;
299 }
300
301 let mut child_min: Point<Pixels> = point(Pixels::MAX, Pixels::MAX);
302 let mut child_max = Point::default();
303 for child_layout_id in &request_layout.child_layout_ids {
304 let child_bounds = window.layout_bounds(*child_layout_id);
305 child_min = child_min.min(&child_bounds.origin);
306 child_max = child_max.max(&child_bounds.bottom_right());
307 }
308
309 let tooltip_size: Size<Pixels> = (child_max - child_min).into();
310 let client_inset = window.client_inset().unwrap_or(px(0.));
311 let tooltip_position = tooltip_overlay_position(
312 self.trigger_bounds,
313 tooltip_size,
314 window.viewport_size(),
315 TOOLTIP_WINDOW_MARGIN + client_inset,
316 );
317
318 let offset = tooltip_position.bounds.origin - bounds.origin;
319 let offset = point(offset.x.round(), offset.y.round());
320
321 window.with_element_offset(offset, |window| {
322 for child in &mut self.children {
323 child.prepaint(window, cx);
324 }
325 });
326 }
327
328 fn paint(
329 &mut self,
330 _id: Option<&GlobalElementId>,
331 _inspector_id: Option<&InspectorElementId>,
332 _bounds: Bounds<Pixels>,
333 _request_layout: &mut Self::RequestLayoutState,
334 _prepaint: &mut Self::PrepaintState,
335 window: &mut Window,
336 cx: &mut App,
337 ) {
338 for child in &mut self.children {
339 child.paint(window, cx);
340 }
341 }
342}
343
344impl IntoElement for TooltipOverlayPositioner {
345 type Element = Self;
346
347 fn into_element(self) -> Self::Element {
348 self
349 }
350}
351
352#[derive(Clone)]
354pub(crate) struct TooltipContent {
355 pub build: Rc<dyn Fn(&mut Window, &mut App) -> AnyView>,
356 pub trigger_bounds: Bounds<Pixels>,
357}
358
359pub struct TooltipOverlay {
364 content: Option<TooltipContent>,
365 prev_trigger_bounds: Option<Bounds<Pixels>>,
366 epoch: usize,
367 had_recent_tooltip: bool,
368 animation_epoch: usize,
369 is_switching: bool,
370
371 _show_task: Option<Task<()>>,
372 _hide_task: Option<Task<()>>,
373}
374
375impl TooltipOverlay {
376 pub fn new() -> Self {
377 Self {
378 content: None,
379 prev_trigger_bounds: None,
380 epoch: 0,
381 had_recent_tooltip: false,
382 animation_epoch: 0,
383 is_switching: false,
384 _show_task: None,
385 _hide_task: None,
386 }
387 }
388
389 fn next_epoch(&mut self) -> usize {
390 self.epoch += 1;
391 self.epoch
392 }
393
394 pub(crate) fn request_show(
397 &mut self,
398 content: TooltipContent,
399 window: &mut Window,
400 cx: &mut Context<Self>,
401 ) {
402 self._hide_task = None;
404
405 let was_visible = self.content.is_some();
406 let in_grace = self.had_recent_tooltip;
407
408 if was_visible || in_grace {
409 self.prev_trigger_bounds = self.content.as_ref().map(|c| c.trigger_bounds);
411 self.content = Some(content);
412 self._show_task = None;
413 self.is_switching = was_visible;
414 self.animation_epoch += 1;
415 cx.notify();
416 } else {
417 let epoch = self.next_epoch();
419 let content = content.clone();
420 self._show_task = Some(cx.spawn_in(window, async move |this, cx| {
421 cx.background_executor().timer(SHOW_DELAY).await;
422 let _ = this.update_in(cx, |this, _, cx| {
423 if this.epoch != epoch {
424 return;
425 }
426
427 this.content = Some(content);
428 this.prev_trigger_bounds = None;
429 this.is_switching = false;
430 this.animation_epoch += 1;
431 cx.notify();
432 });
433 }));
434 }
435 }
436
437 pub(crate) fn request_hide(&mut self, window: &mut Window, cx: &mut Context<Self>) {
440 self._show_task = None;
442
443 if self.content.is_none() {
444 return;
445 }
446
447 let epoch = self.next_epoch();
448 self.had_recent_tooltip = true;
449
450 self._hide_task = Some(cx.spawn_in(window, async move |this, cx| {
451 cx.background_executor().timer(GRACE_PERIOD).await;
452 let _ = this.update_in(cx, |this, _, cx| {
453 if this.epoch != epoch {
454 return;
455 }
456 this.content = None;
457 this.prev_trigger_bounds = None;
458 this.had_recent_tooltip = false;
459 cx.notify();
460 });
461 }));
462 }
463
464 pub(crate) fn hide(&mut self, cx: &mut Context<Self>) {
465 if self.clear_state() {
466 cx.notify();
467 }
468 }
469
470 fn clear_state(&mut self) -> bool {
471 let changed = self.content.is_some()
472 || self.prev_trigger_bounds.is_some()
473 || self.had_recent_tooltip
474 || self.is_switching
475 || self._show_task.is_some()
476 || self._hide_task.is_some();
477
478 self.content = None;
479 self.prev_trigger_bounds = None;
480 self.had_recent_tooltip = false;
481 self.is_switching = false;
482 self._show_task = None;
483 self._hide_task = None;
484
485 changed
486 }
487}
488
489impl Render for TooltipOverlay {
490 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
491 let Some(content) = self.content.as_ref() else {
492 return div().into_any_element();
493 };
494
495 let content_view = (content.build)(window, cx);
496 let trigger_bounds = content.trigger_bounds;
497 let animation_epoch = self.animation_epoch;
498 let is_switching = self.is_switching;
499 let prev_trigger_bounds = self.prev_trigger_bounds;
500
501 deferred(
502 tooltip_overlay_positioner(trigger_bounds).child(div().child(content_view).map(|el| {
503 if is_switching {
504 let Some(prev_bounds) = prev_trigger_bounds else {
505 return el.into_any_element();
506 };
507
508 let is_same_y =
509 (trigger_bounds.origin.y - prev_bounds.origin.y).abs() < px(10.);
510 if !is_same_y {
511 return el.into_any_element();
515 }
516
517 let dx = trigger_bounds.center().x - prev_bounds.center().x;
518
519 Transition::new(SLIDE_DURATION)
520 .ease(ease_in_out_cubic)
521 .slide_x(-dx, px(0.))
522 .apply(
523 el,
524 ElementId::NamedInteger("tooltip-slide".into(), animation_epoch as u64),
525 )
526 .into_any_element()
527 } else {
528 Transition::new(ENTER_DURATION)
530 .ease(ease_out_cubic)
531 .slide_y(px(4.), px(0.))
532 .fade(0.0, 1.0)
533 .apply(
534 el,
535 ElementId::NamedInteger("tooltip-enter".into(), animation_epoch as u64),
536 )
537 .into_any_element()
538 }
539 })),
540 )
541 .with_priority(2)
542 .into_any_element()
543 }
544}
545
546#[derive(Default)]
553pub(crate) struct ComponentTooltip {
554 pub text: Option<(
555 SharedString,
556 Option<(Rc<Box<dyn Action>>, Option<SharedString>)>,
557 )>,
558 pub builder: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView>>,
559}
560
561impl ComponentTooltip {
562 pub fn apply<E: ManagedTooltipExt>(self, el: E) -> E {
564 if let Some(builder) = self.builder {
565 el.managed_tooltip(move |window, cx| builder(window, cx))
566 } else if let Some((text, action)) = self.text {
567 el.managed_tooltip(move |window, cx| {
568 Tooltip::new(text.clone())
569 .when_some(action.clone(), |this, (action, context)| {
570 this.action(
571 action.boxed_clone().as_ref(),
572 context.as_ref().map(|c| c.as_ref()),
573 )
574 })
575 .build(window, cx)
576 })
577 } else {
578 el
579 }
580 }
581}
582
583pub(crate) trait ManagedTooltipExt:
586 StatefulInteractiveElement + crate::ElementExt + Sized
587{
588 fn managed_tooltip(
589 self,
590 build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
591 ) -> Self {
592 let build_tooltip = Rc::new(build_tooltip);
593 let trigger_bounds_cell: Rc<Cell<Bounds<Pixels>>> = Rc::new(Cell::new(Bounds::default()));
594 let bounds_writer = trigger_bounds_cell.clone();
595
596 self.on_prepaint(move |bounds, _, _| {
597 bounds_writer.set(bounds);
598 })
599 .on_hover({
600 let trigger_bounds_cell = trigger_bounds_cell.clone();
601 let build_tooltip = build_tooltip.clone();
602 move |hovered, window, cx| {
603 if let Some(overlay) = Root::tooltip_overlay(window, cx) {
604 if *hovered {
605 let bounds = trigger_bounds_cell.get();
606 overlay.update(cx, |o: &mut TooltipOverlay, cx| {
607 o.request_show(
608 TooltipContent {
609 build: build_tooltip.clone(),
610 trigger_bounds: bounds,
611 },
612 window,
613 cx,
614 );
615 });
616 } else {
617 overlay.update(cx, |o: &mut TooltipOverlay, cx| {
618 o.request_hide(window, cx);
619 });
620 }
621 }
622 }
623 })
624 .on_mouse_down(MouseButton::Left, move |_, window, cx| {
625 if let Some(overlay) = Root::tooltip_overlay(window, cx) {
626 overlay.update(cx, |overlay, cx| {
627 overlay.hide(cx);
628 });
629 }
630 })
631 }
632}
633
634impl<E: StatefulInteractiveElement + crate::ElementExt> ManagedTooltipExt for E {}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use rgpui::size;
640
641 fn test_content(bounds: Bounds<Pixels>) -> TooltipContent {
642 TooltipContent {
643 build: Rc::new(|window, cx| Tooltip::new("Test tooltip").build(window, cx)),
644 trigger_bounds: bounds,
645 }
646 }
647
648 fn test_bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds<Pixels> {
649 Bounds::new(point(px(x), px(y)), size(px(width), px(height)))
650 }
651
652 fn test_size(width: f32, height: f32) -> Size<Pixels> {
653 size(px(width), px(height))
654 }
655
656 #[test]
657 fn tooltip_overlay_clear_state_resets_active_tooltip() {
658 let mut overlay = TooltipOverlay::new();
659
660 overlay.content = Some(test_content(test_bounds(10., 10., 40., 20.)));
661 overlay.prev_trigger_bounds = Some(test_bounds(0., 0., 40., 20.));
662 overlay.had_recent_tooltip = true;
663 overlay.is_switching = true;
664 overlay._show_task = Some(Task::ready(()));
665
666 assert!(overlay.clear_state());
667 assert!(overlay.content.is_none());
668 assert!(overlay.prev_trigger_bounds.is_none());
669 assert!(!overlay.had_recent_tooltip);
670 assert!(!overlay.is_switching);
671 assert!(overlay._show_task.is_none());
672 assert!(overlay._hide_task.is_none());
673 }
674
675 #[test]
676 fn tooltip_overlay_position_prefers_above_when_space_allows() {
677 let trigger_bounds = test_bounds(100., 80., 80., 24.);
678 let position = tooltip_overlay_position(
679 trigger_bounds,
680 test_size(120., 30.),
681 test_size(300., 200.),
682 TOOLTIP_WINDOW_MARGIN,
683 );
684
685 assert_eq!(position.placement, TooltipPlacement::Above);
686 assert_eq!(position.bounds.origin.x, px(80.));
687 assert_eq!(position.bounds.origin.y, px(50.));
688 assert_eq!(position.bounds.bottom(), trigger_bounds.top());
689 }
690
691 #[test]
692 fn tooltip_overlay_position_flips_below_near_top_edge() {
693 let trigger_bounds = test_bounds(24., 4., 120., 32.);
694 let position = tooltip_overlay_position(
695 trigger_bounds,
696 test_size(240., 32.),
697 test_size(520., 260.),
698 TOOLTIP_WINDOW_MARGIN,
699 );
700
701 assert_eq!(position.placement, TooltipPlacement::Below);
702 assert_eq!(position.bounds.top(), trigger_bounds.bottom());
703 assert!(position.bounds.top() >= trigger_bounds.bottom());
704 }
705
706 #[test]
707 fn tooltip_overlay_position_clamps_horizontal_edges() {
708 let trigger_bounds = test_bounds(4., 80., 24., 24.);
709 let position = tooltip_overlay_position(
710 trigger_bounds,
711 test_size(120., 30.),
712 test_size(300., 200.),
713 TOOLTIP_WINDOW_MARGIN,
714 );
715
716 assert_eq!(position.placement, TooltipPlacement::Above);
717 assert_eq!(position.bounds.left(), TOOLTIP_WINDOW_MARGIN);
718 }
719
720 #[test]
721 fn tooltip_overlay_position_uses_larger_side_when_neither_side_fits() {
722 let trigger_bounds = test_bounds(120., 20., 40., 20.);
723 let position = tooltip_overlay_position(
724 trigger_bounds,
725 test_size(160., 120.),
726 test_size(300., 100.),
727 TOOLTIP_WINDOW_MARGIN,
728 );
729
730 assert_eq!(position.placement, TooltipPlacement::Below);
731 assert_eq!(position.bounds.top(), TOOLTIP_WINDOW_MARGIN);
732 assert_eq!(position.bounds.left(), px(60.));
733 }
734}