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