1use crate::motion::{MotionTokens, VisibleAnimationExt as _};
4use crate::theme::SemanticStyledExt as _;
5use gpui::{
6 App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role,
7 SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
8 prelude::FluentBuilder as _,
9};
10use gpui_component::{ActiveTheme as _, StyledExt as _, h_flex};
11use std::panic::Location;
12use std::time::Duration;
13
14const ROWS: usize = 3;
16const COLS: usize = 3;
17
18#[derive(IntoElement)]
35pub struct LoadingState {
36 id: ElementId,
37 style: StyleRefinement,
38 label: SharedString,
39 elapsed: Option<Duration>,
40}
41
42impl LoadingState {
43 #[track_caller]
45 pub fn new() -> Self {
46 Self {
47 id: ElementId::CodeLocation(*Location::caller()),
48 style: StyleRefinement::default(),
49 label: "Working…".into(),
50 elapsed: None,
51 }
52 }
53
54 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
56 self.label = label.into();
57 self
58 }
59
60 pub fn elapsed(mut self, elapsed: Duration) -> Self {
62 self.elapsed = Some(elapsed);
63 self
64 }
65}
66
67impl Default for LoadingState {
68 #[track_caller]
69 fn default() -> Self {
70 Self::new()
71 }
72}
73
74impl Styled for LoadingState {
75 fn style(&mut self) -> &mut StyleRefinement {
76 &mut self.style
77 }
78}
79
80impl RenderOnce for LoadingState {
81 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
82 let tokens = cx.theme().semantic_tokens();
83 let color = cx.theme().primary;
84 let sweep = MotionTokens::read(cx).grid_sweep();
85 let cell_gap = tokens.spacing.xxs;
86 let cell_size = tokens.spacing.xs;
87 let full = crate::motion::motion_is_full(cx);
88 let cell_radius = tokens.spacing.xxs;
89
90 let grid = div()
95 .flex()
96 .flex_col()
97 .gap(cell_gap)
98 .with_visible_animation(
99 "loading-grid",
100 sweep.looping_synced(),
105 move |grid, delta| {
106 let delta = if full { delta } else { 0.0 };
107 grid.children((0..ROWS).map(|row| {
108 h_flex().gap(cell_gap).children((0..COLS).map(move |col| {
109 let phase = (row + col) as f32 / ((ROWS + COLS) as f32);
113 let wave = ((delta - phase).rem_euclid(1.0) * 2.0 - 1.0).abs();
114 div()
115 .size(cell_size)
116 .rounded(cell_radius)
117 .bg(color)
118 .opacity(0.15 + 0.85 * wave)
119 }))
120 }))
121 },
122 );
123
124 h_flex()
125 .id(self.id)
126 .role(Role::ProgressIndicator)
127 .aria_label(self.label.clone())
128 .items_center()
129 .gap(tokens.spacing.md)
130 .child(grid)
131 .child(
132 div()
133 .text_token(tokens.typography.sm)
134 .text_color(cx.theme().muted_foreground)
135 .child(self.label),
136 )
137 .when_some(self.elapsed, |this, elapsed| {
138 this.child(
139 div()
140 .text_token(tokens.typography.xs)
141 .font_family(cx.theme().mono_font_family.clone())
142 .text_color(cx.theme().muted_foreground)
143 .child(format!("{:.0}s", elapsed.as_secs_f64())),
144 )
145 })
146 .refine_style(&self.style)
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use gpui::{Context, Render, TestAppContext, WindowHandle, px, size};
154
155 struct LoaderProbe {
156 elapsed: Option<Duration>,
157 }
158
159 impl Render for LoaderProbe {
160 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
161 let mut loader = LoadingState::new().label("Working");
162 if let Some(elapsed) = self.elapsed {
163 loader = loader.elapsed(elapsed);
164 }
165 div().child(loader)
166 }
167 }
168
169 fn open(elapsed: Option<Duration>, cx: &mut TestAppContext) -> WindowHandle<LoaderProbe> {
170 cx.update(crate::init);
171 let window = cx.open_window(size(px(240.), px(80.)), |_, _| LoaderProbe { elapsed });
172 cx.run_until_parked();
173 next_frame(&window, cx);
176 window
177 }
178
179 fn next_frame(window: &WindowHandle<LoaderProbe>, cx: &mut TestAppContext) -> usize {
180 let callbacks = window
181 .update(cx, |_, window, cx| window.simulate_next_frame(cx))
182 .expect("the loader window should remain open");
183 cx.run_until_parked();
184 callbacks
185 }
186
187 #[gpui::test]
188 fn the_whole_grid_ticks_on_one_clock(cx: &mut TestAppContext) {
189 let window = open(None, cx);
192 assert_eq!(next_frame(&window, cx), 1, "one region, one clock");
193 }
194
195 #[gpui::test]
196 fn an_elapsed_update_does_not_disturb_the_clock(cx: &mut TestAppContext) {
197 let window = open(Some(Duration::from_secs(3)), cx);
198 assert_eq!(next_frame(&window, cx), 1);
199
200 cx.executor().advance_clock(Duration::from_millis(700));
203 window
204 .update(cx, |probe, _, cx| {
205 probe.elapsed = Some(Duration::from_secs(4));
206 cx.notify();
207 })
208 .expect("the loader window should remain open");
209 cx.run_until_parked();
210
211 next_frame(&window, cx);
215
216 assert_eq!(next_frame(&window, cx), 1);
219 }
220
221 #[gpui::test]
222 fn reduced_motion_holds_the_choreographed_still_frame(cx: &mut TestAppContext) {
223 cx.update(|cx| cx.set_reduce_motion(true));
224 let window = open(None, cx);
225 assert_eq!(
226 next_frame(&window, cx),
227 0,
228 "a held sweep schedules nothing; the static diagonal gradient is the frame"
229 );
230 }
231}