1use ratatui::{
9 buffer::Buffer,
10 layout::Rect,
11 style::Style,
12 widgets::{Paragraph, Widget},
13};
14
15use crate::icons::{PROGRESS_EMPTY, PROGRESS_FILLED};
16use crate::palette::color::{ACCENT, TEXT};
17
18pub struct ProgressBar {
47 pub current: usize,
48 pub total: usize,
49 pub label: Option<String>,
50 pub show_percentage: bool,
51 pub bar_style: Style,
52 pub label_style: Style,
53}
54
55impl ProgressBar {
56 #[must_use]
61 pub fn new(current: usize, total: usize) -> Self {
62 Self {
63 current,
64 total,
65 label: None,
66 show_percentage: false,
67 bar_style: Style::default().fg(ACCENT),
68 label_style: Style::default().fg(TEXT),
69 }
70 }
71
72 #[must_use]
74 pub fn with_label(mut self, label: impl Into<String>) -> Self {
75 self.label = Some(label.into());
76 self
77 }
78
79 #[must_use]
81 pub fn with_percentage(mut self) -> Self {
82 self.show_percentage = true;
83 self
84 }
85
86 #[allow(clippy::cast_precision_loss)]
92 fn ratio(&self) -> f64 {
93 if self.total == 0 {
94 0.0
95 } else {
96 (self.current as f64 / self.total as f64).clamp(0.0, 1.0)
97 }
98 }
99
100 #[allow(
102 clippy::cast_precision_loss,
103 clippy::cast_possible_truncation,
104 clippy::cast_sign_loss
105 )]
106 fn bar_string(ratio: f64, width: usize) -> String {
107 let filled = (width as f64 * ratio).round() as usize;
108 let empty = width.saturating_sub(filled);
109 std::iter::repeat_n(PROGRESS_FILLED, filled)
110 .chain(std::iter::repeat_n(PROGRESS_EMPTY, empty))
111 .take(width)
112 .collect()
113 }
114
115 #[must_use]
124 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
125 pub fn to_string_compact(&self, width: usize) -> String {
126 use std::fmt::Write;
127
128 let ratio = self.ratio();
129 let bar = Self::bar_string(ratio, width);
130
131 let mut suffix = String::new();
132 if let Some(ref label) = self.label {
133 suffix.push(' ');
134 suffix.push_str(label);
135 }
136 if self.show_percentage {
137 let percent = (ratio * 100.0) as u32;
138 suffix.push(' ');
139 let _ = write!(suffix, "{percent}%");
140 }
141
142 format!("{bar}{suffix}")
143 }
144}
145
146impl Widget for ProgressBar {
147 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
148 fn render(self, area: Rect, buf: &mut Buffer) {
149 use std::fmt::Write;
150
151 if area.width < 3 || area.height < 1 {
152 return;
153 }
154
155 let ratio = self.ratio();
156
157 let mut suffix = String::new();
159 if let Some(ref label) = self.label {
160 suffix.push(' ');
162 suffix.push_str(label);
163 }
164 if self.show_percentage {
165 let percent = (ratio * 100.0) as u32;
166 suffix.push(' ');
167 let _ = write!(suffix, "{percent}%");
168 }
169
170 let suffix_width = suffix.len() as u16;
171 let bar_width = area.width.saturating_sub(suffix_width);
172
173 if bar_width < 5 {
176 let fallback = suffix.trim_start().to_string();
177 Paragraph::new(fallback)
178 .style(self.label_style)
179 .render(area, buf);
180 return;
181 }
182
183 let bar = Self::bar_string(ratio, bar_width as usize);
185 buf.set_string(area.x, area.y, &bar, self.bar_style);
186
187 if !suffix.is_empty() {
189 let suffix_area = Rect {
190 x: area.x + bar_width,
191 y: area.y,
192 width: suffix_width,
193 height: 1,
194 };
195 Paragraph::new(suffix)
196 .style(self.label_style)
197 .render(suffix_area, buf);
198 }
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use ratatui::style::Color;
206
207 fn render_to_buffer(bar: ProgressBar, width: u16, height: u16) -> Buffer {
212 let area = Rect::new(0, 0, width, height);
213 let mut buf = Buffer::empty(area);
214 bar.render(area, &mut buf);
215 buf
216 }
217
218 #[test]
223 fn new_sets_defaults() {
224 let bar = ProgressBar::new(5, 10);
225 assert_eq!(bar.current, 5);
226 assert_eq!(bar.total, 10);
227 assert!(bar.label.is_none());
228 assert!(!bar.show_percentage);
229 assert_eq!(bar.bar_style, Style::default().fg(Color::Cyan));
230 assert_eq!(bar.label_style, Style::default().fg(Color::White));
231 }
232
233 #[test]
234 fn builder_methods_set_fields() {
235 let bar = ProgressBar::new(1, 2).with_label("hello").with_percentage();
236 assert_eq!(bar.label.as_deref(), Some("hello"));
237 assert!(bar.show_percentage);
238 }
239
240 #[test]
245 fn full_bar_renders_all_filled() {
246 let bar = ProgressBar::new(10, 10);
247 let buf = render_to_buffer(bar, 20, 1);
248
249 let line: String = (0..20)
250 .map(|x| {
251 buf.cell((x, 0))
252 .unwrap()
253 .symbol()
254 .chars()
255 .next()
256 .unwrap_or(' ')
257 })
258 .collect();
259 assert!(
261 line.chars().all(|c| c == PROGRESS_FILLED),
262 "Expected all filled, got: {line:?}",
263 );
264 }
265
266 #[test]
267 fn empty_bar_renders_all_empty() {
268 let bar = ProgressBar::new(0, 10);
269 let buf = render_to_buffer(bar, 20, 1);
270
271 let line: String = (0..20)
272 .map(|x| {
273 buf.cell((x, 0))
274 .unwrap()
275 .symbol()
276 .chars()
277 .next()
278 .unwrap_or(' ')
279 })
280 .collect();
281 assert!(
282 line.chars().all(|c| c == PROGRESS_EMPTY),
283 "Expected all empty, got: {line:?}",
284 );
285 }
286
287 #[test]
288 fn half_bar_renders_mixed() {
289 let bar = ProgressBar::new(5, 10);
290 let buf = render_to_buffer(bar, 20, 1);
291
292 let filled_count = (0..20)
293 .filter(|&x| {
294 buf.cell((x, 0))
295 .unwrap()
296 .symbol()
297 .chars()
298 .next()
299 .unwrap_or(' ')
300 == PROGRESS_FILLED
301 })
302 .count();
303 assert_eq!(filled_count, 10);
305 }
306
307 #[test]
308 fn renders_with_label() {
309 let bar = ProgressBar::new(5, 10).with_label("OK");
310 let buf = render_to_buffer(bar, 30, 1);
311
312 let line: String = (0..30)
314 .map(|x| {
315 buf.cell((x, 0))
316 .unwrap()
317 .symbol()
318 .chars()
319 .next()
320 .unwrap_or(' ')
321 })
322 .collect();
323 assert!(line.contains("OK"), "Label not found in: {line:?}");
324 }
325
326 #[test]
327 fn renders_with_percentage() {
328 let bar = ProgressBar::new(3, 10).with_percentage();
329 let buf = render_to_buffer(bar, 30, 1);
330
331 let line: String = (0..30)
332 .map(|x| {
333 buf.cell((x, 0))
334 .unwrap()
335 .symbol()
336 .chars()
337 .next()
338 .unwrap_or(' ')
339 })
340 .collect();
341 assert!(line.contains("30%"), "Percentage not found in: {line:?}");
342 }
343
344 #[test]
349 fn zero_total_renders_empty_bar() {
350 let bar = ProgressBar::new(5, 0);
351 let buf = render_to_buffer(bar, 20, 1);
352
353 let line: String = (0..20)
354 .map(|x| {
355 buf.cell((x, 0))
356 .unwrap()
357 .symbol()
358 .chars()
359 .next()
360 .unwrap_or(' ')
361 })
362 .collect();
363 assert!(
364 line.chars().all(|c| c == PROGRESS_EMPTY),
365 "Expected all empty for zero total, got: {line:?}",
366 );
367 }
368
369 #[test]
370 fn zero_total_with_percentage_shows_zero() {
371 let compact = ProgressBar::new(0, 0)
372 .with_percentage()
373 .to_string_compact(10);
374 assert!(compact.contains("0%"), "Expected 0%% in: {compact:?}");
375 }
376
377 #[test]
382 fn compact_bar_only() {
383 let s = ProgressBar::new(10, 10).to_string_compact(10);
384 assert_eq!(s.chars().count(), 10);
385 assert!(s.chars().all(|c| c == PROGRESS_FILLED));
386 }
387
388 #[test]
389 fn compact_with_label() {
390 let s = ProgressBar::new(5, 10)
391 .with_label("building")
392 .to_string_compact(10);
393 assert!(s.contains("building"), "Label not in compact: {s:?}");
394 assert!(s.starts_with(&std::iter::repeat_n(PROGRESS_FILLED, 5).collect::<String>()));
396 }
397
398 #[test]
399 fn compact_with_percentage() {
400 let s = ProgressBar::new(3, 10)
401 .with_percentage()
402 .to_string_compact(20);
403 assert!(s.contains("30%"), "Percentage not in compact: {s:?}");
404 let bar_part: String = s.chars().take(20).collect();
406 assert_eq!(bar_part.chars().count(), 20);
407 }
408
409 #[test]
410 fn compact_with_label_and_percentage() {
411 let s = ProgressBar::new(10, 10)
412 .with_label("done")
413 .with_percentage()
414 .to_string_compact(10);
415 assert!(s.contains("done"), "Label not found: {s:?}");
416 assert!(s.contains("100%"), "Percentage not found: {s:?}");
417 }
418
419 #[test]
420 fn compact_zero_width_produces_suffix_only() {
421 let s = ProgressBar::new(5, 10)
422 .with_label("hi")
423 .with_percentage()
424 .to_string_compact(0);
425 assert!(s.contains("hi"));
427 assert!(s.contains("50%"));
428 }
429
430 #[test]
435 fn tiny_area_falls_back_to_label_text() {
436 let bar = ProgressBar::new(5, 10).with_label("Building image step 3 of 10");
439 let buf = render_to_buffer(bar, 10, 1);
441
442 let line: String = (0..10)
443 .map(|x| {
444 buf.cell((x, 0))
445 .unwrap()
446 .symbol()
447 .chars()
448 .next()
449 .unwrap_or(' ')
450 })
451 .collect();
452 assert!(
454 line.starts_with("Building"),
455 "Expected label fallback, got: {line:?}",
456 );
457 }
458
459 #[test]
460 fn percentage_fallback_on_tiny_area() {
461 let bar = ProgressBar::new(5, 10).with_percentage();
462 let buf = render_to_buffer(bar, 6, 1);
464
465 let line: String = (0..6)
466 .map(|x| {
467 buf.cell((x, 0))
468 .unwrap()
469 .symbol()
470 .chars()
471 .next()
472 .unwrap_or(' ')
473 })
474 .collect();
475 assert!(
476 line.contains("50%"),
477 "Expected percentage fallback, got: {line:?}",
478 );
479 }
480
481 #[test]
482 fn zero_height_renders_nothing() {
483 let bar = ProgressBar::new(5, 10);
484 let area = Rect::new(0, 0, 20, 0);
486 let mut buf = Buffer::empty(area);
487 bar.render(area, &mut buf);
488 }
490
491 #[test]
492 fn very_narrow_area_renders_nothing() {
493 let bar = ProgressBar::new(5, 10);
494 let buf = render_to_buffer(bar, 2, 1);
497
498 let line: String = (0..2)
499 .map(|x| {
500 buf.cell((x, 0))
501 .unwrap()
502 .symbol()
503 .chars()
504 .next()
505 .unwrap_or(' ')
506 })
507 .collect();
508 assert!(
510 !line.contains(PROGRESS_FILLED),
511 "Did not expect bar chars in narrow area: {line:?}",
512 );
513 }
514
515 #[test]
520 fn current_exceeding_total_clamps_to_full() {
521 let bar = ProgressBar::new(999, 10);
522 let buf = render_to_buffer(bar, 20, 1);
523
524 let line: String = (0..20)
525 .map(|x| {
526 buf.cell((x, 0))
527 .unwrap()
528 .symbol()
529 .chars()
530 .next()
531 .unwrap_or(' ')
532 })
533 .collect();
534 assert!(
535 line.chars().all(|c| c == PROGRESS_FILLED),
536 "Expected fully filled when current > total, got: {line:?}",
537 );
538 }
539
540 #[test]
541 fn compact_current_exceeding_total_shows_100_percent() {
542 let s = ProgressBar::new(999, 10)
543 .with_percentage()
544 .to_string_compact(10);
545 assert!(s.contains("100%"), "Expected 100%% in: {s:?}");
546 }
547}