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 {}