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