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