Skip to main content

gpui_component/
empty.rs

1use gpui::{
2    AnyElement, App, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window, div,
3    prelude::FluentBuilder as _, relative, rems,
4};
5
6use crate::{ActiveTheme as _, StyledExt as _, v_flex};
7
8/// A presentational empty state with independently styled header and content slots.
9///
10/// The application decides when this element is shown and owns the actions and
11/// state of its children. Additional direct children follow the named slots,
12/// regardless of builder call order.
13#[derive(IntoElement)]
14pub struct Empty {
15    style: StyleRefinement,
16    header: Option<EmptyHeader>,
17    content: Option<EmptyContent>,
18    children: Vec<AnyElement>,
19}
20
21impl Empty {
22    /// Create an empty state without a background or visible border.
23    pub fn new() -> Self {
24        Self {
25            style: StyleRefinement::default(),
26            header: None,
27            content: None,
28            children: Vec::new(),
29        }
30    }
31
32    /// Set the header, replacing any previously configured header.
33    pub fn header(mut self, header: EmptyHeader) -> Self {
34        self.header = Some(header);
35        self
36    }
37
38    /// Set the content, replacing any previously configured content.
39    pub fn content(mut self, content: EmptyContent) -> Self {
40        self.content = Some(content);
41        self
42    }
43}
44
45impl Default for Empty {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl ParentElement for Empty {
52    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
53        self.children.extend(elements);
54    }
55}
56
57impl Styled for Empty {
58    fn style(&mut self) -> &mut StyleRefinement {
59        &mut self.style
60    }
61}
62
63impl RenderOnce for Empty {
64    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
65        v_flex()
66            .w_full()
67            .min_w_0()
68            .flex_1()
69            .items_center()
70            .justify_center()
71            .gap_4()
72            .p_6()
73            .rounded(cx.theme().radius_tokens().xl)
74            .border_dashed()
75            .border_color(cx.theme().border)
76            .text_center()
77            .text_color(cx.theme().foreground)
78            .refine_style(&self.style)
79            .when_some(self.header, |this, header| this.child(header))
80            .when_some(self.content, |this, content| this.child(content))
81            .children(self.children)
82    }
83}
84
85/// The media, title, and description of an [`Empty`] state, in that order.
86///
87/// Each slot is optional. Replacing one slot leaves the other slots intact.
88#[derive(IntoElement)]
89pub struct EmptyHeader {
90    style: StyleRefinement,
91    media: Option<EmptyMedia>,
92    title: Option<EmptyTitle>,
93    description: Option<EmptyDescription>,
94}
95
96impl EmptyHeader {
97    /// Create a centered header with a maximum width of 24 rem.
98    pub fn new() -> Self {
99        Self {
100            style: StyleRefinement::default(),
101            media: None,
102            title: None,
103            description: None,
104        }
105    }
106
107    /// Set the media, replacing any previously configured media.
108    pub fn media(mut self, media: EmptyMedia) -> Self {
109        self.media = Some(media);
110        self
111    }
112
113    /// Set the title, replacing any previously configured title.
114    pub fn title(mut self, title: EmptyTitle) -> Self {
115        self.title = Some(title);
116        self
117    }
118
119    /// Set the description, replacing any previously configured description.
120    pub fn description(mut self, description: EmptyDescription) -> Self {
121        self.description = Some(description);
122        self
123    }
124}
125
126impl Default for EmptyHeader {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132impl Styled for EmptyHeader {
133    fn style(&mut self) -> &mut StyleRefinement {
134        &mut self.style
135    }
136}
137
138impl RenderOnce for EmptyHeader {
139    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
140        v_flex()
141            .w_full()
142            .max_w(rems(24.))
143            .min_w_0()
144            .items_center()
145            .gap_2()
146            .refine_style(&self.style)
147            .when_some(self.media, |this, media| this.child(media))
148            .when_some(self.title, |this, title| this.child(title))
149            .when_some(self.description, |this, description| {
150                this.child(description)
151            })
152    }
153}
154
155/// Visual treatment for the media of an [`Empty`] state.
156#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
157pub enum EmptyMediaVariant {
158    /// Unframed content such as an image, avatar, or avatar group.
159    #[default]
160    Default,
161    /// A muted, rounded two-rem frame for an icon.
162    Icon,
163}
164
165/// A media slot accepting icons, images, avatars, and custom elements.
166#[derive(IntoElement)]
167pub struct EmptyMedia {
168    style: StyleRefinement,
169    variant: EmptyMediaVariant,
170    children: Vec<AnyElement>,
171}
172
173impl EmptyMedia {
174    /// Create an unframed media slot.
175    pub fn new() -> Self {
176        Self {
177            style: StyleRefinement::default(),
178            variant: EmptyMediaVariant::Default,
179            children: Vec::new(),
180        }
181    }
182
183    /// Set the visual treatment without changing the supplied children.
184    pub fn with_variant(mut self, variant: EmptyMediaVariant) -> Self {
185        self.variant = variant;
186        self
187    }
188}
189
190impl Default for EmptyMedia {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196impl ParentElement for EmptyMedia {
197    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
198        self.children.extend(elements);
199    }
200}
201
202impl Styled for EmptyMedia {
203    fn style(&mut self) -> &mut StyleRefinement {
204        &mut self.style
205    }
206}
207
208impl RenderOnce for EmptyMedia {
209    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
210        // A column preserves the intrinsic width of nested rows such as AvatarGroup.
211        v_flex()
212            .flex_shrink_0()
213            .items_center()
214            .justify_center()
215            .mb_2()
216            .when(self.variant == EmptyMediaVariant::Icon, |this| {
217                this.size_8()
218                    .rounded(cx.theme().radius_tokens().lg)
219                    .bg(cx.theme().muted)
220                    .text_color(cx.theme().foreground)
221                    // Icon inherits one rem unless it has an explicit size.
222                    .text_base()
223            })
224            .refine_style(&self.style)
225            .children(self.children)
226    }
227}
228
229/// The title of an [`Empty`] state, accepting text or custom children.
230#[derive(IntoElement)]
231pub struct EmptyTitle {
232    style: StyleRefinement,
233    children: Vec<AnyElement>,
234}
235
236impl EmptyTitle {
237    /// Create an empty title.
238    pub fn new() -> Self {
239        Self {
240            style: StyleRefinement::default(),
241            children: Vec::new(),
242        }
243    }
244}
245
246impl Default for EmptyTitle {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252impl ParentElement for EmptyTitle {
253    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
254        self.children.extend(elements);
255    }
256}
257
258impl Styled for EmptyTitle {
259    fn style(&mut self) -> &mut StyleRefinement {
260        &mut self.style
261    }
262}
263
264impl RenderOnce for EmptyTitle {
265    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
266        div()
267            .max_w_full()
268            .min_w_0()
269            .text_sm()
270            .font_medium()
271            .whitespace_normal()
272            .refine_style(&self.style)
273            .children(self.children)
274    }
275}
276
277/// Supporting text or rich content for an [`Empty`] state.
278#[derive(IntoElement)]
279pub struct EmptyDescription {
280    style: StyleRefinement,
281    children: Vec<AnyElement>,
282}
283
284impl EmptyDescription {
285    /// Create a muted description that wraps to the available width.
286    pub fn new() -> Self {
287        Self {
288            style: StyleRefinement::default(),
289            children: Vec::new(),
290        }
291    }
292}
293
294impl Default for EmptyDescription {
295    fn default() -> Self {
296        Self::new()
297    }
298}
299
300impl ParentElement for EmptyDescription {
301    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
302        self.children.extend(elements);
303    }
304}
305
306impl Styled for EmptyDescription {
307    fn style(&mut self) -> &mut StyleRefinement {
308        &mut self.style
309    }
310}
311
312impl RenderOnce for EmptyDescription {
313    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
314        div()
315            .w_full()
316            .min_w_0()
317            .text_sm()
318            .line_height(relative(1.625))
319            .text_color(cx.theme().muted_foreground)
320            .whitespace_normal()
321            .refine_style(&self.style)
322            .children(self.children)
323    }
324}
325
326/// Actions, inputs, or other application-owned content below an [`EmptyHeader`].
327#[derive(IntoElement)]
328pub struct EmptyContent {
329    style: StyleRefinement,
330    children: Vec<AnyElement>,
331}
332
333impl EmptyContent {
334    /// Create a centered content column with a maximum width of 24 rem.
335    pub fn new() -> Self {
336        Self {
337            style: StyleRefinement::default(),
338            children: Vec::new(),
339        }
340    }
341}
342
343impl Default for EmptyContent {
344    fn default() -> Self {
345        Self::new()
346    }
347}
348
349impl ParentElement for EmptyContent {
350    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
351        self.children.extend(elements);
352    }
353}
354
355impl Styled for EmptyContent {
356    fn style(&mut self) -> &mut StyleRefinement {
357        &mut self.style
358    }
359}
360
361impl RenderOnce for EmptyContent {
362    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
363        v_flex()
364            .w_full()
365            .max_w(rems(24.))
366            .min_w_0()
367            .items_center()
368            .gap_2p5()
369            .text_sm()
370            .refine_style(&self.style)
371            .children(self.children)
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::{
379        Sizable as _,
380        avatar::{Avatar, AvatarGroup},
381    };
382    use gpui::{Context, InteractiveElement as _, Render, TestAppContext, px};
383
384    struct MediaLayout {
385        grouped: bool,
386        leading: bool,
387    }
388
389    impl Render for MediaLayout {
390        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
391            let media = if self.grouped {
392                AvatarGroup::new()
393                    .small()
394                    .debug_selector(|| "media-content".into())
395                    .child(Avatar::new().name("Alex"))
396                    .child(Avatar::new().name("Taylor"))
397                    .child(Avatar::new().name("Sam"))
398                    .into_any_element()
399            } else {
400                Avatar::new()
401                    .small()
402                    .debug_selector(|| "media-content".into())
403                    .name("Alex")
404                    .into_any_element()
405            };
406
407            // Supply the slots out of order; their visual order is a contract.
408            Empty::new()
409                .when(self.leading, |this| this.items_start().text_left())
410                .child(div().size_4().debug_selector(|| "trailing".into()))
411                .content(
412                    EmptyContent::new()
413                        .when(self.leading, |this| this.items_start())
414                        .child(div().size_4().debug_selector(|| "content".into())),
415                )
416                .header(
417                    EmptyHeader::new()
418                        .when(self.leading, |this| this.items_start())
419                        .description(EmptyDescription::new().child("Supporting content"))
420                        .title(
421                            EmptyTitle::new()
422                                .child(div().size_4().debug_selector(|| "title".into())),
423                        )
424                        .media(EmptyMedia::new().child(media)),
425                )
426        }
427    }
428
429    #[gpui::test]
430    fn media_keeps_intrinsic_width_and_slot_alignment(cx: &mut TestAppContext) {
431        cx.update(crate::init);
432
433        for grouped in [false, true] {
434            for leading in [false, true] {
435                let (_, cx) = cx.add_window_view(|_, _| MediaLayout { grouped, leading });
436                for rem in [14., 20.] {
437                    cx.update(|window, cx| {
438                        window.set_rem_size(px(rem));
439                        window.draw(cx).clear(cx);
440                    });
441                    let media = cx.debug_bounds("media-content").unwrap();
442                    let title = cx.debug_bounds("title").unwrap();
443                    let content = cx.debug_bounds("content").unwrap();
444                    let trailing = cx.debug_bounds("trailing").unwrap();
445
446                    // A zero-width group paints outside its centered slot.
447                    assert!(media.size.width > px(0.), "grouped: {grouped}");
448                    let alignment = |bounds: gpui::Bounds<gpui::Pixels>| {
449                        if leading {
450                            bounds.left()
451                        } else {
452                            bounds.center().x
453                        }
454                    };
455                    assert_eq!(alignment(media), alignment(title));
456                    assert_eq!(alignment(content), alignment(title));
457                    assert!(media.bottom() <= title.top());
458                    assert!(title.bottom() <= content.top());
459                    assert!(content.bottom() <= trailing.top());
460                }
461            }
462        }
463    }
464
465    #[test]
466    fn test_empty_builder() {
467        let root_style = StyleRefinement::default().p_4().border_1();
468        let header_style = StyleRefinement::default().items_start();
469        let media_style = StyleRefinement::default().size_10();
470        let title_style = StyleRefinement::default().text_base();
471        let description_style = StyleRefinement::default().text_left();
472        let content_style = StyleRefinement::default().flex_row().flex_wrap().gap_2();
473
474        let empty = Empty::new()
475            .refine_style(&root_style)
476            .child("Before the named setters, still after the slots")
477            .header(EmptyHeader::new().media(EmptyMedia::new()))
478            .content(EmptyContent::new().child("Replaced content"))
479            .header(
480                EmptyHeader::new()
481                    .refine_style(&header_style)
482                    .title(EmptyTitle::new().child("Replaced title"))
483                    .description(EmptyDescription::new().child("Replaced description"))
484                    .media(EmptyMedia::new().child("Replaced media"))
485                    .description(
486                        EmptyDescription::new()
487                            .refine_style(&description_style)
488                            .children(["Description", "Custom content"]),
489                    )
490                    .title(EmptyTitle::new().refine_style(&title_style).child("Title"))
491                    .media(
492                        EmptyMedia::new()
493                            .with_variant(EmptyMediaVariant::Icon)
494                            .refine_style(&media_style)
495                            .child(crate::Icon::new(crate::IconName::Search)),
496                    ),
497            )
498            .content(
499                EmptyContent::new()
500                    .refine_style(&content_style)
501                    .children(["First action", "Second action"]),
502            )
503            .child("Trailing content");
504
505        assert_eq!(empty.style, root_style);
506        assert_eq!(empty.children.len(), 2);
507        let header = empty.header.unwrap();
508        assert_eq!(header.style, header_style);
509        let media = header.media.unwrap();
510        assert_eq!(media.variant, EmptyMediaVariant::Icon);
511        assert_eq!(media.style, media_style);
512        assert_eq!(media.children.len(), 1);
513        let title = header.title.unwrap();
514        assert_eq!(title.style, title_style);
515        assert_eq!(title.children.len(), 1);
516        let description = header.description.unwrap();
517        assert_eq!(description.style, description_style);
518        assert_eq!(description.children.len(), 2);
519        let content = empty.content.unwrap();
520        assert_eq!(content.style, content_style);
521        assert_eq!(content.children.len(), 2);
522
523        let empty = Empty::default();
524        assert!(empty.header.is_none());
525        assert!(empty.content.is_none());
526        assert!(empty.children.is_empty());
527        assert_eq!(EmptyMedia::default().variant, EmptyMediaVariant::Default);
528    }
529}