Skip to main content

qframe/widgets/spinner/
mod.rs

1//! Single-cell activity indicators.
2
3use 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/// How a [`Spinner`] moves. Every style is a built-in [cell animation](crate::animation) with
14/// Nerd Font, Unicode and ASCII frames, which themes and applications can replace.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum SpinnerStyle {
17    /// An arc sweeping round (animation `spinner-arc`). The default.
18    #[default]
19    Arc,
20    /// Braille dots turning in place (animation `spinner-dots`).
21    Dots,
22    /// A single dot orbiting a cell (animation `spinner-orbit`).
23    Orbit,
24    /// A dot growing and shrinking (animation `spinner-pop`).
25    Pop,
26    /// A dot breathing between faint and the spinner's colour (animation `spinner-pulse`).
27    Pulse,
28    /// A filled quarter of the cell turning clockwise (animation `spinner-quarters`). The
29    /// quadrant blocks are drawn by terminals themselves, so they fill exactly one cell in any font.
30    Quarters,
31    /// A pie filling slice by slice, then starting again (animation `spinner-slices`). The Nerd
32    /// Font frames are `nf-md-circle_slice_1..8` (Nerd Font v3).
33    Slices,
34}
35
36impl SpinnerStyle {
37    /// Every style, in alphabetical order.
38    pub const ALL: [Self; 7] =
39        [Self::Arc, Self::Dots, Self::Orbit, Self::Pop, Self::Pulse, Self::Quarters, Self::Slices];
40
41    /// A short name, e.g. for settings screens and locale keys.
42    #[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    /// The name of the built-in animation this style plays, such as `"spinner-arc"`.
56    #[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
76/// The animation a spinner plays once when its work is done.
77const DONE_ANIMATION: &str = "spinner-done";
78
79/// A one-cell indicator that something is working, with an optional label.
80///
81/// It plays a [cell animation](crate::animation): a [`SpinnerStyle`] or any animation by name.
82/// With reduced motion the animation's rest frame stands still. Style keys: `spinner` (`fg`, with
83/// variants for tones such as `spinner.success`) and `spinner-label`. The spinner's colour is the
84/// `$fg` of its animation.
85///
86/// With [`Spinner::done`] the spinner stops turning and plays the animation `spinner-done` once:
87/// the built-in one grows a tick, one `motion.step` per frame, blending from the colour on screen
88/// into `$success`, and rests on the last frame.
89#[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    /// An arc spinner, the default style.
106    #[must_use]
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Chooses how it moves.
112    #[must_use]
113    pub fn style(mut self, style: SpinnerStyle) -> Self {
114        self.animation = style.into();
115        self
116    }
117
118    /// Plays the animation `name` from the icon set or theme instead of a style, e.g. one an
119    /// application defines in its theme. An unknown name draws `⟦`, like a missing icon.
120    #[must_use]
121    pub fn animation(mut self, name: impl Into<AnimationName>) -> Self {
122        self.animation = name.into();
123        self
124    }
125
126    /// Text after the spinner, e.g. "Pulling image".
127    #[must_use]
128    pub fn label(mut self, label: impl Into<String>) -> Self {
129        self.label = Some(label.into());
130        self
131    }
132
133    /// Theme variant, e.g. `"success"` or `"warning"`.
134    #[must_use]
135    pub fn variant(mut self, variant: impl Into<String>) -> Self {
136        self.variant = Some(variant.into());
137        self
138    }
139
140    /// Marks the work as finished. When this turns on, the spinner stops turning, plays the
141    /// animation `spinner-done` once, starting from the colour on screen, and rests on its last
142    /// frame. Turning it off spins again. A spinner that is already done when first drawn, or
143    /// drawn with reduced motion, shows the last frame at once. The cell and the label stay where
144    /// they are. Off by default.
145    #[must_use]
146    pub fn done(mut self, done: bool) -> Self {
147        self.done = done;
148        self
149    }
150
151    /// Shows the spinner only for work that takes long enough to notice: while `busy` has been
152    /// true for less than 300 ms nothing is drawn, and once it shows it stays at least 500 ms,
153    /// even if `busy` turns false sooner. Quick work never blinks an indicator, and a slow one
154    /// never flickers away. The spinner keeps its cells, blank while hidden, so what sits beside
155    /// it never moves; it asks for a frame whenever it is due to appear or disappear. Pass whether
156    /// the work is running on every view. Without it the spinner always shows.
157    #[must_use]
158    pub fn delayed(mut self, busy: bool) -> Self {
159        self.delayed = Some(busy);
160        self
161    }
162
163    /// The style the animation draws in: the `spinner` style, its colour falling back to the accent.
164    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    /// Draws the finish: the frame due now, starting from the colour the spinner had on screen.
170    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                // Where the turning animation is now, so a pulse finishes from the colour on screen.
177                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    /// Whether a delayed spinner shows now, asking for the frame at which that answer changes:
193    /// while it waits or lingers nothing else may be animating to wake the loop.
194    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    /// Draws the frame of the turning spinner due now.
206    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/// Where a spinner is in its finish, kept between frames.
215#[derive(Debug, Clone, Copy, Default)]
216enum Finish {
217    /// Not drawn before: a spinner that starts done rests on its last frame at once.
218    #[default]
219    Unseen,
220    /// Turning.
221    Spinning,
222    /// Finished at `at`, when the spinner showed the colour `from`.
223    Since { at: Duration, from: Option<Rgb> },
224    /// Showing the last frame.
225    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            // The measured cells stay blank, so nothing beside the spinner moves when it shows.
239            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;