Skip to main content

gpui_kit/display/
status.rs

1use gpui::{App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, px};
2use gpui_kit_semantics::{NodeSpec, Role, Semantic};
3use gpui_kit_theme::{ActiveTheme, Radius, Space, TypeScale};
4
5use crate::display::badge::Tone;
6use crate::foundation::{Ident, StyledExt};
7use crate::motion;
8
9/// A tone-colored dot, the smallest state indicator in the system.
10#[derive(Debug, IntoElement)]
11pub struct StatusDot {
12    tone: Tone,
13    /// The identity a breathing dot animates under, when it is reporting
14    /// work that is still going.
15    busy: Option<Ident>,
16}
17
18impl StatusDot {
19    pub fn new(tone: Tone) -> Self {
20        Self { tone, busy: None }
21    }
22
23    /// Breathes the dot, for a state that is still running.
24    ///
25    /// A dot breathes where a glyph would turn, because there is nothing in a
26    /// circle for a rotation to be visible against. It is the same claim made
27    /// with the only motion this shape can carry.
28    pub fn busy(mut self, ident: impl Into<Ident>) -> Self {
29        self.busy = Some(ident.into());
30        self
31    }
32}
33
34impl RenderOnce for StatusDot {
35    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
36        let theme = cx.theme().clone();
37        let dot = div()
38            .flex_none()
39            .size(px(7.0))
40            .rounded_full()
41            .bg(self.tone.color(&theme));
42        match self.busy {
43            Some(ident) => motion::breathe(dot, ident.element_id(), &theme, cx),
44            None => dot.into_any_element(),
45        }
46    }
47}
48
49/// A dot plus a short label, for inline state.
50#[derive(Debug, IntoElement)]
51pub struct StatusLine {
52    ident: Option<Ident>,
53    label: SharedString,
54    tone: Tone,
55}
56
57impl StatusLine {
58    pub fn new(label: impl Into<SharedString>, tone: Tone) -> Self {
59        Self {
60            ident: None,
61            label: label.into(),
62            tone,
63        }
64    }
65
66    pub fn id(mut self, ident: impl Into<Ident>) -> Self {
67        self.ident = Some(ident.into());
68        self
69    }
70}
71
72impl RenderOnce for StatusLine {
73    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
74        let theme = cx.theme().clone();
75        let element = div()
76            .row()
77            .gap_token(&theme, Space::Sm)
78            .type_scale(&theme, TypeScale::Label)
79            .text_color(theme.colors.text_muted)
80            .child(StatusDot::new(self.tone))
81            .child(self.label.clone());
82        match self.ident {
83            Some(ident) => element
84                .semantic_in(
85                    cx,
86                    NodeSpec::new(ident.semantic_id(), Role::Status).text(self.label.clone()),
87                )
88                .into_any_element(),
89            None => element.into_any_element(),
90        }
91    }
92}
93
94/// A bordered message block.
95///
96/// Callouts carry host refusals and stale-data warnings verbatim; they never
97/// summarize an error into a friendlier but less true sentence.
98#[derive(Debug, IntoElement)]
99pub struct Callout {
100    ident: Option<Ident>,
101    message: SharedString,
102    tone: Tone,
103}
104
105impl Callout {
106    pub fn new(message: impl Into<SharedString>, tone: Tone) -> Self {
107        Self {
108            ident: None,
109            message: message.into(),
110            tone,
111        }
112    }
113
114    pub fn id(mut self, ident: impl Into<Ident>) -> Self {
115        self.ident = Some(ident.into());
116        self
117    }
118}
119
120impl RenderOnce for Callout {
121    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
122        let theme = cx.theme().clone();
123        let color = self.tone.color(&theme);
124        let content = div()
125            .w_full()
126            .flex()
127            .flex_row()
128            .items_start()
129            .gap_token(&theme, Space::Sm)
130            .child(div().mt(px(5.0)).child(StatusDot::new(self.tone)))
131            .child(div().min_w_0().child(self.message.clone()));
132
133        let frame = div()
134            .w_full()
135            .px_token(&theme, Space::Lg)
136            .py_token(&theme, Space::Md)
137            .radius(&theme, Radius::Card)
138            .bg(color.opacity(0.14))
139            .type_scale(&theme, TypeScale::Label)
140            .line_height(px(theme.typography.body.line_height))
141            .text_color(color.opacity(0.92));
142
143        // A callout is a report arriving, so it arrives rather than appearing.
144        // The travel is inside the frame that publishes the node, so the
145        // published box never moves. Without an identity there is nothing to
146        // key an animation to, and a callout nothing can address gets none.
147        match self.ident {
148            Some(ident) => frame
149                .child(motion::content_in(
150                    ident.child("in").element_id(),
151                    &theme,
152                    content,
153                ))
154                .semantic_in(
155                    cx,
156                    NodeSpec::new(ident.semantic_id(), Role::Status).text(self.message.clone()),
157                )
158                .into_any_element(),
159            None => frame.child(content).into_any_element(),
160        }
161    }
162}