Skip to main content

ui/widgets/
content.rs

1//! Content pieces — badges, avatar, icon decks, tag, breadcrumb, the empty
2//! state.
3//!
4//! A catalog trait, like every widget group: import it to unlock
5//! `theme.badge(..)`, `theme.avatar(..)`, `theme.empty_state(..)`.
6
7use gpui::{Div, SharedString, Svg, div, prelude::*, px};
8use icons::Icon;
9use theme::{TextStyle, Theme, ThemeExt, Typeset};
10
11/// The cards an [`Content::icon_deck`] shows behind its front one, at most.
12const DECK_DEPTH: usize = 2;
13/// How far each card behind sticks out past the one before it, and the room
14/// left between them, which is what tells one card's edge from the next.
15const DECK_PEEK: f32 = 4.0;
16const DECK_GAP: f32 = 1.5;
17/// Each card behind's height as a share of the front's, nearest first.
18const DECK_HEIGHT: [f32; DECK_DEPTH] = [0.8, 0.6];
19/// The ink the front card and each one behind are washed in.
20const DECK_FRONT_INK: f32 = 0.12;
21const DECK_INK: [f32; DECK_DEPTH] = [0.18, 0.09];
22/// The room around the front card's icon, each side.
23const DECK_PAD: f32 = 3.0;
24/// Every card's corner.
25const DECK_RADIUS: f32 = 4.0;
26
27pub trait Content: ThemeExt {
28    /// Right-anchored badge pill: `rounded-full border px-2 py-0.5`.
29    fn badge(&self, label: impl Into<SharedString>) -> Div {
30        let theme = self.theme();
31        div()
32            .flex_none()
33            .px(px(8.0))
34            .py(px(2.0))
35            .rounded_full()
36            .border_1()
37            .border_color(theme.border)
38            .text_style(TextStyle::Caption)
39            .text_color(theme.text_muted)
40            .child(label.into())
41    }
42
43    /// Emerald status pill (the Accounts "Active" badge:
44    /// `bg-emerald-400/[0.12] text-emerald-300/90`).
45    fn badge_active(&self, label: impl Into<SharedString>) -> Div {
46        let theme = self.theme();
47        let emerald = theme.success;
48        let emerald_text = theme.success_muted; // emerald-300
49        div()
50            .flex_none()
51            .px(px(8.0))
52            .py(px(2.0))
53            .rounded_full()
54            .bg(emerald.opacity(0.12))
55            .text_style(TextStyle::Caption)
56            .text_color(emerald_text.opacity(0.9))
57            .child(label.into())
58    }
59
60    /// Circular avatar holding one or two initials — the fallback every avatar
61    /// needs, and often the whole thing on a monochrome surface. Initials only:
62    /// there is no image variant.
63    fn avatar(&self, initials: impl Into<SharedString>) -> Div {
64        let theme = self.theme();
65        div()
66            .flex_none()
67            .size(px(28.0))
68            .rounded_full()
69            .bg(theme.ink(0.12))
70            .flex()
71            .items_center()
72            .justify_center()
73            .text_style(TextStyle::Subheadline)
74            .font_weight(gpui::FontWeight::MEDIUM)
75            .text_color(theme.text_muted)
76            .child(initials.into())
77    }
78
79    /// A stack shown as a deck of cards: `front` on a square card, with the
80    /// edge of one more card peeking out past it for each of `behind`, up to
81    /// two, each shorter than the one before. More than two behind draws as
82    /// two. Always as wide as a full deck, so a row holding one does not shift
83    /// as the stack grows or shrinks.
84    ///
85    /// The cards behind are drawn only where they show, so the deck needs no
86    /// knowledge of what is painted under it.
87    fn icon_deck(&self, front: impl Into<Icon>, behind: usize) -> Div {
88        let theme = self.theme();
89        let glyph = TextStyle::Caption.painted();
90        let edge = glyph + DECK_PAD * 2.;
91        div()
92            .flex_none()
93            .relative()
94            .w(px(edge + DECK_PEEK * DECK_DEPTH as f32))
95            .h(px(edge))
96            // Each card behind whole, in a frame that shows only what sticks
97            // out past the card before it and the gap after that one.
98            .children((1..=behind.min(DECK_DEPTH)).map(|at| {
99                let height = edge * DECK_HEIGHT[at - 1];
100                let shown = DECK_PEEK - DECK_GAP;
101                div()
102                    .absolute()
103                    .top_0()
104                    .left(px(edge + DECK_PEEK * (at - 1) as f32 + DECK_GAP))
105                    .w(px(shown))
106                    .h(px(edge))
107                    .overflow_hidden()
108                    .child(
109                        div()
110                            .absolute()
111                            .left(px(shown - edge))
112                            .top(px((edge - height) / 2.))
113                            .w(px(edge))
114                            .h(px(height))
115                            .rounded(px(DECK_RADIUS))
116                            .bg(theme.ink(DECK_INK[at - 1])),
117                    )
118            }))
119            .child(
120                div()
121                    .absolute()
122                    .top_0()
123                    .left_0()
124                    .size(px(edge))
125                    .flex()
126                    .items_center()
127                    .justify_center()
128                    .rounded(px(DECK_RADIUS))
129                    .bg(theme.ink(DECK_FRONT_INK))
130                    .child(
131                        crate::icons::icon(front)
132                            .size(px(glyph))
133                            .text_color(theme.text),
134                    ),
135            )
136    }
137
138    /// A removable chip — a token in a filter bar or a recipient field. The
139    /// caller adds the click handler for the ✕.
140    fn tag(&self, label: impl Into<SharedString>) -> Div {
141        let theme = self.theme();
142        div()
143            .self_start()
144            .flex()
145            .flex_row()
146            .items_center()
147            .gap(px(5.0))
148            .pl(px(8.0))
149            .pr(px(5.0))
150            .py(px(3.0))
151            .rounded(px(Theme::control_radius()))
152            .bg(theme.ink(0.07))
153            .border_1()
154            .border_color(theme.border)
155            .text_style(TextStyle::Callout)
156            .text_color(theme.text)
157            .child(label.into())
158            .child(
159                crate::icons::icon(crate::icons::glyph::X)
160                    .size(px(10.0))
161                    .text_color(theme.text_faint),
162            )
163    }
164
165    /// A name in a colour — a label on an entry, an option of a
166    /// multi-select. `tint` is the hue it is told apart by, painted as a wash
167    /// under the theme's text; [`crate::multi_select::tint`] derives one from
168    /// the name. A long name ends in `…`; the caller caps the width.
169    fn chip(&self, label: impl Into<SharedString>, tint: gpui::Hsla) -> Div {
170        let theme = self.theme();
171        div()
172            .flex_none()
173            .flex()
174            .flex_row()
175            .items_center()
176            .gap(px(2.0))
177            .px(px(6.0))
178            .rounded(px(Theme::control_radius()))
179            .bg(tint.opacity(0.22))
180            .text_style(TextStyle::Callout)
181            .text_color(theme.text)
182            .child(div().min_w_0().truncate().child(label.into()))
183    }
184
185    /// Breadcrumb trail. Items are added by the caller with
186    /// [`Self::breadcrumb_item`], separated by [`Self::breadcrumb_separator`].
187    fn breadcrumb(&self) -> Div {
188        div()
189            .self_start()
190            .flex()
191            .flex_row()
192            .items_center()
193            .gap(px(4.0))
194            .min_w_0()
195    }
196
197    /// One crumb. The last one is `current` and stops looking clickable.
198    fn breadcrumb_item(&self, label: impl Into<SharedString>, current: bool) -> Div {
199        let theme = self.theme();
200        let mut crumb = div()
201            .min_w_0()
202            .truncate()
203            .text_style(TextStyle::Callout)
204            .child(label.into());
205        crumb = if current {
206            crumb.text_color(theme.text)
207        } else {
208            crumb.text_color(theme.text_muted).cursor_pointer()
209        };
210        crumb
211    }
212
213    /// The chevron between crumbs.
214    fn breadcrumb_separator(&self) -> Svg {
215        let theme = self.theme();
216        crate::icons::icon(crate::icons::glyph::ChevronRight)
217            .size(px(12.0))
218            .text_color(theme.text_faint)
219    }
220
221    /// The centered "nothing here yet" panel: icon, headline, one line of hint.
222    fn empty_state(
223        &self,
224        icon: impl Into<Icon>,
225        title: impl Into<SharedString>,
226        hint: impl Into<SharedString>,
227    ) -> Div {
228        let theme = self.theme();
229        div()
230            .w_full()
231            .flex()
232            .flex_col()
233            .items_center()
234            .justify_center()
235            .gap(px(6.0))
236            .py(px(40.0))
237            .child(
238                crate::icons::icon(icon)
239                    .size(px(24.0))
240                    .text_color(theme.text_faint),
241            )
242            .child(
243                div()
244                    .text_style(TextStyle::Headline)
245                    .text_color(theme.text)
246                    .child(title.into()),
247            )
248            .child(
249                div()
250                    .text_style(TextStyle::Callout)
251                    .text_color(theme.text_muted)
252                    .child(hint.into()),
253            )
254    }
255}
256
257impl Content for Theme {}