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: crate::tui::Style,
48 message_color_fn: crate::tui::Style,
49 render_indicator_verbatim: bool,
50}
51
52impl Loader {
53 pub fn new(
54 spinner_color_fn: crate::tui::Style,
55 message_color_fn: crate::tui::Style,
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.apply(frame)
131 };
132 let indicator = if frame.is_empty() {
133 String::new()
134 } else {
135 format!("{} ", rendered_frame)
136 };
137 let display = format!(
138 "{}{}",
139 indicator,
140 self.message_color_fn.apply(&self.message)
141 );
142 display
143 }
144}
145
146impl Component for Loader {
147 fn render(&mut self, width: usize) -> Vec<String> {
148 let display = self.update_display();
150 let mut lines = vec![String::new()]; let display_line = {
152 let vw = visible_width(&display);
153 if vw < width {
154 format!("{}{}", display, " ".repeat(width - vw))
155 } else {
156 display
157 }
158 };
159 lines.push(display_line);
160 lines
161 }
162
163 fn handle_input(&mut self, _key: &crossterm::event::KeyEvent) -> bool {
164 false
165 }
166
167 fn invalidate(&mut self) {
168 self.text.invalidate();
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn test_loader_renders_with_spacing() {
178 let mut loader = Loader::new(
179 crate::tui::Style::new(),
180 crate::tui::Style::new(),
181 "Loading...",
182 );
183 let lines = loader.render(40);
184 assert!(lines.len() >= 2, "Should have blank line + content");
185 assert_eq!(lines[0], "", "First line should be blank");
186 }
187
188 #[test]
189 fn test_loader_message() {
190 let mut loader = Loader::new(
191 crate::tui::Style::new(),
192 crate::tui::Style::new(),
193 "Working...",
194 );
195 let lines = loader.render(40);
196 assert!(lines[1].contains("Working..."));
197 }
198
199 #[test]
200 fn test_loader_tick() {
201 let mut loader = Loader::new(crate::tui::Style::new(), crate::tui::Style::new(), "test");
202 loader.start();
203 assert!(!loader.tick());
205 }
206}