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