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