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};
11
12/// How a [`Spinner`] moves. Every style is a built-in [cell animation](crate::animation) with
13/// Nerd Font, Unicode and ASCII frames, which themes and applications can replace.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum SpinnerStyle {
16    /// An arc sweeping round (animation `spinner-arc`). The default.
17    #[default]
18    Arc,
19    /// Braille dots turning in place (animation `spinner-dots`).
20    Dots,
21    /// A single dot orbiting a cell (animation `spinner-orbit`).
22    Orbit,
23    /// A dot growing and shrinking (animation `spinner-pop`).
24    Pop,
25    /// A dot breathing between faint and the spinner's colour (animation `spinner-pulse`).
26    Pulse,
27    /// A filled quarter of the cell turning clockwise (animation `spinner-quarters`). The
28    /// quadrant blocks are drawn by terminals themselves, so they fill exactly one cell in any font.
29    Quarters,
30    /// A pie filling slice by slice, then starting again (animation `spinner-slices`). The Nerd
31    /// Font frames are `nf-md-circle_slice_1..8` (Nerd Font v3).
32    Slices,
33}
34
35impl SpinnerStyle {
36    /// Every style, in alphabetical order.
37    pub const ALL: [Self; 7] =
38        [Self::Arc, Self::Dots, Self::Orbit, Self::Pop, Self::Pulse, Self::Quarters, Self::Slices];
39
40    /// A short name, e.g. for settings screens and locale keys.
41    #[must_use]
42    pub fn name(self) -> &'static str {
43        match self {
44            Self::Arc => "arc",
45            Self::Dots => "dots",
46            Self::Orbit => "orbit",
47            Self::Pop => "pop",
48            Self::Pulse => "pulse",
49            Self::Quarters => "quarters",
50            Self::Slices => "slices",
51        }
52    }
53
54    /// The name of the built-in animation this style plays, such as `"spinner-arc"`.
55    #[must_use]
56    pub fn animation(self) -> &'static str {
57        match self {
58            Self::Arc => "spinner-arc",
59            Self::Dots => "spinner-dots",
60            Self::Orbit => "spinner-orbit",
61            Self::Pop => "spinner-pop",
62            Self::Pulse => "spinner-pulse",
63            Self::Quarters => "spinner-quarters",
64            Self::Slices => "spinner-slices",
65        }
66    }
67}
68
69impl From<SpinnerStyle> for AnimationName {
70    fn from(style: SpinnerStyle) -> Self {
71        Self::from(style.animation())
72    }
73}
74
75/// The animation a spinner plays once when its work is done.
76const DONE_ANIMATION: &str = "spinner-done";
77
78/// A one-cell indicator that something is working, with an optional label.
79///
80/// It plays a [cell animation](crate::animation): a [`SpinnerStyle`] or any animation by name.
81/// With reduced motion the animation's rest frame stands still. Style keys: `spinner` (`fg`, with
82/// variants for tones such as `spinner.success`) and `spinner-label`. The spinner's colour is the
83/// `$fg` of its animation.
84///
85/// With [`Spinner::done`] the spinner stops turning and plays the animation `spinner-done` once:
86/// the built-in one grows a tick, one `motion.step` per frame, blending from the colour on screen
87/// into `$success`, and rests on the last frame.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct Spinner {
90    animation: AnimationName,
91    label: Option<String>,
92    variant: Option<String>,
93    done: bool,
94}
95
96impl Default for Spinner {
97    fn default() -> Self {
98        Self { animation: SpinnerStyle::default().into(), label: None, variant: None, done: false }
99    }
100}
101
102impl Spinner {
103    /// An arc spinner, the default style.
104    #[must_use]
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// Chooses how it moves.
110    #[must_use]
111    pub fn style(mut self, style: SpinnerStyle) -> Self {
112        self.animation = style.into();
113        self
114    }
115
116    /// Plays the animation `name` from the icon set or theme instead of a style, e.g. one an
117    /// application defines in its theme. An unknown name draws `⟦`, like a missing icon.
118    #[must_use]
119    pub fn animation(mut self, name: impl Into<AnimationName>) -> Self {
120        self.animation = name.into();
121        self
122    }
123
124    /// Text after the spinner, e.g. "Pulling image".
125    #[must_use]
126    pub fn label(mut self, label: impl Into<String>) -> Self {
127        self.label = Some(label.into());
128        self
129    }
130
131    /// Theme variant, e.g. `"success"` or `"warning"`.
132    #[must_use]
133    pub fn variant(mut self, variant: impl Into<String>) -> Self {
134        self.variant = Some(variant.into());
135        self
136    }
137
138    /// Marks the work as finished. When this turns on, the spinner stops turning, plays the
139    /// animation `spinner-done` once, starting from the colour on screen, and rests on its last
140    /// frame. Turning it off spins again. A spinner that is already done when first drawn, or
141    /// drawn with reduced motion, shows the last frame at once. The cell and the label stay where
142    /// they are. Off by default.
143    #[must_use]
144    pub fn done(mut self, done: bool) -> Self {
145        self.done = done;
146        self
147    }
148
149    /// The style the animation draws in: the `spinner` style, its colour falling back to the accent.
150    fn cell_style(&self, cx: &mut PaintCx<'_>) -> CellStyle {
151        let style = cx.style("spinner", self.variant.as_deref(), &[]).text();
152        CellStyle { fg: Some(style.fg.unwrap_or_else(|| cx.color("accent"))), ..style }
153    }
154
155    /// Draws the finish: the frame due now, starting from the colour the spinner had on screen.
156    fn paint_done(&self, cx: &mut PaintCx<'_>, area: Rect) {
157        let style = self.cell_style(cx);
158        let started = match *cx.memory::<Finish>() {
159            Finish::Unseen | Finish::Resting => None,
160            Finish::Since { at, from } => Some((at, from)),
161            Finish::Spinning => {
162                // Where the turning animation is now, so a pulse finishes from the colour on screen.
163                let now = cx.now();
164                let turning = cx.animation(self.animation.as_str(), style, Some(Duration::ZERO));
165                Some((now, turning.style.fg))
166            }
167        };
168        let since = started.filter(|_| !cx.reduced_motion());
169        let from = CellStyle { fg: since.and_then(|(_, from)| from).or(style.fg), ..style };
170        let cell = cx.animation(DONE_ANIMATION, from, since.map(|(at, _)| at));
171        *cx.memory::<Finish>() = match since {
172            Some((at, from)) if !cell.finished => Finish::Since { at, from },
173            _ => Finish::Resting,
174        };
175        cx.text(area.x, area.y, &cell.glyph, cell.style, 1);
176    }
177
178    /// Draws the frame of the turning spinner due now.
179    fn paint_turning(&self, cx: &mut PaintCx<'_>, area: Rect) {
180        *cx.memory::<Finish>() = Finish::Spinning;
181        let style = self.cell_style(cx);
182        let cell = cx.animation(self.animation.as_str(), style, Some(Duration::ZERO));
183        cx.text(area.x, area.y, &cell.glyph, cell.style, 1);
184    }
185}
186
187/// Where a spinner is in its finish, kept between frames.
188#[derive(Debug, Clone, Copy, Default)]
189enum Finish {
190    /// Not drawn before: a spinner that starts done rests on its last frame at once.
191    #[default]
192    Unseen,
193    /// Turning.
194    Spinning,
195    /// Finished at `at`, when the spinner showed the colour `from`.
196    Since { at: Duration, from: Option<Rgb> },
197    /// Showing the last frame.
198    Resting,
199}
200
201impl<Msg: 'static> Widget<Msg> for Spinner {
202    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
203        let label = self.label.as_deref().map_or(0, |label| text::width(label).saturating_add(2));
204        Size::new(label.saturating_add(1), 1).min(available)
205    }
206
207    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
208        if self.done {
209            self.paint_done(cx, area);
210        } else {
211            self.paint_turning(cx, area);
212        }
213        if let Some(label) = &self.label {
214            let label_style = cx.style("spinner-label", self.variant.as_deref(), &[]).text();
215            let budget = area.width.saturating_sub(2);
216            let shown = text::truncate(label, budget).into_owned();
217            cx.text(area.x + 2, area.y, &shown, label_style, budget);
218        }
219    }
220}
221
222#[cfg(test)]
223mod tests;