Skip to main content

gpui_kit/layout/
status_bar.rs

1//! The strip along the bottom of a window.
2//!
3//! A status bar is where a desktop tool says what is true right now: which
4//! branch, how many problems, whether the indexer is still running. Every one
5//! of those facts belongs to the host. The bar draws what it is given and
6//! invents nothing — an item with no state carries no state, rather than a
7//! reassuring green dot nobody asked for.
8//!
9//! # Stale is not current
10//!
11//! [`AsyncValue`] already separates a value from what is happening to it,
12//! which is what lets a failed refresh keep the last verified number on
13//! screen. [`StatusItem::tracking`] reads that vocabulary straight: a value
14//! whose status is `Refreshing` or `Error` while a value is still held is
15//! drawn with its last verified text and the word `stale` beside it, and
16//! publishes `stale` as its value. It is never drawn as current.
17
18use std::rc::Rc;
19
20use gpui::{
21    AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
22    Styled, Window, div, prelude::FluentBuilder, px,
23};
24use gpui_kit_assets::{Icon, icon};
25use gpui_kit_semantics::{NodeSpec, Role, Semantic};
26use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, Theme, TypeScale};
27
28use crate::controls::button::Button;
29use crate::display::badge::Tone;
30use crate::display::progress_circle::ProgressCircle;
31use crate::display::status::StatusDot;
32use crate::foundation::{Disableable, Ident, Sizable, StyledExt};
33use crate::state::{AsyncStatus, AsyncValue};
34use crate::strings::{ActiveStrings, StringKey};
35
36/// How tall the strip is. The value occurs only here.
37const HEIGHT: f32 = 26.0;
38
39type ClickHandler = Rc<dyn Fn(&mut Window, &mut App)>;
40
41/// Which group an item sits in.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum StatusGroup {
44    Start,
45    Centre,
46    End,
47}
48
49impl StatusGroup {
50    pub fn name(self) -> &'static str {
51        match self {
52            Self::Start => "start",
53            Self::Centre => "centre",
54            Self::End => "end",
55        }
56    }
57}
58
59enum Content {
60    /// Words, and nothing else.
61    Text,
62    /// A tone-coloured dot and words.
63    State(Tone),
64    /// A ring, for work with or without a known extent.
65    Progress {
66        fraction: Option<f32>,
67        count: Option<(usize, usize)>,
68    },
69    /// A control the typist can operate.
70    Action,
71    /// Whatever the caller put there, usually a `Popover` or a `Menu` that
72    /// opens from the bar.
73    Element(AnyElement),
74}
75
76/// One thing the bar says.
77pub struct StatusItem {
78    id: SharedString,
79    label: SharedString,
80    content: Content,
81    icon: Option<Icon>,
82    /// The name of the state the host gave this item, if it gave one.
83    state: Option<SharedString>,
84    stale: bool,
85    disabled: bool,
86    on_click: Option<ClickHandler>,
87}
88
89impl std::fmt::Debug for StatusItem {
90    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        formatter
92            .debug_struct("StatusItem")
93            .field("id", &self.id)
94            .field("label", &self.label)
95            .field("state", &self.state)
96            .field("stale", &self.stale)
97            .finish()
98    }
99}
100
101impl StatusItem {
102    fn build(
103        id: impl Into<SharedString>,
104        label: impl Into<SharedString>,
105        content: Content,
106    ) -> Self {
107        Self {
108            id: id.into(),
109            label: label.into(),
110            content,
111            icon: None,
112            state: None,
113            stale: false,
114            disabled: false,
115            on_click: None,
116        }
117    }
118
119    pub fn text(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
120        Self::build(id, label, Content::Text)
121    }
122
123    /// A dot and words. The tone is the host's judgement, never the bar's.
124    pub fn state(id: impl Into<SharedString>, label: impl Into<SharedString>, tone: Tone) -> Self {
125        let mut item = Self::build(id, label, Content::State(tone));
126        item.state = Some(SharedString::new_static(tone.name()));
127        item
128    }
129
130    /// A ring. Without a fraction or a count the extent is unknown, and is
131    /// drawn as unknown rather than as a ring that happens to be part full.
132    pub fn progress(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
133        Self::build(
134            id,
135            label,
136            Content::Progress {
137                fraction: None,
138                count: None,
139            },
140        )
141    }
142
143    /// A control. Without `on_click` it installs no handler and is refused.
144    pub fn action(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
145        Self::build(id, label, Content::Action)
146    }
147
148    /// Whatever the caller wants in the strip, usually a `Popover` or a `Menu`
149    /// that opens from it. The element publishes its own identities.
150    pub fn element(
151        id: impl Into<SharedString>,
152        label: impl Into<SharedString>,
153        element: impl IntoElement,
154    ) -> Self {
155        Self::build(id, label, Content::Element(element.into_any_element()))
156    }
157
158    pub fn fraction(mut self, fraction: f32) -> Self {
159        if let Content::Progress { fraction: slot, .. } = &mut self.content {
160            *slot = Some(fraction.clamp(0.0, 1.0));
161        }
162        self
163    }
164
165    pub fn count(mut self, done: usize, total: usize) -> Self {
166        if let Content::Progress { count, .. } = &mut self.content {
167            *count = Some((done, total));
168        }
169        self
170    }
171
172    pub fn icon(mut self, glyph: Icon) -> Self {
173        self.icon = Some(glyph);
174        self
175    }
176
177    /// The name of the state the host says this item is in. Publishing it is
178    /// how a test reads what the host claimed rather than what was painted.
179    pub fn state_name(mut self, state: impl Into<SharedString>) -> Self {
180        self.state = Some(state.into());
181        self
182    }
183
184    /// Marks the text as the last verified value rather than the current one.
185    pub fn stale(mut self, stale: bool) -> Self {
186        self.stale = stale;
187        self
188    }
189
190    /// Takes the text and the staleness from a value the host is refreshing.
191    ///
192    /// A refresh that failed keeps the last verified text on screen and marks
193    /// it stale; a value the host never had shows the item's own label.
194    pub fn tracking<E>(mut self, value: &AsyncValue<SharedString, E>) -> Self {
195        if let Some(held) = value.value.clone() {
196            self.label = held;
197        }
198        self.stale = value.is_stale();
199        self.state = Some(SharedString::new_static(status_name(&value.status)));
200        self
201    }
202
203    pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
204        self.on_click = Some(Rc::new(handler));
205        self
206    }
207
208    pub fn id(&self) -> &SharedString {
209        &self.id
210    }
211
212    pub fn label(&self) -> &SharedString {
213        &self.label
214    }
215
216    pub fn is_stale(&self) -> bool {
217        self.stale
218    }
219}
220
221impl Disableable for StatusItem {
222    fn disabled(mut self, disabled: bool) -> Self {
223        self.disabled = disabled;
224        self
225    }
226}
227
228/// The name of an async status, for a test that reads the tree.
229fn status_name<E>(status: &AsyncStatus<E>) -> &'static str {
230    match status {
231        AsyncStatus::Idle => "idle",
232        AsyncStatus::Loading => "loading",
233        AsyncStatus::Refreshing => "refreshing",
234        AsyncStatus::Ready => "ready",
235        AsyncStatus::Empty => "empty",
236        AsyncStatus::Unavailable(_) => "unavailable",
237        AsyncStatus::Error(_) => "error",
238    }
239}
240
241/// The strip along the bottom of a window.
242#[derive(IntoElement)]
243pub struct StatusBar {
244    ident: Ident,
245    label: Option<SharedString>,
246    groups: [Vec<StatusItem>; 3],
247    disabled: bool,
248}
249
250impl std::fmt::Debug for StatusBar {
251    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        formatter
253            .debug_struct("StatusBar")
254            .field("ident", &self.ident)
255            .field(
256                "items",
257                &self.groups.iter().map(Vec::len).collect::<Vec<_>>(),
258            )
259            .field("disabled", &self.disabled)
260            .finish()
261    }
262}
263
264impl StatusBar {
265    pub fn new(ident: impl Into<Ident>) -> Self {
266        Self {
267            ident: ident.into(),
268            label: None,
269            groups: [Vec::new(), Vec::new(), Vec::new()],
270            disabled: false,
271        }
272    }
273
274    /// What the strip is, for a reader that has only the tree.
275    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
276        self.label = Some(label.into());
277        self
278    }
279
280    pub fn item(mut self, group: StatusGroup, item: StatusItem) -> Self {
281        self.groups[index(group)].push(item);
282        self
283    }
284
285    pub fn items(
286        mut self,
287        group: StatusGroup,
288        items: impl IntoIterator<Item = StatusItem>,
289    ) -> Self {
290        self.groups[index(group)].extend(items);
291        self
292    }
293
294    pub fn start(self, items: impl IntoIterator<Item = StatusItem>) -> Self {
295        self.items(StatusGroup::Start, items)
296    }
297
298    pub fn centre(self, items: impl IntoIterator<Item = StatusItem>) -> Self {
299        self.items(StatusGroup::Centre, items)
300    }
301
302    pub fn end(self, items: impl IntoIterator<Item = StatusItem>) -> Self {
303        self.items(StatusGroup::End, items)
304    }
305
306    fn item_element(&self, item: StatusItem, theme: &Theme, cx: &mut App) -> AnyElement {
307        let ident = self.ident.child(item.id.as_ref());
308        let disabled = self.disabled || item.disabled;
309
310        if let Content::Action = item.content {
311            let mut button = Button::new(ident.clone())
312                .label(item.label.clone())
313                .ghost()
314                .xs()
315                .disabled(disabled);
316            if let Some(glyph) = item.icon {
317                button = button.icon(glyph);
318            }
319            if let (false, Some(handler)) = (disabled, item.on_click.clone()) {
320                button = button.on_click(move |window, cx| handler(window, cx));
321            }
322            return button.into_any_element();
323        }
324
325        let text_color = if disabled {
326            theme.colors.text_faint
327        } else {
328            theme.colors.text_muted
329        };
330
331        let mut row = div()
332            .row()
333            .flex_none()
334            .h(px(theme.control.get(ControlSize::Xs).height))
335            .px_token(theme, Space::Xs)
336            .gap_token(theme, Space::Xs)
337            .radius(theme, Radius::Control)
338            .type_scale(theme, TypeScale::Caption)
339            .text_color(text_color)
340            .children(
341                item.icon
342                    .map(|glyph| icon(glyph).size(px(11.0)).text_color(text_color)),
343            );
344
345        match &item.content {
346            Content::State(tone) => row = row.child(StatusDot::new(*tone)),
347            Content::Progress { fraction, count } => {
348                let mut ring = ProgressCircle::new(ident.child("progress"))
349                    .label(item.label.clone())
350                    .xs();
351                if let Some((done, total)) = count {
352                    ring = ring.count(*done, *total);
353                } else if let Some(fraction) = fraction {
354                    ring = ring.fraction(*fraction);
355                }
356                row = row.child(ring);
357            }
358            _ => {}
359        }
360
361        if let Content::Element(element) = item.content {
362            row = row.child(element);
363        } else {
364            row = row.child(item.label.clone());
365        }
366
367        // A stale value keeps its text and says so beside it. Removing the
368        // text would lose the last thing the host actually verified.
369        if item.stale {
370            row = row.child(
371                div()
372                    .flex_none()
373                    .px(px(theme.spacing.xs / 2.0))
374                    .radius(theme, Radius::Small)
375                    .bg(theme.colors.warning.opacity(0.16))
376                    .text_color(theme.colors.warning)
377                    .child(cx.strings().text(StringKey::StatusStale)),
378            );
379        }
380
381        let mut spec = NodeSpec::new(ident.semantic_id(), Role::Status)
382            .parent(self.ident.semantic_id())
383            .disabled(disabled)
384            .text(item.label.clone());
385        // The bar states what the host claimed and nothing else: an item the
386        // host gave no state to publishes no state.
387        if item.stale {
388            spec = spec.value("stale");
389        } else if let Some(state) = item.state.clone() {
390            spec = spec.value(state);
391        }
392
393        row.semantic_in(cx, spec).into_any_element()
394    }
395}
396
397fn index(group: StatusGroup) -> usize {
398    match group {
399        StatusGroup::Start => 0,
400        StatusGroup::Centre => 1,
401        StatusGroup::End => 2,
402    }
403}
404
405impl Disableable for StatusBar {
406    /// Refuses every item in the strip. A refused item installs no handler.
407    fn disabled(mut self, disabled: bool) -> Self {
408        self.disabled = disabled;
409        self
410    }
411}
412
413impl RenderOnce for StatusBar {
414    fn render(mut self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
415        let theme = cx.theme().clone();
416        let count: usize = self.groups.iter().map(Vec::len).sum();
417        let groups = std::mem::take(&mut self.groups);
418        let mut strip = div()
419            .id(self.ident.element_id())
420            .row()
421            .w_full()
422            .flex_none()
423            .h(px(HEIGHT))
424            .items_center()
425            .px_token(&theme, Space::Sm)
426            .gap_token(&theme, Space::Sm)
427            .bg(theme.colors.panel);
428
429        for (position, items) in groups.into_iter().enumerate() {
430            let elements: Vec<AnyElement> = items
431                .into_iter()
432                .map(|item| self.item_element(item, &theme, cx))
433                .collect();
434            strip = strip.child(
435                div()
436                    .row()
437                    .min_w(px(0.0))
438                    .overflow_hidden()
439                    .gap_token(&theme, Space::Xs)
440                    // The centre group takes the slack so the end group stays
441                    // pinned to the right edge whatever the middle holds.
442                    .when(position == 1, |group| group.flex_1().justify_center())
443                    .when(position == 2, |group| group.justify_end())
444                    .children(elements),
445            );
446        }
447
448        let mut spec =
449            NodeSpec::new(self.ident.semantic_id(), Role::Toolbar).value(count.to_string());
450        if let Some(label) = self.label.clone() {
451            spec = spec.text(label);
452        }
453        strip.semantic_in(cx, spec)
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn a_refresh_that_failed_keeps_the_last_verified_text_and_marks_it() {
463        let mut value = AsyncValue::<SharedString, String>::ready("main@a1b2c3".into());
464        value.refresh();
465        value.fail_refresh("the host is unreachable".into());
466
467        let item = StatusItem::text("vcs", "unknown").tracking(&value);
468        assert_eq!(item.label().as_ref(), "main@a1b2c3");
469        assert!(item.is_stale());
470    }
471
472    #[test]
473    fn a_value_that_is_current_is_not_stale() {
474        let value = AsyncValue::<SharedString, String>::ready("main@a1b2c3".into());
475        let item = StatusItem::text("vcs", "unknown").tracking(&value);
476        assert!(!item.is_stale());
477    }
478
479    #[test]
480    fn a_state_carries_the_tone_the_host_chose() {
481        let item = StatusItem::state("build", "Build passing", Tone::Success);
482        assert_eq!(item.state.as_deref(), Some("success"));
483    }
484
485    #[test]
486    fn an_item_with_no_state_claims_none() {
487        assert!(StatusItem::text("branch", "main").state.is_none());
488    }
489
490    #[test]
491    fn every_async_status_has_a_name_a_test_can_read() {
492        assert_eq!(
493            status_name(&AsyncStatus::<String>::Refreshing),
494            "refreshing"
495        );
496        assert_eq!(
497            status_name(&AsyncStatus::Unavailable::<String>("no".into())),
498            "unavailable"
499        );
500    }
501}