1use std::time::Duration;
4
5use crate::animation::AnimationName;
6use crate::color::Rgb;
7use crate::geometry::{Rect, Size};
8use crate::style::CellStyle;
9use crate::text;
10use crate::widget::{MeasureCx, PaintCx, Widget};
11use crate::widgets::delayed::DelayedIndicator;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum SpinnerStyle {
17 #[default]
19 Arc,
20 Dots,
22 Orbit,
24 Pop,
26 Pulse,
28 Quarters,
31 Slices,
34}
35
36impl SpinnerStyle {
37 pub const ALL: [Self; 7] =
39 [Self::Arc, Self::Dots, Self::Orbit, Self::Pop, Self::Pulse, Self::Quarters, Self::Slices];
40
41 #[must_use]
43 pub fn name(self) -> &'static str {
44 match self {
45 Self::Arc => "arc",
46 Self::Dots => "dots",
47 Self::Orbit => "orbit",
48 Self::Pop => "pop",
49 Self::Pulse => "pulse",
50 Self::Quarters => "quarters",
51 Self::Slices => "slices",
52 }
53 }
54
55 #[must_use]
57 pub fn animation(self) -> &'static str {
58 match self {
59 Self::Arc => "spinner-arc",
60 Self::Dots => "spinner-dots",
61 Self::Orbit => "spinner-orbit",
62 Self::Pop => "spinner-pop",
63 Self::Pulse => "spinner-pulse",
64 Self::Quarters => "spinner-quarters",
65 Self::Slices => "spinner-slices",
66 }
67 }
68}
69
70impl From<SpinnerStyle> for AnimationName {
71 fn from(style: SpinnerStyle) -> Self {
72 Self::from(style.animation())
73 }
74}
75
76const DONE_ANIMATION: &str = "spinner-done";
78
79#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct Spinner {
91 animation: AnimationName,
92 label: Option<String>,
93 variant: Option<String>,
94 done: bool,
95 delayed: Option<bool>,
96}
97
98impl Default for Spinner {
99 fn default() -> Self {
100 Self { animation: SpinnerStyle::default().into(), label: None, variant: None, done: false, delayed: None }
101 }
102}
103
104impl Spinner {
105 #[must_use]
107 pub fn new() -> Self {
108 Self::default()
109 }
110
111 #[must_use]
113 pub fn style(mut self, style: SpinnerStyle) -> Self {
114 self.animation = style.into();
115 self
116 }
117
118 #[must_use]
121 pub fn animation(mut self, name: impl Into<AnimationName>) -> Self {
122 self.animation = name.into();
123 self
124 }
125
126 #[must_use]
128 pub fn label(mut self, label: impl Into<String>) -> Self {
129 self.label = Some(label.into());
130 self
131 }
132
133 #[must_use]
135 pub fn variant(mut self, variant: impl Into<String>) -> Self {
136 self.variant = Some(variant.into());
137 self
138 }
139
140 #[must_use]
146 pub fn done(mut self, done: bool) -> Self {
147 self.done = done;
148 self
149 }
150
151 #[must_use]
158 pub fn delayed(mut self, busy: bool) -> Self {
159 self.delayed = Some(busy);
160 self
161 }
162
163 fn cell_style(&self, cx: &mut PaintCx<'_>) -> CellStyle {
165 let style = cx.style("spinner", self.variant.as_deref(), &[]).text();
166 CellStyle { fg: Some(style.fg.unwrap_or_else(|| cx.color("accent"))), ..style }
167 }
168
169 fn paint_done(&self, cx: &mut PaintCx<'_>, area: Rect) {
171 let style = self.cell_style(cx);
172 let started = match *cx.memory::<Finish>() {
173 Finish::Unseen | Finish::Resting => None,
174 Finish::Since { at, from } => Some((at, from)),
175 Finish::Spinning => {
176 let now = cx.now();
178 let turning = cx.animation(self.animation.as_str(), style, Some(Duration::ZERO));
179 Some((now, turning.style.fg))
180 }
181 };
182 let since = started.filter(|_| !cx.reduced_motion());
183 let from = CellStyle { fg: since.and_then(|(_, from)| from).or(style.fg), ..style };
184 let cell = cx.animation(DONE_ANIMATION, from, since.map(|(at, _)| at));
185 *cx.memory::<Finish>() = match since {
186 Some((at, from)) if !cell.finished => Finish::Since { at, from },
187 _ => Finish::Resting,
188 };
189 cx.text(area.x, area.y, &cell.glyph, cell.style, 1);
190 }
191
192 fn delay_allows(cx: &mut PaintCx<'_>, busy: bool) -> bool {
195 let now = cx.now();
196 let indicator = cx.memory::<DelayedIndicator>();
197 let shown = indicator.update(busy, now);
198 let next = indicator.next_change(busy, now);
199 if let Some(delay) = next {
200 cx.request_frame_in(delay);
201 }
202 shown
203 }
204
205 fn paint_turning(&self, cx: &mut PaintCx<'_>, area: Rect) {
207 *cx.memory::<Finish>() = Finish::Spinning;
208 let style = self.cell_style(cx);
209 let cell = cx.animation(self.animation.as_str(), style, Some(Duration::ZERO));
210 cx.text(area.x, area.y, &cell.glyph, cell.style, 1);
211 }
212}
213
214#[derive(Debug, Clone, Copy, Default)]
216enum Finish {
217 #[default]
219 Unseen,
220 Spinning,
222 Since { at: Duration, from: Option<Rgb> },
224 Resting,
226}
227
228impl<Msg: 'static> Widget<Msg> for Spinner {
229 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
230 let label = self.label.as_deref().map_or(0, |label| text::width(label).saturating_add(2));
231 Size::new(label.saturating_add(1), 1).min(available)
232 }
233
234 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
235 if let Some(busy) = self.delayed
236 && !Self::delay_allows(cx, busy)
237 {
238 return;
240 }
241 if self.done {
242 self.paint_done(cx, area);
243 } else {
244 self.paint_turning(cx, area);
245 }
246 if let Some(label) = &self.label {
247 let label_style = cx.style("spinner-label", self.variant.as_deref(), &[]).text();
248 let budget = area.width.saturating_sub(2);
249 let shown = text::truncate(label, budget).into_owned();
250 cx.text(area.x + 2, area.y, &shown, label_style, budget);
251 }
252 }
253}
254
255#[cfg(test)]
256mod tests;