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 = 1.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_1()
152 .when(selected, |ring| {
153 ring.bg(theme.card_selected_bg())
154 .border_color(theme.border_strong)
155 })
156 .when(!selected, |ring| {
157 ring.border_color(gpui::transparent_black())
158 })
159 .child(frame),
160 )
161 .child(
162 div()
163 .text_style(TextStyle::Body)
164 .text_color(if selected {
165 theme.text
166 } else {
167 theme.text_muted
168 })
169 .child(label.into()),
170 )
171 }
172
173 /// Section card: a rounded plate at [`Theme::card_glass_bg`], its own tone
174 /// carrying the edge the way a SwiftUI grouped `Form` section does.
175 fn group_box(&self) -> Div {
176 let theme = self.theme();
177 div()
178 .rounded(px(Theme::surface_radius()))
179 .border_1()
180 .border_color(theme.border)
181 .bg(theme.card_glass_bg())
182 .overflow_hidden()
183 .flex()
184 .flex_col()
185 }
186
187 /// One card row, split from the row above by a full-width hairline; `first`
188 /// drops the hairline, for the opening row of a card. Hover is
189 /// caller-owned — gpui panics on a second hover, so the wash to chain is
190 /// [`Theme::element_hover`].
191 fn card_row(&self, first: bool) -> Div {
192 let theme = self.theme();
193 div()
194 .px(px(ROW_INSET))
195 .py(px(ROW_PAD_Y))
196 .when(!first, |el| el.border_t_1().border_color(theme.border))
197 .flex()
198 .flex_row()
199 .items_center()
200 .gap(px(ROW_GAP))
201 }
202
203 /// The leading symbol on a row: a bare glyph, sized to the text beside it,
204 /// the way the macOS General pane carries one.
205 fn row_icon(&self, icon: impl Into<Icon>) -> Div {
206 let theme = self.theme();
207 div()
208 .flex_none()
209 .size(px(ROW_ICON))
210 .flex()
211 .items_center()
212 .justify_center()
213 .child(
214 crate::icons::icon(icon)
215 .size(px(ROW_ICON))
216 .text_color(theme.text_muted),
217 )
218 }
219
220 /// Row title. Clipped rather than ellipsised: an ellipsis sets
221 /// `text_overflow`, which opts the label out of gpui's measure cache, and a
222 /// list of them re-measures every row on every frame it scrolls.
223 fn row_title(&self, title: impl Into<SharedString>) -> Div {
224 let theme = self.theme();
225 div()
226 .min_w_0()
227 .overflow_hidden()
228 .whitespace_nowrap()
229 .text_style(TextStyle::Body)
230 .text_color(theme.text)
231 .child(title.into())
232 }
233
234 /// The quiet meta line under a row title: `text-muted-foreground/65`
235 /// fragments joined by dots.
236 fn meta_line(&self, fragments: Vec<AnyElement>) -> Div {
237 let theme = self.theme();
238 let mut line = div()
239 .mt(px(4.0))
240 .flex()
241 .flex_row()
242 .flex_wrap()
243 .items_center()
244 .gap_x(px(Theme::SPACE))
245 .gap_y(px(2.0))
246 .text_style(TextStyle::Subheadline)
247 .text_color(theme.text_muted.opacity(0.65));
248 let mut first = true;
249 for fragment in fragments {
250 if !first {
251 line = line.child(
252 div()
253 .text_color(theme.text_muted.opacity(0.3))
254 .child(SharedString::from("·")),
255 );
256 }
257 line = line.child(fragment);
258 first = false;
259 }
260 line
261 }
262}
263
264impl Scaffolding for Theme {}