tui_realm_stdlib/components/
spinner.rs

1//! ## Spinner
2//!
3//! A loading spinner. You can provide the "spinning sequence". At each `view()` call, the sequence step is increased
4
5use tuirealm::command::{Cmd, CmdResult};
6use tuirealm::props::{Alignment, AttrValue, Attribute, Color, Props, Style};
7use tuirealm::ratatui::text::Line as Spans;
8use tuirealm::ratatui::{
9    layout::Rect,
10    text::{Span as TuiSpan, Text},
11    widgets::Paragraph,
12};
13use tuirealm::{Frame, MockComponent, State};
14
15// -- states
16
17#[derive(Default)]
18pub struct SpinnerStates {
19    pub sequence: Vec<char>,
20    pub step: usize,
21}
22
23impl SpinnerStates {
24    /// ### reset
25    ///
26    /// Re initialize sequence
27    pub fn reset(&mut self, sequence: &str) {
28        self.sequence = sequence.chars().collect();
29        self.step = 0;
30    }
31
32    /// ### step
33    ///
34    /// Get current step char and increments step
35    pub fn step(&mut self) -> char {
36        let ch = self.sequence.get(self.step).copied().unwrap_or(' ');
37        // Incr step
38        if self.step + 1 >= self.sequence.len() {
39            self.step = 0;
40        } else {
41            self.step += 1;
42        }
43        ch
44    }
45
46    /// Get the current char to display
47    ///
48    /// Unlike [`step`](Self::step), this function does not increment the step.
49    pub fn current_step(&self) -> char {
50        self.sequence.get(self.step).copied().unwrap_or(' ')
51    }
52}
53
54// -- Component
55
56/// ## Spinner
57///
58/// A textual spinner which step changes at each `view()` call
59#[must_use]
60pub struct Spinner {
61    props: Props,
62    pub states: SpinnerStates,
63    /// Automatically call [`SpinnerStates::step`] in [`view`](Spinner::view).
64    ///
65    /// This option might be removed in a future major version
66    pub view_auto_step: bool,
67}
68
69impl Default for Spinner {
70    fn default() -> Self {
71        Self {
72            props: Default::default(),
73            states: Default::default(),
74            view_auto_step: true,
75        }
76    }
77}
78
79impl Spinner {
80    pub fn foreground(mut self, fg: Color) -> Self {
81        self.attr(Attribute::Foreground, AttrValue::Color(fg));
82        self
83    }
84
85    pub fn background(mut self, bg: Color) -> Self {
86        self.attr(Attribute::Background, AttrValue::Color(bg));
87        self
88    }
89
90    pub fn sequence<S: Into<String>>(mut self, s: S) -> Self {
91        self.attr(Attribute::Text, AttrValue::String(s.into()));
92        self
93    }
94
95    /// Dont automatically step the sequence in a [`view`](Self::view) call
96    pub fn manual_step(mut self) -> Self {
97        self.view_auto_step = false;
98        self
99    }
100}
101
102impl MockComponent for Spinner {
103    fn view(&mut self, render: &mut Frame, area: Rect) {
104        // Make a Span
105        if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) {
106            // Make text
107            let foreground = self
108                .props
109                .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
110                .unwrap_color();
111            let background = self
112                .props
113                .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
114                .unwrap_color();
115            // Get text
116            let seq_char = if self.view_auto_step {
117                self.states.step()
118            } else {
119                self.states.current_step()
120            };
121            let text: Text = Text::from(Spans::from(TuiSpan::from(seq_char.to_string())));
122            render.render_widget(
123                Paragraph::new(text)
124                    .alignment(Alignment::Left)
125                    .style(Style::default().bg(background).fg(foreground)),
126                area,
127            );
128        }
129    }
130
131    fn query(&self, attr: Attribute) -> Option<AttrValue> {
132        self.props.get(attr)
133    }
134
135    fn attr(&mut self, attr: Attribute, value: AttrValue) {
136        if matches!(attr, Attribute::Text) {
137            // Update sequence
138            self.states.reset(value.unwrap_string().as_str());
139        } else {
140            self.props.set(attr, value);
141        }
142    }
143
144    fn state(&self) -> State {
145        State::None
146    }
147
148    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
149        CmdResult::None
150    }
151}
152
153#[cfg(test)]
154mod tests {
155
156    use super::*;
157
158    use pretty_assertions::assert_eq;
159    use tuirealm::ratatui::{self};
160
161    #[test]
162    fn test_components_span() {
163        let component = Spinner::default()
164            .background(Color::Blue)
165            .foreground(Color::Red)
166            .sequence("⣾⣽⣻⢿⡿⣟⣯⣷");
167        // Get value
168        assert_eq!(component.state(), State::None);
169    }
170
171    #[test]
172    fn should_step_in_view() {
173        let mut component = Spinner::default().sequence("123");
174
175        assert_eq!(component.states.step, 0);
176
177        let mut terminal =
178            ratatui::Terminal::new(ratatui::backend::TestBackend::new(16, 16)).unwrap();
179
180        terminal
181            .draw(|f| {
182                component.view(f, f.area());
183                assert_eq!(component.states.step, 1);
184            })
185            .unwrap();
186
187        terminal
188            .draw(|f| {
189                component.view(f, f.area());
190                assert_eq!(component.states.step, 2);
191            })
192            .unwrap();
193
194        terminal
195            .draw(|f| {
196                component.view(f, f.area());
197                assert_eq!(component.states.step, 0);
198            })
199            .unwrap();
200    }
201
202    #[test]
203    fn should_not_step_in_view() {
204        let mut component = Spinner::default().sequence("123").manual_step();
205
206        assert_eq!(component.states.step, 0);
207
208        let mut terminal =
209            ratatui::Terminal::new(ratatui::backend::TestBackend::new(16, 16)).unwrap();
210
211        terminal
212            .draw(|f| {
213                component.view(f, f.area());
214                assert_eq!(component.states.step, 0);
215            })
216            .unwrap();
217
218        component.states.step();
219
220        terminal
221            .draw(|f| {
222                component.view(f, f.area());
223                assert_eq!(component.states.step, 1);
224            })
225            .unwrap();
226
227        component.states.step();
228
229        terminal
230            .draw(|f| {
231                component.view(f, f.area());
232                assert_eq!(component.states.step, 2);
233            })
234            .unwrap();
235
236        component.states.step();
237
238        terminal
239            .draw(|f| {
240                component.view(f, f.area());
241                assert_eq!(component.states.step, 0);
242            })
243            .unwrap();
244    }
245}