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 }
29
30enum TooltipContext {
33 Text(Text),
34 Element(Box<dyn Fn(&mut Window, &mut App) -> AnyElement>),
35}
36
37pub 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 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 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 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 pub fn key_binding(mut self, key_binding: Option<Kbd>) -> Self {
81 self.key_binding = key_binding;
82 self
83 }
84
85 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 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
151const ENTER_DURATION: Duration = Duration::from_millis(150);
155const 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#[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 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#[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 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 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 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 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 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 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 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 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 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 #[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 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 #[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 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 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 #[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 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 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 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 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}