1use 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
36const HEIGHT: f32 = 26.0;
38
39type ClickHandler = Rc<dyn Fn(&mut Window, &mut App)>;
40
41#[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 Text,
62 State(Tone),
64 Progress {
66 fraction: Option<f32>,
67 count: Option<(usize, usize)>,
68 },
69 Action,
71 Element(AnyElement),
74}
75
76pub struct StatusItem {
78 id: SharedString,
79 label: SharedString,
80 content: Content,
81 icon: Option<Icon>,
82 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 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 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 pub fn action(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
145 Self::build(id, label, Content::Action)
146 }
147
148 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 pub fn state_name(mut self, state: impl Into<SharedString>) -> Self {
180 self.state = Some(state.into());
181 self
182 }
183
184 pub fn stale(mut self, stale: bool) -> Self {
186 self.stale = stale;
187 self
188 }
189
190 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
228fn 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#[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 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 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 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 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 .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}