1use crate::buffer::ScreenBuffer;
7use crate::cell::Cell;
8use crate::geometry::Rect;
9use crate::style::Style;
10use crate::text::truncate_to_display_width;
11use unicode_width::UnicodeWidthStr;
12
13use super::Widget;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum IndicatorStyle {
18 Spinner,
20 Dots,
22 Line,
24 Box,
26 Circle,
28}
29
30impl IndicatorStyle {
31 fn frames(self) -> &'static [&'static str] {
33 match self {
34 IndicatorStyle::Spinner => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
35 IndicatorStyle::Dots => &["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
36 IndicatorStyle::Line => &["─", "\\", "|", "/"],
37 IndicatorStyle::Box => &["▖", "▘", "▝", "▗"],
38 IndicatorStyle::Circle => &["◐", "◓", "◑", "◒"],
39 }
40 }
41}
42
43pub struct LoadingIndicator {
48 style: IndicatorStyle,
50 frame: usize,
52 indicator_style: Style,
54 message: Option<String>,
56}
57
58impl LoadingIndicator {
59 pub fn new() -> Self {
61 Self {
62 style: IndicatorStyle::Spinner,
63 frame: 0,
64 indicator_style: Style::default(),
65 message: None,
66 }
67 }
68
69 #[must_use]
71 pub fn with_style(mut self, style: IndicatorStyle) -> Self {
72 self.style = style;
73 self.frame = 0;
74 self
75 }
76
77 #[must_use]
79 pub fn with_indicator_style(mut self, style: Style) -> Self {
80 self.indicator_style = style;
81 self
82 }
83
84 #[must_use]
86 pub fn with_message(mut self, message: &str) -> Self {
87 self.message = Some(message.to_string());
88 self
89 }
90
91 pub fn tick(&mut self) {
93 let len = self.style.frames().len();
94 if len > 0 {
95 self.frame = (self.frame + 1) % len;
96 }
97 }
98
99 pub fn reset(&mut self) {
101 self.frame = 0;
102 }
103
104 pub fn frame(&self) -> usize {
106 self.frame
107 }
108
109 pub fn animation_style(&self) -> IndicatorStyle {
111 self.style
112 }
113}
114
115impl Default for LoadingIndicator {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121impl Widget for LoadingIndicator {
122 fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
123 if area.size.width == 0 || area.size.height == 0 {
124 return;
125 }
126
127 let frames = self.style.frames();
128 if frames.is_empty() {
129 return;
130 }
131
132 let frame_idx = self.frame % frames.len();
133 let ch = frames[frame_idx];
134 let w = area.size.width as usize;
135 let x0 = area.position.x;
136 let y = area.position.y;
137
138 let char_w = UnicodeWidthStr::width(ch);
140 if char_w > w {
141 return;
142 }
143
144 buf.set(x0, y, Cell::new(ch, self.indicator_style.clone()));
145 let mut col = char_w as u16;
146
147 if let Some(ref msg) = self.message
149 && (col as usize) < w
150 {
151 buf.set(x0 + col, y, Cell::new(" ", self.indicator_style.clone()));
153 col += 1;
154
155 if (col as usize) < w {
156 let remaining = w.saturating_sub(col as usize);
157 let truncated = truncate_to_display_width(msg, remaining);
158 for ch in truncated.chars() {
159 if col as usize >= w {
160 break;
161 }
162 let cw = UnicodeWidthStr::width(ch.encode_utf8(&mut [0; 4]) as &str);
163 if col as usize + cw > w {
164 break;
165 }
166 buf.set(
167 x0 + col,
168 y,
169 Cell::new(ch.to_string(), self.indicator_style.clone()),
170 );
171 col += cw as u16;
172 }
173 }
174 }
175 }
176}
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used)]
180mod tests {
181 use super::*;
182 use crate::geometry::Size;
183
184 #[test]
185 fn create_default() {
186 let li = LoadingIndicator::new();
187 assert_eq!(li.animation_style(), IndicatorStyle::Spinner);
188 assert_eq!(li.frame(), 0);
189 }
190
191 #[test]
192 fn default_trait() {
193 let li: LoadingIndicator = Default::default();
194 assert_eq!(li.animation_style(), IndicatorStyle::Spinner);
195 }
196
197 #[test]
198 fn each_indicator_style() {
199 let styles = [
200 IndicatorStyle::Spinner,
201 IndicatorStyle::Dots,
202 IndicatorStyle::Line,
203 IndicatorStyle::Box,
204 IndicatorStyle::Circle,
205 ];
206 for style in &styles {
207 let li = LoadingIndicator::new().with_style(*style);
208 assert_eq!(li.animation_style(), *style);
209 assert!(!style.frames().is_empty());
210 }
211 }
212
213 #[test]
214 fn render_at_different_frames() {
215 let mut li = LoadingIndicator::new().with_style(IndicatorStyle::Spinner);
216 let mut buf = ScreenBuffer::new(Size::new(5, 1));
217
218 li.render(Rect::new(0, 0, 5, 1), &mut buf);
219 let first = buf.get(0, 0).unwrap().grapheme.clone();
220 assert_eq!(first, "⠋");
221
222 li.tick();
223 let mut buf2 = ScreenBuffer::new(Size::new(5, 1));
224 li.render(Rect::new(0, 0, 5, 1), &mut buf2);
225 let second = buf2.get(0, 0).unwrap().grapheme.clone();
226 assert_eq!(second, "⠙");
227 }
228
229 #[test]
230 fn tick_advances_frame() {
231 let mut li = LoadingIndicator::new();
232 assert_eq!(li.frame(), 0);
233 li.tick();
234 assert_eq!(li.frame(), 1);
235 li.tick();
236 assert_eq!(li.frame(), 2);
237 }
238
239 #[test]
240 fn frame_wraps_at_end() {
241 let mut li = LoadingIndicator::new().with_style(IndicatorStyle::Line);
242 for _ in 0..4 {
244 li.tick();
245 }
246 assert_eq!(li.frame(), 0); }
248
249 #[test]
250 fn reset_returns_to_zero() {
251 let mut li = LoadingIndicator::new();
252 li.tick();
253 li.tick();
254 assert_eq!(li.frame(), 2);
255 li.reset();
256 assert_eq!(li.frame(), 0);
257 }
258
259 #[test]
260 fn message_displayed() {
261 let li = LoadingIndicator::new()
262 .with_style(IndicatorStyle::Spinner)
263 .with_message("Loading...");
264 let mut buf = ScreenBuffer::new(Size::new(20, 1));
265 li.render(Rect::new(0, 0, 20, 1), &mut buf);
266
267 let row: String = (0..20)
268 .map(|x| buf.get(x, 0).map(|c| c.grapheme.as_str()).unwrap_or(" "))
269 .collect();
270 assert!(row.contains("Loading..."));
271 }
272
273 #[test]
274 fn no_message_indicator_only() {
275 let li = LoadingIndicator::new().with_style(IndicatorStyle::Circle);
276 let mut buf = ScreenBuffer::new(Size::new(5, 1));
277 li.render(Rect::new(0, 0, 5, 1), &mut buf);
278
279 assert_eq!(buf.get(0, 0).unwrap().grapheme, "◐");
280 assert_eq!(buf.get(1, 0).unwrap().grapheme, " ");
282 }
283
284 #[test]
285 fn style_applied() {
286 let style = Style::default().bold(true);
287 let li = LoadingIndicator::new().with_indicator_style(style.clone());
288 let mut buf = ScreenBuffer::new(Size::new(5, 1));
289 li.render(Rect::new(0, 0, 5, 1), &mut buf);
290
291 assert!(buf.get(0, 0).unwrap().style.bold);
292 }
293
294 #[test]
295 fn zero_area_no_panic() {
296 let li = LoadingIndicator::new();
297 let mut buf = ScreenBuffer::new(Size::new(1, 1));
298 li.render(Rect::new(0, 0, 0, 0), &mut buf);
299 }
301}