1use crate::motion::swap_progress;
10use crate::stream::ProgressState;
11use crate::theme::SemanticStyledExt as _;
12use gpui::{
13 AnyElement, App, Div, ElementId, FontWeight, Hsla, InteractiveElement as _, IntoElement,
14 ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
15 StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, rems,
16};
17use gpui_component::{
18 ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, spinner::Spinner, v_flex,
19};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum StatusTone {
24 #[default]
26 Neutral,
27 Info,
29 Success,
31 Warning,
33 Danger,
35}
36
37impl StatusTone {
38 pub fn color(self, cx: &App) -> Hsla {
40 match self {
41 Self::Neutral => cx.theme().muted_foreground,
42 Self::Info => cx.theme().info,
43 Self::Success => cx.theme().success,
44 Self::Warning => cx.theme().warning,
45 Self::Danger => cx.theme().danger,
46 }
47 }
48
49 pub fn from_progress(state: &ProgressState) -> Self {
51 match state {
52 ProgressState::Pending => Self::Neutral,
53 ProgressState::Running => Self::Info,
54 ProgressState::Complete => Self::Success,
55 ProgressState::Failed(_) => Self::Danger,
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub(crate) enum ChipStrength {
69 Neutral,
71 #[default]
73 Tinted,
74}
75
76pub(crate) fn chip_frame(color: Hsla, strength: ChipStrength, cx: &App) -> Div {
80 let tokens = cx.theme().semantic_tokens();
81 let (background, foreground, padding) = match strength {
82 ChipStrength::Neutral => (
83 cx.theme().secondary,
84 cx.theme().muted_foreground,
85 tokens.spacing.xs,
86 ),
87 ChipStrength::Tinted => (color.opacity(0.12), color, tokens.spacing.sm),
88 };
89 div()
90 .flex_none()
91 .px(padding)
92 .py(tokens.spacing.xxs)
93 .rounded(tokens.radius.full)
94 .bg(background)
95 .text_token(tokens.typography.xs)
96 .font_weight(FontWeight::MEDIUM)
97 .text_color(foreground)
98}
99
100pub(crate) fn progress_glyph(
109 state: &ProgressState,
110 ack_slot: ElementId,
111 slot: Pixels,
112 window: &mut gpui::Window,
113 cx: &mut gpui::App,
114) -> Div {
115 let tokens = cx.theme().semantic_tokens();
116 let ordinal = match state {
117 ProgressState::Pending => 0,
118 ProgressState::Running => 1,
119 ProgressState::Complete => 2,
120 ProgressState::Failed(_) => 3,
121 };
122 let acknowledged = crate::motion::acknowledged_state(ack_slot, ordinal, window, cx);
123 let roomy = slot >= crate::sizing::slot_md(cx);
124 let glyph: AnyElement = match state {
125 ProgressState::Pending => div()
126 .when(roomy, |dot| dot.size_2())
127 .when(!roomy, |dot| dot.size_1p5())
128 .rounded(tokens.radius.full)
129 .border_1()
130 .border_color(cx.theme().muted_foreground)
131 .into_any_element(),
132 ProgressState::Running => {
133 let spinner = Spinner::new().color(cx.theme().info);
134 if roomy {
135 spinner.small()
136 } else {
137 spinner.xsmall()
138 }
139 .into_any_element()
140 }
141 ProgressState::Complete => {
142 let icon = Icon::new(IconName::CircleCheck).text_color(cx.theme().success);
143 if roomy { icon.small() } else { icon.xsmall() }
144 .opacity(acknowledged)
145 .into_any_element()
146 }
147 ProgressState::Failed(_) => {
148 let icon = Icon::new(IconName::CircleX).text_color(cx.theme().danger);
149 if roomy { icon.small() } else { icon.xsmall() }
150 .opacity(acknowledged)
151 .into_any_element()
152 }
153 };
154 crate::surface::leading_glyph_slot(slot, glyph)
155}
156
157pub fn progress_label(state: &ProgressState) -> &'static str {
159 match state {
160 ProgressState::Pending => "Pending",
161 ProgressState::Running => "Running",
162 ProgressState::Complete => "Completed",
163 ProgressState::Failed(_) => "Failed",
164 }
165}
166
167#[derive(IntoElement)]
180pub struct StatusBadge {
181 id: ElementId,
182 style: StyleRefinement,
183 label: SharedString,
184 tone: StatusTone,
185 active: bool,
186 reserve_lifecycle_width: bool,
187}
188
189struct StatusSwap {
192 label: SharedString,
193 tone: StatusTone,
194 active: bool,
195 generation: u64,
197 outgoing: Option<(SharedString, StatusTone)>,
201}
202
203impl StatusBadge {
204 pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
206 Self {
207 id: id.into(),
208 style: StyleRefinement::default(),
209 label: label.into(),
210 tone: StatusTone::Neutral,
211 active: false,
212 reserve_lifecycle_width: false,
213 }
214 }
215
216 pub fn for_progress(id: impl Into<ElementId>, state: &ProgressState) -> Self {
226 Self::new(id, progress_label(state))
227 .tone(StatusTone::from_progress(state))
228 .active(matches!(state, ProgressState::Running))
229 }
230
231 pub fn reserve_lifecycle_width(mut self, reserve: bool) -> Self {
238 self.reserve_lifecycle_width = reserve;
239 self
240 }
241
242 pub fn tone(mut self, tone: StatusTone) -> Self {
244 self.tone = tone;
245 self
246 }
247
248 pub fn active(mut self, active: bool) -> Self {
250 self.active = active;
251 self
252 }
253}
254
255impl Styled for StatusBadge {
256 fn style(&mut self) -> &mut StyleRefinement {
257 &mut self.style
258 }
259}
260
261impl RenderOnce for StatusBadge {
262 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
263 let tokens = cx.theme().semantic_tokens();
264 let color = self.tone.color(cx);
265
266 let swap =
272 window.use_keyed_state((self.id.clone(), "status-swap"), cx, |_, _| StatusSwap {
273 label: self.label.clone(),
274 tone: self.tone,
275 active: self.active,
276 generation: 0,
277 outgoing: None,
278 });
279 let (generation, outgoing) = swap.update(cx, |swap, _| {
280 if swap.label != self.label || swap.tone != self.tone || swap.active != self.active {
281 if swap.label != self.label || swap.tone != self.tone {
285 swap.outgoing = Some((swap.label.clone(), swap.tone));
286 }
287 swap.generation += 1;
288 swap.label = self.label.clone();
289 swap.tone = self.tone;
290 swap.active = self.active;
291 }
292 (swap.generation, swap.outgoing.clone())
293 });
294 let progress = if generation == 0 {
295 1.0
296 } else {
297 swap_progress(
298 ElementId::NamedInteger(
299 SharedString::from(format!("{:?}-status-swap", self.id)),
300 generation,
301 ),
302 window,
303 cx,
304 )
305 };
306 if progress >= 1.0 && outgoing.is_some() {
307 swap.update(cx, |swap, _| swap.outgoing = None);
308 }
309 let exit = tokens.spacing.xxs * progress * crate::motion::travel(cx);
310 let entry = tokens.spacing.xxs * (1.0 - progress) * crate::motion::travel(cx);
311
312 let indicator_label = self.label.clone();
315 let indicator = div()
316 .debug_selector(move || format!("status-badge-indicator-{indicator_label}"))
317 .flex_none()
318 .size(crate::sizing::slot_xs(cx))
319 .flex()
320 .items_center()
321 .justify_center()
322 .child(if self.active {
323 Spinner::new().xsmall().color(color).into_any_element()
324 } else {
325 div()
326 .debug_selector({
327 let dot_label = self.label.clone();
328 move || format!("status-badge-dot-{dot_label}")
329 })
330 .size_1p5()
331 .rounded(tokens.radius.full)
332 .bg(color)
333 .opacity(progress)
334 .into_any_element()
335 });
336
337 let label_slot = v_flex()
338 .relative()
339 .items_center()
340 .when(self.reserve_lifecycle_width, |slot| {
341 slot.children(LIFECYCLE_LABELS.iter().map(|label| {
347 div()
348 .h(rems(0.))
349 .overflow_hidden()
350 .opacity(0.)
351 .child(*label)
352 }))
353 })
354 .child({
355 let label_name = self.label.clone();
356 div()
357 .debug_selector(move || format!("status-badge-label-{label_name}"))
358 .opacity(progress)
359 .top(entry)
360 .child(self.label.clone())
361 })
362 .when_some(outgoing, |slot, (label, tone)| {
363 slot.child(
366 div()
367 .absolute()
368 .left(rems(0.))
369 .top(exit)
370 .opacity(1.0 - progress)
371 .text_color(tone.color(cx))
372 .child(label),
373 )
374 });
375
376 let slot_slack = (crate::sizing::slot_xs(cx) - INDICATOR_DOT) / 2.;
384 chip_frame(color, ChipStrength::Tinted, cx)
385 .id(self.id)
386 .role(Role::Status)
387 .aria_label(self.label.clone())
388 .flex()
389 .items_center()
390 .pl((tokens.spacing.sm - slot_slack).max(gpui::Pixels::ZERO))
391 .gap(tokens.spacing.xs)
392 .child(indicator)
393 .child(label_slot)
394 .refine_style(&self.style)
395 }
396}
397
398const LIFECYCLE_LABELS: [&str; 4] = ["Pending", "Running", "Completed", "Failed"];
402
403const INDICATOR_DOT: gpui::Pixels = gpui::px(6.);
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use gpui::{Context, Entity, Render, TestAppContext, VisualTestContext, px};
412 use gpui_component::h_flex;
413
414 struct BadgeProbe {
415 state: ProgressState,
416 }
417
418 impl Render for BadgeProbe {
419 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
420 div().w(px(320.)).h(px(80.)).child(
421 h_flex()
422 .debug_selector(|| "badge-hug".into())
423 .flex_none()
424 .child(StatusBadge::for_progress("probe-badge", &self.state)),
425 )
426 }
427 }
428
429 fn open(cx: &mut TestAppContext) -> (Entity<BadgeProbe>, &mut VisualTestContext) {
430 cx.update(crate::init);
431 let (probe, cx) = cx.add_window_view(|_, _| BadgeProbe {
432 state: ProgressState::Pending,
433 });
434 cx.update(|window, cx| window.draw(cx).clear(cx));
435 (probe, cx)
436 }
437
438 fn draw(cx: &mut VisualTestContext) {
439 cx.update(|window, cx| window.draw(cx).clear(cx));
440 }
441
442 fn set_state(probe: &Entity<BadgeProbe>, cx: &mut VisualTestContext, state: ProgressState) {
443 probe.update(cx, |probe, cx| {
444 probe.state = state;
445 cx.notify();
446 });
447 draw(cx);
448 }
449
450 fn settle_swap(cx: &mut VisualTestContext) {
451 cx.executor()
452 .advance_clock(crate::motion::MotionTokens::DEFAULT.quick() * 2);
453 draw(cx);
454 draw(cx);
455 }
456
457 #[gpui::test]
458 fn a_status_change_stages_a_swap_and_settles_quiet(cx: &mut TestAppContext) {
459 let (probe, cx) = open(cx);
460 crate::motion::take_reveal_frame_requests();
461
462 set_state(&probe, cx, ProgressState::Complete);
463 assert!(
464 crate::motion::take_reveal_frame_requests() > 0,
465 "a status change must acknowledge itself"
466 );
467
468 settle_swap(cx);
469 crate::motion::take_reveal_frame_requests();
470 draw(cx);
471 assert_eq!(
472 crate::motion::take_reveal_frame_requests(),
473 0,
474 "a settled swap must schedule nothing"
475 );
476 }
477
478 #[gpui::test]
479 fn the_first_render_is_not_a_transition(cx: &mut TestAppContext) {
480 let (_, cx) = open(cx);
481 crate::motion::take_reveal_frame_requests();
482 draw(cx);
483 assert_eq!(
484 crate::motion::take_reveal_frame_requests(),
485 0,
486 "a badge mounts at rest, whatever state it mounts in"
487 );
488 }
489
490 #[gpui::test]
491 fn reduced_motion_swaps_crossfade_and_snap_is_instant(cx: &mut TestAppContext) {
492 cx.update(|cx| cx.set_reduce_motion(true));
495 let (probe, cx) = open(cx);
496 crate::motion::take_reveal_frame_requests();
497
498 set_state(&probe, cx, ProgressState::Failed("offline".into()));
499 draw(cx);
500 assert!(
501 crate::motion::take_reveal_frame_requests() > 0,
502 "the crossfade swap runs"
503 );
504 settle_swap(cx);
505 crate::motion::take_reveal_frame_requests();
506 draw(cx);
507 assert_eq!(crate::motion::take_reveal_frame_requests(), 0);
508
509 cx.update(|_, cx| {
511 crate::motion::MotionTokens::default()
512 .with_preference(crate::motion::MotionPreference::Snap)
513 .set(cx)
514 });
515 set_state(&probe, cx, ProgressState::Complete);
516 draw(cx);
517 assert_eq!(
518 crate::motion::take_reveal_frame_requests(),
519 0,
520 "snap resolves the swap instantly"
521 );
522 }
523
524 #[test]
525 fn every_lifecycle_label_is_reserved() {
526 for state in [
527 ProgressState::Pending,
528 ProgressState::Running,
529 ProgressState::Complete,
530 ProgressState::Failed("offline".into()),
531 ] {
532 assert!(
533 LIFECYCLE_LABELS.contains(&progress_label(&state)),
534 "the width reservation must know every label for_progress can show"
535 );
536 }
537 }
538
539 #[test]
540 fn progress_maps_onto_one_tone_and_label_each() {
541 let cases = [
542 (ProgressState::Pending, StatusTone::Neutral, "Pending"),
543 (ProgressState::Running, StatusTone::Info, "Running"),
544 (ProgressState::Complete, StatusTone::Success, "Completed"),
545 (
546 ProgressState::Failed("offline".into()),
547 StatusTone::Danger,
548 "Failed",
549 ),
550 ];
551 for (state, tone, label) in cases {
552 assert_eq!(StatusTone::from_progress(&state), tone);
553 assert_eq!(progress_label(&state), label);
554 let badge = StatusBadge::for_progress("badge", &state);
555 assert_eq!(badge.tone, tone);
556 assert_eq!(badge.label.as_ref(), label);
557 assert_eq!(badge.active, matches!(state, ProgressState::Running));
558 }
559 }
560}