1use 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
39pub const PLAIN_TOOLTIP_MAX_WIDTH: f32 = 200.;
41pub const RICH_TOOLTIP_WIDTH: f32 = 312.;
43const TOOLTIP_OFFSET: f32 = 4.;
45const 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#[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#[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 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 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 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 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 pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
147 self.trigger = Some(trigger.into_any_element());
148 self
149 }
150 pub fn placement(mut self, placement: Placement) -> Self {
152 self.placement = placement;
153 self
154 }
155 pub fn align(mut self, align: Align) -> Self {
158 self.align = align;
159 self
160 }
161 pub fn show_on_hover(mut self, show: bool) -> Self {
163 self.timing.on_hover = show;
164 self
165 }
166 pub fn show_on_focus(mut self, show: bool) -> Self {
169 self.timing.on_focus = show;
170 self
171 }
172 pub fn show_delay(mut self, delay: Duration) -> Self {
174 self.timing.show_delay = Some(delay);
175 self
176 }
177 pub fn hide_delay(mut self, delay: Duration) -> Self {
180 self.timing.hide_delay = Some(delay);
181 self
182 }
183 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#[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 pointer_over: bool,
271 bounds: Option<Bounds<Pixels>>,
272 focus: Option<FocusHandle>,
273 seen_focus: Option<FocusHandle>,
274 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#[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
304fn 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 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 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 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 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 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 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 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 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}