Skip to main content

gpui_component/
tooltip.rs

1use crate::root::WindowState;
2use std::{
3    cell::RefCell,
4    rc::{Rc, Weak},
5    time::Duration,
6};
7
8use gpui::{
9    Action, AnyElement, AnyView, App, AppContext, Bounds, Context, ElementId, GlobalElementId,
10    IntoElement, MouseButton, ParentElement, Pixels, Render, SharedString,
11    StatefulInteractiveElement, StyleRefinement, Styled, Window, div, prelude::FluentBuilder, px,
12};
13pub use gpui_base::TooltipDefaults;
14use gpui_base::{
15    Tooltip as BaseTooltip, TooltipOverlay as BaseTooltipOverlay,
16    TooltipRequest as BaseTooltipRequest, TooltipTransition as BaseTooltipTransition,
17};
18
19use crate::{
20    ActiveTheme, Placement, StyledExt,
21    animation::{EffectTransition, ease_in_out_cubic, ease_out_cubic},
22    kbd::Kbd,
23    text::Text,
24};
25
26pub(crate) fn init(_cx: &mut App) {
27    // No app-level init needed — TooltipOverlay is per-window via Root.
28}
29
30// ── Tooltip view (unchanged API) ────────────────────────────────────────────
31
32enum TooltipContext {
33    Text(Text),
34    Element(Box<dyn Fn(&mut Window, &mut App) -> AnyElement>),
35}
36
37/// A Tooltip element that can display text or custom content,
38/// with optional key binding information.
39pub struct Tooltip {
40    style: StyleRefinement,
41    content: TooltipContext,
42    key_binding: Option<Kbd>,
43    action: Option<(Box<dyn Action>, Option<SharedString>)>,
44}
45
46impl Tooltip {
47    /// Create a Tooltip with a text content.
48    pub fn new(text: impl Into<Text>) -> Self {
49        Self {
50            style: StyleRefinement::default(),
51            content: TooltipContext::Text(text.into()),
52            key_binding: None,
53            action: None,
54        }
55    }
56
57    /// Create a Tooltip with a custom element.
58    pub fn element<E, F>(builder: F) -> Self
59    where
60        E: IntoElement,
61        F: Fn(&mut Window, &mut App) -> E + 'static,
62    {
63        Self {
64            style: StyleRefinement::default(),
65            key_binding: None,
66            action: None,
67            content: TooltipContext::Element(Box::new(move |window, cx| {
68                builder(window, cx).into_any_element()
69            })),
70        }
71    }
72
73    /// Set Action to display key binding information for the tooltip if it exists.
74    pub fn action(mut self, action: &dyn Action, context: Option<&str>) -> Self {
75        self.action = Some((action.boxed_clone(), context.map(SharedString::new)));
76        self
77    }
78
79    /// Set KeyBinding information for the tooltip.
80    pub fn key_binding(mut self, key_binding: Option<Kbd>) -> Self {
81        self.key_binding = key_binding;
82        self
83    }
84
85    /// Build the tooltip and return it as an `AnyView`.
86    pub fn build(self, _: &mut Window, cx: &mut App) -> AnyView {
87        cx.new(|_| self).into()
88    }
89}
90
91impl FluentBuilder for Tooltip {}
92impl Styled for Tooltip {
93    fn style(&mut self) -> &mut StyleRefinement {
94        &mut self.style
95    }
96}
97impl Render for Tooltip {
98    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
99        let key_binding = if let Some(key_binding) = &self.key_binding {
100            Some(key_binding.clone())
101        } else {
102            if let Some((action, context)) = &self.action {
103                Kbd::binding_for_action(
104                    action.as_ref(),
105                    context.as_ref().map(|s| s.as_ref()),
106                    window,
107                )
108            } else {
109                None
110            }
111        };
112
113        div().child(
114            // Wrap in a child, to ensure the left margin is applied to the tooltip
115            BaseTooltip::new("tooltip-popup")
116                .h_flex()
117                .font_family(cx.theme().font_family.clone())
118                .m_3()
119                .bg(cx.theme().tokens.popover)
120                .text_color(cx.theme().popover_foreground)
121                .bg(cx.theme().tokens.popover)
122                .border_1()
123                .border_color(cx.theme().border)
124                .shadow_md()
125                .rounded(cx.theme().radius)
126                .justify_between()
127                .py_0p5()
128                .px_2()
129                .text_sm()
130                .gap_3()
131                .refine_style(&self.style)
132                .map(|this| {
133                    this.child(div().map(|this| match self.content {
134                        TooltipContext::Text(ref text) => this.child(text.clone()),
135                        TooltipContext::Element(ref builder) => this.child(builder(window, cx)),
136                    }))
137                })
138                .when_some(key_binding, |this, kbd| {
139                    this.child(
140                        div()
141                            .text_xs()
142                            .flex_shrink_0()
143                            .text_color(cx.theme().muted_foreground)
144                            .child(kbd.appearance(false)),
145                    )
146                }),
147        )
148    }
149}
150
151// ── Managed tooltip system ──────────────────────────────────────────────────
152
153/// Duration of the slide-down enter animation.
154const ENTER_DURATION: Duration = Duration::from_millis(150);
155/// Duration of the position-slide animation when switching tooltips.
156const SLIDE_DURATION: Duration = Duration::from_millis(200);
157pub(crate) fn render_tooltip(
158    content_view: AnyView,
159    transition: BaseTooltipTransition,
160    _: &mut Window,
161    _: &mut App,
162) -> AnyElement {
163    div().child(content_view).map(|element| match transition {
164        BaseTooltipTransition::Switch {
165            epoch,
166            previous,
167            current,
168        } => {
169            let same_row = (current.origin.y - previous.origin.y).abs() < px(10.);
170            if !same_row {
171                return element.into_any_element();
172            }
173            let dx = current.center().x - previous.center().x;
174            EffectTransition::new(SLIDE_DURATION)
175                .ease(ease_in_out_cubic)
176                .slide_x(-dx, px(0.))
177                .apply(
178                    element,
179                    ElementId::NamedInteger("tooltip-slide".into(), epoch as u64),
180                )
181                .into_any_element()
182        }
183        BaseTooltipTransition::Enter { epoch } => EffectTransition::new(ENTER_DURATION)
184            .ease(ease_out_cubic)
185            .slide_y(px(4.), px(0.))
186            .fade(0.0, 1.0)
187            .apply(
188                element,
189                ElementId::NamedInteger("tooltip-enter".into(), epoch as u64),
190            )
191            .into_any_element(),
192    })
193}
194
195// ── Extension trait for managed tooltips ─────────────────────────────────────
196
197// ── Shared tooltip state for components ─────────────────────────────────────
198
199/// Shared tooltip state that components (Button, Switch, Checkbox, Radio, etc.)
200/// can embed to get `.tooltip()` support with minimal boilerplate.
201#[derive(Default)]
202pub(crate) struct ComponentTooltip {
203    pub text: Option<(
204        SharedString,
205        Option<(Rc<Box<dyn Action>>, Option<SharedString>)>,
206    )>,
207    pub builder: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView>>,
208    pub show_delay: Option<Duration>,
209}
210
211impl ComponentTooltip {
212    /// Apply this tooltip to a `Stateful<Div>` (or any `ManagedTooltipExt` element).
213    pub fn apply<E: ManagedTooltipExt>(self, el: E) -> E {
214        let show_delay = self.show_delay;
215        if let Some(builder) = self.builder {
216            el.managed_tooltip_with(None, show_delay, move |window, cx| builder(window, cx))
217        } else if let Some((text, action)) = self.text {
218            el.managed_tooltip_with(None, show_delay, move |window, cx| {
219                Tooltip::new(text.clone())
220                    .when_some(action.clone(), |this, (action, context)| {
221                        this.action(
222                            action.boxed_clone().as_ref(),
223                            context.as_ref().map(|c| c.as_ref()),
224                        )
225                    })
226                    .build(window, cx)
227            })
228        } else {
229            el
230        }
231    }
232}
233
234// ── Internal managed tooltip trait ──────────────────────────────────────────
235
236#[derive(Default)]
237struct TooltipLifetime(Rc<()>);
238
239#[derive(Default)]
240struct TooltipTrigger {
241    id: Option<GlobalElementId>,
242    lifetime: Weak<()>,
243    bounds: Bounds<Pixels>,
244}
245
246pub(crate) trait ManagedTooltipExt:
247    StatefulInteractiveElement + crate::ElementExt + Sized
248{
249    fn managed_tooltip(
250        self,
251        build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
252    ) -> Self {
253        self.managed_tooltip_with(None, None, build_tooltip)
254    }
255
256    fn managed_tooltip_at(
257        self,
258        placement: Placement,
259        build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
260    ) -> Self {
261        self.managed_tooltip_with(Some(placement), None, build_tooltip)
262    }
263
264    fn managed_tooltip_with(
265        self,
266        preferred_placement: Option<Placement>,
267        show_delay: Option<Duration>,
268        build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
269    ) -> Self {
270        let build_tooltip = Rc::new(build_tooltip);
271        let trigger = Rc::new(RefCell::new(TooltipTrigger::default()));
272        let trigger_writer = trigger.clone();
273
274        self.on_prepaint(move |bounds, window, _| {
275            let id = window.with_global_id("managed-tooltip-trigger".into(), |id, _| id.clone());
276            // GPUI retains keyed element state for cached subtrees too, and
277            // drops it after a frame that no longer contains the trigger.
278            let lifetime = window.with_element_state(&id, |state: Option<TooltipLifetime>, _| {
279                let state = state.unwrap_or_default();
280                (Rc::downgrade(&state.0), state)
281            });
282            *trigger_writer.borrow_mut() = TooltipTrigger {
283                id: Some(id),
284                lifetime,
285                bounds,
286            };
287        })
288        .on_hover({
289            let trigger = trigger.clone();
290            let build_tooltip = build_tooltip.clone();
291            move |hovered, window, cx| {
292                let trigger = trigger.borrow();
293                let Some(id) = trigger.id.as_ref() else {
294                    return;
295                };
296                let bounds = trigger.bounds;
297                // A press keeps the tooltip dismissed until the pointer leaves
298                // the trigger. This also drops the hover-enter that a click
299                // removing the trigger dispatches to the stale frame.
300                if *hovered && WindowState::is_tooltip_trigger_pressed(bounds, window, cx) {
301                    return;
302                }
303                if let Some(overlay) = WindowState::tooltip_overlay_for_trigger(
304                    id,
305                    trigger.lifetime.clone(),
306                    *hovered,
307                    window,
308                    cx,
309                ) {
310                    if *hovered {
311                        overlay.update(cx, |o: &mut BaseTooltipOverlay, cx| {
312                            let build = build_tooltip.clone();
313                            let request = BaseTooltipRequest::new(bounds, move |window, cx| {
314                                build(window, cx)
315                            });
316                            let request = match preferred_placement {
317                                Some(placement) => request.with_placement(placement),
318                                None => request,
319                            };
320                            let request = match show_delay {
321                                Some(delay) => request.with_show_delay(delay),
322                                None => request,
323                            };
324                            o.request_show(request, window, cx);
325                        });
326                    } else {
327                        overlay.update(cx, |o: &mut BaseTooltipOverlay, cx| {
328                            o.request_hide(window, cx);
329                        });
330                    }
331                }
332            }
333        })
334        .on_mouse_down(MouseButton::Left, move |_, window, cx| {
335            WindowState::press_tooltip_trigger(trigger.borrow().bounds, window, cx);
336            if let Some(overlay) = WindowState::tooltip_overlay(window, cx) {
337                overlay.update(cx, |overlay, cx| {
338                    overlay.hide(cx);
339                });
340            }
341        })
342    }
343}
344
345impl<E: StatefulInteractiveElement + crate::ElementExt> ManagedTooltipExt for E {}
346
347#[cfg(test)]
348mod tests {
349    use std::{cell::Cell, rc::Rc, time::Duration};
350
351    use gpui::{
352        AppContext as _, Context, Entity, InputEvent as _, InteractiveElement as _, IntoElement,
353        MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Render,
354        StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext, Window,
355        div, point, prelude::FluentBuilder as _, px,
356    };
357
358    use super::ManagedTooltipExt as _;
359    use crate::Root;
360
361    // Distinct scene markers let assertions observe retained tooltip output
362    // without requiring its renderer to run on every frame.
363    const TOOLTIP_COLORS: [u32; 2] = [0x7a135e, 0x136b7a];
364
365    fn assert_tooltip_painted(cx: &mut VisualTestContext, index: usize, shown: bool) {
366        draw(cx);
367        let painted = cx.update(|window, _| {
368            let marker: gpui::Background = gpui::rgb(TOOLTIP_COLORS[index]).into();
369            window
370                .painted_quads()
371                .iter()
372                .any(|quad| quad.background == marker)
373        });
374        assert_eq!(
375            painted, shown,
376            "tooltip {index} visibility in the rendered scene"
377        );
378    }
379
380    /// A trigger that removes itself when clicked, like a button that
381    /// replaces the view it sits in.
382    struct Probe {
383        shown: bool,
384        removes_on_click: bool,
385        builds: Rc<Cell<usize>>,
386    }
387
388    impl Render for Probe {
389        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
390            let builds = self.builds.clone();
391            div().size_full().when(self.shown, |this| {
392                this.child(
393                    div()
394                        .id("trigger")
395                        .absolute()
396                        .left(px(10.))
397                        .top(px(10.))
398                        .w(px(100.))
399                        .h(px(30.))
400                        .managed_tooltip(move |_, cx| {
401                            builds.set(builds.get() + 1);
402                            cx.new(|_| super::Tooltip::new("tip").bg(gpui::rgb(TOOLTIP_COLORS[0])))
403                                .into()
404                        })
405                        .on_click(cx.listener(|this, _, _, cx| {
406                            if this.removes_on_click {
407                                this.shown = false;
408                            }
409                            cx.notify();
410                        })),
411                )
412            })
413        }
414    }
415
416    fn open(
417        cx: &mut TestAppContext,
418        removes_on_click: bool,
419        builds: Rc<Cell<usize>>,
420    ) -> (VisualTestContext, Entity<Probe>) {
421        cx.update(|cx| {
422            crate::init(cx);
423            // These tests cover tooltip lifetime, not intermediate fade frames.
424            cx.set_reduce_motion(true);
425        });
426        let probe = cx.new(|_| Probe {
427            shown: true,
428            removes_on_click,
429            builds,
430        });
431        let window = cx.update(|cx| {
432            cx.open_window(Default::default(), |window, cx| {
433                cx.new(|cx| Root::new(probe.clone(), window, cx))
434            })
435            .unwrap()
436        });
437        let mut cx = VisualTestContext::from_window(window.into(), cx);
438        draw(&mut cx);
439        (cx, probe)
440    }
441
442    /// Presses at `at` and releases a pixel away, as most real clicks do.
443    fn click_with_drift(cx: &mut VisualTestContext, at: gpui::Point<gpui::Pixels>) {
444        let drift = point(at.x + px(1.), at.y);
445        cx.simulate_mouse_down(at, MouseButton::Left, Default::default());
446        cx.simulate_mouse_move(drift, Some(MouseButton::Left), Default::default());
447        cx.simulate_mouse_up(drift, MouseButton::Left, Default::default());
448    }
449
450    fn draw(cx: &mut VisualTestContext) {
451        cx.update(|window, cx| {
452            let _ = window.draw(cx);
453        })
454    }
455
456    /// Lets any show delay or grace period run out, then draws.
457    fn wait(cx: &mut VisualTestContext) {
458        cx.executor().advance_clock(Duration::from_secs(1));
459        cx.run_until_parked();
460        draw(cx);
461    }
462
463    struct AdjacentTriggers;
464
465    impl Render for AdjacentTriggers {
466        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
467            div().size_full().children((0..2).map(|index| {
468                div()
469                    .id(("trigger", index))
470                    .absolute()
471                    .left(px(10. + index as f32 * 100.))
472                    .top(px(10.))
473                    .w(px(100.))
474                    .h(px(30.))
475                    .managed_tooltip(move |_, cx| {
476                        cx.new(|_| super::Tooltip::new("tip").bg(gpui::rgb(TOOLTIP_COLORS[index])))
477                            .into()
478                    })
479            }))
480        }
481    }
482
483    fn open_adjacent_triggers(
484        cx: &mut TestAppContext,
485    ) -> (VisualTestContext, Entity<AdjacentTriggers>) {
486        cx.update(|cx| {
487            crate::init(cx);
488            // These tests cover tooltip lifetime, not intermediate fade frames.
489            cx.set_reduce_motion(true);
490        });
491        let probe = cx.new(|_| AdjacentTriggers);
492        let window = cx.update(|cx| {
493            cx.open_window(Default::default(), |window, cx| {
494                cx.new(|cx| Root::new(probe.clone(), window, cx))
495            })
496            .unwrap()
497        });
498        let mut cx = VisualTestContext::from_window(window.into(), cx);
499        draw(&mut cx);
500        (cx, probe)
501    }
502
503    fn assert_tooltip_shown(cx: &mut VisualTestContext, index: usize) {
504        assert_tooltip_painted(cx, index, true);
505        assert_tooltip_painted(cx, 1 - index, false);
506    }
507
508    #[gpui::test]
509    fn tooltips_switch_in_both_directions(cx: &mut TestAppContext) {
510        let (mut cx, probe) = open_adjacent_triggers(cx);
511        for (step, index) in [0, 1, 0].into_iter().enumerate() {
512            let at = point(px(50. + index as f32 * 100.), px(25.));
513            cx.simulate_mouse_move(at, None, Default::default());
514            if step > 0 {
515                draw(&mut cx);
516                assert_tooltip_shown(&mut cx, index);
517            }
518            wait(&mut cx);
519            assert_tooltip_shown(&mut cx, index);
520            // Rebuilding the triggers must preserve ownership for hover-leave.
521            cx.update(|_, cx| probe.update(cx, |_, cx| cx.notify()));
522            draw(&mut cx);
523        }
524        cx.simulate_mouse_move(point(px(300.), px(300.)), None, Default::default());
525        wait(&mut cx);
526        assert_tooltip_painted(&mut cx, 0, false);
527        assert_tooltip_painted(&mut cx, 1, false);
528    }
529
530    #[gpui::test]
531    fn leaving_a_trigger_does_not_cancel_the_next_tooltip_delay(cx: &mut TestAppContext) {
532        for (source, destination) in [(0, 1), (1, 0)] {
533            let (mut cx, _) = open_adjacent_triggers(cx);
534            cx.simulate_mouse_move(
535                point(px(50. + source as f32 * 100.), px(25.)),
536                None,
537                Default::default(),
538            );
539            cx.executor().advance_clock(Duration::from_millis(100));
540            cx.run_until_parked();
541            cx.simulate_mouse_move(
542                point(px(50. + destination as f32 * 100.), px(25.)),
543                None,
544                Default::default(),
545            );
546            wait(&mut cx);
547            assert_tooltip_shown(&mut cx, destination);
548        }
549    }
550
551    /// A press dismisses the tooltip, and it stays dismissed while the
552    /// pointer stays on the trigger; leaving and coming back shows it again.
553    #[gpui::test]
554    fn a_press_dismisses_the_tooltip_until_the_pointer_leaves(cx: &mut TestAppContext) {
555        let builds = Rc::new(Cell::new(0));
556        let (mut cx, _) = open(cx, false, builds.clone());
557        let at = point(px(50.), px(25.));
558        cx.simulate_mouse_move(at, None, Default::default());
559        wait(&mut cx);
560        assert!(builds.get() > 0, "the hovered trigger shows its tooltip");
561
562        // Hover reads false while the button is held, so the move after the
563        // release is a hover-enter that must not show the tooltip.
564        click_with_drift(&mut cx, at);
565        cx.simulate_mouse_move(point(px(52.), px(25.)), None, Default::default());
566        wait(&mut cx);
567        assert_tooltip_painted(&mut cx, 0, false);
568
569        cx.simulate_mouse_move(point(px(300.), px(300.)), None, Default::default());
570        wait(&mut cx);
571        cx.simulate_mouse_move(at, None, Default::default());
572        wait(&mut cx);
573        assert_tooltip_painted(&mut cx, 0, true);
574    }
575
576    /// A pointer move that lands between the release and the next frame is
577    /// dispatched to the old frame, where the trigger is still hovered, so it
578    /// used to request the tooltip again. The trigger is gone from the next
579    /// frame, nothing ever sent the hover-leave, and the tooltip appeared
580    /// after the show delay and stayed.
581    #[gpui::test]
582    fn clicking_a_trigger_away_does_not_leave_its_tooltip(cx: &mut TestAppContext) {
583        let builds = Rc::new(Cell::new(0));
584        let (mut cx, _) = open(cx, true, builds.clone());
585
586        let at = point(px(50.), px(25.));
587        cx.simulate_mouse_move(at, None, Default::default());
588        draw(&mut cx);
589        wait(&mut cx);
590        draw(&mut cx);
591        assert!(builds.get() > 0, "the hovered trigger shows its tooltip");
592
593        // Press, drift a pixel while held, release, and drift again before
594        // the next frame is drawn.
595        cx.simulate_mouse_down(at, MouseButton::Left, Default::default());
596        let drift = point(px(51.), px(25.));
597        cx.simulate_mouse_move(drift, Some(MouseButton::Left), Default::default());
598        // One update, so no frame is drawn between the two: the test
599        // platform otherwise draws a dirty window after every event.
600        cx.update(|window, cx| {
601            window.dispatch_event(
602                MouseUpEvent {
603                    button: MouseButton::Left,
604                    position: drift,
605                    modifiers: Default::default(),
606                    click_count: 1,
607                }
608                .to_platform_input(),
609                cx,
610            );
611            window.dispatch_event(
612                MouseMoveEvent {
613                    position: point(px(52.), px(25.)),
614                    pressed_button: None,
615                    modifiers: Default::default(),
616                }
617                .to_platform_input(),
618                cx,
619            );
620        });
621        draw(&mut cx);
622        wait(&mut cx);
623
624        let before = builds.get();
625        draw(&mut cx);
626        assert_eq!(
627            builds.get(),
628            before,
629            "no tooltip may be shown for a trigger the click removed"
630        );
631    }
632
633    /// A trigger that a click removed never reports a hover-leave, so the
634    /// press must end when the pointer moves away, or a trigger later shown
635    /// at the same bounds would lose its tooltip.
636    #[gpui::test]
637    fn a_press_on_a_removed_trigger_ends_when_the_pointer_moves_away(cx: &mut TestAppContext) {
638        let builds = Rc::new(Cell::new(0));
639        let (mut cx, probe) = open(cx, true, builds.clone());
640        let at = point(px(50.), px(25.));
641        cx.simulate_mouse_move(at, None, Default::default());
642        wait(&mut cx);
643
644        cx.simulate_click(at, Default::default());
645        wait(&mut cx);
646        cx.simulate_mouse_move(point(px(300.), px(300.)), None, Default::default());
647        wait(&mut cx);
648
649        cx.update(|_, cx| {
650            probe.update(cx, |probe, cx| {
651                probe.shown = true;
652                cx.notify();
653            })
654        });
655        wait(&mut cx);
656        let before = builds.get();
657        cx.simulate_mouse_move(at, None, Default::default());
658        wait(&mut cx);
659        draw(&mut cx);
660        assert!(
661            builds.get() > before,
662            "the trigger shown again has its tooltip"
663        );
664    }
665
666    struct Buttons {
667        shown: [bool; 2],
668        delay: Duration,
669    }
670
671    impl Render for Buttons {
672        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
673            div().size_full().children((0..2).filter_map(|index| {
674                self.shown[index].then(|| {
675                    crate::button::Button::new(("button", index))
676                        .absolute()
677                        .left(px(10. + index as f32 * 100.))
678                        .top(px(10.))
679                        .w(px(80.))
680                        .h(px(30.))
681                        .label(if index == 0 { "A" } else { "B" })
682                        .tooltip(if index == 0 { "First" } else { "Second" })
683                        .tooltip_show_delay(self.delay)
684                })
685            }))
686        }
687    }
688
689    struct CachedButtons(Entity<Buttons>);
690
691    impl Render for CachedButtons {
692        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
693            div().size_full().child(
694                self.0
695                    .clone()
696                    .cached(gpui::StyleRefinement::default().size_full()),
697            )
698        }
699    }
700
701    fn open_buttons(
702        cx: &mut TestAppContext,
703        shown: [bool; 2],
704        delay: Duration,
705    ) -> (VisualTestContext, Entity<Buttons>, Rc<Cell<usize>>) {
706        cx.update(|cx| {
707            crate::init(cx);
708            // These tests cover tooltip lifetime, not intermediate fade frames.
709            cx.set_reduce_motion(true);
710        });
711        let buttons = cx.new(|_| Buttons { shown, delay });
712        let window = cx.update(|cx| {
713            cx.open_window(Default::default(), |window, cx| {
714                let content = cx.new(|_| CachedButtons(buttons.clone()));
715                cx.new(|cx| Root::new(content, window, cx))
716            })
717            .unwrap()
718        });
719        let mut cx = VisualTestContext::from_window(window.into(), cx);
720        draw(&mut cx);
721        let builds = Rc::new(Cell::new(0));
722        cx.update(|window, cx| {
723            let builds = builds.clone();
724            // Observe the real Button's overlay without changing Base's public
725            // API or adding test-only markup to the production Tooltip.
726            super::WindowState::tooltip_overlay(window, cx)
727                .unwrap()
728                .update(cx, |overlay, _| {
729                    *overlay =
730                        std::mem::take(overlay).render_with(move |view, transition, window, cx| {
731                            builds.set(builds.get() + 1);
732                            div()
733                                .bg(gpui::rgb(TOOLTIP_COLORS[0]))
734                                .child(super::render_tooltip(view, transition, window, cx))
735                                .into_any_element()
736                        });
737                });
738        });
739        (cx, buttons, builds)
740    }
741
742    #[gpui::test]
743    fn removing_button_hides_visible_tooltip(cx: &mut TestAppContext) {
744        let (mut cx, buttons, _) = open_buttons(cx, [true, false], Duration::ZERO);
745        cx.simulate_mouse_move(point(px(50.), px(25.)), None, Default::default());
746        wait(&mut cx);
747        assert_tooltip_painted(&mut cx, 0, true);
748        // A redraw reusing the cached button subtree preserves the owner.
749        draw(&mut cx);
750        assert_tooltip_painted(&mut cx, 0, true);
751        cx.update(|_, cx| {
752            buttons.update(cx, |buttons, cx| {
753                buttons.shown[0] = false;
754                cx.notify();
755            })
756        });
757        draw(&mut cx);
758        cx.run_until_parked();
759        assert_tooltip_painted(&mut cx, 0, false);
760    }
761
762    #[gpui::test]
763    fn removing_button_cancels_pending_tooltip(cx: &mut TestAppContext) {
764        let (mut cx, buttons, builds) = open_buttons(cx, [true, false], Duration::from_millis(500));
765        cx.simulate_mouse_move(point(px(50.), px(25.)), None, Default::default());
766        draw(&mut cx);
767        // Finish the hover frame and drain its deferred cleanup before removal,
768        // without advancing the pending tooltip's show delay.
769        cx.run_until_parked();
770        assert_eq!(builds.get(), 0, "tooltip is still pending");
771        cx.update(|_, cx| {
772            buttons.update(cx, |buttons, cx| {
773                buttons.shown[0] = false;
774                cx.notify();
775            })
776        });
777        draw(&mut cx);
778        cx.run_until_parked();
779        wait(&mut cx);
780        assert_eq!(
781            builds.get(),
782            0,
783            "a removed trigger must never build its tooltip"
784        );
785        assert_tooltip_painted(&mut cx, 0, false);
786    }
787
788    #[gpui::test]
789    fn pending_button_shows_after_deferred_work_drains(cx: &mut TestAppContext) {
790        let (mut cx, _, builds) = open_buttons(cx, [true, false], Duration::from_millis(500));
791        cx.simulate_mouse_move(point(px(50.), px(25.)), None, Default::default());
792        draw(&mut cx);
793        cx.run_until_parked();
794        assert_eq!(builds.get(), 0, "tooltip is still pending");
795        wait(&mut cx);
796        assert!(
797            builds.get() > 0,
798            "a surviving trigger builds its delayed tooltip"
799        );
800        assert_tooltip_painted(&mut cx, 0, true);
801    }
802
803    #[gpui::test]
804    fn removing_old_button_preserves_new_tooltip(cx: &mut TestAppContext) {
805        let (mut cx, buttons, _) = open_buttons(cx, [true, true], Duration::ZERO);
806        cx.simulate_mouse_move(point(px(50.), px(25.)), None, Default::default());
807        wait(&mut cx);
808        cx.simulate_mouse_move(point(px(150.), px(25.)), None, Default::default());
809        wait(&mut cx);
810        assert_tooltip_painted(&mut cx, 0, true);
811        cx.update(|_, cx| {
812            buttons.update(cx, |buttons, cx| {
813                buttons.shown[0] = false;
814                cx.notify();
815            })
816        });
817        draw(&mut cx);
818        cx.run_until_parked();
819        assert_tooltip_painted(&mut cx, 0, true);
820    }
821}