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 #[must_use]
26 pub fn new() -> Self {
27 Self::default()
28 }
29
30 #[must_use]
32 pub fn block(mut self, block: Block<'a>) -> Self {
33 self.block = Some(block);
34 self
35 }
36
37 #[must_use]
39 pub fn ratio(mut self, ratio: f64) -> Self {
40 self.ratio = ratio.clamp(0.0, 1.0);
41 self
42 }
43
44 #[must_use]
46 pub fn label(mut self, label: &'a str) -> Self {
47 self.label = Some(label);
48 self
49 }
50
51 #[must_use]
53 pub fn style(mut self, style: Style) -> Self {
54 self.style = style;
55 self
56 }
57
58 #[must_use]
60 pub fn gauge_style(mut self, style: Style) -> Self {
61 self.gauge_style = style;
62 self
63 }
64}
65
66impl<'a> Widget for ProgressBar<'a> {
67 fn render(&self, area: Rect, frame: &mut Frame) {
68 #[cfg(feature = "tracing")]
69 let _span = tracing::debug_span!(
70 "widget_render",
71 widget = "ProgressBar",
72 x = area.x,
73 y = area.y,
74 w = area.width,
75 h = area.height
76 )
77 .entered();
78
79 let deg = frame.buffer.degradation;
80
81 if !deg.render_content() {
83 return;
84 }
85
86 if !deg.render_decorative() {
88 let pct = format!("{}%", (self.ratio * 100.0) as u8);
89 crate::draw_text_span(frame, area.x, area.y, &pct, Style::default(), area.right());
90 return;
91 }
92
93 let bar_area = match &self.block {
94 Some(b) => {
95 b.render(area, frame);
96 b.inner(area)
97 }
98 None => area,
99 };
100
101 if bar_area.is_empty() {
102 return;
103 }
104
105 if deg.apply_styling() {
106 set_style_area(&mut frame.buffer, bar_area, self.style);
107 }
108
109 let max_width = bar_area.width as f64;
110 let filled_width = if self.ratio >= 1.0 {
111 bar_area.width
112 } else {
113 (max_width * self.ratio).floor() as u16
114 };
115
116 let gauge_style = if deg.apply_styling() {
118 self.gauge_style
119 } else {
120 Style::default()
122 };
123 let fill_char = if deg.apply_styling() { ' ' } else { '#' };
124
125 for y in bar_area.top()..bar_area.bottom() {
126 for x in 0..filled_width {
127 let cell_x = bar_area.left().saturating_add(x);
128 if cell_x < bar_area.right() {
129 let mut cell = Cell::from_char(fill_char);
130 crate::apply_style(&mut cell, gauge_style);
131 frame.buffer.set_fast(cell_x, y, cell);
132 }
133 }
134 }
135
136 let label_style = if deg.apply_styling() {
138 self.style
139 } else {
140 Style::default()
141 };
142 if let Some(label) = self.label {
143 let label_width = display_width(label);
144 let label_x = bar_area
145 .left()
146 .saturating_add(((bar_area.width as usize).saturating_sub(label_width) / 2) as u16);
147 let label_y = bar_area.top().saturating_add(bar_area.height / 2);
148
149 crate::draw_text_span(
150 frame,
151 label_x,
152 label_y,
153 label,
154 label_style,
155 bar_area.right(),
156 );
157 }
158 }
159}
160
161impl MeasurableWidget for ProgressBar<'_> {
162 fn measure(&self, _available: Size) -> SizeConstraints {
163 let (block_width, block_height) = self
165 .block
166 .as_ref()
167 .map(|b| {
168 let inner = b.inner(Rect::new(0, 0, 100, 100));
169 let w_overhead = 100u16.saturating_sub(inner.width);
170 let h_overhead = 100u16.saturating_sub(inner.height);
171 (w_overhead, h_overhead)
172 })
173 .unwrap_or((0, 0));
174
175 let min_width = 1u16.saturating_add(block_width);
178 let min_height = 1u16.saturating_add(block_height);
179
180 SizeConstraints {
181 min: Size::new(min_width, min_height),
182 preferred: Size::new(min_width, min_height), max: None, }
185 }
186
187 fn has_intrinsic_size(&self) -> bool {
188 true
191 }
192}
193
194#[derive(Debug, Clone, Copy)]
200pub struct MiniBarColors {
201 pub high: PackedRgba,
202 pub mid: PackedRgba,
203 pub low: PackedRgba,
204 pub critical: PackedRgba,
205}
206
207impl MiniBarColors {
208 pub fn new(high: PackedRgba, mid: PackedRgba, low: PackedRgba, critical: PackedRgba) -> Self {
209 Self {
210 high,
211 mid,
212 low,
213 critical,
214 }
215 }
216}
217
218impl Default for MiniBarColors {
219 fn default() -> Self {
220 Self {
221 high: PackedRgba::rgb(64, 200, 120),
222 mid: PackedRgba::rgb(255, 180, 64),
223 low: PackedRgba::rgb(80, 200, 240),
224 critical: PackedRgba::rgb(160, 160, 160),
225 }
226 }
227}
228
229#[derive(Debug, Clone, Copy)]
231pub struct MiniBarThresholds {
232 pub high: f64,
233 pub mid: f64,
234 pub low: f64,
235}
236
237impl Default for MiniBarThresholds {
238 fn default() -> Self {
239 Self {
240 high: 0.75,
241 mid: 0.50,
242 low: 0.25,
243 }
244 }
245}
246
247#[derive(Debug, Clone)]
249pub struct MiniBar {
250 value: f64,
251 width: u16,
252 show_percent: bool,
253 style: Style,
254 filled_char: char,
255 empty_char: char,
256 colors: MiniBarColors,
257 thresholds: MiniBarThresholds,
258}
259
260impl MiniBar {
261 pub fn new(value: f64, width: u16) -> Self {
263 Self {
264 value,
265 width,
266 show_percent: false,
267 style: Style::new(),
268 filled_char: '█',
269 empty_char: '░',
270 colors: MiniBarColors::default(),
271 thresholds: MiniBarThresholds::default(),
272 }
273 }
274
275 #[must_use]
277 pub fn value(mut self, value: f64) -> Self {
278 self.value = value;
279 self
280 }
281
282 #[must_use]
284 pub fn width(mut self, width: u16) -> Self {
285 self.width = width;
286 self
287 }
288
289 #[must_use]
291 pub fn show_percent(mut self, show: bool) -> Self {
292 self.show_percent = show;
293 self
294 }
295
296 #[must_use]
298 pub fn style(mut self, style: Style) -> Self {
299 self.style = style;
300 self
301 }
302
303 #[must_use]
305 pub fn filled_char(mut self, ch: char) -> Self {
306 self.filled_char = ch;
307 self
308 }
309
310 #[must_use]
312 pub fn empty_char(mut self, ch: char) -> Self {
313 self.empty_char = ch;
314 self
315 }
316
317 #[must_use]
319 pub fn thresholds(mut self, thresholds: MiniBarThresholds) -> Self {
320 self.thresholds = thresholds;
321 self
322 }
323
324 #[must_use]
326 pub fn colors(mut self, colors: MiniBarColors) -> Self {
327 self.colors = colors;
328 self
329 }
330
331 pub fn color_for_value(value: f64) -> PackedRgba {
333 let v = if value.is_finite() { value } else { 0.0 };
334 let v = v.clamp(0.0, 1.0);
335 let thresholds = MiniBarThresholds::default();
336 let colors = MiniBarColors::default();
337 if v > thresholds.high {
338 colors.high
339 } else if v > thresholds.mid {
340 colors.mid
341 } else if v > thresholds.low {
342 colors.low
343 } else {
344 colors.critical
345 }
346 }
347
348 pub fn render_string(&self) -> String {
350 let width = self.width as usize;
351 if width == 0 {
352 return String::new();
353 }
354 let filled = self.filled_cells(width);
355 let empty = width.saturating_sub(filled);
356 let mut out = String::with_capacity(width);
357 out.extend(std::iter::repeat_n(self.filled_char, filled));
358 out.extend(std::iter::repeat_n(self.empty_char, empty));
359 out
360 }
361
362 fn normalized_value(&self) -> f64 {
363 if self.value.is_finite() {
364 self.value.clamp(0.0, 1.0)
365 } else {
366 0.0
367 }
368 }
369
370 fn filled_cells(&self, width: usize) -> usize {
371 if width == 0 {
372 return 0;
373 }
374 let v = self.normalized_value();
375 let filled = (v * width as f64).round() as usize;
376 filled.min(width)
377 }
378
379 fn color_for_value_with_palette(&self, value: f64) -> PackedRgba {
380 let v = if value.is_finite() { value } else { 0.0 };
381 let v = v.clamp(0.0, 1.0);
382 if v > self.thresholds.high {
383 self.colors.high
384 } else if v > self.thresholds.mid {
385 self.colors.mid
386 } else if v > self.thresholds.low {
387 self.colors.low
388 } else {
389 self.colors.critical
390 }
391 }
392}
393
394impl Widget for MiniBar {
395 fn render(&self, area: Rect, frame: &mut Frame) {
396 #[cfg(feature = "tracing")]
397 let _span = tracing::debug_span!(
398 "widget_render",
399 widget = "MiniBar",
400 x = area.x,
401 y = area.y,
402 w = area.width,
403 h = area.height
404 )
405 .entered();
406
407 if area.is_empty() {
408 return;
409 }
410
411 let deg = frame.buffer.degradation;
412 if !deg.render_content() {
413 return;
414 }
415
416 let value = self.normalized_value();
417
418 if !deg.render_decorative() {
419 if self.show_percent {
420 let pct = format!("{:3.0}%", value * 100.0);
421 crate::draw_text_span(frame, area.x, area.y, &pct, Style::default(), area.right());
422 }
423 return;
424 }
425
426 let mut bar_width = self.width.min(area.width) as usize;
427 let mut render_percent = false;
428 let mut percent_text = String::new();
429 let percent_width = if self.show_percent {
430 percent_text = format!(" {:3.0}%", value * 100.0);
431 render_percent = true;
432 display_width(&percent_text) as u16
433 } else {
434 0
435 };
436
437 if render_percent {
438 let available = area.width.saturating_sub(percent_width);
439 if available == 0 {
440 render_percent = false;
441 } else {
442 bar_width = bar_width.min(available as usize);
443 }
444 }
445
446 if bar_width == 0 {
447 if render_percent {
448 crate::draw_text_span(
449 frame,
450 area.x,
451 area.y,
452 &percent_text,
453 Style::default(),
454 area.right(),
455 );
456 }
457 return;
458 }
459
460 let color = self.color_for_value_with_palette(value);
461 let filled = self.filled_cells(bar_width);
462
463 for i in 0..bar_width {
464 let x = area.x + i as u16;
465 if x >= area.right() {
466 break;
467 }
468 let ch = if i < filled {
469 self.filled_char
470 } else {
471 self.empty_char
472 };
473 let mut cell = Cell::from_char(ch);
474 if deg.apply_styling() {
475 apply_style(&mut cell, self.style);
476 if i < filled {
477 cell.fg = color;
478 }
479 }
480 frame.buffer.set_fast(x, area.y, cell);
481 }
482
483 if render_percent {
484 let text_x = area.x + bar_width as u16;
485 crate::draw_text_span(
486 frame,
487 text_x,
488 area.y,
489 &percent_text,
490 Style::default(),
491 area.right(),
492 );
493 }
494 }
495}
496
497impl MeasurableWidget for MiniBar {
498 fn measure(&self, _available: Size) -> SizeConstraints {
499 let percent_width = if self.show_percent { 5 } else { 0 }; let total_width = self.width.saturating_add(percent_width);
502
503 SizeConstraints {
504 min: Size::new(1, 1), preferred: Size::new(total_width, 1),
506 max: Some(Size::new(total_width, 1)), }
508 }
509
510 fn has_intrinsic_size(&self) -> bool {
511 self.width > 0
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518 use ftui_render::cell::PackedRgba;
519 use ftui_render::grapheme_pool::GraphemePool;
520
521 fn cell_at(frame: &Frame, x: u16, y: u16) -> Cell {
522 let cell = frame.buffer.get(x, y).copied();
523 assert!(cell.is_some(), "test cell should exist at ({x},{y})");
524 cell.unwrap()
525 }
526
527 #[test]
530 fn default_progress_bar() {
531 let pb = ProgressBar::new();
532 assert_eq!(pb.ratio, 0.0);
533 assert!(pb.label.is_none());
534 assert!(pb.block.is_none());
535 }
536
537 #[test]
538 fn ratio_clamped_above_one() {
539 let pb = ProgressBar::new().ratio(1.5);
540 assert_eq!(pb.ratio, 1.0);
541 }
542
543 #[test]
544 fn ratio_clamped_below_zero() {
545 let pb = ProgressBar::new().ratio(-0.5);
546 assert_eq!(pb.ratio, 0.0);
547 }
548
549 #[test]
550 fn ratio_normal_range() {
551 let pb = ProgressBar::new().ratio(0.5);
552 assert!((pb.ratio - 0.5).abs() < f64::EPSILON);
553 }
554
555 #[test]
556 fn builder_label() {
557 let pb = ProgressBar::new().label("50%");
558 assert_eq!(pb.label, Some("50%"));
559 }
560
561 #[test]
564 fn render_zero_area() {
565 let pb = ProgressBar::new().ratio(0.5);
566 let area = Rect::new(0, 0, 0, 0);
567 let mut pool = GraphemePool::new();
568 let mut frame = Frame::new(1, 1, &mut pool);
569 Widget::render(&pb, area, &mut frame);
570 }
572
573 #[test]
574 fn render_zero_ratio_no_fill() {
575 let gauge_style = Style::new().bg(PackedRgba::RED);
576 let pb = ProgressBar::new().ratio(0.0).gauge_style(gauge_style);
577 let area = Rect::new(0, 0, 10, 1);
578 let mut pool = GraphemePool::new();
579 let mut frame = Frame::new(10, 1, &mut pool);
580 Widget::render(&pb, area, &mut frame);
581
582 for x in 0..10 {
584 let cell = cell_at(&frame, x, 0);
585 assert_ne!(
586 cell.bg,
587 PackedRgba::RED,
588 "cell at x={x} should not have gauge bg"
589 );
590 }
591 }
592
593 #[test]
594 fn render_full_ratio_fills_all() {
595 let gauge_style = Style::new().bg(PackedRgba::GREEN);
596 let pb = ProgressBar::new().ratio(1.0).gauge_style(gauge_style);
597 let area = Rect::new(0, 0, 10, 1);
598 let mut pool = GraphemePool::new();
599 let mut frame = Frame::new(10, 1, &mut pool);
600 Widget::render(&pb, area, &mut frame);
601
602 for x in 0..10 {
604 let cell = cell_at(&frame, x, 0);
605 assert_eq!(
606 cell.bg,
607 PackedRgba::GREEN,
608 "cell at x={x} should have gauge bg"
609 );
610 }
611 }
612
613 #[test]
614 fn render_half_ratio() {
615 let gauge_style = Style::new().bg(PackedRgba::BLUE);
616 let pb = ProgressBar::new().ratio(0.5).gauge_style(gauge_style);
617 let area = Rect::new(0, 0, 10, 1);
618 let mut pool = GraphemePool::new();
619 let mut frame = Frame::new(10, 1, &mut pool);
620 Widget::render(&pb, area, &mut frame);
621
622 let filled_count = (0..10)
624 .filter(|&x| cell_at(&frame, x, 0).bg == PackedRgba::BLUE)
625 .count();
626 assert_eq!(filled_count, 5);
627 }
628
629 #[test]
630 fn render_multi_row_bar() {
631 let gauge_style = Style::new().bg(PackedRgba::RED);
632 let pb = ProgressBar::new().ratio(1.0).gauge_style(gauge_style);
633 let area = Rect::new(0, 0, 5, 3);
634 let mut pool = GraphemePool::new();
635 let mut frame = Frame::new(5, 3, &mut pool);
636 Widget::render(&pb, area, &mut frame);
637
638 for y in 0..3 {
640 for x in 0..5 {
641 let cell = cell_at(&frame, x, y);
642 assert_eq!(
643 cell.bg,
644 PackedRgba::RED,
645 "cell at ({x},{y}) should have gauge bg"
646 );
647 }
648 }
649 }
650
651 #[test]
652 fn render_with_label_centered() {
653 let pb = ProgressBar::new().ratio(0.5).label("50%");
654 let area = Rect::new(0, 0, 10, 1);
655 let mut pool = GraphemePool::new();
656 let mut frame = Frame::new(10, 1, &mut pool);
657 Widget::render(&pb, area, &mut frame);
658
659 let c = frame.buffer.get(3, 0).and_then(|c| c.content.as_char());
662 assert_eq!(c, Some('5'));
663 let c = frame.buffer.get(4, 0).and_then(|c| c.content.as_char());
664 assert_eq!(c, Some('0'));
665 let c = frame.buffer.get(5, 0).and_then(|c| c.content.as_char());
666 assert_eq!(c, Some('%'));
667 }
668
669 #[test]
670 fn render_with_block() {
671 let pb = ProgressBar::new()
672 .ratio(1.0)
673 .gauge_style(Style::new().bg(PackedRgba::GREEN))
674 .block(Block::bordered());
675 let area = Rect::new(0, 0, 10, 3);
676 let mut pool = GraphemePool::new();
677 let mut frame = Frame::new(10, 3, &mut pool);
678 Widget::render(&pb, area, &mut frame);
679
680 for x in 1..9 {
683 let cell = cell_at(&frame, x, 1);
684 assert_eq!(
685 cell.bg,
686 PackedRgba::GREEN,
687 "inner cell at x={x} should have gauge bg"
688 );
689 }
690 }
691
692 #[test]
695 fn degradation_skeleton_skips_entirely() {
696 use ftui_render::budget::DegradationLevel;
697
698 let pb = ProgressBar::new()
699 .ratio(0.5)
700 .gauge_style(Style::new().bg(PackedRgba::GREEN));
701 let area = Rect::new(0, 0, 10, 1);
702 let mut pool = GraphemePool::new();
703 let mut frame = Frame::new(10, 1, &mut pool);
704 frame.buffer.degradation = DegradationLevel::Skeleton;
705 Widget::render(&pb, area, &mut frame);
706
707 for x in 0..10 {
709 assert!(
710 cell_at(&frame, x, 0).is_empty(),
711 "cell at x={x} should be empty at Skeleton"
712 );
713 }
714 }
715
716 #[test]
717 fn degradation_essential_only_shows_percentage() {
718 use ftui_render::budget::DegradationLevel;
719
720 let pb = ProgressBar::new()
721 .ratio(0.5)
722 .gauge_style(Style::new().bg(PackedRgba::GREEN));
723 let area = Rect::new(0, 0, 10, 1);
724 let mut pool = GraphemePool::new();
725 let mut frame = Frame::new(10, 1, &mut pool);
726 frame.buffer.degradation = DegradationLevel::EssentialOnly;
727 Widget::render(&pb, area, &mut frame);
728
729 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('5'));
731 assert_eq!(cell_at(&frame, 1, 0).content.as_char(), Some('0'));
732 assert_eq!(cell_at(&frame, 2, 0).content.as_char(), Some('%'));
733 assert_ne!(cell_at(&frame, 0, 0).bg, PackedRgba::GREEN);
735 }
736
737 #[test]
738 fn degradation_full_renders_bar() {
739 use ftui_render::budget::DegradationLevel;
740
741 let pb = ProgressBar::new()
742 .ratio(1.0)
743 .gauge_style(Style::new().bg(PackedRgba::BLUE));
744 let area = Rect::new(0, 0, 10, 1);
745 let mut pool = GraphemePool::new();
746 let mut frame = Frame::new(10, 1, &mut pool);
747 frame.buffer.degradation = DegradationLevel::Full;
748 Widget::render(&pb, area, &mut frame);
749
750 for x in 0..10 {
752 assert_eq!(
753 cell_at(&frame, x, 0).bg,
754 PackedRgba::BLUE,
755 "cell at x={x} should have gauge bg at Full"
756 );
757 }
758 }
759
760 #[test]
763 fn minibar_zero_is_empty() {
764 let bar = MiniBar::new(0.0, 10);
765 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
766 assert_eq!(filled, 0);
767 }
768
769 #[test]
770 fn minibar_full_is_complete() {
771 let bar = MiniBar::new(1.0, 10);
772 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
773 assert_eq!(filled, 10);
774 }
775
776 #[test]
777 fn minibar_half_is_half() {
778 let bar = MiniBar::new(0.5, 10);
779 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
780 assert!((4..=6).contains(&filled));
781 }
782
783 #[test]
784 fn minibar_color_thresholds() {
785 let high = MiniBar::color_for_value(0.80);
786 let mid = MiniBar::color_for_value(0.60);
787 let low = MiniBar::color_for_value(0.30);
788 let crit = MiniBar::color_for_value(0.10);
789 assert_ne!(high, mid);
790 assert_ne!(mid, low);
791 assert_ne!(low, crit);
792 }
793
794 #[test]
795 fn minibar_respects_width() {
796 for width in [5, 10, 20] {
797 let bar = MiniBar::new(0.5, width);
798 assert_eq!(bar.render_string().chars().count(), width as usize);
799 }
800 }
801
802 #[test]
805 fn progress_bar_measure_has_intrinsic_size() {
806 let pb = ProgressBar::new();
807 assert!(pb.has_intrinsic_size());
808 }
809
810 #[test]
811 fn progress_bar_measure_min_size() {
812 let pb = ProgressBar::new();
813 let c = pb.measure(Size::MAX);
814
815 assert_eq!(c.min.width, 1);
816 assert_eq!(c.min.height, 1);
817 assert!(c.max.is_none()); }
819
820 #[test]
821 fn progress_bar_measure_with_block() {
822 let pb = ProgressBar::new().block(Block::bordered());
823 let c = pb.measure(Size::MAX);
824
825 assert_eq!(c.min.width, 3);
827 assert_eq!(c.min.height, 3);
828 }
829
830 #[test]
831 fn minibar_measure_fixed_width() {
832 let bar = MiniBar::new(0.5, 10);
833 let c = bar.measure(Size::MAX);
834
835 assert_eq!(c.preferred.width, 10);
836 assert_eq!(c.preferred.height, 1);
837 assert_eq!(c.max, Some(Size::new(10, 1)));
838 }
839
840 #[test]
841 fn minibar_measure_with_percent() {
842 let bar = MiniBar::new(0.5, 10).show_percent(true);
843 let c = bar.measure(Size::MAX);
844
845 assert_eq!(c.preferred.width, 15);
847 assert_eq!(c.preferred.height, 1);
848 }
849
850 #[test]
851 fn minibar_measure_has_intrinsic_size() {
852 let bar = MiniBar::new(0.5, 10);
853 assert!(bar.has_intrinsic_size());
854
855 let zero_width = MiniBar::new(0.5, 0);
856 assert!(!zero_width.has_intrinsic_size());
857 }
858
859 #[test]
862 fn ratio_nan_clamped_to_zero() {
863 let pb = ProgressBar::new().ratio(f64::NAN);
864 let mut pool = GraphemePool::new();
867 let mut frame = Frame::new(10, 1, &mut pool);
868 let area = Rect::new(0, 0, 10, 1);
869 Widget::render(&pb, area, &mut frame);
870 }
871
872 #[test]
873 fn ratio_infinity_clamped() {
874 let pb = ProgressBar::new().ratio(f64::INFINITY);
875 assert_eq!(pb.ratio, 1.0);
876
877 let pb_neg = ProgressBar::new().ratio(f64::NEG_INFINITY);
878 assert_eq!(pb_neg.ratio, 0.0);
879 }
880
881 #[test]
882 fn label_wider_than_area() {
883 let pb = ProgressBar::new()
884 .ratio(0.5)
885 .label("This is a very long label text");
886 let mut pool = GraphemePool::new();
887 let mut frame = Frame::new(10, 1, &mut pool);
888 let area = Rect::new(0, 0, 5, 1);
889 Widget::render(&pb, area, &mut frame); }
891
892 #[test]
893 fn label_on_multi_row_bar_vertically_centered() {
894 let pb = ProgressBar::new().ratio(0.5).label("X");
895 let mut pool = GraphemePool::new();
896 let mut frame = Frame::new(10, 5, &mut pool);
897 let area = Rect::new(0, 0, 10, 5);
898 Widget::render(&pb, area, &mut frame);
899 let c = frame.buffer.get(4, 2).and_then(|c| c.content.as_char());
901 assert_eq!(c, Some('X'));
902 }
903
904 #[test]
905 fn empty_label_renders_no_text() {
906 let pb = ProgressBar::new().ratio(0.5).label("");
907 let mut pool = GraphemePool::new();
908 let mut frame = Frame::new(10, 1, &mut pool);
909 let area = Rect::new(0, 0, 10, 1);
910 Widget::render(&pb, area, &mut frame); }
912
913 #[test]
914 fn progress_bar_clone_and_debug() {
915 let pb = ProgressBar::new().ratio(0.5).label("test");
916 let cloned = pb.clone();
917 assert!((cloned.ratio - 0.5).abs() < f64::EPSILON);
918 assert_eq!(cloned.label, Some("test"));
919 let dbg = format!("{:?}", pb);
920 assert!(dbg.contains("ProgressBar"));
921 }
922
923 #[test]
924 fn progress_bar_default_trait() {
925 let pb = ProgressBar::default();
926 assert_eq!(pb.ratio, 0.0);
927 assert!(pb.label.is_none());
928 }
929
930 #[test]
931 fn render_width_one() {
932 let pb = ProgressBar::new()
933 .ratio(1.0)
934 .gauge_style(Style::new().bg(PackedRgba::RED));
935 let mut pool = GraphemePool::new();
936 let mut frame = Frame::new(1, 1, &mut pool);
937 let area = Rect::new(0, 0, 1, 1);
938 Widget::render(&pb, area, &mut frame);
939 assert_eq!(cell_at(&frame, 0, 0).bg, PackedRgba::RED);
940 }
941
942 #[test]
943 fn render_ratio_just_above_zero() {
944 let pb = ProgressBar::new()
945 .ratio(0.01)
946 .gauge_style(Style::new().bg(PackedRgba::GREEN));
947 let mut pool = GraphemePool::new();
948 let mut frame = Frame::new(100, 1, &mut pool);
949 let area = Rect::new(0, 0, 100, 1);
950 Widget::render(&pb, area, &mut frame);
951 assert_eq!(cell_at(&frame, 0, 0).bg, PackedRgba::GREEN);
953 assert_ne!(cell_at(&frame, 1, 0).bg, PackedRgba::GREEN);
954 }
955
956 #[test]
959 fn minibar_nan_value_treated_as_zero() {
960 let bar = MiniBar::new(f64::NAN, 10);
961 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
962 assert_eq!(filled, 0);
963 }
964
965 #[test]
966 fn minibar_infinity_clamped_to_full() {
967 let bar = MiniBar::new(f64::INFINITY, 10);
968 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
969 assert_eq!(filled, 0); }
971
972 #[test]
973 fn minibar_negative_value() {
974 let bar = MiniBar::new(-0.5, 10);
975 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
976 assert_eq!(filled, 0);
977 }
978
979 #[test]
980 fn minibar_value_above_one() {
981 let bar = MiniBar::new(1.5, 10);
982 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
983 assert_eq!(filled, 10); }
985
986 #[test]
987 fn minibar_width_zero() {
988 let bar = MiniBar::new(0.5, 0);
989 assert_eq!(bar.render_string(), "");
990 }
991
992 #[test]
993 fn minibar_width_one() {
994 let bar = MiniBar::new(1.0, 1);
995 let s = bar.render_string();
996 assert_eq!(s.chars().count(), 1);
997 assert_eq!(s.chars().next(), Some('█'));
998 }
999
1000 #[test]
1001 fn minibar_custom_chars() {
1002 let bar = MiniBar::new(0.5, 4).filled_char('#').empty_char('-');
1003 let s = bar.render_string();
1004 assert!(s.contains('#'));
1005 assert!(s.contains('-'));
1006 assert_eq!(s.chars().count(), 4);
1007 }
1008
1009 #[test]
1010 fn minibar_value_and_width_setters() {
1011 let bar = MiniBar::new(0.0, 5).value(1.0).width(3);
1012 assert_eq!(bar.render_string().chars().count(), 3);
1013 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
1014 assert_eq!(filled, 3);
1015 }
1016
1017 #[test]
1018 fn minibar_color_boundary_exactly_at_high() {
1019 let at_thresh = MiniBar::color_for_value(0.75);
1021 let above = MiniBar::color_for_value(0.76);
1022 let defaults = MiniBarColors::default();
1023 assert_eq!(above, defaults.high);
1024 assert_eq!(at_thresh, defaults.mid); }
1026
1027 #[test]
1028 fn minibar_color_boundary_exactly_at_mid() {
1029 let at_thresh = MiniBar::color_for_value(0.50);
1030 let defaults = MiniBarColors::default();
1031 assert_eq!(at_thresh, defaults.low); }
1033
1034 #[test]
1035 fn minibar_color_boundary_exactly_at_low() {
1036 let at_thresh = MiniBar::color_for_value(0.25);
1037 let defaults = MiniBarColors::default();
1038 assert_eq!(at_thresh, defaults.critical); }
1040
1041 #[test]
1042 fn minibar_color_for_value_nan() {
1043 let c = MiniBar::color_for_value(f64::NAN);
1044 let defaults = MiniBarColors::default();
1045 assert_eq!(c, defaults.critical); }
1047
1048 #[test]
1049 fn minibar_colors_new() {
1050 let r = PackedRgba::rgb(255, 0, 0);
1051 let g = PackedRgba::rgb(0, 255, 0);
1052 let b = PackedRgba::rgb(0, 0, 255);
1053 let w = PackedRgba::rgb(255, 255, 255);
1054 let colors = MiniBarColors::new(r, g, b, w);
1055 assert_eq!(colors.high, r);
1056 assert_eq!(colors.mid, g);
1057 assert_eq!(colors.low, b);
1058 assert_eq!(colors.critical, w);
1059 }
1060
1061 #[test]
1062 fn minibar_custom_thresholds_and_colors() {
1063 let colors = MiniBarColors::new(
1064 PackedRgba::rgb(1, 1, 1),
1065 PackedRgba::rgb(2, 2, 2),
1066 PackedRgba::rgb(3, 3, 3),
1067 PackedRgba::rgb(4, 4, 4),
1068 );
1069 let thresholds = MiniBarThresholds {
1070 high: 0.9,
1071 mid: 0.5,
1072 low: 0.1,
1073 };
1074 let bar = MiniBar::new(0.95, 10).colors(colors).thresholds(thresholds);
1075 let c = bar.color_for_value_with_palette(0.95);
1076 assert_eq!(c, PackedRgba::rgb(1, 1, 1));
1077 }
1078
1079 #[test]
1080 fn minibar_clone_and_debug() {
1081 let bar = MiniBar::new(0.5, 10).show_percent(true);
1082 let cloned = bar.clone();
1083 assert_eq!(cloned.render_string(), bar.render_string());
1084 let dbg = format!("{:?}", bar);
1085 assert!(dbg.contains("MiniBar"));
1086 }
1087
1088 #[test]
1089 fn minibar_render_zero_area() {
1090 let bar = MiniBar::new(0.5, 10);
1091 let mut pool = GraphemePool::new();
1092 let mut frame = Frame::new(10, 1, &mut pool);
1093 let area = Rect::new(0, 0, 0, 0);
1094 Widget::render(&bar, area, &mut frame); }
1096
1097 #[test]
1098 fn minibar_render_with_percent_narrow() {
1099 let bar = MiniBar::new(0.5, 10).show_percent(true);
1100 let mut pool = GraphemePool::new();
1101 let mut frame = Frame::new(5, 1, &mut pool);
1102 let area = Rect::new(0, 0, 5, 1);
1104 Widget::render(&bar, area, &mut frame); }
1106
1107 #[test]
1108 fn minibar_render_percent_only_no_bar_room() {
1109 let bar = MiniBar::new(0.5, 10).show_percent(true);
1110 let mut pool = GraphemePool::new();
1111 let mut frame = Frame::new(5, 1, &mut pool);
1112 let area = Rect::new(0, 0, 5, 1);
1114 Widget::render(&bar, area, &mut frame);
1115 }
1116
1117 #[test]
1118 fn minibar_thresholds_default_values() {
1119 let t = MiniBarThresholds::default();
1120 assert!((t.high - 0.75).abs() < f64::EPSILON);
1121 assert!((t.mid - 0.50).abs() < f64::EPSILON);
1122 assert!((t.low - 0.25).abs() < f64::EPSILON);
1123 }
1124
1125 #[test]
1126 fn minibar_colors_default_not_all_same() {
1127 let c = MiniBarColors::default();
1128 assert_ne!(c.high, c.mid);
1129 assert_ne!(c.mid, c.low);
1130 assert_ne!(c.low, c.critical);
1131 }
1132
1133 #[test]
1134 fn minibar_colors_copy() {
1135 let c = MiniBarColors::default();
1136 let c2 = c; assert_eq!(c.high, c2.high);
1138 }
1139
1140 #[test]
1141 fn minibar_thresholds_copy() {
1142 let t = MiniBarThresholds::default();
1143 let t2 = t; assert!((t.high - t2.high).abs() < f64::EPSILON);
1145 }
1146
1147 #[test]
1148 fn minibar_style_setter() {
1149 let bar = MiniBar::new(0.5, 10).style(Style::new().bold());
1150 let dbg = format!("{:?}", bar);
1151 assert!(dbg.contains("MiniBar"));
1152 }
1153}