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