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