1#![forbid(unsafe_code)]
2
3use crate::block::Block;
6use crate::{MeasurableWidget, SizeConstraints, Widget, apply_style, set_style_area};
7use ftui_core::geometry::{Rect, Size};
8use ftui_render::cell::{Cell, PackedRgba};
9use ftui_render::frame::Frame;
10use ftui_style::Style;
11use ftui_text::display_width;
12
13#[derive(Debug, Clone, Default)]
15pub struct ProgressBar<'a> {
16 block: Option<Block<'a>>,
17 ratio: f64,
18 label: Option<&'a str>,
19 style: Style,
20 gauge_style: Style,
21}
22
23impl<'a> ProgressBar<'a> {
24 pub fn new() -> Self {
26 Self::default()
27 }
28
29 pub fn block(mut self, block: Block<'a>) -> Self {
31 self.block = Some(block);
32 self
33 }
34
35 pub fn ratio(mut self, ratio: f64) -> Self {
37 self.ratio = ratio.clamp(0.0, 1.0);
38 self
39 }
40
41 pub fn label(mut self, label: &'a str) -> Self {
43 self.label = Some(label);
44 self
45 }
46
47 pub fn style(mut self, style: Style) -> Self {
49 self.style = style;
50 self
51 }
52
53 pub fn gauge_style(mut self, style: Style) -> Self {
55 self.gauge_style = style;
56 self
57 }
58}
59
60impl<'a> Widget for ProgressBar<'a> {
61 fn render(&self, area: Rect, frame: &mut Frame) {
62 #[cfg(feature = "tracing")]
63 let _span = tracing::debug_span!(
64 "widget_render",
65 widget = "ProgressBar",
66 x = area.x,
67 y = area.y,
68 w = area.width,
69 h = area.height
70 )
71 .entered();
72
73 let deg = frame.buffer.degradation;
74
75 if !deg.render_content() {
77 return;
78 }
79
80 if !deg.render_decorative() {
82 let pct = format!("{}%", (self.ratio * 100.0) as u8);
83 crate::draw_text_span(frame, area.x, area.y, &pct, Style::default(), area.right());
84 return;
85 }
86
87 let bar_area = match &self.block {
88 Some(b) => {
89 b.render(area, frame);
90 b.inner(area)
91 }
92 None => area,
93 };
94
95 if bar_area.is_empty() {
96 return;
97 }
98
99 if deg.apply_styling() {
100 set_style_area(&mut frame.buffer, bar_area, self.style);
101 }
102
103 let max_width = bar_area.width as f64;
104 let filled_width = if self.ratio >= 1.0 {
105 bar_area.width
106 } else {
107 (max_width * self.ratio).floor() as u16
108 };
109
110 let gauge_style = if deg.apply_styling() {
112 self.gauge_style
113 } else {
114 Style::default()
116 };
117 let fill_char = if deg.apply_styling() { ' ' } else { '#' };
118
119 for y in bar_area.top()..bar_area.bottom() {
120 for x in 0..filled_width {
121 let cell_x = bar_area.left().saturating_add(x);
122 if cell_x < bar_area.right() {
123 let mut cell = Cell::from_char(fill_char);
124 crate::apply_style(&mut cell, gauge_style);
125 frame.buffer.set(cell_x, y, cell);
126 }
127 }
128 }
129
130 let label_style = if deg.apply_styling() {
132 self.style
133 } else {
134 Style::default()
135 };
136 if let Some(label) = self.label {
137 let label_width = display_width(label);
138 let label_x = bar_area
139 .left()
140 .saturating_add(((bar_area.width as usize).saturating_sub(label_width) / 2) as u16);
141 let label_y = bar_area.top().saturating_add(bar_area.height / 2);
142
143 crate::draw_text_span(
144 frame,
145 label_x,
146 label_y,
147 label,
148 label_style,
149 bar_area.right(),
150 );
151 }
152 }
153}
154
155impl MeasurableWidget for ProgressBar<'_> {
156 fn measure(&self, _available: Size) -> SizeConstraints {
157 let (block_width, block_height) = self
159 .block
160 .as_ref()
161 .map(|b| {
162 let inner = b.inner(Rect::new(0, 0, 100, 100));
163 let w_overhead = 100u16.saturating_sub(inner.width);
164 let h_overhead = 100u16.saturating_sub(inner.height);
165 (w_overhead, h_overhead)
166 })
167 .unwrap_or((0, 0));
168
169 let min_width = 1u16.saturating_add(block_width);
172 let min_height = 1u16.saturating_add(block_height);
173
174 SizeConstraints {
175 min: Size::new(min_width, min_height),
176 preferred: Size::new(min_width, min_height), max: None, }
179 }
180
181 fn has_intrinsic_size(&self) -> bool {
182 true
185 }
186}
187
188#[derive(Debug, Clone, Copy)]
194pub struct MiniBarColors {
195 pub high: PackedRgba,
196 pub mid: PackedRgba,
197 pub low: PackedRgba,
198 pub critical: PackedRgba,
199}
200
201impl MiniBarColors {
202 pub fn new(high: PackedRgba, mid: PackedRgba, low: PackedRgba, critical: PackedRgba) -> Self {
203 Self {
204 high,
205 mid,
206 low,
207 critical,
208 }
209 }
210}
211
212impl Default for MiniBarColors {
213 fn default() -> Self {
214 Self {
215 high: PackedRgba::rgb(64, 200, 120),
216 mid: PackedRgba::rgb(255, 180, 64),
217 low: PackedRgba::rgb(80, 200, 240),
218 critical: PackedRgba::rgb(160, 160, 160),
219 }
220 }
221}
222
223#[derive(Debug, Clone, Copy)]
225pub struct MiniBarThresholds {
226 pub high: f64,
227 pub mid: f64,
228 pub low: f64,
229}
230
231impl Default for MiniBarThresholds {
232 fn default() -> Self {
233 Self {
234 high: 0.75,
235 mid: 0.50,
236 low: 0.25,
237 }
238 }
239}
240
241#[derive(Debug, Clone)]
243pub struct MiniBar {
244 value: f64,
245 width: u16,
246 show_percent: bool,
247 style: Style,
248 filled_char: char,
249 empty_char: char,
250 colors: MiniBarColors,
251 thresholds: MiniBarThresholds,
252}
253
254impl MiniBar {
255 pub fn new(value: f64, width: u16) -> Self {
257 Self {
258 value,
259 width,
260 show_percent: false,
261 style: Style::new(),
262 filled_char: '█',
263 empty_char: '░',
264 colors: MiniBarColors::default(),
265 thresholds: MiniBarThresholds::default(),
266 }
267 }
268
269 pub fn value(mut self, value: f64) -> Self {
271 self.value = value;
272 self
273 }
274
275 pub fn width(mut self, width: u16) -> Self {
277 self.width = width;
278 self
279 }
280
281 pub fn show_percent(mut self, show: bool) -> Self {
283 self.show_percent = show;
284 self
285 }
286
287 pub fn style(mut self, style: Style) -> Self {
289 self.style = style;
290 self
291 }
292
293 pub fn filled_char(mut self, ch: char) -> Self {
295 self.filled_char = ch;
296 self
297 }
298
299 pub fn empty_char(mut self, ch: char) -> Self {
301 self.empty_char = ch;
302 self
303 }
304
305 pub fn thresholds(mut self, thresholds: MiniBarThresholds) -> Self {
307 self.thresholds = thresholds;
308 self
309 }
310
311 pub fn colors(mut self, colors: MiniBarColors) -> Self {
313 self.colors = colors;
314 self
315 }
316
317 pub fn color_for_value(value: f64) -> PackedRgba {
319 let v = if value.is_finite() { value } else { 0.0 };
320 let v = v.clamp(0.0, 1.0);
321 let thresholds = MiniBarThresholds::default();
322 let colors = MiniBarColors::default();
323 if v > thresholds.high {
324 colors.high
325 } else if v > thresholds.mid {
326 colors.mid
327 } else if v > thresholds.low {
328 colors.low
329 } else {
330 colors.critical
331 }
332 }
333
334 pub fn render_string(&self) -> String {
336 let width = self.width as usize;
337 if width == 0 {
338 return String::new();
339 }
340 let filled = self.filled_cells(width);
341 let empty = width.saturating_sub(filled);
342 let mut out = String::with_capacity(width);
343 out.extend(std::iter::repeat_n(self.filled_char, filled));
344 out.extend(std::iter::repeat_n(self.empty_char, empty));
345 out
346 }
347
348 fn normalized_value(&self) -> f64 {
349 if self.value.is_finite() {
350 self.value.clamp(0.0, 1.0)
351 } else {
352 0.0
353 }
354 }
355
356 fn filled_cells(&self, width: usize) -> usize {
357 if width == 0 {
358 return 0;
359 }
360 let v = self.normalized_value();
361 let filled = (v * width as f64).round() as usize;
362 filled.min(width)
363 }
364
365 fn color_for_value_with_palette(&self, value: f64) -> PackedRgba {
366 let v = if value.is_finite() { value } else { 0.0 };
367 let v = v.clamp(0.0, 1.0);
368 if v > self.thresholds.high {
369 self.colors.high
370 } else if v > self.thresholds.mid {
371 self.colors.mid
372 } else if v > self.thresholds.low {
373 self.colors.low
374 } else {
375 self.colors.critical
376 }
377 }
378}
379
380impl Widget for MiniBar {
381 fn render(&self, area: Rect, frame: &mut Frame) {
382 #[cfg(feature = "tracing")]
383 let _span = tracing::debug_span!(
384 "widget_render",
385 widget = "MiniBar",
386 x = area.x,
387 y = area.y,
388 w = area.width,
389 h = area.height
390 )
391 .entered();
392
393 if area.is_empty() {
394 return;
395 }
396
397 let deg = frame.buffer.degradation;
398 if !deg.render_content() {
399 return;
400 }
401
402 let value = self.normalized_value();
403
404 if !deg.render_decorative() {
405 if self.show_percent {
406 let pct = format!("{:3.0}%", value * 100.0);
407 crate::draw_text_span(frame, area.x, area.y, &pct, Style::default(), area.right());
408 }
409 return;
410 }
411
412 let mut bar_width = self.width.min(area.width) as usize;
413 let mut render_percent = false;
414 let mut percent_text = String::new();
415 let percent_width = if self.show_percent {
416 percent_text = format!(" {:3.0}%", value * 100.0);
417 render_percent = true;
418 display_width(&percent_text) as u16
419 } else {
420 0
421 };
422
423 if render_percent {
424 let available = area.width.saturating_sub(percent_width);
425 if available == 0 {
426 render_percent = false;
427 } else {
428 bar_width = bar_width.min(available as usize);
429 }
430 }
431
432 if bar_width == 0 {
433 if render_percent {
434 crate::draw_text_span(
435 frame,
436 area.x,
437 area.y,
438 &percent_text,
439 Style::default(),
440 area.right(),
441 );
442 }
443 return;
444 }
445
446 let color = self.color_for_value_with_palette(value);
447 let filled = self.filled_cells(bar_width);
448
449 for i in 0..bar_width {
450 let x = area.x + i as u16;
451 if x >= area.right() {
452 break;
453 }
454 let ch = if i < filled {
455 self.filled_char
456 } else {
457 self.empty_char
458 };
459 let mut cell = Cell::from_char(ch);
460 if deg.apply_styling() {
461 apply_style(&mut cell, self.style);
462 if i < filled {
463 cell.fg = color;
464 }
465 }
466 frame.buffer.set(x, area.y, cell);
467 }
468
469 if render_percent {
470 let text_x = area.x + bar_width as u16;
471 crate::draw_text_span(
472 frame,
473 text_x,
474 area.y,
475 &percent_text,
476 Style::default(),
477 area.right(),
478 );
479 }
480 }
481}
482
483impl MeasurableWidget for MiniBar {
484 fn measure(&self, _available: Size) -> SizeConstraints {
485 let percent_width = if self.show_percent { 5 } else { 0 }; let total_width = self.width.saturating_add(percent_width);
488
489 SizeConstraints {
490 min: Size::new(1, 1), preferred: Size::new(total_width, 1),
492 max: Some(Size::new(total_width, 1)), }
494 }
495
496 fn has_intrinsic_size(&self) -> bool {
497 self.width > 0
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use ftui_render::cell::PackedRgba;
505 use ftui_render::grapheme_pool::GraphemePool;
506
507 fn cell_at(frame: &Frame, x: u16, y: u16) -> Cell {
508 frame
509 .buffer
510 .get(x, y)
511 .copied()
512 .unwrap_or_else(|| panic!("test cell should exist at ({x},{y})"))
513 }
514
515 #[test]
518 fn default_progress_bar() {
519 let pb = ProgressBar::new();
520 assert_eq!(pb.ratio, 0.0);
521 assert!(pb.label.is_none());
522 assert!(pb.block.is_none());
523 }
524
525 #[test]
526 fn ratio_clamped_above_one() {
527 let pb = ProgressBar::new().ratio(1.5);
528 assert_eq!(pb.ratio, 1.0);
529 }
530
531 #[test]
532 fn ratio_clamped_below_zero() {
533 let pb = ProgressBar::new().ratio(-0.5);
534 assert_eq!(pb.ratio, 0.0);
535 }
536
537 #[test]
538 fn ratio_normal_range() {
539 let pb = ProgressBar::new().ratio(0.5);
540 assert!((pb.ratio - 0.5).abs() < f64::EPSILON);
541 }
542
543 #[test]
544 fn builder_label() {
545 let pb = ProgressBar::new().label("50%");
546 assert_eq!(pb.label, Some("50%"));
547 }
548
549 #[test]
552 fn render_zero_area() {
553 let pb = ProgressBar::new().ratio(0.5);
554 let area = Rect::new(0, 0, 0, 0);
555 let mut pool = GraphemePool::new();
556 let mut frame = Frame::new(1, 1, &mut pool);
557 Widget::render(&pb, area, &mut frame);
558 }
560
561 #[test]
562 fn render_zero_ratio_no_fill() {
563 let gauge_style = Style::new().bg(PackedRgba::RED);
564 let pb = ProgressBar::new().ratio(0.0).gauge_style(gauge_style);
565 let area = Rect::new(0, 0, 10, 1);
566 let mut pool = GraphemePool::new();
567 let mut frame = Frame::new(10, 1, &mut pool);
568 Widget::render(&pb, area, &mut frame);
569
570 for x in 0..10 {
572 let cell = cell_at(&frame, x, 0);
573 assert_ne!(
574 cell.bg,
575 PackedRgba::RED,
576 "cell at x={x} should not have gauge bg"
577 );
578 }
579 }
580
581 #[test]
582 fn render_full_ratio_fills_all() {
583 let gauge_style = Style::new().bg(PackedRgba::GREEN);
584 let pb = ProgressBar::new().ratio(1.0).gauge_style(gauge_style);
585 let area = Rect::new(0, 0, 10, 1);
586 let mut pool = GraphemePool::new();
587 let mut frame = Frame::new(10, 1, &mut pool);
588 Widget::render(&pb, area, &mut frame);
589
590 for x in 0..10 {
592 let cell = cell_at(&frame, x, 0);
593 assert_eq!(
594 cell.bg,
595 PackedRgba::GREEN,
596 "cell at x={x} should have gauge bg"
597 );
598 }
599 }
600
601 #[test]
602 fn render_half_ratio() {
603 let gauge_style = Style::new().bg(PackedRgba::BLUE);
604 let pb = ProgressBar::new().ratio(0.5).gauge_style(gauge_style);
605 let area = Rect::new(0, 0, 10, 1);
606 let mut pool = GraphemePool::new();
607 let mut frame = Frame::new(10, 1, &mut pool);
608 Widget::render(&pb, area, &mut frame);
609
610 let filled_count = (0..10)
612 .filter(|&x| cell_at(&frame, x, 0).bg == PackedRgba::BLUE)
613 .count();
614 assert_eq!(filled_count, 5);
615 }
616
617 #[test]
618 fn render_multi_row_bar() {
619 let gauge_style = Style::new().bg(PackedRgba::RED);
620 let pb = ProgressBar::new().ratio(1.0).gauge_style(gauge_style);
621 let area = Rect::new(0, 0, 5, 3);
622 let mut pool = GraphemePool::new();
623 let mut frame = Frame::new(5, 3, &mut pool);
624 Widget::render(&pb, area, &mut frame);
625
626 for y in 0..3 {
628 for x in 0..5 {
629 let cell = cell_at(&frame, x, y);
630 assert_eq!(
631 cell.bg,
632 PackedRgba::RED,
633 "cell at ({x},{y}) should have gauge bg"
634 );
635 }
636 }
637 }
638
639 #[test]
640 fn render_with_label_centered() {
641 let pb = ProgressBar::new().ratio(0.5).label("50%");
642 let area = Rect::new(0, 0, 10, 1);
643 let mut pool = GraphemePool::new();
644 let mut frame = Frame::new(10, 1, &mut pool);
645 Widget::render(&pb, area, &mut frame);
646
647 let c = frame.buffer.get(3, 0).and_then(|c| c.content.as_char());
650 assert_eq!(c, Some('5'));
651 let c = frame.buffer.get(4, 0).and_then(|c| c.content.as_char());
652 assert_eq!(c, Some('0'));
653 let c = frame.buffer.get(5, 0).and_then(|c| c.content.as_char());
654 assert_eq!(c, Some('%'));
655 }
656
657 #[test]
658 fn render_with_block() {
659 let pb = ProgressBar::new()
660 .ratio(1.0)
661 .gauge_style(Style::new().bg(PackedRgba::GREEN))
662 .block(Block::bordered());
663 let area = Rect::new(0, 0, 10, 3);
664 let mut pool = GraphemePool::new();
665 let mut frame = Frame::new(10, 3, &mut pool);
666 Widget::render(&pb, area, &mut frame);
667
668 for x in 1..9 {
671 let cell = cell_at(&frame, x, 1);
672 assert_eq!(
673 cell.bg,
674 PackedRgba::GREEN,
675 "inner cell at x={x} should have gauge bg"
676 );
677 }
678 }
679
680 #[test]
683 fn degradation_skeleton_skips_entirely() {
684 use ftui_render::budget::DegradationLevel;
685
686 let pb = ProgressBar::new()
687 .ratio(0.5)
688 .gauge_style(Style::new().bg(PackedRgba::GREEN));
689 let area = Rect::new(0, 0, 10, 1);
690 let mut pool = GraphemePool::new();
691 let mut frame = Frame::new(10, 1, &mut pool);
692 frame.buffer.degradation = DegradationLevel::Skeleton;
693 Widget::render(&pb, area, &mut frame);
694
695 for x in 0..10 {
697 assert!(
698 cell_at(&frame, x, 0).is_empty(),
699 "cell at x={x} should be empty at Skeleton"
700 );
701 }
702 }
703
704 #[test]
705 fn degradation_essential_only_shows_percentage() {
706 use ftui_render::budget::DegradationLevel;
707
708 let pb = ProgressBar::new()
709 .ratio(0.5)
710 .gauge_style(Style::new().bg(PackedRgba::GREEN));
711 let area = Rect::new(0, 0, 10, 1);
712 let mut pool = GraphemePool::new();
713 let mut frame = Frame::new(10, 1, &mut pool);
714 frame.buffer.degradation = DegradationLevel::EssentialOnly;
715 Widget::render(&pb, area, &mut frame);
716
717 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('5'));
719 assert_eq!(cell_at(&frame, 1, 0).content.as_char(), Some('0'));
720 assert_eq!(cell_at(&frame, 2, 0).content.as_char(), Some('%'));
721 assert_ne!(cell_at(&frame, 0, 0).bg, PackedRgba::GREEN);
723 }
724
725 #[test]
726 fn degradation_full_renders_bar() {
727 use ftui_render::budget::DegradationLevel;
728
729 let pb = ProgressBar::new()
730 .ratio(1.0)
731 .gauge_style(Style::new().bg(PackedRgba::BLUE));
732 let area = Rect::new(0, 0, 10, 1);
733 let mut pool = GraphemePool::new();
734 let mut frame = Frame::new(10, 1, &mut pool);
735 frame.buffer.degradation = DegradationLevel::Full;
736 Widget::render(&pb, area, &mut frame);
737
738 for x in 0..10 {
740 assert_eq!(
741 cell_at(&frame, x, 0).bg,
742 PackedRgba::BLUE,
743 "cell at x={x} should have gauge bg at Full"
744 );
745 }
746 }
747
748 #[test]
751 fn minibar_zero_is_empty() {
752 let bar = MiniBar::new(0.0, 10);
753 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
754 assert_eq!(filled, 0);
755 }
756
757 #[test]
758 fn minibar_full_is_complete() {
759 let bar = MiniBar::new(1.0, 10);
760 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
761 assert_eq!(filled, 10);
762 }
763
764 #[test]
765 fn minibar_half_is_half() {
766 let bar = MiniBar::new(0.5, 10);
767 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
768 assert!((4..=6).contains(&filled));
769 }
770
771 #[test]
772 fn minibar_color_thresholds() {
773 let high = MiniBar::color_for_value(0.80);
774 let mid = MiniBar::color_for_value(0.60);
775 let low = MiniBar::color_for_value(0.30);
776 let crit = MiniBar::color_for_value(0.10);
777 assert_ne!(high, mid);
778 assert_ne!(mid, low);
779 assert_ne!(low, crit);
780 }
781
782 #[test]
783 fn minibar_respects_width() {
784 for width in [5, 10, 20] {
785 let bar = MiniBar::new(0.5, width);
786 assert_eq!(bar.render_string().chars().count(), width as usize);
787 }
788 }
789
790 #[test]
793 fn progress_bar_measure_has_intrinsic_size() {
794 let pb = ProgressBar::new();
795 assert!(pb.has_intrinsic_size());
796 }
797
798 #[test]
799 fn progress_bar_measure_min_size() {
800 let pb = ProgressBar::new();
801 let c = pb.measure(Size::MAX);
802
803 assert_eq!(c.min.width, 1);
804 assert_eq!(c.min.height, 1);
805 assert!(c.max.is_none()); }
807
808 #[test]
809 fn progress_bar_measure_with_block() {
810 let pb = ProgressBar::new().block(Block::bordered());
811 let c = pb.measure(Size::MAX);
812
813 assert_eq!(c.min.width, 3);
815 assert_eq!(c.min.height, 3);
816 }
817
818 #[test]
819 fn minibar_measure_fixed_width() {
820 let bar = MiniBar::new(0.5, 10);
821 let c = bar.measure(Size::MAX);
822
823 assert_eq!(c.preferred.width, 10);
824 assert_eq!(c.preferred.height, 1);
825 assert_eq!(c.max, Some(Size::new(10, 1)));
826 }
827
828 #[test]
829 fn minibar_measure_with_percent() {
830 let bar = MiniBar::new(0.5, 10).show_percent(true);
831 let c = bar.measure(Size::MAX);
832
833 assert_eq!(c.preferred.width, 15);
835 assert_eq!(c.preferred.height, 1);
836 }
837
838 #[test]
839 fn minibar_measure_has_intrinsic_size() {
840 let bar = MiniBar::new(0.5, 10);
841 assert!(bar.has_intrinsic_size());
842
843 let zero_width = MiniBar::new(0.5, 0);
844 assert!(!zero_width.has_intrinsic_size());
845 }
846}