Skip to main content

throbber_widgets_tui/widgets/
throbber.rs

1use alloc::string::String;
2
3#[cfg(feature = "rand")]
4use rand::Rng as _;
5
6/// State to be used for Throbber render.
7#[derive(Debug, Clone, Default, PartialEq, Eq)]
8pub struct ThrobberState {
9    /// Index of Set.symbols used when Spin is specified for WhichUse.
10    ///
11    /// If out of range, it is normalized at render time.
12    index: i8,
13}
14
15impl ThrobberState {
16    /// Get a index.
17    pub fn index(&self) -> i8 {
18        self.index
19    }
20
21    /// Increase index.
22    ///
23    /// # Examples:
24    /// ```
25    /// let mut throbber_state = throbber_widgets_tui::ThrobberState::default();
26    /// assert_eq!(throbber_state.index(), 0);
27    /// throbber_state.calc_next();
28    /// assert_eq!(throbber_state.index(), 1);
29    /// ```
30    pub fn calc_next(&mut self) {
31        self.calc_step(1);
32    }
33
34    /// Calculate the index by specifying step.
35    ///
36    /// Negative numbers can also be specified for step.
37    ///
38    /// If step is 0, the index is determined at random.
39    ///
40    /// # Examples:
41    /// ```
42    /// let mut throbber_state = throbber_widgets_tui::ThrobberState::default();
43    /// assert_eq!(throbber_state.index(), 0);
44    /// throbber_state.calc_step(2);
45    /// assert_eq!(throbber_state.index(), 2);
46    /// throbber_state.calc_step(-3);
47    /// assert_eq!(throbber_state.index(), -1);
48    /// throbber_state.calc_step(0); // random
49    /// assert!((std::i8::MIN..=std::i8::MAX).contains(&throbber_state.index()))
50    /// ```
51    pub fn calc_step(&mut self, step: i8) {
52        self.index = if step == 0 {
53            #[cfg(feature = "rand")]
54            {
55                let mut rng = rand::rng();
56                rng.random()
57            }
58            #[cfg(all(not(feature = "rand"), feature = "std"))]
59            {
60                let duration = std::time::SystemTime::now()
61                    .duration_since(std::time::UNIX_EPOCH)
62                    .unwrap_or_default();
63                // If the resolution is low, it might become zero, so I add multiple values.
64                (duration.as_nanos() % 0x100
65                    + duration.as_micros() % 0x100
66                    + duration.as_millis() % 0x100) as i8
67            }
68            #[cfg(all(not(feature = "rand"), not(feature = "std")))]
69            {
70                // In no_std without rand, keep current index unchanged.
71                // Use calc_next() or calc_step(N) where N != 0 for animation.
72                self.index
73            }
74        } else {
75            self.index.checked_add(step).unwrap_or(0)
76        }
77    }
78
79    /// Set the index to the range of throbber_set.symbols.len().
80    ///
81    /// This is called from render function automatically.
82    ///
83    /// # Examples:
84    /// ```
85    /// let mut throbber_state = throbber_widgets_tui::ThrobberState::default();
86    /// let throbber = throbber_widgets_tui::Throbber::default();
87    /// let len = 6; //throbber.throbber_set.symbols.len() as i8;
88    ///
89    /// throbber_state.normalize(&throbber);
90    /// assert_eq!(throbber_state.index(), 0);
91    ///
92    /// throbber_state.calc_step(len + 2);
93    /// assert_eq!(throbber_state.index(), len + 2);
94    /// throbber_state.normalize(&throbber);
95    /// assert_eq!(throbber_state.index(), 2);
96    ///
97    /// // Negative numbers are indexed from backward
98    /// throbber_state.calc_step(-3 - len);
99    /// assert_eq!(throbber_state.index(), -1 - len);
100    /// throbber_state.normalize(&throbber);
101    /// assert_eq!(throbber_state.index(), len - 1);
102    /// ```
103    pub fn normalize(&mut self, throbber: &Throbber) {
104        let len = throbber.throbber_set.symbols.len() as i8;
105        if len <= 0 {
106            //ng but it's not used, so it stays.
107        } else {
108            self.index %= len;
109            if self.index < 0 {
110                // Negative numbers are indexed from the tail
111                self.index += len;
112            }
113        }
114    }
115}
116
117/// A compact widget to display a throbber.
118///
119/// A throbber may also be called:
120/// - activity indicator
121/// - indeterminate progress bar
122/// - loading icon
123/// - spinner
124/// - guru guru
125///
126/// # Examples:
127///
128/// ```
129/// let throbber = throbber_widgets_tui::Throbber::default()
130///     .throbber_style(ratatui::style::Style::default().fg(ratatui::style::Color::White).bg(ratatui::style::Color::Black))
131///     .label("NOW LOADING...");
132/// // frame.render_widget(throbber, chunks[0]);
133/// let throbber_state = throbber_widgets_tui::ThrobberState::default();
134/// // frame.render_stateful_widget(throbber, chunks[0], &mut throbber_state);
135/// ```
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Throbber<'a> {
138    label: Option<ratatui::text::Span<'a>>,
139    style: ratatui::style::Style,
140    throbber_style: ratatui::style::Style,
141    throbber_set: crate::symbols::throbber::Set,
142    use_type: crate::symbols::throbber::WhichUse,
143}
144
145impl Default for Throbber<'_> {
146    fn default() -> Self {
147        Self {
148            label: None,
149            style: ratatui::style::Style::default(),
150            throbber_style: ratatui::style::Style::default(),
151            throbber_set: crate::symbols::throbber::BRAILLE_SIX,
152            use_type: crate::symbols::throbber::WhichUse::Spin,
153        }
154    }
155}
156
157impl<'a> Throbber<'a> {
158    pub fn label<T>(mut self, label: T) -> Self
159    where
160        T: Into<ratatui::text::Span<'a>>,
161    {
162        self.label = Some(label.into());
163        self
164    }
165
166    pub fn style(mut self, style: ratatui::style::Style) -> Self {
167        self.style = style;
168        self
169    }
170
171    pub fn throbber_style(mut self, style: ratatui::style::Style) -> Self {
172        self.throbber_style = style;
173        self
174    }
175
176    pub fn throbber_set(mut self, set: crate::symbols::throbber::Set) -> Self {
177        self.throbber_set = set;
178        self
179    }
180
181    pub fn use_type(mut self, use_type: crate::symbols::throbber::WhichUse) -> Self {
182        self.use_type = use_type;
183        self
184    }
185
186    /// Convert symbol only to Span with state.
187    pub fn to_symbol_span(&self, state: &ThrobberState) -> ratatui::text::Span<'a> {
188        let symbol = match self.use_type {
189            crate::symbols::throbber::WhichUse::Full => self.throbber_set.full,
190            crate::symbols::throbber::WhichUse::Empty => self.throbber_set.empty,
191            crate::symbols::throbber::WhichUse::Spin => {
192                let mut state = state.clone();
193                state.normalize(self);
194                let len = self.throbber_set.symbols.len() as i8;
195                if 0 <= state.index && state.index < len {
196                    self.throbber_set.symbols[state.index as usize]
197                } else {
198                    self.throbber_set.empty
199                }
200            }
201        };
202        let mut symbol_text = String::from(symbol);
203        symbol_text.push(' ');
204        let symbol_span =
205            ratatui::text::Span::styled(symbol_text, self.style).patch_style(self.throbber_style);
206        symbol_span
207    }
208
209    /// Convert symbol and label to Line with state.
210    pub fn to_line(&self, state: &ThrobberState) -> ratatui::text::Line<'a> {
211        let mut line = ratatui::text::Line::default().style(self.style);
212        line.spans.push(self.to_symbol_span(state));
213        if let Some(label) = &self.label.clone() {
214            line.spans.push(label.clone());
215        }
216        line
217    }
218}
219
220impl ratatui::widgets::Widget for Throbber<'_> {
221    /// Render random step symbols.
222    fn render(self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
223        let mut state = ThrobberState::default();
224        state.calc_step(0);
225        ratatui::widgets::StatefulWidget::render(self, area, buf, &mut state);
226    }
227}
228
229impl ratatui::widgets::StatefulWidget for Throbber<'_> {
230    type State = ThrobberState;
231
232    /// Render specified index symbols.
233    fn render(
234        self,
235        area: ratatui::layout::Rect,
236        buf: &mut ratatui::buffer::Buffer,
237        state: &mut Self::State,
238    ) {
239        buf.set_style(area, self.style);
240
241        let throbber_area = area;
242        if throbber_area.height < 1 {
243            return;
244        }
245
246        // render a symbol.
247        let symbol = match self.use_type {
248            crate::symbols::throbber::WhichUse::Full => self.throbber_set.full,
249            crate::symbols::throbber::WhichUse::Empty => self.throbber_set.empty,
250            crate::symbols::throbber::WhichUse::Spin => {
251                state.normalize(&self);
252                let len = self.throbber_set.symbols.len() as i8;
253                if 0 <= state.index && state.index < len {
254                    self.throbber_set.symbols[state.index as usize]
255                } else {
256                    self.throbber_set.empty
257                }
258            }
259        };
260        let mut symbol_text = String::from(symbol);
261        symbol_text.push(' ');
262        let symbol_span = ratatui::text::Span::styled(symbol_text, self.throbber_style);
263        let (col, row) = buf.set_span(
264            throbber_area.left(),
265            throbber_area.top(),
266            &symbol_span,
267            symbol_span.width() as u16,
268        );
269
270        // render a label.
271        if let Some(label) = self.label {
272            if throbber_area.right() <= col {
273                return;
274            }
275            buf.set_span(col, row, &label, label.width() as u16);
276        }
277    }
278}
279
280/// Convert symbol only to Span without state(mostly random index).
281///
282/// If you want to specify a state, use `Throbber::to_symbol_span()`.
283impl<'a> From<Throbber<'a>> for ratatui::text::Span<'a> {
284    fn from(throbber: Throbber<'a>) -> ratatui::text::Span<'a> {
285        let mut state = ThrobberState::default();
286        state.calc_step(0);
287        throbber.to_symbol_span(&state)
288    }
289}
290
291/// Convert symbol and label to Line without state(mostly random index).
292///
293/// If you want to specify a state, use `Throbber::to_line()`.
294impl<'a> From<Throbber<'a>> for ratatui::text::Line<'a> {
295    fn from(throbber: Throbber<'a>) -> ratatui::text::Line<'a> {
296        let mut state = ThrobberState::default();
297        state.calc_step(0);
298        throbber.to_line(&state)
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    #[test]
306    #[allow(unused_variables, unused_assignments)]
307    fn throbber_state_calc_step() {
308        let mut throbber_state = ThrobberState::default();
309        assert_eq!(throbber_state.index(), 0);
310
311        // random test
312        // The probability of failure is not zero, but it is as low as possible.
313        let mut difference = false;
314        for _ in 0..100 {
315            throbber_state.calc_step(0);
316            assert!((i8::MIN..=i8::MAX).contains(&throbber_state.index()));
317
318            if 0 != throbber_state.index() {
319                difference = true;
320            }
321        }
322        // In no_std without rand, calc_step(0) keeps index unchanged, so no difference expected
323        #[cfg(any(feature = "rand", feature = "std"))]
324        assert!(difference);
325    }
326
327    #[test]
328    fn throbber_state_normalize() {
329        let mut throbber_state = ThrobberState::default();
330        let throbber = Throbber::default();
331        let len = throbber.throbber_set.symbols.len() as i8;
332        let max = len - 1;
333
334        // check upper
335        throbber_state.calc_step(max);
336        throbber_state.normalize(&throbber);
337        assert_eq!(throbber_state.index(), max);
338
339        // check overflow
340        throbber_state.calc_next();
341        throbber_state.normalize(&throbber);
342        assert_eq!(throbber_state.index(), 0);
343
344        // check underflow
345        throbber_state.calc_step(-1);
346        throbber_state.normalize(&throbber);
347        assert_eq!(throbber_state.index(), max);
348
349        // check negative out of range
350        throbber_state.calc_step(len * -2);
351        throbber_state.normalize(&throbber);
352        assert_eq!(throbber_state.index(), max);
353    }
354
355    #[test]
356    fn throbber_converts_to_span() {
357        let throbber = Throbber::default().use_type(crate::symbols::throbber::WhichUse::Full);
358        let span: ratatui::text::Span = throbber.into();
359        assert_eq!(span.content, "⠿ ");
360    }
361
362    #[test]
363    fn throbber_converts_to_line() {
364        let throbber = Throbber::default().use_type(crate::symbols::throbber::WhichUse::Full);
365        let line: ratatui::text::Line = throbber.into();
366        assert_eq!(line.spans[0].content, "⠿ ");
367    }
368
369    #[test]
370    fn throbber_reaches_upper_limit_step_resets_to_zero() {
371        let mut throbber_state = ThrobberState::default();
372
373        for _ in 0..i8::MAX {
374            throbber_state.calc_next();
375        }
376        throbber_state.calc_next();
377        assert!(throbber_state.index() != i8::MAX);
378    }
379}