rab/tui/components/
loader.rs1use std::time::Instant;
2
3use crate::tui::Component;
4use crate::tui::components::text::Text;
5use crate::tui::util::visible_width;
6
7pub struct LoaderIndicatorOptions {
9 pub frames: Vec<String>,
11 pub interval_ms: u64,
13}
14
15impl Default for LoaderIndicatorOptions {
16 fn default() -> Self {
17 Self {
18 frames: vec![
19 "⠋".into(),
20 "⠙".into(),
21 "⠹".into(),
22 "⠸".into(),
23 "⠼".into(),
24 "⠴".into(),
25 "⠦".into(),
26 "⠧".into(),
27 "⠇".into(),
28 "⠏".into(),
29 ],
30 interval_ms: 80,
31 }
32 }
33}
34
35pub struct Loader {
40 text: Text,
41 frames: Vec<String>,
42 interval_ms: u64,
43 current_frame: usize,
44 started: bool,
45 last_tick: Instant,
46 message: String,
47 spinner_color_fn: Box<dyn Fn(&str) -> String>,
48 message_color_fn: Box<dyn Fn(&str) -> String>,
49 render_indicator_verbatim: bool,
50}
51
52impl Loader {
53 pub fn new(
54 spinner_color_fn: Box<dyn Fn(&str) -> String>,
55 message_color_fn: Box<dyn Fn(&str) -> String>,
56 message: impl Into<String>,
57 ) -> Self {
58 let indicator = LoaderIndicatorOptions::default();
59 Self {
60 text: Text::new("", 1, 0, None),
61 frames: indicator.frames,
62 interval_ms: indicator.interval_ms,
63 current_frame: 0,
64 started: false,
65 last_tick: Instant::now(),
66 message: message.into(),
67 spinner_color_fn,
68 message_color_fn,
69 render_indicator_verbatim: false,
70 }
71 }
72
73 pub fn start(&mut self) {
74 self.started = true;
75 self.last_tick = Instant::now();
76 self.update_display();
77 }
78
79 pub fn stop(&mut self) {
80 self.started = false;
81 }
82
83 pub fn set_message(&mut self, message: impl Into<String>) {
84 self.message = message.into();
85 self.update_display();
86 }
87
88 pub fn set_indicator(&mut self, indicator: LoaderIndicatorOptions) {
89 self.render_indicator_verbatim = true;
90 self.frames = if indicator.frames.is_empty() {
91 vec![] } else {
93 indicator.frames
94 };
95 self.interval_ms = if indicator.interval_ms > 0 {
96 indicator.interval_ms
97 } else {
98 80
99 };
100 self.current_frame = 0;
101 self.update_display();
102 }
103
104 pub fn tick(&mut self) -> bool {
106 if !self.started || self.frames.is_empty() || self.frames.len() <= 1 {
107 return false;
108 }
109 let elapsed = self.last_tick.elapsed();
110 if elapsed.as_millis() >= self.interval_ms as u128 {
111 self.current_frame = (self.current_frame + 1) % self.frames.len();
112 self.last_tick = Instant::now();
113 self.update_display();
114 return true;
115 }
116 false
117 }
118
119 fn update_display(&self) -> String {
120 let frame = self
121 .frames
122 .get(self.current_frame)
123 .map(|s| s.as_str())
124 .unwrap_or("");
125 let rendered_frame = if frame.is_empty() {
126 String::new()
127 } else if self.render_indicator_verbatim {
128 frame.to_string()
129 } else {
130 (self.spinner_color_fn)(frame)
131 };
132 let indicator = if frame.is_empty() {
133 String::new()
134 } else {
135 format!("{} ", rendered_frame)
136 };
137 let display = format!("{}{}", indicator, (self.message_color_fn)(&self.message));
138 display
139 }
140}
141
142impl Component for Loader {
143 fn render(&self, width: usize) -> Vec<String> {
144 let display = self.update_display();
146 let mut lines = vec![String::new()]; let display_line = {
148 let vw = visible_width(&display);
149 if vw < width {
150 format!("{}{}", display, " ".repeat(width - vw))
151 } else {
152 display
153 }
154 };
155 lines.push(display_line);
156 lines
157 }
158
159 fn handle_input(&mut self, _key: &crossterm::event::KeyEvent) -> bool {
160 false
161 }
162
163 fn invalidate(&mut self) {
164 self.text.invalidate();
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn test_loader_renders_with_spacing() {
174 let loader = Loader::new(
175 Box::new(|s| s.to_string()),
176 Box::new(|s| s.to_string()),
177 "Loading...",
178 );
179 let lines = loader.render(40);
180 assert!(lines.len() >= 2, "Should have blank line + content");
181 assert_eq!(lines[0], "", "First line should be blank");
182 }
183
184 #[test]
185 fn test_loader_message() {
186 let loader = Loader::new(
187 Box::new(|s| s.to_string()),
188 Box::new(|s| s.to_string()),
189 "Working...",
190 );
191 let lines = loader.render(40);
192 assert!(lines[1].contains("Working..."));
193 }
194
195 #[test]
196 fn test_loader_tick() {
197 let mut loader = Loader::new(
198 Box::new(|s| s.to_string()),
199 Box::new(|s| s.to_string()),
200 "test",
201 );
202 loader.start();
203 assert!(!loader.tick());
205 }
206}