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