throbber_widgets_tui/widgets/
throbber.rs

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