Skip to main content

ui/widgets/
scaffolding.rs

1//! The page skeleton — column, header, section cards, rows — the shared
2//! rhythm of the settings pages (the reference settings.devices.tsx /
3//! settings.agents.tsx / settings.archived.tsx).
4//!
5//! A catalog trait, like every widget group: import it to unlock
6//! `theme.group_box()`, `theme.page_header(..)`, `theme.card_row(..)`.
7//! Extends [`ThemeExt`], which carries the environment; the `Theme` impl is
8//! empty because every method below has a default. Not object-safe — its
9//! methods are statically dispatched onto [`Theme`].
10
11use crate::stack;
12use gpui::{AnyElement, Div, SharedString, div, prelude::*, px};
13use theme::{TextStyle, Theme, ThemeExt, Typeset};
14
15/// Default height of an [`Scaffolding::option_card`] preview frame.
16pub const OPTION_CARD_HEIGHT: f32 = 148.0;
17/// Corner radius of the preview frame.
18///
19/// Public because the preview has to round *itself* to this. gpui content masks
20/// are axis-aligned rectangles, so `overflow_hidden` on the frame clips to its
21/// bounding box and not to its corner radius — a preview that paints its own
22/// background will square off the corners and cover the frame's border with it.
23pub const OPTION_CARD_RADIUS: f32 = 10.0;
24/// A card row's horizontal padding — `../desktop`'s `Row`: `px-4`.
25const ROW_INSET: f32 = 16.0;
26/// The leading symbol's size.
27const ROW_ICON: f32 = 16.0;
28/// Between a row's leading symbol and its text.
29const ROW_GAP: f32 = Theme::SPACE;
30/// A card row's vertical padding. Measured 2026-09-01 off the macOS General
31/// pane: a ~40pt row over `TextStyle::Body`'s 21pt line box.
32const ROW_PAD_Y: f32 = 10.0;
33/// Clear space between the frame and the selection ring.
34const RING_GAP: f32 = 2.0;
35/// Thickness of the selection ring.
36const RING_WIDTH: f32 = 2.0;
37
38pub trait Scaffolding: ThemeExt {
39    /// Centered page column: `mx-auto w-full max-w-3xl px-6 pb-16 pt-8`.
40    fn page_column(&self) -> Div {
41        div()
42            .w_full()
43            .max_w(px(768.0))
44            .mx_auto()
45            .px(px(24.0))
46            .pt(px(32.0))
47            .pb(px(64.0))
48            .flex()
49            .flex_col()
50    }
51
52    /// Page headline row: `flex items-baseline gap-2.5` — title and count
53    /// sharing a baseline (the reference settings.devices.tsx).
54    fn page_header(&self, title: impl Into<SharedString>, count: Option<usize>) -> Div {
55        let theme = self.theme();
56        div()
57            .flex()
58            .flex_row()
59            .items_baseline()
60            .gap(px(10.0))
61            .child(
62                div()
63                    .text_style(TextStyle::Title2)
64                    .text_color(theme.text)
65                    .child(title.into()),
66            )
67            .when_some(count, |el, count| {
68                el.child(
69                    div()
70                        .text_style(TextStyle::Body)
71                        .text_color(theme.text_muted.opacity(0.7))
72                        .child(format!("{count}")),
73                )
74            })
75    }
76
77    /// Subtitle under the headline: `mt-1 text-muted-foreground`.
78    fn page_subtitle(&self, copy: impl Into<SharedString>) -> Div {
79        let theme = self.theme();
80        div()
81            .mt(px(4.0))
82            .text_style(TextStyle::Body)
83            .text_color(theme.text_muted)
84            .child(copy.into())
85    }
86
87    /// Small label above a group of controls (`font-medium`) — the
88    /// "Theme" caption over a picker, not a page headline.
89    fn field_label(&self, label: impl Into<SharedString>) -> Div {
90        let theme = self.theme();
91        div()
92            .text_style(TextStyle::Body)
93            .font_weight(gpui::FontWeight::MEDIUM)
94            .text_color(theme.text)
95            .child(label.into())
96    }
97
98    /// A row of equally-sized preview cards for picking one of N *visual* options.
99    ///
100    /// Deliberately knows nothing about themes: the caller supplies each preview as
101    /// an arbitrary element and picks however many cards it wants, so the same
102    /// control works for a density picker, a layout picker or anything else where
103    /// the choice is easier to show than to describe. Pair with [`Self::option_card`].
104    fn option_card_row(&self) -> Div {
105        div().flex().flex_row().items_start().gap(px(16.0)).w_full()
106    }
107
108    /// One card in an [`Self::option_card_row`]: a fixed-height preview frame
109    /// that carries the selection ring, with a caption underneath.
110    ///
111    /// `preview` fills the frame and **must round its own corners** to
112    /// [`OPTION_CARD_RADIUS`] if it paints a background — see that constant.
113    ///
114    /// Returns a plain `Div` like the rest of this module — the caller adds
115    /// `.id(..)` and `.on_click(..)`, so selection behaviour stays with the
116    /// page that owns the state.
117    fn option_card(
118        &self,
119        label: impl Into<SharedString>,
120        selected: bool,
121        preview: AnyElement,
122    ) -> Div {
123        let theme = self.theme();
124        let frame = div()
125            .h(px(OPTION_CARD_HEIGHT))
126            .w_full()
127            .rounded(px(OPTION_CARD_RADIUS))
128            .overflow_hidden()
129            .border_1()
130            .border_color(theme.border)
131            .child(preview);
132
133        // The ring is a *wrapper border*, not a spread shadow. A shadow's spread
134        // grows the rectangle without growing its corner radius, so the halo's
135        // corners tighten relative to the frame's and the two visibly drift apart by
136        // a pixel at each rounded corner. Concentric borders can't do that: each
137        // element rounds itself, and the outer radius is the inner one plus the gap
138        // it sits behind. Always present, transparent when unselected, so selecting a
139        // card never reflows the row.
140        stack::column()
141            .flex_1()
142            .min_w_0()
143            .items_center()
144            .cursor_pointer()
145            .child(
146                div()
147                    .w_full()
148                    .rounded(px(OPTION_CARD_RADIUS + RING_GAP + RING_WIDTH))
149                    .p(px(RING_GAP))
150                    .border_2()
151                    .border_color(if selected {
152                        theme.accent
153                    } else {
154                        gpui::transparent_black()
155                    })
156                    .child(frame),
157            )
158            .child(
159                div()
160                    .text_style(TextStyle::Body)
161                    .text_color(if selected {
162                        theme.text
163                    } else {
164                        theme.text_muted
165                    })
166                    .child(label.into()),
167            )
168    }
169
170    /// Section card: a rounded plate at [`Theme::card_glass_bg`], its own tone
171    /// carrying the edge the way a SwiftUI grouped `Form` section does.
172    fn group_box(&self) -> Div {
173        let theme = self.theme();
174        div()
175            .rounded(px(Theme::surface_radius()))
176            .border_1()
177            .border_color(theme.border)
178            .bg(theme.card_glass_bg())
179            .overflow_hidden()
180            .flex()
181            .flex_col()
182    }
183
184    /// One card row, split from the row above by a full-width hairline. Hover is
185    /// caller-owned — gpui panics on a second hover, so the wash to chain is
186    /// [`Theme::element_hover`].
187    fn card_row(&self, first: bool) -> Div {
188        let theme = self.theme();
189        div()
190            .px(px(ROW_INSET))
191            .py(px(ROW_PAD_Y))
192            .when(!first, |el| el.border_t_1().border_color(theme.border))
193            .flex()
194            .flex_row()
195            .items_center()
196            .gap(px(ROW_GAP))
197    }
198
199    /// The leading symbol on a row: a bare glyph, sized to the text beside it,
200    /// the way the macOS General pane carries one.
201    fn row_icon(&self, icon_path: &'static [u8]) -> Div {
202        let theme = self.theme();
203        div()
204            .flex_none()
205            .size(px(ROW_ICON))
206            .flex()
207            .items_center()
208            .justify_center()
209            .child(
210                crate::icons::icon(icon_path)
211                    .size(px(ROW_ICON))
212                    .text_color(theme.text_muted),
213            )
214    }
215
216    /// Row title. Clipped rather than ellipsised: an ellipsis sets
217    /// `text_overflow`, which opts the label out of gpui's measure cache, and a
218    /// list of them re-measures every row on every frame it scrolls.
219    fn row_title(&self, title: impl Into<SharedString>) -> Div {
220        let theme = self.theme();
221        div()
222            .min_w_0()
223            .overflow_hidden()
224            .whitespace_nowrap()
225            .text_style(TextStyle::Body)
226            .text_color(theme.text)
227            .child(title.into())
228    }
229
230    /// The quiet meta line under a row title: `text-muted-foreground/65`
231    /// fragments joined by dots.
232    fn meta_line(&self, fragments: Vec<AnyElement>) -> Div {
233        let theme = self.theme();
234        let mut line = div()
235            .mt(px(4.0))
236            .flex()
237            .flex_row()
238            .flex_wrap()
239            .items_center()
240            .gap_x(px(Theme::SPACE))
241            .gap_y(px(2.0))
242            .text_style(TextStyle::Subheadline)
243            .text_color(theme.text_muted.opacity(0.65));
244        let mut first = true;
245        for fragment in fragments {
246            if !first {
247                line = line.child(
248                    div()
249                        .text_color(theme.text_muted.opacity(0.3))
250                        .child(SharedString::from("·")),
251                );
252            }
253            line = line.child(fragment);
254            first = false;
255        }
256        line
257    }
258}
259
260impl Scaffolding for Theme {}