Skip to main content

gpui_kit_material/components/
tooltip.rs

1//! M3 tooltips: plain (a short label) and rich (subhead, supporting text and
2//! optional actions).
3//!
4//! Visuals follow the Figma "Material 3 Design Kit" `Plain Tooltip` and
5//! `Rich Tooltip`: a plain tooltip is inverse-surface with 4dp corners, 4/8dp
6//! padding and body-small inverse-on-surface text, at most 200dp wide; a rich
7//! tooltip is surface-container with 12dp corners at level 2, 12/16dp
8//! padding, a title-small subhead and body-medium supporting text (both
9//! on-surface-variant) and text actions 8dp apart.
10//!
11//! Behavior reuses GPUI Base: the tooltip popup is Base's [`gpui_base::Tooltip`]
12//! (the tooltip role), placed by Base's [`gpui_base::Positioner`] (side
13//! placement, flipping and viewport clamping), with Base's application-wide
14//! [`gpui_base::TooltipDefaults`] show delay and grace period. Base's
15//! `TooltipOverlay` provider is not used because it removes its content at
16//! once on hide (no exit motion) and cannot keep a rich tooltip open while the
17//! pointer moves onto its actions; this wrapper adds exactly that.
18//!
19//! The tooltip shows after the show delay on hover, at once on keyboard
20//! focus, and hides after the grace period once neither the trigger nor (for
21//! a rich tooltip) the tooltip is hovered or focused; Escape hides it. It
22//! fades on the fast effects spring and moves 4dp toward its trigger on the
23//! fast spatial spring (GPUI has no element scale transform, so the M3 scale
24//! is carried by that movement); reduced motion keeps only the fade. The
25//! trigger's accessibility node is described by the tooltip text.
26use crate::{
27    color::ColorRole,
28    motion::{self, MotionToken},
29    theme::color,
30    tokens::{
31        elevation,
32        typography::{TypeRole, rem},
33    },
34};
35use gpui::{prelude::*, *};
36use gpui_base::{Align, Placement};
37use std::time::Duration;
38
39/// The plain tooltip's maximum width before it wraps.
40pub const PLAIN_TOOLTIP_MAX_WIDTH: f32 = 200.;
41/// The rich tooltip's width in the design kit (M3 allows up to 320dp).
42pub const RICH_TOOLTIP_WIDTH: f32 = 312.;
43/// Gap between a tooltip and its trigger.
44const TOOLTIP_OFFSET: f32 = 4.;
45/// How far a tooltip travels toward its trigger while it appears.
46const TOOLTIP_TRAVEL: f32 = 4.;
47
48fn typed<E: Styled>(element: E, role: TypeRole) -> E {
49    let scale = role.scale();
50    element
51        .font_family(scale.font_family)
52        .text_size(scale.font_size)
53        .line_height(scale.line_height)
54        .font_weight(FontWeight(scale.font_weight as f32))
55}
56
57enum Content {
58    Plain(SharedString),
59    Rich {
60        subhead: Option<SharedString>,
61        supporting: SharedString,
62        actions: Vec<AnyElement>,
63    },
64}
65
66/// An M3 tooltip. With [`MaterialTooltip::trigger`] it wraps that element
67/// and shows on hover and keyboard focus; without one it renders the tooltip
68/// container in place (a specimen).
69#[derive(gpui::IntoElement)]
70pub struct MaterialTooltip {
71    id: ElementId,
72    content: Content,
73    trigger: Option<AnyElement>,
74    placement: Placement,
75    align: Align,
76    timing: TipTiming,
77    forced: Option<bool>,
78}
79
80/// How a tooltip responds to input: which inputs show it and its delays
81/// (`None` uses Base's application-wide [`gpui_base::TooltipDefaults`]).
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83struct TipTiming {
84    on_hover: bool,
85    on_focus: bool,
86    show_delay: Option<Duration>,
87    hide_delay: Option<Duration>,
88}
89
90impl Default for TipTiming {
91    fn default() -> Self {
92        Self {
93            on_hover: true,
94            on_focus: true,
95            show_delay: None,
96            hide_delay: None,
97        }
98    }
99}
100
101impl MaterialTooltip {
102    /// A plain tooltip with `text`.
103    pub fn plain(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
104        Self {
105            id: id.into(),
106            content: Content::Plain(text.into()),
107            trigger: None,
108            placement: Placement::Top,
109            align: Align::Center,
110            timing: TipTiming::default(),
111            forced: None,
112        }
113    }
114    /// A rich tooltip with `supporting` text.
115    pub fn rich(id: impl Into<ElementId>, supporting: impl Into<SharedString>) -> Self {
116        Self {
117            id: id.into(),
118            content: Content::Rich {
119                subhead: None,
120                supporting: supporting.into(),
121                actions: Vec::new(),
122            },
123            trigger: None,
124            placement: Placement::Bottom,
125            align: Align::Center,
126            timing: TipTiming::default(),
127            forced: None,
128        }
129    }
130    /// A rich tooltip's title-small subhead.
131    pub fn subhead(mut self, subhead: impl Into<SharedString>) -> Self {
132        if let Content::Rich { subhead: slot, .. } = &mut self.content {
133            *slot = Some(subhead.into());
134        }
135        self
136    }
137    /// An action of a rich tooltip; the tooltip then stays open while the
138    /// pointer is over it.
139    pub fn action(mut self, action: impl IntoElement) -> Self {
140        if let Content::Rich { actions, .. } = &mut self.content {
141            actions.push(action.into_any_element());
142        }
143        self
144    }
145    /// The element the tooltip describes.
146    pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
147        self.trigger = Some(trigger.into_any_element());
148        self
149    }
150    /// The preferred side; it flips when the tooltip does not fit.
151    pub fn placement(mut self, placement: Placement) -> Self {
152        self.placement = placement;
153        self
154    }
155    /// How the tooltip lines up with its trigger along the placement side:
156    /// centered (default), or flush with the trigger's start or end edge.
157    pub fn align(mut self, align: Align) -> Self {
158        self.align = align;
159        self
160    }
161    /// Whether hovering the trigger shows the tooltip (default true).
162    pub fn show_on_hover(mut self, show: bool) -> Self {
163        self.timing.on_hover = show;
164        self
165    }
166    /// Whether keyboard focus in the trigger shows the tooltip (default
167    /// true).
168    pub fn show_on_focus(mut self, show: bool) -> Self {
169        self.timing.on_focus = show;
170        self
171    }
172    /// The hover delay before showing (default: Base's show delay).
173    pub fn show_delay(mut self, delay: Duration) -> Self {
174        self.timing.show_delay = Some(delay);
175        self
176    }
177    /// The grace period before hiding once input leaves (default: Base's
178    /// grace period).
179    pub fn hide_delay(mut self, delay: Duration) -> Self {
180        self.timing.hide_delay = Some(delay);
181        self
182    }
183    /// Keep the tooltip shown (or hidden) regardless of input, e.g. for a
184    /// captured demo.
185    pub fn force_visible(mut self, visible: bool) -> Self {
186        self.forced = Some(visible);
187        self
188    }
189
190    fn description(&self) -> SharedString {
191        match &self.content {
192            Content::Plain(text) => text.clone(),
193            Content::Rich {
194                subhead: Some(subhead),
195                supporting,
196                ..
197            } => format!("{subhead}. {supporting}").into(),
198            Content::Rich { supporting, .. } => supporting.clone(),
199        }
200    }
201
202    fn interactive(&self) -> bool {
203        matches!(&self.content, Content::Rich { actions, .. } if !actions.is_empty())
204    }
205}
206
207fn container(id: ElementId, content: Content) -> gpui_base::Tooltip {
208    match content {
209        Content::Plain(text) => typed(gpui_base::Tooltip::new(id), TypeRole::BodySmall)
210            .max_w(rem(PLAIN_TOOLTIP_MAX_WIDTH))
211            .px(rem(8.))
212            .py(rem(4.))
213            .rounded(px(4.))
214            .bg(color(ColorRole::INVERSE_SURFACE))
215            .text_color(color(ColorRole::INVERSE_ON_SURFACE))
216            .child(text),
217        Content::Rich {
218            subhead,
219            supporting,
220            actions,
221        } => {
222            let has_actions = !actions.is_empty();
223            gpui_base::Tooltip::new(id)
224                .w(rem(RICH_TOOLTIP_WIDTH))
225                .flex()
226                .flex_col()
227                .gap(rem(8.))
228                .pb(rem(if has_actions { 8. } else { 0. }))
229                .rounded(px(12.))
230                .bg(color(ColorRole::SURFACE_CONTAINER))
231                .shadow(elevation::level2())
232                .child(
233                    div()
234                        .flex()
235                        .flex_col()
236                        .gap(rem(4.))
237                        .pt(rem(12.))
238                        .px(rem(16.))
239                        .pb(rem(if has_actions { 4. } else { 12. }))
240                        .text_color(color(ColorRole::ON_SURFACE_VARIANT))
241                        .when_some(subhead, |content, subhead| {
242                            content.child(typed(div(), TypeRole::TitleSmall).child(subhead))
243                        })
244                        .child(typed(div(), TypeRole::BodyMedium).child(supporting)),
245                )
246                .when(has_actions, |tip| {
247                    tip.child(
248                        div()
249                            .flex()
250                            .items_center()
251                            .gap(rem(8.))
252                            .px(rem(4.))
253                            .children(actions),
254                    )
255                })
256        }
257    }
258}
259
260/// Input that keeps a tooltip open.
261#[derive(Default)]
262struct TipState {
263    trigger_hovered: bool,
264    tip_hovered: bool,
265    keyboard_focus: bool,
266    visible: bool,
267    epoch: usize,
268    timing: TipTiming,
269    /// Whether the pointer is over the trigger, shown or not.
270    pointer_over: bool,
271    bounds: Option<Bounds<Pixels>>,
272    focus: Option<FocusHandle>,
273    seen_focus: Option<FocusHandle>,
274    /// The side the positioner last placed the tooltip on, which differs
275    /// from the requested placement when it flipped to fit.
276    placed: std::rc::Rc<std::cell::Cell<Option<Placement>>>,
277}
278
279impl TipState {
280    fn wanted(&self) -> bool {
281        self.trigger_hovered || self.tip_hovered || self.keyboard_focus
282    }
283}
284
285/// What a change of input does to a tooltip: show now, show after the show
286/// delay, hide after the grace period, or nothing.
287#[derive(Clone, Copy, Debug, PartialEq, Eq)]
288pub(crate) enum TipChange {
289    ShowNow,
290    ShowAfterDelay,
291    HideAfterGrace,
292    Keep,
293}
294
295pub(crate) fn tip_change(visible: bool, wanted: bool, keyboard: bool) -> TipChange {
296    match (visible, wanted) {
297        (false, true) if keyboard => TipChange::ShowNow,
298        (false, true) => TipChange::ShowAfterDelay,
299        (true, false) => TipChange::HideAfterGrace,
300        _ => TipChange::Keep,
301    }
302}
303
304/// Apply an input change to the tooltip `state`, scheduling the delayed
305/// show or hide on the window.
306fn update_tip(
307    state: &Entity<TipState>,
308    window: &mut Window,
309    cx: &mut App,
310    change: impl FnOnce(&mut TipState),
311) {
312    let (visible, wanted, keyboard, epoch) = state.update(cx, |tip, _| {
313        change(tip);
314        tip.epoch += 1;
315        (tip.visible, tip.wanted(), tip.keyboard_focus, tip.epoch)
316    });
317    let defaults = gpui_base::TooltipDefaults::global(cx);
318    let timing = state.read(cx).timing;
319    let (delay, show) = match tip_change(visible, wanted, keyboard) {
320        TipChange::ShowNow => {
321            state.update(cx, |tip, _| tip.visible = true);
322            window.refresh();
323            return;
324        }
325        TipChange::ShowAfterDelay => (
326            timing.show_delay.unwrap_or_else(|| defaults.show_delay()),
327            true,
328        ),
329        TipChange::HideAfterGrace => (
330            timing.hide_delay.unwrap_or_else(|| defaults.grace_period()),
331            false,
332        ),
333        TipChange::Keep => return,
334    };
335    let state = state.downgrade();
336    window
337        .spawn(cx, async move |cx| {
338            cx.background_executor().timer(delay).await;
339            let _ = cx.update(|window, cx| {
340                let Some(state) = state.upgrade() else { return };
341                let current = state.read(cx);
342                if current.epoch == epoch && current.wanted() == show && current.visible != show {
343                    state.update(cx, |tip, _| tip.visible = show);
344                    window.refresh();
345                }
346            });
347        })
348        .detach();
349}
350
351impl RenderOnce for MaterialTooltip {
352    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
353        let Some(trigger) = self.trigger.take() else {
354            return container(self.id, self.content).into_any_element();
355        };
356        let description = self.description();
357        let interactive = self.interactive();
358        let placement = self.placement;
359        let align = self.align;
360        let timing = self.timing;
361        let id = self.id;
362        let state = window.use_keyed_state((id.clone(), "tooltip"), cx, |_, cx| TipState {
363            focus: Some(cx.focus_handle()),
364            ..TipState::default()
365        });
366        if state.read(cx).timing != timing {
367            state.update(cx, |tip, _| tip.timing = timing);
368            // Input that no longer shows the tooltip stops holding it.
369            let changed = state.clone();
370            window.defer(cx, move |window, cx| {
371                update_tip(&changed, window, cx, |tip| {
372                    tip.trigger_hovered = tip.pointer_over && timing.on_hover;
373                    tip.keyboard_focus &= timing.on_focus;
374                });
375            });
376        }
377        let focus = state
378            .read(cx)
379            .focus
380            .clone()
381            .expect("a tooltip focus handle");
382        // Keyboard focus inside the trigger shows the tooltip. Containment is
383        // reliable after the frame commits, so resolve it then, and only when
384        // window focus has moved.
385        let focused = window.focused(cx);
386        if state.read(cx).seen_focus != focused {
387            let observed = state.clone();
388            let container_focus = focus.clone();
389            window.defer(cx, move |window, cx| {
390                let keyboard = timing.on_focus
391                    && container_focus.contains_focused(window, cx)
392                    && window.last_input_was_keyboard();
393                observed.update(cx, |tip, _| tip.seen_focus = focused);
394                if keyboard != observed.read(cx).keyboard_focus {
395                    update_tip(&observed, window, cx, |tip| tip.keyboard_focus = keyboard);
396                }
397            });
398        }
399        let visible = self.forced.unwrap_or(state.read(cx).visible);
400        let target = if visible { 1. } else { 0. };
401        let fade = motion::token_value(
402            (id.clone(), "tooltip-fade"),
403            target,
404            MotionToken::FastEffects,
405            window,
406            cx,
407        );
408        let travel = motion::token_value(
409            (id.clone(), "tooltip-travel"),
410            target,
411            MotionToken::FastSpatial,
412            window,
413            cx,
414        );
415        let shown = visible || fade > 0.;
416        let bounds = state.read(cx).bounds;
417
418        let hover_state = state.clone();
419        let escape_state = state.clone();
420        let measure = state.clone();
421        let wrapper = div()
422            .id(id.clone())
423            .relative()
424            .track_focus(&focus)
425            .tab_stop(false)
426            .aria_description(description)
427            .on_hover(move |hovered, window, cx| {
428                let hovered = *hovered;
429                update_tip(&hover_state, window, cx, |tip| {
430                    tip.pointer_over = hovered;
431                    tip.trigger_hovered = hovered && tip.timing.on_hover;
432                });
433            })
434            .on_key_down(move |event, window, cx| {
435                if event.keystroke.key == "escape" && escape_state.read(cx).visible {
436                    escape_state.update(cx, |tip, _| {
437                        tip.visible = false;
438                        tip.keyboard_focus = false;
439                        tip.trigger_hovered = false;
440                        tip.epoch += 1;
441                    });
442                    window.refresh();
443                }
444            })
445            .child(trigger)
446            .child(
447                canvas(
448                    move |bounds, window, cx| {
449                        if measure.read(cx).bounds != Some(bounds) {
450                            measure.update(cx, |tip, _| tip.bounds = Some(bounds));
451                            // A shown tooltip follows a moved trigger in the
452                            // next frame; settled bounds stop the refreshes.
453                            if shown {
454                                window.refresh();
455                            }
456                        }
457                    },
458                    |_, _, _, _| {},
459                )
460                .absolute()
461                .top_0()
462                .left_0()
463                .size_full(),
464            );
465
466        let Some(bounds) = bounds.filter(|_| shown) else {
467            return div()
468                .relative()
469                .flex_shrink_0()
470                .child(wrapper)
471                .into_any_element();
472        };
473        let tip_state = state.clone();
474        // The tooltip starts `TOOLTIP_TRAVEL` farther from its trigger, on
475        // the side it is actually shown: a flipped tooltip travels away from
476        // the trigger, not into it.
477        let placed = state.read(cx).placed.clone();
478        let side = placed.get().unwrap_or(placement);
479        let away = rem(TOOLTIP_TRAVEL * (1. - travel));
480        let tip = div()
481            .id("tooltip-motion")
482            .when(cfg!(test), |tip| {
483                tip.debug_selector(|| "tooltip-popup".into())
484            })
485            .relative()
486            .opacity(fade.clamp(0., 1.))
487            .map(|tip| match side {
488                Placement::Top => tip.top(-away),
489                Placement::Bottom => tip.top(away),
490                Placement::Left => tip.left(-away),
491                Placement::Right => tip.left(away),
492            })
493            .when(interactive, |tip| {
494                tip.on_hover(move |hovered, window, cx| {
495                    let hovered = *hovered;
496                    update_tip(&tip_state, window, cx, |tip| tip.tip_hovered = hovered);
497                })
498            })
499            .child(container(
500                ElementId::NamedChild(std::sync::Arc::new(id), "popup".into()),
501                self.content,
502            ))
503            .child({
504                // The side is known only once positioned (before this
505                // prepaints): draw again when it changed.
506                let placed = placed.clone();
507                canvas(
508                    move |_, window, _| {
509                        if placed.get().is_some_and(|placed| placed != side) {
510                            window.request_animation_frame();
511                        }
512                    },
513                    |_, _, _, _| {},
514                )
515                .absolute()
516                .size_0()
517            });
518        let positioner = gpui_base::Positioner::side(bounds)
519            .placement(placement)
520            .align(align)
521            .offset(rem(TOOLTIP_OFFSET).to_pixels(window.rem_size()))
522            .on_position(move |resolved| placed.set(resolved.placement))
523            .child(tip);
524        let positioner = if interactive {
525            positioner.occlude()
526        } else {
527            positioner
528        };
529        div()
530            .relative()
531            .flex_shrink_0()
532            .child(wrapper)
533            .child(deferred(positioner).with_priority(200))
534            .into_any_element()
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use core::prelude::v1::test;
542    use gpui::{Context, TestAppContext, VisualTestContext};
543    use std::time::Duration;
544
545    #[test]
546    fn keyboard_focus_shows_at_once_and_hover_waits() {
547        assert_eq!(tip_change(false, true, true), TipChange::ShowNow);
548        assert_eq!(tip_change(false, true, false), TipChange::ShowAfterDelay);
549        assert_eq!(tip_change(true, false, false), TipChange::HideAfterGrace);
550        assert_eq!(tip_change(true, true, false), TipChange::Keep);
551        assert_eq!(tip_change(false, false, false), TipChange::Keep);
552    }
553
554    struct Harness;
555    impl Render for Harness {
556        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
557            window.set_rem_size(px(16.));
558            div().p(px(100.)).child(
559                MaterialTooltip::plain("tip", "Save")
560                    .trigger(div().debug_selector(|| "trigger".into()).size(px(40.))),
561            )
562        }
563    }
564
565    fn draw(cx: &mut VisualTestContext) {
566        cx.update(|window, cx| {
567            window.refresh();
568            window.draw(cx).clear(cx);
569        });
570        cx.run_until_parked();
571    }
572
573    fn tooltip_shown(cx: &mut VisualTestContext) -> bool {
574        cx.debug_bounds("tooltip-popup").is_some()
575    }
576
577    #[gpui::test]
578    fn hover_shows_after_the_delay_and_hides_after_the_grace(cx: &mut TestAppContext) {
579        cx.update(|cx| {
580            gpui_base::init(cx);
581            cx.set_reduce_motion(true);
582            gpui_base::TooltipDefaults::new()
583                .with_show_delay(Duration::from_millis(500))
584                .with_grace_period(Duration::from_millis(300))
585                .install(cx);
586        });
587        let (_, cx) = cx.add_window_view(|_, _| Harness);
588        draw(cx);
589        let trigger = cx.debug_bounds("trigger").unwrap();
590        cx.simulate_mouse_move(trigger.center(), None, Modifiers::default());
591        draw(cx);
592        assert!(!tooltip_shown(cx));
593        cx.executor().advance_clock(Duration::from_millis(500));
594        draw(cx);
595        draw(cx);
596        assert!(tooltip_shown(cx), "shown after the show delay");
597        let popup = cx.debug_bounds("tooltip-popup").unwrap();
598        assert!(
599            popup.bottom() <= trigger.top(),
600            "a plain tooltip sits above"
601        );
602        cx.simulate_mouse_move(point(px(5.), px(5.)), None, Modifiers::default());
603        draw(cx);
604        assert!(tooltip_shown(cx), "kept during the grace period");
605        cx.executor().advance_clock(Duration::from_millis(300));
606        draw(cx);
607        cx.executor().advance_clock(Duration::from_secs(1));
608        draw(cx);
609        draw(cx);
610        assert!(!tooltip_shown(cx), "hidden after the grace period");
611    }
612
613    struct OptionsHarness {
614        hover: bool,
615        align: Align,
616    }
617    impl Render for OptionsHarness {
618        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
619            window.set_rem_size(px(16.));
620            div().p(px(100.)).child(
621                MaterialTooltip::plain("tip", "Save to favorites")
622                    .align(self.align)
623                    .show_on_hover(self.hover)
624                    .show_delay(Duration::from_millis(50))
625                    .hide_delay(Duration::from_millis(20))
626                    .trigger(div().debug_selector(|| "trigger".into()).size(px(40.))),
627            )
628        }
629    }
630
631    #[gpui::test]
632    fn options_set_the_delays_alignment_and_hover_response(cx: &mut TestAppContext) {
633        cx.update(|cx| {
634            gpui_base::init(cx);
635            cx.set_reduce_motion(true);
636            gpui_base::TooltipDefaults::new()
637                .with_show_delay(Duration::from_secs(5))
638                .install(cx);
639        });
640        let (view, cx) = cx.add_window_view(|_, _| OptionsHarness {
641            hover: false,
642            align: Align::Start,
643        });
644        draw(cx);
645        let trigger = cx.debug_bounds("trigger").unwrap();
646        cx.simulate_mouse_move(trigger.center(), None, Modifiers::default());
647        cx.executor().advance_clock(Duration::from_millis(200));
648        draw(cx);
649        assert!(!tooltip_shown(cx), "hover does not show it");
650        view.update(cx, |h, cx| {
651            h.hover = true;
652            cx.notify();
653        });
654        draw(cx);
655        cx.simulate_mouse_move(
656            trigger.center() + point(px(1.), px(1.)),
657            None,
658            Modifiers::default(),
659        );
660        draw(cx);
661        // The tooltip's own show delay replaces the 5s default.
662        cx.executor().advance_clock(Duration::from_millis(50));
663        draw(cx);
664        draw(cx);
665        assert!(tooltip_shown(cx), "shown after its own delay");
666        let popup = cx.debug_bounds("tooltip-popup").unwrap();
667        assert_eq!(popup.left(), trigger.left(), "start-aligned");
668        cx.simulate_mouse_move(point(px(5.), px(5.)), None, Modifiers::default());
669        draw(cx);
670        cx.executor().advance_clock(Duration::from_millis(20));
671        draw(cx);
672        cx.executor().advance_clock(Duration::from_secs(1));
673        draw(cx);
674        draw(cx);
675        assert!(!tooltip_shown(cx), "hidden after its own hide delay");
676    }
677
678    /// A trigger against the window's top edge: a top tooltip flips below.
679    struct EdgeHarness;
680    impl Render for EdgeHarness {
681        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
682            window.set_rem_size(px(crate::tokens::typography::BASE_PX));
683            div().pl(px(100.)).child(
684                MaterialTooltip::plain("tip", "Save")
685                    .trigger(div().debug_selector(|| "trigger".into()).size(px(40.))),
686            )
687        }
688    }
689
690    #[gpui::test]
691    fn a_flipped_tooltip_travels_away_from_its_trigger(cx: &mut TestAppContext) {
692        cx.update(|cx| {
693            gpui_base::init(cx);
694            gpui_base::TooltipDefaults::new()
695                .with_show_delay(Duration::ZERO)
696                .install(cx);
697        });
698        let (_, cx) = cx.add_window_view(|_, _| EdgeHarness);
699        draw(cx);
700        let trigger = cx.debug_bounds("trigger").unwrap();
701        cx.simulate_mouse_move(trigger.center(), None, Modifiers::default());
702        // Mid-entrance (the motion clock does not advance between draws).
703        for _ in 0..3 {
704            cx.update(|window, cx| {
705                window.refresh();
706                window.draw(cx).clear(cx);
707            });
708        }
709        let popup = cx
710            .debug_bounds("tooltip-popup")
711            .expect("the tooltip is shown");
712        assert!(
713            popup.top() >= trigger.bottom() + px(TOOLTIP_OFFSET),
714            "a tooltip flipped below its trigger moved up into it: {popup:?} vs {trigger:?}"
715        );
716    }
717}