Skip to main content

ui/popover/
parts.rs

1//! Rows, headings, key hints and dialog primitives.
2
3use super::*;
4
5/// One menu row (the reference `menuItem`): `gap-2.5 rounded-lg px-2 py-1.5`,
6/// active = `bg-white/10 text-foreground`. The caller adds the id/click
7/// listener.
8///
9/// `active` is the row the cursor is on, and a menu has exactly one cursor.
10/// `Some(fade)` lets the mouse light a row by itself, animated over
11/// `transition-colors` (floating-styles.ts), for a menu holding no cursor of
12/// its own; its key must be stable across frames (the id string is a good
13/// choice). `None` is for a menu that owns an active index and moves it from
14/// `on_mouse_move` — move, not hover: gpui settles hover at paint time, so a
15/// list that re-filters or scrolls under a still mouse would drag the cursor
16/// to wherever the pointer sits.
17pub fn menu_row(theme: &Theme, active: bool, fade: Option<Fade>) -> gpui::Div {
18    let row = div()
19        .flex()
20        .flex_row()
21        .items_center()
22        .gap(px(10.0))
23        .px(px(MENU_ROW_INSET))
24        .py(px(MENU_ROW_PAD_Y))
25        // Concentric with the card it sits in rather than a radius of its own:
26        // 12 − 4 = 8, which is where the crate's most-repeated corner value
27        // came from all along.
28        .rounded(px(Theme::inset_radius(Theme::surface_radius(), MENU_PAD)))
29        .text_style(TextStyle::Body)
30        .cursor_pointer();
31    match (active, fade) {
32        (true, _) => row
33            .bg(theme.card_selected_bg())
34            .shadow(theme::glass_selected_shadows())
35            .text_color(theme.text),
36        (false, None) => row.text_color(theme.text.opacity(0.9)),
37        (false, Some(fade)) => {
38            let mut row = row
39                .text_color(motion::hover_blend(
40                    &fade,
41                    theme.text.opacity(0.9),
42                    theme.text,
43                ))
44                .bg(motion::hover_blend(
45                    &fade,
46                    theme::ink(0.0),
47                    theme.element_hover,
48                ));
49            // Imperative form — the caller's `.id(...)` makes the element stateful
50            // (hover listeners need element state, `.on_hover` needs `Stateful`).
51            row.interactivity().on_hover(motion::hover_listener(fade));
52            row
53        }
54    }
55}
56
57/// Small uppercase section heading inside a floating menu (the reference
58/// `MenuHeading`): `px-2 pb-1 pt-1.5 uppercase tracking-[0.1em]
59/// text-muted-foreground/60`. gpui has no letter-spacing at the pinned rev;
60/// the tracking is approximated with hair spaces.
61pub fn menu_heading(theme: &Theme, label: impl Into<SharedString>) -> gpui::Div {
62    let label = label.into();
63    div()
64        .px(px(8.0))
65        .pb(px(4.0))
66        .pt(px(6.0))
67        .text_style(TextStyle::Caption2)
68        .text_color(theme.text_muted.opacity(0.6))
69        .child(tracked_upper(&label))
70}
71
72/// A line of plain text in a menu — see [`crate::menu::Item::Note`].
73pub fn menu_note(theme: &Theme, text: impl Into<SharedString>) -> gpui::Div {
74    div()
75        .px(px(8.0))
76        .py(px(2.0))
77        .text_style(TextStyle::Caption)
78        .text_color(theme.text_muted)
79        .child(text.into())
80}
81
82/// Uppercase + hair-space tracking (see [`menu_heading`]).
83pub fn tracked_upper(label: &str) -> String {
84    let upper = label.to_uppercase();
85    let mut out = String::with_capacity(upper.len() * 2);
86    let mut first = true;
87    for ch in upper.chars() {
88        if !first {
89            out.push('\u{200A}'); // hair space ≈ 0.1em tracking
90        }
91        out.push(ch);
92        first = false;
93    }
94    out
95}
96
97/// Hairline divider between menu sections (the reference `MenuSeparator`:
98/// `mx-1 my-1 h-px bg-white/[0.07]`).
99pub fn divider(theme: &Theme) -> gpui::Div {
100    // Full-bleed: negative margins cancel the card's p-1 inset so the hairline
101    // runs border to border (user request).
102    div()
103        .h(px(1.0))
104        .mx(px(-4.0))
105        .my(px(4.0))
106        .bg(theme.border_faint)
107}
108
109/// The recessed band tone for a palette/picker header or footer strip — a
110/// translucent black so the glass still reads through (the add-space palette
111/// converged on this; measured subtler tones vanish against the dim scrim).
112/// Free function (like [`ink`]/[`hairline`]/[`wash`]), mirroring
113/// [`Theme::band`], for the several callers with no `Theme`/`cx` in scope
114/// (some outside this crate's `ui` module tree — threading a `&Theme` param
115/// would ripple past this task's file scope).
116pub fn band() -> gpui::Hsla {
117    theme::band()
118}
119
120/// One footer key-cap (22px, rounded-5, `white/[0.05]`) holding arbitrary
121/// children — the base of [`key_hint`]/[`key_hint_pair`] and the search-bar
122/// chips ("⌘K", "esc").
123pub fn key_cap(_theme: &Theme) -> gpui::Div {
124    div()
125        .h(px(22.0))
126        .px(px(5.0))
127        .rounded(px(5.0))
128        .flex()
129        .flex_row()
130        .items_center()
131        .justify_center()
132        .gap(px(4.0))
133        .bg(ink(0.05))
134}
135
136/// The tiny verb after a key-cap.
137pub(super) fn key_hint_label(theme: &Theme, label: &'static str) -> gpui::Div {
138    div()
139        .text_style(TextStyle::Caption)
140        .text_color(theme.text_muted.opacity(0.45))
141        .child(SharedString::from(label))
142}
143
144/// A footer legend: one icon key-cap + tiny verb (the add-space palette's
145/// footer voice, shared by the pickers).
146pub fn key_hint(theme: &Theme, icon: impl Into<Icon>, label: &'static str) -> gpui::Div {
147    div()
148        .flex()
149        .flex_row()
150        .items_center()
151        .gap(px(5.0))
152        .child(
153            key_cap(theme).child(
154                crate::icons::icon(icon)
155                    .size(px(12.5))
156                    .text_color(theme.text_muted.opacity(0.7)),
157            ),
158        )
159        .child(key_hint_label(theme, label))
160}
161
162/// A footer legend whose cap holds a WORD ("tab", "esc") instead of a glyph
163/// — for keys with no icon in the set.
164pub fn key_hint_text(
165    theme: &Theme,
166    cap: impl Into<SharedString>,
167    label: &'static str,
168) -> gpui::Div {
169    div()
170        .flex()
171        .flex_row()
172        .items_center()
173        .gap(px(5.0))
174        .child(
175            key_cap(theme)
176                .text_style(TextStyle::Subheadline)
177                .font_family(theme.font_mono.clone())
178                .text_color(theme.text_muted.opacity(0.7))
179                .child(cap.into()),
180        )
181        .child(key_hint_label(theme, label))
182}
183
184/// A footer legend whose cap holds TWO glyphs split by a hairline
185/// ("[ ↑ | ↓ ] Navigate") sharing one verb.
186pub fn key_hint_pair(
187    theme: &Theme,
188    first: impl Into<Icon>,
189    second: impl Into<Icon>,
190    label: &'static str,
191) -> gpui::Div {
192    div()
193        .flex()
194        .flex_row()
195        .items_center()
196        .gap(px(5.0))
197        .child(
198            key_cap(theme)
199                .child(
200                    crate::icons::icon(first)
201                        .size(px(12.5))
202                        .text_color(theme.text_muted.opacity(0.7)),
203                )
204                .child(div().w(px(1.0)).h(px(11.0)).bg(theme.border))
205                .child(
206                    crate::icons::icon(second)
207                        .size(px(12.5))
208                        .text_color(theme.text_muted.opacity(0.7)),
209                ),
210        )
211        .child(key_hint_label(theme, label))
212}
213
214/// A muted kbd hint chip inside menu rows (`⌘↵`-style accelerators).
215pub fn kbd_hint(theme: &Theme, label: impl Into<SharedString>) -> gpui::Div {
216    div()
217        .flex_none()
218        .px(px(5.0))
219        .py(px(1.0))
220        .rounded(px(5.0))
221        .bg(ink(0.05))
222        .text_style(TextStyle::Caption)
223        .font_family(theme.font_mono.clone())
224        .text_color(theme.text_muted.opacity(0.6))
225        .child(label.into())
226}
227
228/// The query line at the top of a picker popover: a magnifier, the field, and
229/// a hairline under it.
230///
231/// The field belongs in `with_frame(false)` — a box here would be a second
232/// frame inside the card's. Full-bleed like [`divider`], and the glyph sits on
233/// the row labels' own inset so the line reads as the head of the list rather
234/// than a control dropped on top of it.
235pub fn search_line(theme: &Theme, input: AnyElement) -> gpui::Div {
236    stack::row()
237        .mx(px(-MENU_PAD))
238        .px(px(MENU_PAD + MENU_ROW_INSET))
239        .py(px(7.0))
240        .mb(px(MENU_PAD))
241        .border_b_1()
242        .border_color(theme.border_faint)
243        .text_style(TextStyle::Body)
244        .child(
245            icons::icon(icons::glyph::Search)
246                .size(px(13.0))
247                .text_color(theme.text_faint),
248        )
249        .child(div().flex_1().child(input))
250}
251
252/// A bordered trailing menu section (the reference picker action groups /
253/// branch-picker worktree block: `mt-1 flex flex-col gap-0.5 border-t
254/// border-white/[0.06] pt-1` — the hairline runs edge-to-edge of the card's
255/// p-1 inset, unlike [`divider`]'s mx-1).
256pub fn menu_section(theme: &Theme) -> gpui::Div {
257    div()
258        .mt(px(4.0))
259        .pt(px(4.0))
260        .border_t_1()
261        .border_color(theme.border_faint)
262        .flex()
263        .flex_col()
264        .gap(px(2.0))
265}
266
267// ---------------------------------------------------------------------------
268// Dialog primitives (the reference dialog.tsx / sidebar dialogs.tsx)
269// ---------------------------------------------------------------------------
270
271/// The centered dialog card (`dialog-pop`): `w-[360px] rounded-2xl border
272/// border-white/[0.1] bg-popover/95 p-5 shadow-2xl` — popover tone ≈ #101010.
273pub fn dialog_card(theme: &Theme) -> gpui::Div {
274    div()
275        .w(px(360.0))
276        .p(px(20.0))
277        .rounded(px(DIALOG_RADIUS))
278        .bg(theme.surface_dialog)
279        .border_1()
280        .border_color(theme.border)
281        .shadow_lg()
282        .flex()
283        .flex_col()
284        .text_color(theme.text)
285}
286
287/// Dialog title.
288pub fn dialog_title(theme: &Theme, title: impl Into<SharedString>) -> gpui::Div {
289    div()
290        .text_style(TextStyle::Headline)
291        .text_color(theme.text)
292        .child(title.into())
293}
294
295/// Dialog body copy: `leading-relaxed text-muted-foreground`.
296pub fn dialog_body(theme: &Theme, copy: impl Into<SharedString>) -> gpui::Div {
297    div()
298        .text_style(TextStyle::Body)
299        .line_height(px(19.0))
300        .text_color(theme.text_muted)
301        .child(copy.into())
302}
303
304/// Dialog text-field frame: `rounded-lg border border-white/[0.08]
305/// bg-white/[0.04] px-3 py-2`.
306pub fn dialog_field(theme: &Theme, input: AnyElement) -> gpui::Div {
307    div()
308        .w_full()
309        .px(px(12.0))
310        .py(px(8.0))
311        .rounded(px(Theme::button_radius()))
312        .border_1()
313        .border_color(theme.border)
314        .bg(ink(0.04))
315        .text_style(TextStyle::Body)
316        .child(input)
317}
318
319/// Pulsing skeleton rows shown while a list loads (the reference:
320/// `h-7 animate-pulse rounded-md bg-white/[0.04]`). Each row enters the wave
321/// after the one above it.
322pub fn redacted_rows(
323    _id: &'static str,
324    _theme: &Theme,
325    count: usize,
326    painter: Painter,
327    cx: &mut gpui::App,
328) -> AnyElement {
329    let wash = ink(0.04);
330    let delta = motion::pulse_delta(&PULSE, painter, cx);
331    div()
332        .flex()
333        .flex_col()
334        .gap(px(6.0))
335        .py(px(4.0))
336        .children((0..count).map(move |i| {
337            let phase = motion::staggered_phase(delta, i, 0.08);
338            div()
339                .h(px(28.0))
340                .rounded(px(Theme::control_radius()))
341                .bg(wash)
342                .opacity(0.35 + 0.4 * motion::pulse_wave(phase))
343        }))
344        .into_any_element()
345}
346
347/// Inline error row. A retry control is the caller's child.
348pub fn error_row(theme: &Theme, message: impl Into<SharedString>) -> gpui::Div {
349    div()
350        .flex()
351        .flex_col()
352        .gap(px(6.0))
353        .p(px(Theme::SPACE))
354        .text_style(TextStyle::Callout)
355        .text_color(theme.danger)
356        .child(message.into())
357}