Skip to main content

ui/
popover.rs

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