Skip to main content

tui_realm_stdlib/components/
spinner.rs

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