Skip to main content

ui/
popover.rs

1//! Popover / menu primitives: an anchored floating layer with the `menu-in`
2//! animation, outside-click dismissal, and pure keyboard-navigation + search
3//! reducers shared by every picker and menu (feature-inventory §1.12 popovers).
4//!
5//! gpui pattern (examples/popover.rs at the pinned rev): the trigger element
6//! conditionally children a `deferred(anchored().child(content))` — deferred
7//! paints on a floating layer above everything, anchored positions it relative
8//! to the trigger (or an explicit point for context menus).
9//!
10//! Pure logic (wrap-around list navigation, ranked substring filtering, key
11//! classification) lives in free functions with unit tests; the elements only
12//! feed them measurements/events.
13
14mod anchor;
15mod modal;
16mod nav;
17mod parts;
18pub mod submenu;
19
20pub use anchor::*;
21pub use modal::*;
22pub use nav::*;
23pub use parts::*;
24pub use submenu::Chain;
25
26use crate::{icons, stack};
27use gpui::{
28    Anchor, AnyElement, ElementId, IntoElement, Pixels, Point, SharedString, div, prelude::*, px,
29};
30use icons::Icon;
31use motion::{self as motion, AnimationExt as _, Fade, PULSE, Painter};
32use theme::{TextStyle, Theme, Typeset, ink};
33
34/// The deferred priority a modal and a sheet paint at.
35const MODAL_LAYER: usize = 2;
36
37/// The deferred priority a menu and its submenus paint at: over a modal, so a
38/// menu opened inside one shows.
39const MENU_LAYER: usize = 3;
40
41// ---------------------------------------------------------------------------
42// Loadable — async slot state shared by pickers/settings pages
43// ---------------------------------------------------------------------------
44
45/// One async-loaded slot: `Idle` (never requested) → `Loading` (skeletons) →
46/// `Ready` / `Error` (inline message + Retry).
47#[derive(Debug, Clone, PartialEq, Default)]
48pub enum Loadable<T> {
49    #[default]
50    Idle,
51    Loading,
52    Ready(T),
53    Error(String),
54}
55
56impl<T> Loadable<T> {
57    pub fn ready(&self) -> Option<&T> {
58        match self {
59            Loadable::Ready(value) => Some(value),
60            _ => None,
61        }
62    }
63
64    pub fn is_loading(&self) -> bool {
65        matches!(self, Loadable::Loading)
66    }
67
68    pub fn error(&self) -> Option<&str> {
69        match self {
70            Loadable::Error(message) => Some(message),
71            _ => None,
72        }
73    }
74}
75
76// ---------------------------------------------------------------------------
77// Popup — open/closing/closed lifecycle (exit animations)
78// ---------------------------------------------------------------------------
79
80/// Popup state with an exit phase. gpui unmounts an element the frame its
81/// state drops, so a closing animation needs the state held alive while
82/// [`motion::menu_out`] plays: `open` → `begin_close` (render keeps mounting,
83/// with the out animation and dead hit-testing) → [`reap_popup`]'s timer
84/// `finish_close`es ~[`motion::MENU_OUT`] later. Use [`Self::is_open`] for
85/// logic (a closing popup already reads as closed) and [`Self::get`] /
86/// [`Self::is_closing`] for rendering.
87pub struct Popup<T> {
88    /// `Some((state, closing_since))` while mounted; `closing_since` is the
89    /// exit-phase start.
90    inner: Option<(T, Option<web_time::Instant>)>,
91    /// Whether the popup was still mounted when the current trigger press
92    /// began — see [`Self::note_trigger_press`].
93    pressed_while_open: bool,
94    generation: u64,
95}
96
97impl<T> Default for Popup<T> {
98    fn default() -> Self {
99        Self {
100            inner: None,
101            pressed_while_open: false,
102            generation: 0,
103        }
104    }
105}
106
107impl<T> Popup<T> {
108    pub fn open(&mut self, value: T) {
109        self.generation = self.generation.wrapping_add(1);
110        self.inner = Some((value, None));
111    }
112
113    /// Open and interactive (not closing).
114    pub fn is_open(&self) -> bool {
115        matches!(self.inner, Some((_, None)))
116    }
117
118    pub fn is_closing(&self) -> bool {
119        matches!(self.inner, Some((_, Some(_))))
120    }
121
122    /// When the exit phase began — what the render path hands to the popover
123    /// wrappers, which derive the eased exit progress from it each frame.
124    pub fn closing_since(&self) -> Option<web_time::Instant> {
125        match &self.inner {
126            Some((_, Some(since))) => Some(*since),
127            _ => None,
128        }
129    }
130
131    /// The state while mounted — open OR playing the exit animation. Render
132    /// paths use this; logic paths use [`Self::as_open`]/[`Self::open_mut`].
133    pub fn get(&self) -> Option<&T> {
134        self.inner.as_ref().map(|(value, _)| value)
135    }
136
137    /// The state only while genuinely open — `None` during the exit phase, so
138    /// event handlers on a dying popup fall through.
139    pub fn as_open(&self) -> Option<&T> {
140        match &self.inner {
141            Some((value, None)) => Some(value),
142            _ => None,
143        }
144    }
145
146    pub fn open_mut(&mut self) -> Option<&mut T> {
147        match &mut self.inner {
148            Some((value, None)) => Some(value),
149            _ => None,
150        }
151    }
152
153    /// Unmount now, with no exit phase.
154    ///
155    /// For a surface that has no exit animation to play — [`modal`], which
156    /// takes no `closing` and paints the same either way. Sending one of those
157    /// through [`Self::begin_close`] buys nothing and costs everything: it
158    /// stays fully painted for the animation's span, and if the reap never
159    /// lands it stays forever, because nothing retries.
160    ///
161    /// Not for a popup something toggles: unmounting on the press erases what
162    /// [`Self::note_trigger_press`] has to read, so the note comes back false
163    /// and the trigger reopens what it just shut. Those close through
164    /// [`close_popup`].
165    pub fn close(&mut self) {
166        self.inner = None;
167    }
168
169    /// Enter the exit phase. Returns `true` when this call started it (the
170    /// caller then schedules [`reap_popup`]); `false` if already closing or
171    /// closed.
172    pub fn begin_close(&mut self) -> bool {
173        match &mut self.inner {
174            Some((_, closing @ None)) => {
175                self.generation = self.generation.wrapping_add(1);
176                *closing = Some(web_time::Instant::now());
177                true
178            }
179            _ => false,
180        }
181    }
182
183    /// Record, from the trigger's `on_mouse_down`, whether this popup is
184    /// still mounted. The anchored card's `on_mouse_down_out` fires on that
185    /// same press and begins the close, so by click (mouse-up) time the
186    /// popup already reads as closed — the click handler alone cannot tell
187    /// "this press dismissed it; stay closed" from "open fresh", and a
188    /// plain toggle closes-and-reopens (user report). Both handler orders
189    /// work: open and mid-exit each count as mounted. Every trigger click
190    /// is preceded by a trigger mouse-down, so the note is never stale.
191    ///
192    /// [`menu_trigger`] wires this and the matching click together. Reach for
193    /// it directly only when the open is not a click — a gutter handle whose
194    /// menu belongs to the release of a possible drag.
195    pub fn note_trigger_press(&mut self) {
196        self.note_trigger_press_matching(|_| true);
197    }
198
199    /// [`Self::note_trigger_press`] for popups whose state distinguishes
200    /// which trigger owns them (e.g. one `Popup<PickerKind>` shared by
201    /// several triggers): only a press on the OWNING trigger counts, so
202    /// clicking a different trigger switches menus instead of swallowing.
203    pub fn note_trigger_press_matching(&mut self, owns: impl FnOnce(&T) -> bool) {
204        self.pressed_while_open = self.inner.as_ref().is_some_and(|(value, _)| owns(value));
205    }
206
207    /// Consume the press note: `true` when the press that produced the
208    /// current click found the popup mounted — the click should leave it
209    /// closed rather than reopen it.
210    pub fn take_press_was_open(&mut self) -> bool {
211        std::mem::take(&mut self.pressed_while_open)
212    }
213
214    /// Drop the state now the exit phase has run its course. A popup reopened
215    /// since the matching [`Self::begin_close`] is left alone — it is `None`
216    /// again in the second slot, and the newer phase's own reap handles it.
217    ///
218    /// It does not re-check the clock. [`reap_popup`] already waited out the
219    /// span on the executor's timer, and asking `Instant::elapsed` to agree
220    /// makes one deadline depend on two clocks — where they disagree, and a
221    /// throttled executor is where, the popup is stranded open with nothing
222    /// left to retry.
223    pub fn finish_close(&mut self) {
224        if matches!(&self.inner, Some((_, Some(_)))) {
225            self.inner = None;
226        }
227    }
228}
229
230/// Schedule the reap for a [`Popup::begin_close`]: after the exit animation's
231/// span, drop the popup state and repaint. `popup` re-borrows the field from
232/// the view (the state can't be captured — the view owns it).
233pub fn reap_popup<V: 'static, T: 'static>(
234    view: &mut V,
235    cx: &mut gpui::Context<V>,
236    popup: impl Fn(&mut V) -> &mut Popup<T> + 'static,
237) {
238    let generation = popup(view).generation;
239    cx.spawn(async move |view, cx| {
240        cx.background_executor()
241            .timer(
242                motion::MENU_OUT
243                    .total()
244                    .mul_f32(motion::speed_scale())
245                    .saturating_add(std::time::Duration::from_millis(20)),
246            )
247            .await;
248        view.update(cx, |view, cx| {
249            let popup = popup(view);
250            if popup.generation == generation && popup.is_closing() {
251                popup.finish_close();
252                cx.notify();
253            }
254        })
255        .ok();
256    })
257    .detach();
258}
259
260/// Begin a popup's exit phase and schedule its reap — [`Popup::begin_close`]
261/// and [`reap_popup`], which are only ever correct together. A popup already
262/// closing or closed is left alone.
263pub fn close_popup<V: 'static, T: 'static>(
264    view: &mut V,
265    cx: &mut gpui::Context<V>,
266    popup: impl Fn(&mut V) -> &mut Popup<T> + Copy + 'static,
267) {
268    if popup(view).begin_close() {
269        reap_popup(view, cx, popup);
270        cx.notify();
271    }
272}
273
274/// Dismiss `popup` on a press outside `el` — the card side of the pair
275/// [`menu_trigger`] completes.
276///
277/// It closes through [`close_popup`] rather than [`Popup::close`], and that is
278/// load-bearing rather than cosmetic: the exit phase is what keeps the popup
279/// reading as mounted while the trigger's own press handler runs, whichever of
280/// the two the frame happens to dispatch first.
281///
282/// The press is swallowed, as [`crate::menu::card`]'s own dismissal swallows
283/// it: this listener runs in the capture phase, and stopping there skips the
284/// bubble phase where gpui records the press a click is built from. A press
285/// that dismisses an open card is spent on the dismissal, and whatever it
286/// landed on is one more press away.
287pub fn dismiss_on_out<V: 'static, T: 'static, E: gpui::InteractiveElement>(
288    el: E,
289    popup: impl Fn(&mut V) -> &mut Popup<T> + Copy + 'static,
290    cx: &gpui::Context<V>,
291) -> E {
292    el.on_mouse_down_out(cx.listener(move |view, _: &gpui::MouseDownEvent, _, cx| {
293        close_popup(view, cx, popup);
294        cx.stop_propagation();
295    }))
296}
297
298/// Wire a trigger to the popup it toggles: press note on the way down, open or
299/// close on the way up.
300///
301/// The press/release split is why this exists rather than a plain
302/// `on_click`. `on_mouse_down_out` fires on the **press** and `on_click` on the
303/// **release**, so one physical click on the trigger of an open menu runs the
304/// card's dismissal first and the trigger's toggle second — by which time the
305/// state a toggle would branch on is already gone, and the menu reopens on the
306/// click that shut it (user report). No click handler can tell the two apart;
307/// the note has to be taken in the phase the dismissal cannot precede.
308///
309/// `value` is what to open with, from the click that opened it — a point for a
310/// menu anchored where it was pressed, or `move |_| ..` for one that already
311/// knows.
312///
313/// This is a true toggle either way: a card that dismisses itself on the
314/// out-press is already closing by the release and the close here is a no-op,
315/// while a card that does not (one dismissed by Escape alone, or one the
316/// trigger sits inside) is closed by the release itself.
317pub fn menu_trigger<V: 'static, T: 'static, E: gpui::StatefulInteractiveElement>(
318    el: E,
319    popup: impl Fn(&mut V) -> &mut Popup<T> + Copy + 'static,
320    value: impl Fn(&gpui::ClickEvent) -> T + 'static,
321    cx: &gpui::Context<V>,
322) -> E {
323    menu_trigger_matching(el, popup, |_| true, value, cx)
324}
325
326/// [`menu_trigger`] for one popup shared by several triggers (a `Popup<Menu>`
327/// with a row's index inside it): `owns` says whether the open popup is *this*
328/// trigger's, so pressing another trigger switches menus instead of swallowing
329/// the press. See [`Popup::note_trigger_press_matching`].
330pub fn menu_trigger_matching<V: 'static, T: 'static, E: gpui::StatefulInteractiveElement>(
331    el: E,
332    popup: impl Fn(&mut V) -> &mut Popup<T> + Copy + 'static,
333    owns: impl Fn(&T) -> bool + 'static,
334    value: impl Fn(&gpui::ClickEvent) -> T + 'static,
335    cx: &gpui::Context<V>,
336) -> E {
337    trigger_press_matching(el, popup, owns, cx).on_click(cx.listener(
338        move |view, event: &gpui::ClickEvent, _, cx| {
339            if popup(view).take_press_was_open() {
340                close_popup(view, cx, popup);
341            } else {
342                popup(view).open(value(event));
343            }
344            cx.notify();
345        },
346    ))
347}
348
349/// The press half of [`menu_trigger`] on its own, for a trigger whose open is
350/// more than `Popup::open` — a combobox that clears its query and focuses it, a
351/// gutter handle whose menu belongs to the release of a possible drag. The
352/// click side is then the caller's, and reads the note with
353/// [`Popup::take_press_was_open`].
354pub fn trigger_press<V: 'static, T: 'static, E: gpui::InteractiveElement>(
355    el: E,
356    popup: impl Fn(&mut V) -> &mut Popup<T> + Copy + 'static,
357    cx: &gpui::Context<V>,
358) -> E {
359    trigger_press_matching(el, popup, |_| true, cx)
360}
361
362/// [`trigger_press`] for one popup shared by several triggers — see
363/// [`menu_trigger_matching`].
364pub fn trigger_press_matching<V: 'static, T: 'static, E: gpui::InteractiveElement>(
365    el: E,
366    popup: impl Fn(&mut V) -> &mut Popup<T> + Copy + 'static,
367    owns: impl Fn(&T) -> bool + 'static,
368    cx: &gpui::Context<V>,
369) -> E {
370    // Capture, not bubble: `on_mouse_down_out` is itself a capture-phase
371    // listener, so a bubble-phase note is dispatched after every dismissal in
372    // the frame and reads whatever they left behind. Survivable while every
373    // dismissal goes through [`close_popup`] — mid-exit still counts as
374    // mounted — and not survivable the moment one reaches for [`Popup::close`],
375    // which is a footgun to leave lying under a component's own trigger.
376    el.capture_any_mouse_down(cx.listener(move |view, _: &gpui::MouseDownEvent, _, _| {
377        popup(view).note_trigger_press_matching(&owns);
378    }))
379}
380
381// ---------------------------------------------------------------------------
382// Elements
383// ---------------------------------------------------------------------------
384
385/// The inner inset of a [`popover_card`], and so the amount [`menu_row`]'s
386/// corners come in by. Named because two things read it: the card's padding and
387/// its rows' radius. Change it and the rows follow.
388pub(crate) const MENU_PAD: f32 = 4.0;
389
390/// The gap a floating layer keeps from the window edge once it has run out of
391/// room and is snapped back inside it.
392pub(crate) const SNAP: f32 = 8.0;
393
394/// How far a [`menu_row`]'s text sits in from its own edge. Content that is
395/// not a row lines up with the rows' text by insetting this much.
396pub const MENU_ROW_INSET: f32 = 8.0;
397
398/// A [`menu_row`]'s padding above and below its line box. Named because a
399/// list that caps itself at a row count has to know how tall a row is.
400pub(crate) const MENU_ROW_PAD_Y: f32 = 6.0;
401
402/// How tall a one-line [`menu_row`] paints. Derived rather than stored: the
403/// two would drift, and it moves with the reader's text size.
404pub(crate) fn menu_row_height() -> f32 {
405    TextStyle::Body.painted_line_height() + 2.0 * MENU_ROW_PAD_Y
406}
407
408/// The floating-menu card. With `theme.glass` on it paints contents only, and
409/// the fill, hairline and shadow are the surface's it is mounted on.
410pub fn popover_card(theme: &Theme) -> gpui::Div {
411    let card = div()
412        .rounded(px(Theme::surface_radius()))
413        .p(px(MENU_PAD))
414        .overflow_hidden()
415        .text_style(TextStyle::Body)
416        .text_color(theme.text);
417    // Contents only. Fill, boundary and shadow are the surface's — every look
418    // paints its own, so nothing here has to know which one is under it. An
419    // card with the recipes off has no surface at all, and keeps the fill.
420    if theme.glass {
421        card
422    } else {
423        card.bg(theme.surface_overlay)
424            .border_1()
425            .border_color(theme.border)
426            .shadow_lg()
427    }
428}
429
430/// [`popover_card`] without the `p-1` inset — for popovers that manage their
431/// own internal panes (the harness/model picker's rail + list split).
432pub fn popover_card_flush(theme: &Theme) -> gpui::Div {
433    popover_card(theme).p(px(0.0))
434}