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