Skip to main content

gpui_kit/overlay/
hover_card.rs

1//! A rich preview that opens on hover and can be reached.
2//!
3//! # What separates this from a tooltip and from a popover
4//!
5//! A [`Tooltip`](crate::overlay::Tooltip) is help. It is never actionable, it
6//! never holds the only copy of anything, and it is allowed to vanish the
7//! instant the pointer leaves, because nobody was ever going to point at it.
8//!
9//! A [`Popover`](crate::overlay::Popover) is a surface you opened on purpose,
10//! by clicking. It has no hover behaviour to get wrong.
11//!
12//! A hover card is the awkward third thing: it opens by hover *and* holds
13//! content worth reaching — a link, a button, text to read. So the pointer has
14//! to be able to travel from the trigger into the card, and between the two
15//! there is a gap the surface does not cover. A card that closed the moment
16//! the pointer left the trigger would put its content behind a race the user
17//! loses every time, which is a broken component rather than a fussy one.
18//!
19//! # The grace period
20//!
21//! Two facts are tracked, not one: whether the pointer is over the trigger and
22//! whether it is over the card. Leaving *both* starts a countdown; entering
23//! *either* cancels it. Only a countdown that runs out closes the card, so the
24//! diagonal trip across the gap is a period during which the card is leaving
25//! and has not left, and arriving anywhere inside it calls the whole thing
26//! off.
27//!
28//! Opening has its own countdown, for the opposite reason: a card that opened
29//! the instant a pointer crossed it would flash open and shut all the way
30//! across a row of them. Leaving the trigger before that countdown runs out
31//! cancels it, so a pointer passing through opens nothing.
32//!
33//! Both durations are caller-settable and neither is a token: they are
34//! reaction times, not paint, and nothing in `crates/gpui-kit-tokens/tokens/*.json` describes how
35//! long a hand takes to cross two centimetres.
36//!
37//! # The keyboard
38//!
39//! Hover is not the only way in. The trigger is a tab stop; focusing it opens
40//! the card at once, with no delay, because a keyboard user did not wander
41//! there by accident. The card is a tab stop of its own so its content can be
42//! reached, and escape closes the card and hands the keyboard back to the
43//! trigger.
44
45use std::rc::Rc;
46use std::time::Duration;
47
48use gpui::{
49    AnyElement, App, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement,
50    IntoElement, KeyDownEvent, ParentElement, Render, SharedString, StatefulInteractiveElement,
51    Styled, Window, div, px,
52};
53use gpui_kit_semantics::{NodeSpec, Role, Semantic};
54use gpui_kit_theme::{ActiveTheme, Elevation, Space};
55use web_time::Instant;
56
57use crate::foundation::{FocusRing, Ident, StyledExt};
58use crate::overlay::layer::{Overlay, Placement, surface};
59use crate::overlay::popover::anchored_slot;
60
61/// How long a pointer has to rest on the trigger before the card opens.
62///
63/// Long enough that crossing a row of triggers opens none of them, short
64/// enough that resting on one on purpose does not feel broken.
65pub const DEFAULT_OPEN_DELAY: Duration = Duration::from_millis(400);
66
67/// How long the card survives with the pointer over neither surface.
68///
69/// This is the time to cross the gap between the trigger and the card, plus
70/// the slack a hand that overshoots needs to come back.
71pub const DEFAULT_GRACE: Duration = Duration::from_millis(300);
72
73/// The widest the card gets before its text wraps.
74const CARD_MAX_WIDTH: f32 = 320.0;
75
76/// What the card is currently counting towards.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum Phase {
79    Opening,
80    Leaving,
81}
82
83/// What a hover card reports. The owner decides what any of it means.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum HoverCardEvent {
86    Opened,
87    Closed,
88}
89
90impl EventEmitter<HoverCardEvent> for HoverCard {}
91
92/// Builds the trigger or the card body for one frame.
93type Content = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
94
95/// A preview surface that opens on hover and can be pointed at.
96pub struct HoverCard {
97    ident: Ident,
98    focus_handle: FocusHandle,
99    trigger_focus: FocusHandle,
100    trigger: Option<Content>,
101    /// What the trigger is called, for a reader who has only the tree.
102    name: Option<SharedString>,
103    content: Option<Content>,
104    placement: Placement,
105    open_delay: Duration,
106    grace: Duration,
107    over_trigger: bool,
108    over_card: bool,
109    open: bool,
110    /// The phase in flight and how much of it is left.
111    countdown: Option<(Phase, Duration)>,
112    /// When the last frame that spent the countdown happened.
113    last_tick: Option<Instant>,
114    /// Set by opening, cleared by the first frame that can act on it.
115    pending_focus: bool,
116}
117
118impl std::fmt::Debug for HoverCard {
119    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        formatter
121            .debug_struct("HoverCard")
122            .field("ident", &self.ident)
123            .field("open", &self.open)
124            .field("over_trigger", &self.over_trigger)
125            .field("over_card", &self.over_card)
126            .field("countdown", &self.countdown)
127            .finish()
128    }
129}
130
131impl HoverCard {
132    pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
133        Self {
134            ident: ident.into(),
135            focus_handle: cx.focus_handle(),
136            trigger_focus: cx.focus_handle(),
137            trigger: None,
138            name: None,
139            content: None,
140            placement: Placement::Below,
141            open_delay: DEFAULT_OPEN_DELAY,
142            grace: DEFAULT_GRACE,
143            over_trigger: false,
144            over_card: false,
145            open: false,
146            countdown: None,
147            last_tick: None,
148            pending_focus: false,
149        }
150    }
151
152    /// Supplies what is hovered, rebuilt on every frame.
153    pub fn trigger(
154        mut self,
155        trigger: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
156    ) -> Self {
157        self.trigger = Some(Rc::new(trigger));
158        self
159    }
160
161    /// Names the trigger. A preview hanging off a picture or an avatar has no
162    /// words of its own, and a control nobody can name is one nobody can
163    /// reach.
164    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
165        self.name = Some(name.into());
166        self
167    }
168
169    /// Supplies the card body, rebuilt on every frame the card is open.
170    pub fn content(
171        mut self,
172        content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
173    ) -> Self {
174        self.content = Some(Rc::new(content));
175        self
176    }
177
178    pub fn placement(mut self, placement: Placement) -> Self {
179        self.placement = placement;
180        self
181    }
182
183    /// How long the pointer rests before the card opens. Zero opens at once.
184    pub fn open_delay(mut self, delay: Duration) -> Self {
185        self.open_delay = delay;
186        self
187    }
188
189    /// How long the card survives the pointer being over neither surface.
190    pub fn grace(mut self, grace: Duration) -> Self {
191        self.grace = grace;
192        self
193    }
194
195    pub fn is_open(&self) -> bool {
196        self.open
197    }
198
199    /// True while the card is on screen with the pointer over neither surface,
200    /// which is the window during which the trip can still be completed.
201    pub fn is_leaving(&self) -> bool {
202        matches!(self.countdown, Some((Phase::Leaving, _)))
203    }
204
205    pub fn grace_period(&self) -> Duration {
206        self.grace
207    }
208
209    pub fn open(&mut self, cx: &mut Context<Self>) {
210        self.countdown = None;
211        self.last_tick = None;
212        if self.open {
213            return;
214        }
215        self.open = true;
216        self.pending_focus = true;
217        cx.emit(HoverCardEvent::Opened);
218        cx.notify();
219    }
220
221    /// Closes the card without giving the keyboard back, which is what a
222    /// pointer leaving should do: the keyboard was never here.
223    pub fn close(&mut self, cx: &mut Context<Self>) {
224        self.countdown = None;
225        self.last_tick = None;
226        if !self.open {
227            return;
228        }
229        self.open = false;
230        self.pending_focus = false;
231        cx.emit(HoverCardEvent::Closed);
232        cx.notify();
233    }
234
235    /// Closes and hands the keyboard back to the trigger, which is what escape
236    /// should do.
237    pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
238        if !self.open {
239            return;
240        }
241        self.close(cx);
242        self.trigger_focus.focus(window, cx);
243    }
244
245    fn set_over_trigger(&mut self, over: bool, cx: &mut Context<Self>) {
246        if self.over_trigger == over {
247            return;
248        }
249        self.over_trigger = over;
250        self.reconsider(cx);
251    }
252
253    fn set_over_card(&mut self, over: bool, cx: &mut Context<Self>) {
254        if self.over_card == over {
255            return;
256        }
257        self.over_card = over;
258        self.reconsider(cx);
259    }
260
261    /// Decides what the two hover facts now mean.
262    fn reconsider(&mut self, cx: &mut Context<Self>) {
263        let inside = self.over_trigger || self.over_card;
264        match (self.open, inside) {
265            // Arriving anywhere inside calls off a departure in progress. This
266            // is the line that makes the trip across the gap survivable.
267            (true, true) => {
268                if self.countdown.is_some() {
269                    self.countdown = None;
270                    self.last_tick = None;
271                    cx.notify();
272                }
273            }
274            (true, false) => self.start(Phase::Leaving, self.grace, cx),
275            (false, true) => self.start(Phase::Opening, self.open_delay, cx),
276            (false, false) => {
277                if self.countdown.is_some() {
278                    self.countdown = None;
279                    self.last_tick = None;
280                    cx.notify();
281                }
282            }
283        }
284    }
285
286    fn start(&mut self, phase: Phase, duration: Duration, cx: &mut Context<Self>) {
287        if matches!(self.countdown, Some((current, _)) if current == phase) {
288            return;
289        }
290        if duration.is_zero() {
291            match phase {
292                Phase::Opening => self.open(cx),
293                Phase::Leaving => self.close(cx),
294            }
295            return;
296        }
297        self.countdown = Some((phase, duration));
298        self.last_tick = None;
299        cx.notify();
300    }
301
302    /// Spends one frame of whichever countdown is running.
303    fn tick(&mut self, window: &mut Window, cx: &mut Context<Self>) {
304        let Some((phase, remaining)) = self.countdown else {
305            self.last_tick = None;
306            return;
307        };
308        let now = cx.background_executor().now();
309        let spent = self
310            .last_tick
311            .map(|last| now.saturating_duration_since(last))
312            .unwrap_or_default();
313        let left = remaining.saturating_sub(spent);
314        if left.is_zero() {
315            match phase {
316                Phase::Opening => self.open(cx),
317                Phase::Leaving => self.close(cx),
318            }
319            return;
320        }
321        self.countdown = Some((phase, left));
322        self.last_tick = Some(now);
323        window.request_animation_frame();
324    }
325
326    fn on_trigger_key(
327        &mut self,
328        event: &KeyDownEvent,
329        window: &mut Window,
330        cx: &mut Context<Self>,
331    ) {
332        match event.keystroke.key.as_str() {
333            "enter" | "space" => {
334                if self.open {
335                    self.dismiss(window, cx);
336                } else {
337                    self.open(cx);
338                }
339                cx.stop_propagation();
340            }
341            "escape" if self.open => {
342                self.dismiss(window, cx);
343                cx.stop_propagation();
344            }
345            _ => {}
346        }
347    }
348
349    fn on_card_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
350        if event.keystroke.key.as_str() != "escape" {
351            return;
352        }
353        self.dismiss(window, cx);
354        cx.stop_propagation();
355    }
356}
357
358impl Focusable for HoverCard {
359    fn focus_handle(&self, _cx: &App) -> FocusHandle {
360        self.trigger_focus.clone()
361    }
362}
363
364impl Render for HoverCard {
365    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
366        self.tick(window, cx);
367        let theme = cx.theme().clone();
368        let trigger_ident = self.ident.child("trigger");
369        let card_ident = self.ident.child("card");
370
371        let trigger_body = self.trigger.clone().map(|build| build(window, cx));
372        let trigger = div()
373            .id(trigger_ident.element_id())
374            .flex()
375            .flex_none()
376            .items_center()
377            .tab_index(0)
378            .track_focus(&self.trigger_focus)
379            .focus_ring(&theme)
380            .on_hover(cx.listener(|card, hovered: &bool, _, cx| {
381                card.set_over_trigger(*hovered, cx);
382            }))
383            .on_key_down(cx.listener(Self::on_trigger_key))
384            .children(trigger_body)
385            .semantic_in(cx, {
386                let mut spec = NodeSpec::new(trigger_ident.semantic_id(), Role::Button)
387                    .parent(self.ident.semantic_id())
388                    .expanded(self.open)
389                    .focus(&self.trigger_focus);
390                if let Some(name) = self.name.clone() {
391                    spec = spec.text(name);
392                }
393                spec
394            })
395            .into_any_element();
396
397        let overlay = self.open.then(|| {
398            if self.pending_focus {
399                // Focusing the trigger is what a keyboard opening should
400                // leave behind; a pointer opening never took the keyboard in
401                // the first place, so nothing else is moved here.
402                self.pending_focus = false;
403            }
404            let body = self.content.clone().map(|build| build(window, cx));
405            let card = surface(&theme, Elevation::Overlay)
406                .id(card_ident.element_id())
407                .max_w(px(CARD_MAX_WIDTH))
408                .p_token(&theme, Space::Sm)
409                .gap_token(&theme, Space::Xs)
410                .tab_index(0)
411                .track_focus(&self.focus_handle)
412                .focus_ring(&theme)
413                .on_hover(cx.listener(|card, hovered: &bool, _, cx| {
414                    card.set_over_card(*hovered, cx);
415                }))
416                .on_key_down(cx.listener(Self::on_card_key))
417                .children(body)
418                .semantic_in(
419                    cx,
420                    NodeSpec::new(card_ident.semantic_id(), Role::Group)
421                        .parent(self.ident.semantic_id())
422                        .focus(&self.focus_handle),
423                );
424
425            Overlay::new(self.ident.child("overlay"))
426                .placement(self.placement)
427                .child(card)
428                .into_any_element()
429        });
430
431        anchored_slot(self.placement, trigger, overlay).semantic_in(
432            cx,
433            NodeSpec::new(self.ident.semantic_id(), Role::Group).expanded(self.open),
434        )
435    }
436}