Skip to main content

gpui_kit/display/
empty.rs

1//! What to show when there is nothing to show.
2//!
3//! Empty, unavailable and failed are different facts, and a surface that
4//! renders all three the same way tells the typist that their data is gone
5//! when the truth may be that nobody asked for it yet.
6
7use gpui::{
8    AnyElement, App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
9    prelude::FluentBuilder, px,
10};
11use gpui_kit_assets::{Icon, icon};
12use gpui_kit_semantics::{NodeSpec, Role, Semantic};
13use gpui_kit_theme::{ActiveTheme, Space};
14
15use crate::foundation::Ident;
16use crate::motion;
17
18/// Which fact the surface is reporting.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum EmptyKind {
21    /// The query succeeded and returned nothing.
22    #[default]
23    Empty,
24    /// Nothing has been asked for yet.
25    Unstarted,
26    /// The host refused, or could not be reached.
27    Unavailable,
28    /// The attempt failed.
29    Failed,
30}
31
32/// A centred explanation with an optional action.
33#[derive(IntoElement)]
34pub struct EmptyState {
35    ident: Ident,
36    kind: EmptyKind,
37    title: SharedString,
38    detail: Option<SharedString>,
39    action: Option<AnyElement>,
40}
41
42impl std::fmt::Debug for EmptyState {
43    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        formatter
45            .debug_struct("EmptyState")
46            .field("ident", &self.ident)
47            .field("kind", &self.kind)
48            .field("title", &self.title)
49            .finish()
50    }
51}
52
53impl EmptyState {
54    pub fn new(ident: impl Into<Ident>, title: impl Into<SharedString>) -> Self {
55        Self {
56            ident: ident.into(),
57            kind: EmptyKind::default(),
58            title: title.into(),
59            detail: None,
60            action: None,
61        }
62    }
63
64    pub fn kind(mut self, kind: EmptyKind) -> Self {
65        self.kind = kind;
66        self
67    }
68
69    /// Why the surface is empty, in the host's own words. A refusal is shown
70    /// as the refusal it is rather than rewritten as an absence of data.
71    pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
72        self.detail = Some(detail.into());
73        self
74    }
75
76    /// What the typist can do about it, usually a retry or a first step.
77    pub fn action(mut self, action: impl IntoElement) -> Self {
78        self.action = Some(action.into_any_element());
79        self
80    }
81}
82
83impl RenderOnce for EmptyState {
84    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
85        let theme = cx.theme().clone();
86        let (glyph, tint) = match self.kind {
87            EmptyKind::Empty => (Icon::Checklist, theme.colors.text_faint),
88            EmptyKind::Unstarted => (Icon::Document, theme.colors.text_faint),
89            EmptyKind::Unavailable => (Icon::CloseCircle, theme.colors.warning),
90            EmptyKind::Failed => (Icon::Danger, theme.colors.danger),
91        };
92
93        let content = div()
94            .flex()
95            .flex_col()
96            .items_center()
97            .justify_center()
98            .gap(px(theme.space(Space::Sm)))
99            .w_full()
100            .text_align(gpui::TextAlign::Center)
101            .child(icon(glyph).size(px(20.0)).text_color(tint))
102            .child(
103                div()
104                    .text_size(px(theme.typography.body.size))
105                    .text_color(theme.colors.text)
106                    .child(self.title.clone()),
107            )
108            .when_some(self.detail.clone(), |element, detail| {
109                element.child(
110                    div()
111                        .max_w(px(360.0))
112                        .text_size(px(theme.typography.caption.size))
113                        .text_color(theme.colors.text_muted)
114                        .child(detail),
115                )
116            })
117            .children(self.action);
118
119        // The rise happens inside the element that publishes the node, so the
120        // published box is the settled one and only the pixels travel.
121        div()
122            .flex()
123            .flex_col()
124            .items_center()
125            .justify_center()
126            .p(px(theme.space(Space::Lg)))
127            .w_full()
128            .child(motion::content_in(
129                self.ident.child("in").element_id(),
130                &theme,
131                content,
132            ))
133            .semantic_in(
134                cx,
135                NodeSpec::new(self.ident.semantic_id(), Role::Status)
136                    .text(self.title.clone())
137                    .value(match self.kind {
138                        EmptyKind::Empty => "empty",
139                        EmptyKind::Unstarted => "unstarted",
140                        EmptyKind::Unavailable => "unavailable",
141                        EmptyKind::Failed => "failed",
142                    }),
143            )
144    }
145}
146
147/// A horizontal rule between groups.
148#[derive(Debug, IntoElement)]
149pub struct Divider {
150    ident: Option<Ident>,
151    label: Option<SharedString>,
152}
153
154impl Default for Divider {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160impl Divider {
161    pub fn new() -> Self {
162        Self {
163            ident: None,
164            label: None,
165        }
166    }
167
168    pub fn id(mut self, ident: impl Into<Ident>) -> Self {
169        self.ident = Some(ident.into());
170        self
171    }
172
173    /// A caption sitting in the rule, naming what follows.
174    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
175        self.label = Some(label.into());
176        self
177    }
178}
179
180impl RenderOnce for Divider {
181    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
182        let theme = cx.theme().clone();
183        let rule = || {
184            div()
185                .h(px(theme.borders.hairline))
186                .flex_1()
187                .bg(theme.colors.hairline)
188        };
189        let spec = self.ident.as_ref().map(|ident| {
190            let mut spec = NodeSpec::new(ident.semantic_id(), Role::Separator);
191            if let Some(label) = self.label.clone() {
192                spec = spec.text(label);
193            }
194            spec
195        });
196
197        let element = div()
198            .flex()
199            .flex_row()
200            .items_center()
201            .w_full()
202            .gap(px(theme.space(Space::Sm)))
203            .child(rule())
204            .when_some(self.label.clone(), |element, label| {
205                element.child(
206                    div()
207                        .flex_none()
208                        .text_size(px(theme.typography.caption.size))
209                        .text_color(theme.colors.text_faint)
210                        .child(label),
211                )
212            })
213            .when(self.label.is_some(), |element| element.child(rule()));
214        match spec {
215            Some(spec) => element.semantic_in(cx, spec).into_any_element(),
216            None => element.into_any_element(),
217        }
218    }
219}