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