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::{
16    Anchor, AnyElement, ElementId, IntoElement, Pixels, Point, SharedString, div, prelude::*, px,
17};
18use motion::{self as motion, AnimationExt as _, Fade, PULSE, Painter};
19use theme::{TextStyle, Theme, Typeset, hairline, ink};
20
21// ---------------------------------------------------------------------------
22// Loadable — async slot state shared by pickers/settings pages
23// ---------------------------------------------------------------------------
24
25/// One async-loaded slot: `Idle` (never requested) → `Loading` (skeletons) →
26/// `Ready` / `Error` (inline message + Retry).
27#[derive(Debug, Clone, PartialEq, Default)]
28pub enum Loadable<T> {
29    #[default]
30    Idle,
31    Loading,
32    Ready(T),
33    Error(String),
34}
35
36impl<T> Loadable<T> {
37    pub fn ready(&self) -> Option<&T> {
38        match self {
39            Loadable::Ready(value) => Some(value),
40            _ => None,
41        }
42    }
43
44    pub fn is_loading(&self) -> bool {
45        matches!(self, Loadable::Loading)
46    }
47
48    pub fn error(&self) -> Option<&str> {
49        match self {
50            Loadable::Error(message) => Some(message),
51            _ => None,
52        }
53    }
54}
55
56// ---------------------------------------------------------------------------
57// Popup — open/closing/closed lifecycle (exit animations)
58// ---------------------------------------------------------------------------
59
60/// Popup state with an exit phase. gpui unmounts an element the frame its
61/// state drops, so a closing animation needs the state held alive while
62/// [`motion::menu_out`] plays: `open` → `begin_close` (render keeps mounting,
63/// with the out animation and dead hit-testing) → [`reap_popup`]'s timer
64/// `finish_close`es ~[`motion::MENU_OUT`] later. Use [`Self::is_open`] for
65/// logic (a closing popup already reads as closed) and [`Self::get`] /
66/// [`Self::is_closing`] for rendering.
67pub struct Popup<T> {
68    /// `Some((state, closing_since))` while mounted; `closing_since` is the
69    /// exit-phase start.
70    inner: Option<(T, Option<web_time::Instant>)>,
71    /// Whether the popup was still mounted when the current trigger press
72    /// began — see [`Self::note_trigger_press`].
73    pressed_while_open: bool,
74}
75
76impl<T> Default for Popup<T> {
77    fn default() -> Self {
78        Self {
79            inner: None,
80            pressed_while_open: false,
81        }
82    }
83}
84
85impl<T> Popup<T> {
86    pub fn open(&mut self, value: T) {
87        self.inner = Some((value, None));
88    }
89
90    /// Open and interactive (not closing).
91    pub fn is_open(&self) -> bool {
92        matches!(self.inner, Some((_, None)))
93    }
94
95    pub fn is_closing(&self) -> bool {
96        matches!(self.inner, Some((_, Some(_))))
97    }
98
99    /// When the exit phase began — what the render path hands to the popover
100    /// wrappers, which derive the eased exit progress from it each frame.
101    pub fn closing_since(&self) -> Option<web_time::Instant> {
102        match &self.inner {
103            Some((_, Some(since))) => Some(*since),
104            _ => None,
105        }
106    }
107
108    /// The state while mounted — open OR playing the exit animation. Render
109    /// paths use this; logic paths use [`Self::as_open`]/[`Self::open_mut`].
110    pub fn get(&self) -> Option<&T> {
111        self.inner.as_ref().map(|(value, _)| value)
112    }
113
114    /// The state only while genuinely open — `None` during the exit phase, so
115    /// event handlers on a dying popup fall through.
116    pub fn as_open(&self) -> Option<&T> {
117        match &self.inner {
118            Some((value, None)) => Some(value),
119            _ => None,
120        }
121    }
122
123    pub fn open_mut(&mut self) -> Option<&mut T> {
124        match &mut self.inner {
125            Some((value, None)) => Some(value),
126            _ => None,
127        }
128    }
129
130    /// Unmount now, with no exit phase.
131    ///
132    /// For a surface that has no exit animation to play — [`modal`], which
133    /// takes no `closing` and paints the same either way. Sending one of those
134    /// through [`Self::begin_close`] buys nothing and costs everything: it
135    /// stays fully painted for the animation's span, and if the reap never
136    /// lands it stays forever, because nothing retries.
137    pub fn close(&mut self) {
138        self.inner = None;
139    }
140
141    /// Enter the exit phase. Returns `true` when this call started it (the
142    /// caller then schedules [`reap_popup`]); `false` if already closing or
143    /// closed.
144    pub fn begin_close(&mut self) -> bool {
145        match &mut self.inner {
146            Some((_, closing @ None)) => {
147                *closing = Some(web_time::Instant::now());
148                true
149            }
150            _ => false,
151        }
152    }
153
154    /// Record, from the trigger's `on_mouse_down`, whether this popup is
155    /// still mounted. The anchored card's `on_mouse_down_out` fires on that
156    /// same press and begins the close, so by click (mouse-up) time the
157    /// popup already reads as closed — the click handler alone cannot tell
158    /// "this press dismissed it; stay closed" from "open fresh", and a
159    /// plain toggle closes-and-reopens (user report). Both handler orders
160    /// work: open and mid-exit each count as mounted. Every trigger click
161    /// is preceded by a trigger mouse-down, so the note is never stale.
162    pub fn note_trigger_press(&mut self) {
163        self.note_trigger_press_matching(|_| true);
164    }
165
166    /// [`Self::note_trigger_press`] for popups whose state distinguishes
167    /// which trigger owns them (e.g. one `Popup<PickerKind>` shared by
168    /// several triggers): only a press on the OWNING trigger counts, so
169    /// clicking a different trigger switches menus instead of swallowing.
170    pub fn note_trigger_press_matching(&mut self, owns: impl FnOnce(&T) -> bool) {
171        self.pressed_while_open = self.inner.as_ref().is_some_and(|(value, _)| owns(value));
172    }
173
174    /// Consume the press note: `true` when the press that produced the
175    /// current click found the popup mounted — the click should leave it
176    /// closed rather than reopen it.
177    pub fn take_press_was_open(&mut self) -> bool {
178        std::mem::take(&mut self.pressed_while_open)
179    }
180
181    /// Drop the state now the exit phase has run its course. A popup reopened
182    /// since the matching [`Self::begin_close`] is left alone — it is `None`
183    /// again in the second slot, and the newer phase's own reap handles it.
184    ///
185    /// It does not re-check the clock. [`reap_popup`] already waited out the
186    /// span on the executor's timer, and asking `Instant::elapsed` to agree
187    /// makes one deadline depend on two clocks — where they disagree, and a
188    /// throttled executor is where, the popup is stranded open with nothing
189    /// left to retry.
190    pub fn finish_close(&mut self) {
191        if matches!(&self.inner, Some((_, Some(_)))) {
192            self.inner = None;
193        }
194    }
195}
196
197/// Schedule the reap for a [`Popup::begin_close`]: after the exit animation's
198/// span, drop the popup state and repaint. `popup` re-borrows the field from
199/// the view (the state can't be captured — the view owns it).
200pub fn reap_popup<V: 'static, T: 'static>(
201    cx: &mut gpui::Context<V>,
202    popup: impl Fn(&mut V) -> &mut Popup<T> + 'static,
203) {
204    cx.spawn(async move |view, cx| {
205        cx.background_executor()
206            .timer(
207                motion::MENU_OUT
208                    .total()
209                    .mul_f32(motion::speed_scale())
210                    .saturating_add(std::time::Duration::from_millis(20)),
211            )
212            .await;
213        view.update(cx, |view, cx| {
214            popup(view).finish_close();
215            cx.notify();
216        })
217        .ok();
218    })
219    .detach();
220}
221
222// ---------------------------------------------------------------------------
223// Pure reducers
224// ---------------------------------------------------------------------------
225
226/// Step the active row of a menu: wraps at both ends; `None` enters at the
227/// edge matching the direction. Empty menus stay `None`.
228pub fn menu_step(active: Option<usize>, count: usize, delta: isize) -> Option<usize> {
229    if count == 0 {
230        return None;
231    }
232    let count_i = count as isize;
233    let next = match active {
234        None => {
235            if delta >= 0 {
236                0
237            } else {
238                count_i - 1
239            }
240        }
241        Some(at) => (at as isize + delta).rem_euclid(count_i),
242    };
243    Some(next as usize)
244}
245
246/// Match rank of a label against a query: `0` prefix match, `1` substring,
247/// `None` no match. Case-insensitive; an empty query matches everything at
248/// rank 1 (input order preserved).
249pub fn match_rank(query: &str, label: &str) -> Option<usize> {
250    let query = query.trim().to_lowercase();
251    if query.is_empty() {
252        return Some(1);
253    }
254    let label = label.to_lowercase();
255    if label.starts_with(&query) {
256        Some(0)
257    } else if label.contains(&query) {
258        Some(1)
259    } else {
260        None
261    }
262}
263
264/// Filter + rank labels for a search query: prefix matches first, then
265/// substring matches, stable within each rank. Returns indices into `labels`.
266pub fn filter_indices<S: AsRef<str>>(query: &str, labels: &[S]) -> Vec<usize> {
267    let mut ranked: Vec<(usize, usize)> = labels
268        .iter()
269        .enumerate()
270        .filter_map(|(ix, label)| match_rank(query, label.as_ref()).map(|rank| (rank, ix)))
271        .collect();
272    ranked.sort_by_key(|&(rank, ix)| (rank, ix));
273    ranked.into_iter().map(|(_, ix)| ix).collect()
274}
275
276/// The state behind a searchable list: the items, the ranked view of them, and
277/// which row of that view is active. Shared by every picker — the palette, the
278/// combobox — so the mapping below is written and tested once.
279pub struct Filter {
280    items: Vec<SharedString>,
281    /// Indices into `items`, ranked by [`filter_indices`].
282    filtered: Vec<usize>,
283    /// Position within `filtered`, not within `items`.
284    active: Option<usize>,
285}
286
287impl Filter {
288    pub fn new(items: Vec<SharedString>) -> Self {
289        let filtered: Vec<usize> = (0..items.len()).collect();
290        let active = (!filtered.is_empty()).then_some(0);
291        Self {
292            items,
293            filtered,
294            active,
295        }
296    }
297
298    pub fn items(&self) -> &[SharedString] {
299        &self.items
300    }
301
302    /// The ranked view: indices into [`Self::items`], in display order.
303    pub fn filtered(&self) -> &[usize] {
304        &self.filtered
305    }
306
307    /// The highlighted row's position in the FILTERED view — what a renderer
308    /// compares each row against.
309    pub fn active(&self) -> Option<usize> {
310        self.active
311    }
312
313    /// Re-rank against `query`, re-entering the list at the top: after
314    /// narrowing, the best match should be one Enter away.
315    pub fn refilter(&mut self, query: &str) {
316        self.filtered = filter_indices(query, &self.items);
317        self.active = (!self.filtered.is_empty()).then_some(0);
318    }
319
320    pub fn step(&mut self, delta: isize) {
321        self.active = menu_step(self.active, self.filtered.len(), delta);
322    }
323
324    /// Put the cursor on a position in the FILTERED view — what the mouse
325    /// calls as it crosses a row, so a menu never shows a mouse cursor and a
326    /// keyboard cursor at once.
327    pub fn set_active(&mut self, position: usize) {
328        if position < self.filtered.len() {
329            self.active = Some(position);
330        }
331    }
332
333    /// The item confirming right now would pick — an index into
334    /// [`Self::items`], never into the filtered view. Confusing the two is the
335    /// defining bug of a filtered list: it only appears once a query narrows
336    /// the rows, and then every selection picks the wrong thing.
337    pub fn active_item(&self) -> Option<usize> {
338        self.active
339            .and_then(|position| self.filtered.get(position))
340            .copied()
341    }
342}
343
344/// Keys the pickers care about, classified from a raw keystroke.
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub enum MenuKey {
347    Up,
348    Down,
349    /// Plain Enter — activate the highlighted row.
350    Enter,
351    /// Cmd/Ctrl+Enter — the "pick this folder" accelerator in the browser.
352    ModEnter,
353    Escape,
354    Backspace,
355    Other,
356}
357
358pub fn classify_key(key: &str, cmd: bool, ctrl: bool) -> MenuKey {
359    match key {
360        "up" => MenuKey::Up,
361        "down" => MenuKey::Down,
362        // Readline/emacs motion: ctrl-n/ctrl-p mirror ↓/↑ in every picker.
363        // Safe to claim frame-wide — neither chord is a text-editing binding
364        // in the palette keymaps, so they always bubble here unconsumed.
365        "n" if ctrl => MenuKey::Down,
366        "p" if ctrl => MenuKey::Up,
367        "enter" if cmd || ctrl => MenuKey::ModEnter,
368        "enter" => MenuKey::Enter,
369        "escape" => MenuKey::Escape,
370        "backspace" => MenuKey::Backspace,
371        _ => MenuKey::Other,
372    }
373}
374
375// ---------------------------------------------------------------------------
376// Elements
377// ---------------------------------------------------------------------------
378
379/// The floating-menu surface: `rounded-xl border border-white/[0.1] p-1` over
380/// whichever look [`Theme::menu_style`] names — the hairline and the baked-in
381/// shadow are the card's, and the surface under it paints everything inside
382/// them. Opaque platforms keep the near-opaque tone the reference composites
383/// to on the dark panels (~#161616).
384/// The inner inset of a [`popover_card`], and so the amount [`menu_row`]'s
385/// corners come in by. Named because two things read it: the card's padding and
386/// its rows' radius. Change it and the rows follow.
387pub(crate) const MENU_PAD: f32 = 4.0;
388
389pub fn popover_card(theme: &Theme) -> gpui::Div {
390    let card = div()
391        .rounded(px(Theme::surface_radius()))
392        .p(px(MENU_PAD))
393        .overflow_hidden()
394        .text_style(TextStyle::Body)
395        .text_color(theme.text);
396    // Contents only. Fill, boundary and shadow are the surface's — every look
397    // paints its own, so nothing here has to know which one is under it. An
398    // card with the recipes off has no surface at all, and keeps the fill.
399    if theme.glass {
400        card
401    } else {
402        card.bg(theme.surface_overlay)
403            .border_1()
404            .border_color(hairline(0.10))
405            .shadow_lg()
406    }
407}
408
409/// [`popover_card`] without the `p-1` inset — for popovers that manage their
410/// own internal panes (the harness/model picker's rail + list split).
411pub fn popover_card_flush(theme: &Theme) -> gpui::Div {
412    popover_card(theme).p(px(0.0))
413}
414
415/// Pin a floating layer's origin to the trigger's top-left. The anchored
416/// element is absolutely positioned; without explicit insets its *static*
417/// position is subject to the trigger's own flex alignment (an `items_center`
418/// trigger would vertically center the whole floating layer). A zero-size
419/// absolutely-inset wrapper fixes the origin at the corner.
420fn pinned_layer(layer: AnyElement) -> AnyElement {
421    div()
422        .absolute()
423        .top_0()
424        .left_0()
425        .size_0()
426        .child(layer)
427        .into_any_element()
428}
429
430/// Eased exit progress (0..=1) for a [`Popup`] closing instant, computed from
431/// the wall clock at render time. Monotonic by construction — unlike the
432/// animation element's own clock, it can never replay from 0 mid-exit.
433fn exit_progress(since: web_time::Instant) -> f32 {
434    let total = motion::MENU_OUT
435        .total()
436        .mul_f32(motion::speed_scale())
437        .as_secs_f32();
438    let raw = if total <= 0.0 {
439        1.0
440    } else {
441        (since.elapsed().as_secs_f32() / total).clamp(0.0, 1.0)
442    };
443    motion::MENU_OUT.progress(raw)
444}
445
446/// The surface under a popover layer, on whichever look [`Theme::menu_style`]
447/// names. It needs no exit ramp of its own: the primitive reads the element
448/// tree's opacity, so a layer playing `menu_out` fades its surface with
449/// everything else in it.
450fn material_menu(content: AnyElement) -> AnyElement {
451    crate::surface::popover(Theme::surface_radius(), content).into_any_element()
452}
453
454/// Entrance or exit motion for a popover layer. While exiting (the [`Popup`]
455/// closing phase, `exit = Some(progress)`) the content plays
456/// [`motion::menu_out`] under a fresh animation id (same-id reuse would
457/// inherit the entrance's finished clock and snap to the end state) and gets
458/// an occluding overlay on top — the dying menu's rows must not take clicks,
459/// and the overlay also keeps stray clicks from reaching whatever sits
460/// underneath.
461fn menu_motion(id: SharedString, exit: Option<f32>, inner: gpui::Div) -> AnyElement {
462    if let Some(t) = exit {
463        let inner = inner.relative().child(div().absolute().inset_0().occlude());
464        motion::menu_out(SharedString::from(format!("{id}-out")), t, inner).into_any_element()
465    } else {
466        motion::menu_in(id, inner).into_any_element()
467    }
468}
469
470/// Wrap popover content in a floating anchored layer attached to the trigger:
471/// the caller `.child(anchored_menu(...))`s this from the trigger element while
472/// open. Plays `menu-in` (0.14s fade + 2px drop); `closing` (the [`Popup`]
473/// exit phase) swaps in `menu-out`. Dismissal is the caller's
474/// `.on_mouse_down_out` on the content. The layer `.occlude()`s: hitboxes are
475/// paint-order only in gpui, so without it clicks on menu rows would ALSO fire
476/// whatever clickable sits under the floating layer.
477pub fn anchored_menu(
478    id: impl Into<SharedString>,
479    content: AnyElement,
480    closing: Option<web_time::Instant>,
481) -> AnyElement {
482    let exit = closing.map(exit_progress);
483    let content = material_menu(content);
484    pinned_layer(
485        gpui::deferred(
486            gpui::anchored()
487                .anchor(Anchor::TopLeft)
488                .snap_to_window_with_margin(px(8.0))
489                .child(menu_motion(
490                    id.into(),
491                    exit,
492                    div().occlude().pt(px(6.0)).child(content),
493                )),
494        )
495        .priority(1)
496        .into_any_element(),
497    )
498}
499
500/// [`anchored_menu`] opening DOWNWARD from the trigger's bottom edge — a
501/// dropdown proper (the sidebar's space filter). The default variant pins to
502/// the trigger's top-left, which reads fine for context-style menus but
503/// covers a button-shaped trigger.
504pub fn anchored_menu_below(
505    id: impl Into<SharedString>,
506    content: AnyElement,
507    closing: Option<web_time::Instant>,
508) -> AnyElement {
509    anchored_menu_below_gap(id, content, closing, 6.0)
510}
511
512/// [`anchored_menu_below`] with a caller-chosen trigger→card gap — the
513/// changes-header dropdowns hang off a tight titlebar band and need more
514/// breathing room than the default 6px (user report; t3code sits near 10).
515pub fn anchored_menu_below_gap(
516    id: impl Into<SharedString>,
517    content: AnyElement,
518    closing: Option<web_time::Instant>,
519    gap: f32,
520) -> AnyElement {
521    let exit = closing.map(exit_progress);
522    let content = material_menu(content);
523    div()
524        .absolute()
525        .bottom_0()
526        .left_0()
527        .size_0()
528        .child(
529            gpui::deferred(
530                gpui::anchored()
531                    .anchor(Anchor::TopLeft)
532                    .snap_to_window_with_margin(px(8.0))
533                    .child(menu_motion(
534                        id.into(),
535                        exit,
536                        div().occlude().pt(px(gap)).child(content),
537                    )),
538            )
539            .priority(1)
540            .into_any_element(),
541        )
542        .into_any_element()
543}
544
545/// [`anchored_menu`] opening UPWARD from the trigger (composer pickers, the
546/// user menu — anything anchored near the window bottom; Radix flips these
547/// automatically, gpui's `anchored` needs the side picked).
548pub fn anchored_menu_above(
549    id: impl Into<SharedString>,
550    content: AnyElement,
551    closing: Option<web_time::Instant>,
552) -> AnyElement {
553    let exit = closing.map(exit_progress);
554    let content = material_menu(content);
555    pinned_layer(
556        gpui::deferred(
557            gpui::anchored()
558                .anchor(Anchor::BottomLeft)
559                .snap_to_window_with_margin(px(8.0))
560                .child(menu_motion(
561                    id.into(),
562                    exit,
563                    div().occlude().pb(px(6.0)).child(content),
564                )),
565        )
566        .priority(1)
567        .into_any_element(),
568    )
569}
570
571/// Open an upward menu at a point inside a relative trigger. Useful for text
572/// completions, whose natural anchor is the token/caret rather than the input
573/// element's outer edge.
574pub fn anchored_menu_above_at(
575    id: impl Into<SharedString>,
576    position: Point<Pixels>,
577    content: AnyElement,
578    closing: Option<web_time::Instant>,
579) -> AnyElement {
580    div()
581        .absolute()
582        .left(position.x)
583        .top(position.y)
584        .size_0()
585        .child(anchored_menu_above(id, content, closing))
586        .into_any_element()
587}
588
589/// [`anchored_menu_above`] right-aligned to the trigger's right edge (t3code
590/// ComboboxPopup `align="end"` — right-side triggers like the composer's ref
591/// picker open leftward instead of running off the window).
592pub fn anchored_menu_above_end(
593    id: impl Into<SharedString>,
594    content: AnyElement,
595    closing: Option<web_time::Instant>,
596) -> AnyElement {
597    let exit = closing.map(exit_progress);
598    let content = material_menu(content);
599    div()
600        .absolute()
601        .top_0()
602        .right_0()
603        .size_0()
604        .child(
605            gpui::deferred(
606                gpui::anchored()
607                    .anchor(Anchor::BottomRight)
608                    .snap_to_window_with_margin(px(8.0))
609                    .child(menu_motion(
610                        id.into(),
611                        exit,
612                        div().occlude().pb(px(6.0)).child(content),
613                    )),
614            )
615            .priority(1)
616            .into_any_element(),
617        )
618        .into_any_element()
619}
620
621/// A floating menu at an explicit window position (context menus). Occludes
622/// like [`anchored_menu`] so row clicks never reach elements underneath.
623pub fn menu_at(
624    id: impl Into<SharedString>,
625    position: Point<Pixels>,
626    content: AnyElement,
627    closing: Option<web_time::Instant>,
628) -> AnyElement {
629    let exit = closing.map(exit_progress);
630    let content = material_menu(content);
631    gpui::deferred(
632        gpui::anchored()
633            .position(position)
634            .anchor(Anchor::TopLeft)
635            .snap_to_window_with_margin(px(8.0))
636            .child(menu_motion(id.into(), exit, div().occlude().child(content))),
637    )
638    .priority(1)
639    .into_any_element()
640}
641
642/// Modal/overlay scrim at the *current* appearance, quoted in dark-mode terms
643/// like [`ink`]/[`hairline`] — for callers (`modal`, the attachment lightbox)
644/// that paint from a `deferred`/`anchored` layer with no `Theme`/`cx` in
645/// scope. Mirrors [`Theme::scrim`], which is pinned at `X = 0.6` dark /
646/// `0.32` light; other dark-mode alphas scale the light side by the same
647/// ratio so the *dark* result is always exactly `alpha_dark` (never routed
648/// through [`Hsla::opacity`], whose `0..=1` clamp would clip a
649/// larger-than-0.6 alpha before it could scale the light side).
650pub(crate) fn scrim_alpha(alpha_dark: f32) -> gpui::Hsla {
651    theme::scrim(alpha_dark)
652}
653
654/// Full-window modal: dim scrim + centered card with the `dialog-in` entrance.
655/// The scrim swallows clicks; the caller wires its own dismiss/confirm.
656/// `viewport` is the window size (an `anchored` layer sizes to its children,
657/// so the scrim needs explicit dimensions). The frost radius matches
658/// [`dialog_card`]'s 16px rounding.
659/// `on_dismiss` is the scrim press. It is a parameter rather than the caller's
660/// `.on_mouse_down_out`, for the same reason [`sheet`]'s is: the scrim lives
661/// inside this deferred layer, so nothing outside can reach it. Without it a
662/// dialog could not be dismissed by clicking away from it *by any caller* —
663/// which is how this one shipped, and what it looked like was a dialog that
664/// only closed on its own buttons.
665pub fn modal(
666    id: impl Into<ElementId>,
667    viewport: gpui::Size<Pixels>,
668    card: AnyElement,
669    on_dismiss: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
670) -> AnyElement {
671    modal_with(id, viewport, card, DIALOG_RADIUS, 0.6, on_dismiss)
672}
673
674/// [`modal`] for glass-tinted cards (the add-space palette): a LIGHTER scrim,
675/// so the material card reads like the popovers — the standard 0.6 dim buried
676/// the backdrop hue under the blur and the palette came out a flat grey slab
677/// next to the hue-inheriting menus (user report).
678///
679/// The radius is [`Theme::surface_radius`], not a parameter: a glass-tinted
680/// modal *is* a popover surface, and the parameter this used to take carried
681/// the doc line "must match the card's rounding" — a footgun handed to the
682/// caller in writing.
683pub fn modal_glass(
684    id: impl Into<ElementId>,
685    viewport: gpui::Size<Pixels>,
686    card: AnyElement,
687    on_dismiss: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
688) -> AnyElement {
689    modal_with(
690        id,
691        viewport,
692        card,
693        Theme::surface_radius(),
694        0.35,
695        on_dismiss,
696    )
697}
698
699fn modal_with(
700    id: impl Into<ElementId>,
701    viewport: gpui::Size<Pixels>,
702    card: AnyElement,
703    corner_radius: f32,
704    scrim: f32,
705    on_dismiss: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
706) -> AnyElement {
707    let card = crate::surface::popover(corner_radius, card).into_any_element();
708    gpui::deferred(
709        gpui::anchored()
710            .position(gpui::point(px(0.0), px(0.0)))
711            .child(
712                div()
713                    .occlude()
714                    .w(viewport.width)
715                    .h(viewport.height)
716                    .bg(scrim_alpha(scrim))
717                    .flex()
718                    .items_center()
719                    .justify_center()
720                    // On the card's wrapper, not the scrim: a press inside the
721                    // card is not "out", so the dialog's own buttons keep
722                    // working with no occluding overlay and no propagation
723                    // games. The scrim covers the viewport and occludes, so
724                    // "outside the card" and "on the scrim" are the same press.
725                    .child(motion::dialog_in(
726                        id,
727                        div().child(card).on_mouse_down_out(on_dismiss),
728                    )),
729            ),
730    )
731    .priority(2)
732    .into_any_element()
733}
734
735// ---------------------------------------------------------------------------
736// Sheet — a dialog pinned to an edge
737// ---------------------------------------------------------------------------
738
739/// Which edge a [`sheet`] slides in from.
740#[derive(Debug, Clone, Copy, PartialEq, Eq)]
741pub enum Side {
742    Left,
743    Right,
744}
745
746/// Corner rounding of [`dialog_card`], and of a [`sheet_panel`]'s two inner
747/// corners (the two on the window edge are off-screen). One number rather than
748/// two that happen to match: a sheet *is* the dialog card, pinned to an edge
749/// instead of centred. Read three times over — the card, the sheet panel, and
750/// the blur under each — which is exactly why it is not a literal.
751const DIALOG_RADIUS: f32 = 16.0;
752
753/// The full-height panel body of a [`sheet`]: glass card chrome rounded and
754/// hairlined on its *inner* edge only, so it reads as pulled out of the window
755/// side rather than floating near it.
756pub fn sheet_panel(theme: &Theme, side: Side) -> gpui::Div {
757    let card = div()
758        .size_full()
759        .flex()
760        .flex_col()
761        .shadow_lg()
762        .text_color(theme.text);
763    let card = match side {
764        Side::Left => card
765            .rounded_r(px(DIALOG_RADIUS))
766            .border_r_1()
767            .border_color(hairline(0.10)),
768        Side::Right => card
769            .rounded_l(px(DIALOG_RADIUS))
770            .border_l_1()
771            .border_color(hairline(0.10)),
772    };
773    if theme.glass {
774        card.bg(theme.glass_overlay())
775    } else {
776        card.bg(theme.surface_overlay)
777    }
778}
779
780/// Full-height side panel over a dim scrim — [`modal`] pinned to an edge. It
781/// slides in over [`motion::DIALOG_IN`] and, once the caller's [`Popup`]
782/// enters its exit phase, back out over [`motion::MENU_OUT`] — which it must,
783/// because [`Popup::finish_close`] reaps on that spec's span.
784///
785/// `on_dismiss` is the scrim click. Unlike the anchored menus, dismissal
786/// cannot be the caller's `.on_mouse_down_out`: the scrim lives inside this
787/// deferred layer, so nothing outside can reach it.
788///
789/// The slide is written here rather than as a `motion` helper because
790/// only the *spec* is motion — which inset carries it is layout, and it
791/// differs per side.
792pub fn sheet(
793    id: impl Into<SharedString>,
794    viewport: gpui::Size<Pixels>,
795    side: Side,
796    width: Pixels,
797    content: AnyElement,
798    closing: Option<web_time::Instant>,
799    on_dismiss: impl Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static,
800) -> AnyElement {
801    let id = id.into();
802    let exit = closing.map(exit_progress);
803    let panel = div()
804        .absolute()
805        .top_0()
806        .bottom_0()
807        .w(width)
808        .child(crate::surface::popover(DIALOG_RADIUS, content));
809    // `t` runs 0 (fully off-screen) → 1 (seated against the edge).
810    let seat = move |el: gpui::Div, t: f32| {
811        let inset = width * (t - 1.0);
812        match side {
813            Side::Left => el.left(inset),
814            Side::Right => el.right(inset),
815        }
816    };
817    let panel = if let Some(t) = exit {
818        // The dying panel must not take clicks — same overlay `menu_motion`
819        // puts over an exiting menu.
820        let panel = seat(panel, 1.0 - t).child(div().absolute().inset_0().occlude());
821        panel
822            .with_animation(
823                SharedString::from(format!("{id}-out")),
824                motion::MENU_OUT.animation(),
825                move |el, _| el,
826            )
827            .into_any_element()
828    } else {
829        panel
830            .with_animation(id.clone(), motion::DIALOG_IN.animation(), seat)
831            .into_any_element()
832    };
833
834    gpui::deferred(
835        gpui::anchored()
836            .position(gpui::point(px(0.0), px(0.0)))
837            .child(
838                div()
839                    .id(SharedString::from(format!("{id}-scrim")))
840                    .occlude()
841                    .relative()
842                    .w(viewport.width)
843                    .h(viewport.height)
844                    .bg(scrim_alpha(0.6 * (1.0 - exit.unwrap_or(0.0))))
845                    .on_click(on_dismiss)
846                    .child(panel),
847            ),
848    )
849    .priority(2)
850    .into_any_element()
851}
852
853/// One menu row (the reference `menuItem`): `gap-2.5 rounded-lg px-2 py-1.5`,
854/// active = `bg-white/10 text-foreground`. The caller adds the id/click
855/// listener.
856///
857/// `active` is the row the cursor is on, and a menu has exactly one cursor.
858/// `Some(fade)` lets the mouse light a row by itself, animated over
859/// `transition-colors` (floating-styles.ts), for a menu holding no cursor of
860/// its own; its key must be stable across frames (the id string is a good
861/// choice). `None` is for a menu that owns an active index and moves it from
862/// `on_mouse_move` — move, not hover: gpui settles hover at paint time, so a
863/// list that re-filters or scrolls under a still mouse would drag the cursor
864/// to wherever the pointer sits.
865pub fn menu_row(theme: &Theme, active: bool, fade: Option<Fade>) -> gpui::Div {
866    let row = div()
867        .flex()
868        .flex_row()
869        .items_center()
870        .gap(px(10.0))
871        .px(px(8.0))
872        .py(px(6.0))
873        // Concentric with the card it sits in rather than a radius of its own:
874        // 12 − 4 = 8, which is where the crate's most-repeated corner value
875        // came from all along.
876        .rounded(px(Theme::inset_radius(Theme::surface_radius(), MENU_PAD)))
877        .text_style(TextStyle::Body)
878        .cursor_pointer();
879    match (active, fade) {
880        (true, _) => row.bg(theme::card_selected_bg()).text_color(theme.text),
881        (false, None) => row.text_color(theme.text.opacity(0.9)),
882        (false, Some(fade)) => {
883            let mut row = row
884                .text_color(motion::hover_blend(
885                    &fade,
886                    theme.text.opacity(0.9),
887                    theme.text,
888                ))
889                .bg(motion::hover_blend(
890                    &fade,
891                    theme::ink(0.0),
892                    theme.element_hover,
893                ));
894            // Imperative form — the caller's `.id(...)` makes the element stateful
895            // (hover listeners need element state, `.on_hover` needs `Stateful`).
896            row.interactivity().on_hover(motion::hover_listener(fade));
897            row
898        }
899    }
900}
901
902/// Small uppercase section heading inside a floating menu (the reference
903/// `MenuHeading`): `px-2 pb-1 pt-1.5 uppercase tracking-[0.1em]
904/// text-muted-foreground/60`. gpui has no letter-spacing at the pinned rev;
905/// the tracking is approximated with hair spaces.
906pub fn menu_heading(theme: &Theme, label: impl Into<SharedString>) -> gpui::Div {
907    let label = label.into();
908    div()
909        .px(px(8.0))
910        .pb(px(4.0))
911        .pt(px(6.0))
912        .text_style(TextStyle::Caption2)
913        .text_color(theme.text_muted.opacity(0.6))
914        .child(tracked_upper(&label))
915}
916
917/// Uppercase + hair-space tracking (see [`menu_heading`]).
918pub fn tracked_upper(label: &str) -> String {
919    let upper = label.to_uppercase();
920    let mut out = String::with_capacity(upper.len() * 2);
921    let mut first = true;
922    for ch in upper.chars() {
923        if !first {
924            out.push('\u{200A}'); // hair space ≈ 0.1em tracking
925        }
926        out.push(ch);
927        first = false;
928    }
929    out
930}
931
932/// Hairline divider between menu sections (the reference `MenuSeparator`:
933/// `mx-1 my-1 h-px bg-white/[0.07]`).
934pub fn divider() -> gpui::Div {
935    // Full-bleed: negative margins cancel the card's p-1 inset so the hairline
936    // runs border to border (user request).
937    div().h(px(1.0)).mx(px(-4.0)).my(px(4.0)).bg(hairline(0.07))
938}
939
940/// The recessed band tone for a palette/picker header or footer strip — a
941/// translucent black so the glass still reads through (the add-space palette
942/// converged on this; measured subtler tones vanish against the dim scrim).
943/// Free function (like [`ink`]/[`hairline`]/[`wash`]), mirroring
944/// [`Theme::band`], for the several callers with no `Theme`/`cx` in scope
945/// (some outside this crate's `ui` module tree — threading a `&Theme` param
946/// would ripple past this task's file scope).
947pub fn band() -> gpui::Hsla {
948    theme::band()
949}
950
951/// One footer key-cap (22px, rounded-5, `white/[0.05]`) holding arbitrary
952/// children — the base of [`key_hint`]/[`key_hint_pair`] and the search-bar
953/// chips ("⌘K", "esc").
954pub fn key_cap(_theme: &Theme) -> gpui::Div {
955    div()
956        .h(px(22.0))
957        .px(px(5.0))
958        .rounded(px(5.0))
959        .flex()
960        .flex_row()
961        .items_center()
962        .justify_center()
963        .gap(px(4.0))
964        .bg(ink(0.05))
965}
966
967/// The tiny verb after a key-cap.
968fn key_hint_label(theme: &Theme, label: &'static str) -> gpui::Div {
969    div()
970        .text_style(TextStyle::Caption)
971        .text_color(theme.text_muted.opacity(0.45))
972        .child(SharedString::from(label))
973}
974
975/// A footer legend: one icon key-cap + tiny verb (the add-space palette's
976/// footer voice, shared by the pickers).
977pub fn key_hint(theme: &Theme, icon_path: &'static str, label: &'static str) -> gpui::Div {
978    div()
979        .flex()
980        .flex_row()
981        .items_center()
982        .gap(px(5.0))
983        .child(
984            key_cap(theme).child(
985                crate::icons::icon(icon_path)
986                    .size(px(12.5))
987                    .text_color(theme.text_muted.opacity(0.7)),
988            ),
989        )
990        .child(key_hint_label(theme, label))
991}
992
993/// A footer legend whose cap holds a WORD ("tab", "esc") instead of a glyph
994/// — for keys with no icon in the set.
995pub fn key_hint_text(theme: &Theme, cap: &'static str, label: &'static str) -> gpui::Div {
996    div()
997        .flex()
998        .flex_row()
999        .items_center()
1000        .gap(px(5.0))
1001        .child(
1002            key_cap(theme)
1003                .text_style(TextStyle::Subheadline)
1004                .font_family(theme.font_mono.clone())
1005                .text_color(theme.text_muted.opacity(0.7))
1006                .child(SharedString::from(cap)),
1007        )
1008        .child(key_hint_label(theme, label))
1009}
1010
1011/// A footer legend whose cap holds TWO glyphs split by a hairline
1012/// ("[ ↑ | ↓ ] Navigate") sharing one verb.
1013pub fn key_hint_pair(
1014    theme: &Theme,
1015    first: &'static str,
1016    second: &'static str,
1017    label: &'static str,
1018) -> gpui::Div {
1019    div()
1020        .flex()
1021        .flex_row()
1022        .items_center()
1023        .gap(px(5.0))
1024        .child(
1025            key_cap(theme)
1026                .child(
1027                    crate::icons::icon(first)
1028                        .size(px(12.5))
1029                        .text_color(theme.text_muted.opacity(0.7)),
1030                )
1031                .child(div().w(px(1.0)).h(px(11.0)).bg(hairline(0.10)))
1032                .child(
1033                    crate::icons::icon(second)
1034                        .size(px(12.5))
1035                        .text_color(theme.text_muted.opacity(0.7)),
1036                ),
1037        )
1038        .child(key_hint_label(theme, label))
1039}
1040
1041/// A muted kbd hint chip inside menu rows (`⌘↵`-style accelerators).
1042pub fn kbd_hint(theme: &Theme, label: impl Into<SharedString>) -> gpui::Div {
1043    div()
1044        .flex_none()
1045        .px(px(5.0))
1046        .py(px(1.0))
1047        .rounded(px(5.0))
1048        .bg(ink(0.05))
1049        .text_style(TextStyle::Caption)
1050        .font_family(theme.font_mono.clone())
1051        .text_color(theme.text_muted.opacity(0.6))
1052        .child(label.into())
1053}
1054
1055/// The query line at the top of a picker popover: a magnifier, the field, and
1056/// a hairline under it.
1057///
1058/// The field belongs in `with_frame(false)` — a box here would be a second
1059/// frame inside the card's. Full-bleed like [`divider`], and the glyph sits on
1060/// the row labels' own inset so the line reads as the head of the list rather
1061/// than a control dropped on top of it.
1062pub fn search_line(theme: &Theme, input: AnyElement) -> gpui::Div {
1063    stack::row()
1064        .mx(px(-MENU_PAD))
1065        .px(px(MENU_PAD + 8.0))
1066        .py(px(7.0))
1067        .mb(px(MENU_PAD))
1068        .border_b_1()
1069        .border_color(hairline(0.07))
1070        .text_style(TextStyle::Body)
1071        .child(
1072            icons::icon(icons::system::MAGNIFER)
1073                .size(px(13.0))
1074                .text_color(theme.text_faint),
1075        )
1076        .child(div().flex_1().child(input))
1077}
1078
1079/// A bordered trailing menu section (the reference picker action groups /
1080/// branch-picker worktree block: `mt-1 flex flex-col gap-0.5 border-t
1081/// border-white/[0.06] pt-1` — the hairline runs edge-to-edge of the card's
1082/// p-1 inset, unlike [`divider`]'s mx-1).
1083pub fn menu_section() -> gpui::Div {
1084    div()
1085        .mt(px(4.0))
1086        .pt(px(4.0))
1087        .border_t_1()
1088        .border_color(hairline(0.06))
1089        .flex()
1090        .flex_col()
1091        .gap(px(2.0))
1092}
1093
1094// ---------------------------------------------------------------------------
1095// Dialog primitives (the reference dialog.tsx / sidebar dialogs.tsx)
1096// ---------------------------------------------------------------------------
1097
1098/// The centered dialog card (`dialog-pop`): `w-[360px] rounded-2xl border
1099/// border-white/[0.1] bg-popover/95 p-5 shadow-2xl` — popover tone ≈ #101010.
1100pub fn dialog_card(theme: &Theme) -> gpui::Div {
1101    div()
1102        .w(px(360.0))
1103        .p(px(20.0))
1104        .rounded(px(DIALOG_RADIUS))
1105        .bg(theme.surface_dialog)
1106        .border_1()
1107        .border_color(hairline(0.10))
1108        .shadow_lg()
1109        .flex()
1110        .flex_col()
1111        .text_color(theme.text)
1112}
1113
1114/// Dialog title.
1115pub fn dialog_title(theme: &Theme, title: impl Into<SharedString>) -> gpui::Div {
1116    div()
1117        .text_style(TextStyle::Headline)
1118        .text_color(theme.text)
1119        .child(title.into())
1120}
1121
1122/// Dialog body copy: `leading-relaxed text-muted-foreground`.
1123pub fn dialog_body(theme: &Theme, copy: impl Into<SharedString>) -> gpui::Div {
1124    div()
1125        .text_style(TextStyle::Body)
1126        .line_height(px(19.0))
1127        .text_color(theme.text_muted)
1128        .child(copy.into())
1129}
1130
1131/// Dialog text-field frame: `rounded-lg border border-white/[0.08]
1132/// bg-white/[0.04] px-3 py-2`.
1133pub fn dialog_field(input: AnyElement) -> gpui::Div {
1134    div()
1135        .w_full()
1136        .px(px(12.0))
1137        .py(px(8.0))
1138        .rounded(px(Theme::button_radius()))
1139        .border_1()
1140        .border_color(hairline(0.08))
1141        .bg(ink(0.04))
1142        .text_style(TextStyle::Body)
1143        .child(input)
1144}
1145
1146/// Pulsing skeleton rows shown while a list loads (the reference:
1147/// `h-7 animate-pulse rounded-md bg-white/[0.04]`).
1148pub fn redacted_rows(
1149    _id: &'static str,
1150    _theme: &Theme,
1151    count: usize,
1152    painter: Painter,
1153    cx: &mut gpui::App,
1154) -> AnyElement {
1155    let wash = ink(0.04);
1156    let delta = motion::pulse_delta(&PULSE, painter, cx);
1157    div()
1158        .flex()
1159        .flex_col()
1160        .gap(px(6.0))
1161        .py(px(4.0))
1162        .children((0..count).map(move |i| {
1163            let phase = motion::staggered_phase(delta, i, 0.08);
1164            div()
1165                .h(px(28.0))
1166                .rounded(px(Theme::control_radius()))
1167                .bg(wash)
1168                .opacity(0.35 + 0.4 * motion::pulse_wave(phase))
1169        }))
1170        .into_any_element()
1171}
1172
1173/// Inline error row + Retry affordance (the caller attaches the listener to the
1174/// returned id).
1175pub fn error_row(theme: &Theme, message: impl Into<SharedString>) -> gpui::Div {
1176    div()
1177        .flex()
1178        .flex_col()
1179        .gap(px(6.0))
1180        .p(px(Theme::SPACE))
1181        .text_style(TextStyle::Callout)
1182        .text_color(theme.danger)
1183        .child(message.into())
1184}