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