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