Skip to main content

rgpui_component/
tooltip.rs

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    // No app-level init needed — TooltipOverlay is per-window via Root.
21}
22
23// ── Tooltip view (unchanged API) ────────────────────────────────────────────
24
25enum TooltipContext {
26    Text(Text),
27    Element(Box<dyn Fn(&mut Window, &mut App) -> AnyElement>),
28}
29
30/// A Tooltip element that can display text or custom content,
31/// with optional key binding information.
32pub 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    /// Create a Tooltip with a text content.
41    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    /// Create a Tooltip with a custom element.
51    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    /// Set Action to display key binding information for the tooltip if it exists.
67    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    /// Set KeyBinding information for the tooltip.
73    pub fn key_binding(mut self, key_binding: Option<Kbd>) -> Self {
74        self.key_binding = key_binding;
75        self
76    }
77
78    /// Build the tooltip and return it as an `AnyView`.
79    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            // Wrap in a child, to ensure the left margin is applied to the tooltip
108            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
143// ── Managed tooltip system ──────────────────────────────────────────────────
144
145/// Grace period: if a tooltip was hidden within this time, skip delay for next show.
146const GRACE_PERIOD: Duration = Duration::from_millis(300);
147/// Delay before showing a tooltip when no tooltip is currently active.
148const SHOW_DELAY: Duration = Duration::from_millis(500);
149/// Duration of the slide-down enter animation.
150const ENTER_DURATION: Duration = Duration::from_millis(150);
151/// Duration of the position-slide animation when switching tooltips.
152const 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/// Content for a managed tooltip.
353#[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
359/// Manages tooltip lifecycle: delay, grace period, animations, and rendering.
360///
361/// A single instance lives in [`Root`] per window. Components register hover
362/// via [`ManagedTooltipExt::managed_tooltip`] which calls into this overlay.
363pub 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    /// Request showing a tooltip. If another tooltip is active or was recently
395    /// hidden, shows immediately with a slide animation. Otherwise starts a delay.
396    pub(crate) fn request_show(
397        &mut self,
398        content: TooltipContent,
399        window: &mut Window,
400        cx: &mut Context<Self>,
401    ) {
402        // Cancel any pending hide
403        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            // Switch: show immediately with slide animation
410            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            // New: delay then show with slideDown
418            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    /// Request hiding the current tooltip. Starts a brief grace period so that
438    /// moving to another tooltip-bearing element feels instant.
439    pub(crate) fn request_hide(&mut self, window: &mut Window, cx: &mut Context<Self>) {
440        // Cancel any pending show
441        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                        // If the new trigger is at a different Y level, don't slide horizontally
512                        // to avoid weird diagonal movement. (We could consider sliding vertically
513                        // in this case, but it might be less visually clear.)
514                        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                    // New tooltip: slideDown + fadeIn
529                    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// ── Extension trait for managed tooltips ─────────────────────────────────────
547
548// ── Shared tooltip state for components ─────────────────────────────────────
549
550/// Shared tooltip state that components (Button, Switch, Checkbox, Radio, etc.)
551/// can embed to get `.tooltip()` support with minimal boilerplate.
552#[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    /// Apply this tooltip to a `Stateful<Div>` (or any `ManagedTooltipExt` element).
563    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
583// ── Internal managed tooltip trait ──────────────────────────────────────────
584
585pub(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}