1#![forbid(unsafe_code)]
2
3use crate::block::Block;
6use crate::{
7 MeasurableWidget, SizeConstraints, Widget, apply_style, clear_text_area, clear_text_row,
8};
9use ftui_core::geometry::{Rect, Size};
10use ftui_render::cell::{Cell, PackedRgba};
11use ftui_render::frame::Frame;
12use ftui_style::Style;
13use ftui_text::display_width;
14
15#[derive(Debug, Clone, Default)]
17pub struct ProgressBar<'a> {
18 block: Option<Block<'a>>,
19 ratio: f64,
20 label: Option<&'a str>,
21 style: Style,
22 gauge_style: Style,
23}
24
25impl<'a> ProgressBar<'a> {
26 #[must_use]
28 pub fn new() -> Self {
29 Self::default()
30 }
31
32 #[must_use]
34 pub fn block(mut self, block: Block<'a>) -> Self {
35 self.block = Some(block);
36 self
37 }
38
39 #[must_use]
41 pub fn ratio(mut self, ratio: f64) -> Self {
42 self.ratio = if ratio.is_nan() {
43 0.0
44 } else {
45 ratio.clamp(0.0, 1.0)
46 };
47 self
48 }
49
50 #[must_use]
52 pub fn label(mut self, label: &'a str) -> Self {
53 self.label = Some(label);
54 self
55 }
56
57 #[must_use]
59 pub fn style(mut self, style: Style) -> Self {
60 self.style = style;
61 self
62 }
63
64 #[must_use]
66 pub fn gauge_style(mut self, style: Style) -> Self {
67 self.gauge_style = style;
68 self
69 }
70}
71
72impl<'a> Widget for ProgressBar<'a> {
73 fn render(&self, area: Rect, frame: &mut Frame) {
74 #[cfg(feature = "tracing")]
75 let _span = tracing::debug_span!(
76 "widget_render",
77 widget = "ProgressBar",
78 x = area.x,
79 y = area.y,
80 w = area.width,
81 h = area.height
82 )
83 .entered();
84
85 let deg = frame.buffer.degradation;
86
87 if !deg.render_content() {
89 return;
90 }
91
92 if !deg.render_decorative() {
94 clear_text_area(frame, area, Style::default());
95 let pct = format!("{}%", (self.ratio * 100.0) as u8);
96 crate::draw_text_span(frame, area.x, area.y, &pct, Style::default(), area.right());
97 return;
98 }
99
100 let base_style = if deg.apply_styling() {
101 self.style
102 } else {
103 Style::default()
104 };
105
106 clear_text_area(frame, area, base_style);
107
108 let bar_area = match &self.block {
109 Some(b) => {
110 b.render(area, frame);
111 b.inner(area)
112 }
113 None => area,
114 };
115
116 if bar_area.is_empty() {
117 return;
118 }
119
120 let max_width = bar_area.width as f64;
121 let filled_width = if self.ratio >= 1.0 {
122 bar_area.width
123 } else {
124 (max_width * self.ratio).floor() as u16
125 };
126
127 let gauge_style = if deg.apply_styling() {
129 self.gauge_style
130 } else {
131 Style::default()
133 };
134 let fill_char = if deg.apply_styling() { ' ' } else { '#' };
135
136 for y in bar_area.top()..bar_area.bottom() {
137 for x in 0..filled_width {
138 let cell_x = bar_area.left().saturating_add(x);
139 if cell_x < bar_area.right() {
140 let mut cell = Cell::from_char(fill_char);
141 crate::apply_style(&mut cell, gauge_style);
142 frame.buffer.set_fast(cell_x, y, cell);
143 }
144 }
145 }
146
147 let label_style = if deg.apply_styling() {
149 self.style
150 } else {
151 Style::default()
152 };
153 if let Some(label) = self.label {
154 let label_width = display_width(label);
155 let label_x = bar_area
156 .left()
157 .saturating_add(((bar_area.width as usize).saturating_sub(label_width) / 2) as u16);
158 let label_y = bar_area.top().saturating_add(bar_area.height / 2);
159
160 crate::draw_text_span(
161 frame,
162 label_x,
163 label_y,
164 label,
165 label_style,
166 bar_area.right(),
167 );
168 }
169 }
170}
171
172impl MeasurableWidget for ProgressBar<'_> {
173 fn measure(&self, _available: Size) -> SizeConstraints {
174 let (block_width, block_height) = self
176 .block
177 .as_ref()
178 .map(|b| {
179 let inner = b.inner(Rect::new(0, 0, 100, 100));
180 let w_overhead = 100u16.saturating_sub(inner.width);
181 let h_overhead = 100u16.saturating_sub(inner.height);
182 (w_overhead, h_overhead)
183 })
184 .unwrap_or((0, 0));
185
186 let min_width = 1u16.saturating_add(block_width);
189 let min_height = 1u16.saturating_add(block_height);
190
191 SizeConstraints {
192 min: Size::new(min_width, min_height),
193 preferred: Size::new(min_width, min_height), max: None, }
196 }
197
198 fn has_intrinsic_size(&self) -> bool {
199 true
202 }
203}
204
205impl ftui_a11y::Accessible for ProgressBar<'_> {
210 fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
211 use ftui_a11y::node::{A11yNodeInfo, A11yRole, A11yState};
212
213 let id = crate::a11y_node_id(area);
214 let pct = (self.ratio * 100.0).round() as u32;
215 let name = self
216 .label
217 .map(String::from)
218 .unwrap_or_else(|| format!("{pct}%"));
219
220 let state = A11yState {
221 value_now: Some(self.ratio),
222 value_min: Some(0.0),
223 value_max: Some(1.0),
224 value_text: Some(format!("{pct}%")),
225 ..A11yState::default()
226 };
227
228 vec![
229 A11yNodeInfo::new(id, A11yRole::ProgressBar, area)
230 .with_name(name)
231 .with_state(state),
232 ]
233 }
234}
235
236#[derive(Debug, Clone, Copy)]
242pub struct MiniBarColors {
243 pub high: PackedRgba,
244 pub mid: PackedRgba,
245 pub low: PackedRgba,
246 pub critical: PackedRgba,
247}
248
249impl MiniBarColors {
250 pub fn new(high: PackedRgba, mid: PackedRgba, low: PackedRgba, critical: PackedRgba) -> Self {
251 Self {
252 high,
253 mid,
254 low,
255 critical,
256 }
257 }
258}
259
260impl Default for MiniBarColors {
261 fn default() -> Self {
262 Self {
263 high: PackedRgba::rgb(64, 200, 120),
264 mid: PackedRgba::rgb(255, 180, 64),
265 low: PackedRgba::rgb(80, 200, 240),
266 critical: PackedRgba::rgb(160, 160, 160),
267 }
268 }
269}
270
271#[derive(Debug, Clone, Copy)]
273pub struct MiniBarThresholds {
274 pub high: f64,
275 pub mid: f64,
276 pub low: f64,
277}
278
279impl Default for MiniBarThresholds {
280 fn default() -> Self {
281 Self {
282 high: 0.75,
283 mid: 0.50,
284 low: 0.25,
285 }
286 }
287}
288
289#[derive(Debug, Clone)]
291pub struct MiniBar {
292 value: f64,
293 width: u16,
294 show_percent: bool,
295 style: Style,
296 filled_char: char,
297 empty_char: char,
298 colors: MiniBarColors,
299 thresholds: MiniBarThresholds,
300}
301
302impl MiniBar {
303 pub fn new(value: f64, width: u16) -> Self {
305 Self {
306 value,
307 width,
308 show_percent: false,
309 style: Style::new(),
310 filled_char: '█',
311 empty_char: '░',
312 colors: MiniBarColors::default(),
313 thresholds: MiniBarThresholds::default(),
314 }
315 }
316
317 #[must_use]
319 pub fn value(mut self, value: f64) -> Self {
320 self.value = value;
321 self
322 }
323
324 #[must_use]
326 pub fn width(mut self, width: u16) -> Self {
327 self.width = width;
328 self
329 }
330
331 #[must_use]
333 pub fn show_percent(mut self, show: bool) -> Self {
334 self.show_percent = show;
335 self
336 }
337
338 #[must_use]
340 pub fn style(mut self, style: Style) -> Self {
341 self.style = style;
342 self
343 }
344
345 #[must_use]
347 pub fn filled_char(mut self, ch: char) -> Self {
348 self.filled_char = ch;
349 self
350 }
351
352 #[must_use]
354 pub fn empty_char(mut self, ch: char) -> Self {
355 self.empty_char = ch;
356 self
357 }
358
359 #[must_use]
361 pub fn thresholds(mut self, thresholds: MiniBarThresholds) -> Self {
362 self.thresholds = thresholds;
363 self
364 }
365
366 #[must_use]
368 pub fn colors(mut self, colors: MiniBarColors) -> Self {
369 self.colors = colors;
370 self
371 }
372
373 pub fn color_for_value(value: f64) -> PackedRgba {
375 let v = if value.is_finite() { value } else { 0.0 };
376 let v = v.clamp(0.0, 1.0);
377 let thresholds = MiniBarThresholds::default();
378 let colors = MiniBarColors::default();
379 if v > thresholds.high {
380 colors.high
381 } else if v > thresholds.mid {
382 colors.mid
383 } else if v > thresholds.low {
384 colors.low
385 } else {
386 colors.critical
387 }
388 }
389
390 pub fn render_string(&self) -> String {
392 let width = self.width as usize;
393 if width == 0 {
394 return String::new();
395 }
396 let filled = self.filled_cells(width);
397 let empty = width.saturating_sub(filled);
398 let mut out = String::with_capacity(width);
399 out.extend(std::iter::repeat_n(self.filled_char, filled));
400 out.extend(std::iter::repeat_n(self.empty_char, empty));
401 out
402 }
403
404 fn normalized_value(&self) -> f64 {
405 if self.value.is_finite() {
406 self.value.clamp(0.0, 1.0)
407 } else {
408 0.0
409 }
410 }
411
412 fn filled_cells(&self, width: usize) -> usize {
413 if width == 0 {
414 return 0;
415 }
416 let v = self.normalized_value();
417 let filled = (v * width as f64).round() as usize;
418 filled.min(width)
419 }
420
421 fn color_for_value_with_palette(&self, value: f64) -> PackedRgba {
422 let v = if value.is_finite() { value } else { 0.0 };
423 let v = v.clamp(0.0, 1.0);
424 if v > self.thresholds.high {
425 self.colors.high
426 } else if v > self.thresholds.mid {
427 self.colors.mid
428 } else if v > self.thresholds.low {
429 self.colors.low
430 } else {
431 self.colors.critical
432 }
433 }
434}
435
436impl Widget for MiniBar {
437 fn render(&self, area: Rect, frame: &mut Frame) {
438 #[cfg(feature = "tracing")]
439 let _span = tracing::debug_span!(
440 "widget_render",
441 widget = "MiniBar",
442 x = area.x,
443 y = area.y,
444 w = area.width,
445 h = area.height
446 )
447 .entered();
448
449 if area.is_empty() {
450 return;
451 }
452
453 let deg = frame.buffer.degradation;
454 if !deg.render_content() {
455 return;
456 }
457
458 let base_style = if deg.apply_styling() {
459 self.style
460 } else {
461 Style::default()
462 };
463 clear_text_row(frame, area, base_style);
464
465 let value = self.normalized_value();
466
467 if !deg.render_decorative() {
468 if self.show_percent {
469 let pct = format!("{:3.0}%", value * 100.0);
470 let pct_width = display_width(&pct) as u16;
471 if area.width >= pct_width {
472 let text_x = area.right().saturating_sub(pct_width);
473 crate::draw_text_span(
474 frame,
475 text_x,
476 area.y,
477 &pct,
478 Style::default(),
479 area.right(),
480 );
481 } else {
482 crate::draw_text_span(
483 frame,
484 area.x,
485 area.y,
486 pct.trim_start(),
487 Style::default(),
488 area.right(),
489 );
490 }
491 }
492 return;
493 }
494
495 let mut bar_width = self.width.min(area.width) as usize;
496 let mut render_percent = false;
497 let mut percent_text = String::new();
498 let mut percent_only_text = String::new();
499 let percent_width = if self.show_percent {
500 percent_text = format!(" {:3.0}%", value * 100.0);
501 percent_only_text = percent_text.trim_start().to_owned();
502 render_percent = true;
503 display_width(&percent_text) as u16
504 } else {
505 0
506 };
507
508 if render_percent {
509 if area.width <= percent_width {
510 bar_width = 0;
511 } else {
512 let available = area.width - percent_width;
513 bar_width = bar_width.min(available as usize);
514 }
515 }
516
517 if bar_width == 0 {
518 if render_percent {
519 crate::draw_text_span(
520 frame,
521 area.x,
522 area.y,
523 &percent_only_text,
524 Style::default(),
525 area.right(),
526 );
527 }
528 return;
529 }
530
531 let color = self.color_for_value_with_palette(value);
532 let filled = self.filled_cells(bar_width);
533
534 for i in 0..bar_width {
535 let x = area.x + i as u16;
536 if x >= area.right() {
537 break;
538 }
539 let ch = if i < filled {
540 self.filled_char
541 } else {
542 self.empty_char
543 };
544 let mut cell = Cell::from_char(ch);
545 if deg.apply_styling() {
546 apply_style(&mut cell, self.style);
547 if i < filled {
548 cell.fg = color;
549 }
550 }
551 frame.buffer.set_fast(x, area.y, cell);
552 }
553
554 if render_percent {
555 let text_x = area.x + bar_width as u16;
556 crate::draw_text_span(
557 frame,
558 text_x,
559 area.y,
560 &percent_text,
561 Style::default(),
562 area.right(),
563 );
564 }
565 }
566}
567
568impl MeasurableWidget for MiniBar {
569 fn measure(&self, _available: Size) -> SizeConstraints {
570 let percent_width = if self.show_percent { 5 } else { 0 }; let total_width = self.width.saturating_add(percent_width);
573
574 SizeConstraints {
575 min: Size::new(1, 1), preferred: Size::new(total_width, 1),
577 max: Some(Size::new(total_width, 1)), }
579 }
580
581 fn has_intrinsic_size(&self) -> bool {
582 self.width > 0
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use ftui_render::cell::PackedRgba;
590 use ftui_render::grapheme_pool::GraphemePool;
591
592 fn cell_at(frame: &Frame, x: u16, y: u16) -> Cell {
593 let cell = frame.buffer.get(x, y).copied();
594 assert!(cell.is_some(), "test cell should exist at ({x},{y})");
595 cell.unwrap()
596 }
597
598 fn raw_row_text(frame: &Frame, y: u16, width: u16) -> String {
599 (0..width)
600 .map(|x| {
601 frame
602 .buffer
603 .get(x, y)
604 .and_then(|cell| cell.content.as_char())
605 .unwrap_or(' ')
606 })
607 .collect()
608 }
609
610 #[test]
613 fn default_progress_bar() {
614 let pb = ProgressBar::new();
615 assert_eq!(pb.ratio, 0.0);
616 assert!(pb.label.is_none());
617 assert!(pb.block.is_none());
618 }
619
620 #[test]
621 fn ratio_clamped_above_one() {
622 let pb = ProgressBar::new().ratio(1.5);
623 assert_eq!(pb.ratio, 1.0);
624 }
625
626 #[test]
627 fn ratio_clamped_below_zero() {
628 let pb = ProgressBar::new().ratio(-0.5);
629 assert_eq!(pb.ratio, 0.0);
630 }
631
632 #[test]
633 fn ratio_normal_range() {
634 let pb = ProgressBar::new().ratio(0.5);
635 assert!((pb.ratio - 0.5).abs() < f64::EPSILON);
636 }
637
638 #[test]
639 fn builder_label() {
640 let pb = ProgressBar::new().label("50%");
641 assert_eq!(pb.label, Some("50%"));
642 }
643
644 #[test]
647 fn render_zero_area() {
648 let pb = ProgressBar::new().ratio(0.5);
649 let area = Rect::new(0, 0, 0, 0);
650 let mut pool = GraphemePool::new();
651 let mut frame = Frame::new(1, 1, &mut pool);
652 Widget::render(&pb, area, &mut frame);
653 }
655
656 #[test]
657 fn render_zero_ratio_no_fill() {
658 let gauge_style = Style::new().bg(PackedRgba::RED);
659 let pb = ProgressBar::new().ratio(0.0).gauge_style(gauge_style);
660 let area = Rect::new(0, 0, 10, 1);
661 let mut pool = GraphemePool::new();
662 let mut frame = Frame::new(10, 1, &mut pool);
663 Widget::render(&pb, area, &mut frame);
664
665 for x in 0..10 {
667 let cell = cell_at(&frame, x, 0);
668 assert_ne!(
669 cell.bg,
670 PackedRgba::RED,
671 "cell at x={x} should not have gauge bg"
672 );
673 }
674 }
675
676 #[test]
677 fn render_full_ratio_fills_all() {
678 let gauge_style = Style::new().bg(PackedRgba::GREEN);
679 let pb = ProgressBar::new().ratio(1.0).gauge_style(gauge_style);
680 let area = Rect::new(0, 0, 10, 1);
681 let mut pool = GraphemePool::new();
682 let mut frame = Frame::new(10, 1, &mut pool);
683 Widget::render(&pb, area, &mut frame);
684
685 for x in 0..10 {
687 let cell = cell_at(&frame, x, 0);
688 assert_eq!(
689 cell.bg,
690 PackedRgba::GREEN,
691 "cell at x={x} should have gauge bg"
692 );
693 }
694 }
695
696 #[test]
697 fn render_half_ratio() {
698 let gauge_style = Style::new().bg(PackedRgba::BLUE);
699 let pb = ProgressBar::new().ratio(0.5).gauge_style(gauge_style);
700 let area = Rect::new(0, 0, 10, 1);
701 let mut pool = GraphemePool::new();
702 let mut frame = Frame::new(10, 1, &mut pool);
703 Widget::render(&pb, area, &mut frame);
704
705 let filled_count = (0..10)
707 .filter(|&x| cell_at(&frame, x, 0).bg == PackedRgba::BLUE)
708 .count();
709 assert_eq!(filled_count, 5);
710 }
711
712 #[test]
713 fn render_multi_row_bar() {
714 let gauge_style = Style::new().bg(PackedRgba::RED);
715 let pb = ProgressBar::new().ratio(1.0).gauge_style(gauge_style);
716 let area = Rect::new(0, 0, 5, 3);
717 let mut pool = GraphemePool::new();
718 let mut frame = Frame::new(5, 3, &mut pool);
719 Widget::render(&pb, area, &mut frame);
720
721 for y in 0..3 {
723 for x in 0..5 {
724 let cell = cell_at(&frame, x, y);
725 assert_eq!(
726 cell.bg,
727 PackedRgba::RED,
728 "cell at ({x},{y}) should have gauge bg"
729 );
730 }
731 }
732 }
733
734 #[test]
735 fn render_with_label_centered() {
736 let pb = ProgressBar::new().ratio(0.5).label("50%");
737 let area = Rect::new(0, 0, 10, 1);
738 let mut pool = GraphemePool::new();
739 let mut frame = Frame::new(10, 1, &mut pool);
740 Widget::render(&pb, area, &mut frame);
741
742 let c = frame.buffer.get(3, 0).and_then(|c| c.content.as_char());
745 assert_eq!(c, Some('5'));
746 let c = frame.buffer.get(4, 0).and_then(|c| c.content.as_char());
747 assert_eq!(c, Some('0'));
748 let c = frame.buffer.get(5, 0).and_then(|c| c.content.as_char());
749 assert_eq!(c, Some('%'));
750 }
751
752 #[test]
753 fn render_with_block() {
754 let pb = ProgressBar::new()
755 .ratio(1.0)
756 .gauge_style(Style::new().bg(PackedRgba::GREEN))
757 .block(Block::bordered());
758 let area = Rect::new(0, 0, 10, 5);
761 let mut pool = GraphemePool::new();
762 let mut frame = Frame::new(10, 5, &mut pool);
763 Widget::render(&pb, area, &mut frame);
764
765 for x in 2..8 {
768 let cell = cell_at(&frame, x, 2);
769 assert_eq!(
770 cell.bg,
771 PackedRgba::GREEN,
772 "inner cell at x={x} should have gauge bg"
773 );
774 }
775 }
776
777 #[test]
780 fn degradation_skeleton_skips_entirely() {
781 use ftui_render::budget::DegradationLevel;
782
783 let pb = ProgressBar::new()
784 .ratio(0.5)
785 .gauge_style(Style::new().bg(PackedRgba::GREEN));
786 let area = Rect::new(0, 0, 10, 1);
787 let mut pool = GraphemePool::new();
788 let mut frame = Frame::new(10, 1, &mut pool);
789 frame.buffer.degradation = DegradationLevel::Skeleton;
790 Widget::render(&pb, area, &mut frame);
791
792 for x in 0..10 {
794 assert!(
795 cell_at(&frame, x, 0).is_empty(),
796 "cell at x={x} should be empty at Skeleton"
797 );
798 }
799 }
800
801 #[test]
802 fn degradation_essential_only_shows_percentage() {
803 use ftui_render::budget::DegradationLevel;
804
805 let pb = ProgressBar::new()
806 .ratio(0.5)
807 .gauge_style(Style::new().bg(PackedRgba::GREEN));
808 let area = Rect::new(0, 0, 10, 1);
809 let mut pool = GraphemePool::new();
810 let mut frame = Frame::new(10, 1, &mut pool);
811 frame.buffer.degradation = DegradationLevel::EssentialOnly;
812 Widget::render(&pb, area, &mut frame);
813
814 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('5'));
816 assert_eq!(cell_at(&frame, 1, 0).content.as_char(), Some('0'));
817 assert_eq!(cell_at(&frame, 2, 0).content.as_char(), Some('%'));
818 assert_ne!(cell_at(&frame, 0, 0).bg, PackedRgba::GREEN);
820 }
821
822 #[test]
823 fn degradation_full_renders_bar() {
824 use ftui_render::budget::DegradationLevel;
825
826 let pb = ProgressBar::new()
827 .ratio(1.0)
828 .gauge_style(Style::new().bg(PackedRgba::BLUE));
829 let area = Rect::new(0, 0, 10, 1);
830 let mut pool = GraphemePool::new();
831 let mut frame = Frame::new(10, 1, &mut pool);
832 frame.buffer.degradation = DegradationLevel::Full;
833 Widget::render(&pb, area, &mut frame);
834
835 for x in 0..10 {
837 assert_eq!(
838 cell_at(&frame, x, 0).bg,
839 PackedRgba::BLUE,
840 "cell at x={x} should have gauge bg at Full"
841 );
842 }
843 }
844
845 #[test]
846 fn render_no_styling_ratio_shrink_clears_stale_fill() {
847 use ftui_render::budget::DegradationLevel;
848
849 let area = Rect::new(0, 0, 10, 1);
850 let mut pool = GraphemePool::new();
851 let mut frame = Frame::new(10, 1, &mut pool);
852 frame.buffer.degradation = DegradationLevel::NoStyling;
853
854 Widget::render(&ProgressBar::new().ratio(0.8), area, &mut frame);
855 Widget::render(&ProgressBar::new().ratio(0.2), area, &mut frame);
856
857 assert_eq!(raw_row_text(&frame, 0, 10), "## ");
858 }
859
860 #[test]
861 fn degradation_essential_only_clears_previous_bar_content() {
862 use ftui_render::budget::DegradationLevel;
863
864 let pb = ProgressBar::new().ratio(0.5);
865 let area = Rect::new(0, 0, 10, 1);
866 let mut pool = GraphemePool::new();
867 let mut frame = Frame::new(10, 1, &mut pool);
868
869 Widget::render(&pb, area, &mut frame);
870 frame.buffer.degradation = DegradationLevel::EssentialOnly;
871 Widget::render(&pb, area, &mut frame);
872
873 assert_eq!(raw_row_text(&frame, 0, 10), "50% ");
874 }
875
876 #[test]
879 fn minibar_zero_is_empty() {
880 let bar = MiniBar::new(0.0, 10);
881 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
882 assert_eq!(filled, 0);
883 }
884
885 #[test]
886 fn minibar_full_is_complete() {
887 let bar = MiniBar::new(1.0, 10);
888 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
889 assert_eq!(filled, 10);
890 }
891
892 #[test]
893 fn minibar_half_is_half() {
894 let bar = MiniBar::new(0.5, 10);
895 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
896 assert!((4..=6).contains(&filled));
897 }
898
899 #[test]
900 fn minibar_color_thresholds() {
901 let high = MiniBar::color_for_value(0.80);
902 let mid = MiniBar::color_for_value(0.60);
903 let low = MiniBar::color_for_value(0.30);
904 let crit = MiniBar::color_for_value(0.10);
905 assert_ne!(high, mid);
906 assert_ne!(mid, low);
907 assert_ne!(low, crit);
908 }
909
910 #[test]
911 fn minibar_respects_width() {
912 for width in [5, 10, 20] {
913 let bar = MiniBar::new(0.5, width);
914 assert_eq!(bar.render_string().chars().count(), width as usize);
915 }
916 }
917
918 #[test]
921 fn progress_bar_measure_has_intrinsic_size() {
922 let pb = ProgressBar::new();
923 assert!(pb.has_intrinsic_size());
924 }
925
926 #[test]
927 fn progress_bar_measure_min_size() {
928 let pb = ProgressBar::new();
929 let c = pb.measure(Size::MAX);
930
931 assert_eq!(c.min.width, 1);
932 assert_eq!(c.min.height, 1);
933 assert!(c.max.is_none()); }
935
936 #[test]
937 fn progress_bar_measure_with_block() {
938 let pb = ProgressBar::new().block(Block::bordered());
939 let c = pb.measure(Size::MAX);
940
941 assert_eq!(c.min.width, 5);
943 assert_eq!(c.min.height, 5);
944 }
945
946 #[test]
947 fn minibar_measure_fixed_width() {
948 let bar = MiniBar::new(0.5, 10);
949 let c = bar.measure(Size::MAX);
950
951 assert_eq!(c.preferred.width, 10);
952 assert_eq!(c.preferred.height, 1);
953 assert_eq!(c.max, Some(Size::new(10, 1)));
954 }
955
956 #[test]
957 fn minibar_measure_with_percent() {
958 let bar = MiniBar::new(0.5, 10).show_percent(true);
959 let c = bar.measure(Size::MAX);
960
961 assert_eq!(c.preferred.width, 15);
963 assert_eq!(c.preferred.height, 1);
964 }
965
966 #[test]
967 fn minibar_measure_has_intrinsic_size() {
968 let bar = MiniBar::new(0.5, 10);
969 assert!(bar.has_intrinsic_size());
970
971 let zero_width = MiniBar::new(0.5, 0);
972 assert!(!zero_width.has_intrinsic_size());
973 }
974
975 #[test]
978 fn ratio_nan_clamped_to_zero() {
979 let pb = ProgressBar::new().ratio(f64::NAN);
980 let mut pool = GraphemePool::new();
983 let mut frame = Frame::new(10, 1, &mut pool);
984 let area = Rect::new(0, 0, 10, 1);
985 Widget::render(&pb, area, &mut frame);
986 }
987
988 #[test]
989 fn ratio_infinity_clamped() {
990 let pb = ProgressBar::new().ratio(f64::INFINITY);
991 assert_eq!(pb.ratio, 1.0);
992
993 let pb_neg = ProgressBar::new().ratio(f64::NEG_INFINITY);
994 assert_eq!(pb_neg.ratio, 0.0);
995 }
996
997 #[test]
998 fn label_wider_than_area() {
999 let pb = ProgressBar::new()
1000 .ratio(0.5)
1001 .label("This is a very long label text");
1002 let mut pool = GraphemePool::new();
1003 let mut frame = Frame::new(10, 1, &mut pool);
1004 let area = Rect::new(0, 0, 5, 1);
1005 Widget::render(&pb, area, &mut frame); }
1007
1008 #[test]
1009 fn label_on_multi_row_bar_vertically_centered() {
1010 let pb = ProgressBar::new().ratio(0.5).label("X");
1011 let mut pool = GraphemePool::new();
1012 let mut frame = Frame::new(10, 5, &mut pool);
1013 let area = Rect::new(0, 0, 10, 5);
1014 Widget::render(&pb, area, &mut frame);
1015 let c = frame.buffer.get(4, 2).and_then(|c| c.content.as_char());
1017 assert_eq!(c, Some('X'));
1018 }
1019
1020 #[test]
1021 fn empty_label_renders_no_text() {
1022 let pb = ProgressBar::new().ratio(0.5).label("");
1023 let mut pool = GraphemePool::new();
1024 let mut frame = Frame::new(10, 1, &mut pool);
1025 let area = Rect::new(0, 0, 10, 1);
1026 Widget::render(&pb, area, &mut frame); }
1028
1029 #[test]
1030 fn progress_bar_clone_and_debug() {
1031 let pb = ProgressBar::new().ratio(0.5).label("test");
1032 let cloned = pb.clone();
1033 assert!((cloned.ratio - 0.5).abs() < f64::EPSILON);
1034 assert_eq!(cloned.label, Some("test"));
1035 let dbg = format!("{:?}", pb);
1036 assert!(dbg.contains("ProgressBar"));
1037 }
1038
1039 #[test]
1040 fn progress_bar_default_trait() {
1041 let pb = ProgressBar::default();
1042 assert_eq!(pb.ratio, 0.0);
1043 assert!(pb.label.is_none());
1044 }
1045
1046 #[test]
1047 fn render_width_one() {
1048 let pb = ProgressBar::new()
1049 .ratio(1.0)
1050 .gauge_style(Style::new().bg(PackedRgba::RED));
1051 let mut pool = GraphemePool::new();
1052 let mut frame = Frame::new(1, 1, &mut pool);
1053 let area = Rect::new(0, 0, 1, 1);
1054 Widget::render(&pb, area, &mut frame);
1055 assert_eq!(cell_at(&frame, 0, 0).bg, PackedRgba::RED);
1056 }
1057
1058 #[test]
1059 fn render_ratio_just_above_zero() {
1060 let pb = ProgressBar::new()
1061 .ratio(0.01)
1062 .gauge_style(Style::new().bg(PackedRgba::GREEN));
1063 let mut pool = GraphemePool::new();
1064 let mut frame = Frame::new(100, 1, &mut pool);
1065 let area = Rect::new(0, 0, 100, 1);
1066 Widget::render(&pb, area, &mut frame);
1067 assert_eq!(cell_at(&frame, 0, 0).bg, PackedRgba::GREEN);
1069 assert_ne!(cell_at(&frame, 1, 0).bg, PackedRgba::GREEN);
1070 }
1071
1072 #[test]
1075 fn minibar_nan_value_treated_as_zero() {
1076 let bar = MiniBar::new(f64::NAN, 10);
1077 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
1078 assert_eq!(filled, 0);
1079 }
1080
1081 #[test]
1082 fn minibar_infinity_clamped_to_full() {
1083 let bar = MiniBar::new(f64::INFINITY, 10);
1084 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
1085 assert_eq!(filled, 0); }
1087
1088 #[test]
1089 fn minibar_negative_value() {
1090 let bar = MiniBar::new(-0.5, 10);
1091 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
1092 assert_eq!(filled, 0);
1093 }
1094
1095 #[test]
1096 fn minibar_value_above_one() {
1097 let bar = MiniBar::new(1.5, 10);
1098 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
1099 assert_eq!(filled, 10); }
1101
1102 #[test]
1103 fn minibar_width_zero() {
1104 let bar = MiniBar::new(0.5, 0);
1105 assert_eq!(bar.render_string(), "");
1106 }
1107
1108 #[test]
1109 fn minibar_width_one() {
1110 let bar = MiniBar::new(1.0, 1);
1111 let s = bar.render_string();
1112 assert_eq!(s.chars().count(), 1);
1113 assert_eq!(s.chars().next(), Some('█'));
1114 }
1115
1116 #[test]
1117 fn minibar_custom_chars() {
1118 let bar = MiniBar::new(0.5, 4).filled_char('#').empty_char('-');
1119 let s = bar.render_string();
1120 assert!(s.contains('#'));
1121 assert!(s.contains('-'));
1122 assert_eq!(s.chars().count(), 4);
1123 }
1124
1125 #[test]
1126 fn minibar_value_and_width_setters() {
1127 let bar = MiniBar::new(0.0, 5).value(1.0).width(3);
1128 assert_eq!(bar.render_string().chars().count(), 3);
1129 let filled = bar.render_string().chars().filter(|c| *c == '█').count();
1130 assert_eq!(filled, 3);
1131 }
1132
1133 #[test]
1134 fn minibar_color_boundary_exactly_at_high() {
1135 let at_thresh = MiniBar::color_for_value(0.75);
1137 let above = MiniBar::color_for_value(0.76);
1138 let defaults = MiniBarColors::default();
1139 assert_eq!(above, defaults.high);
1140 assert_eq!(at_thresh, defaults.mid); }
1142
1143 #[test]
1144 fn minibar_color_boundary_exactly_at_mid() {
1145 let at_thresh = MiniBar::color_for_value(0.50);
1146 let defaults = MiniBarColors::default();
1147 assert_eq!(at_thresh, defaults.low); }
1149
1150 #[test]
1151 fn minibar_color_boundary_exactly_at_low() {
1152 let at_thresh = MiniBar::color_for_value(0.25);
1153 let defaults = MiniBarColors::default();
1154 assert_eq!(at_thresh, defaults.critical); }
1156
1157 #[test]
1158 fn minibar_color_for_value_nan() {
1159 let c = MiniBar::color_for_value(f64::NAN);
1160 let defaults = MiniBarColors::default();
1161 assert_eq!(c, defaults.critical); }
1163
1164 #[test]
1165 fn minibar_colors_new() {
1166 let r = PackedRgba::rgb(255, 0, 0);
1167 let g = PackedRgba::rgb(0, 255, 0);
1168 let b = PackedRgba::rgb(0, 0, 255);
1169 let w = PackedRgba::rgb(255, 255, 255);
1170 let colors = MiniBarColors::new(r, g, b, w);
1171 assert_eq!(colors.high, r);
1172 assert_eq!(colors.mid, g);
1173 assert_eq!(colors.low, b);
1174 assert_eq!(colors.critical, w);
1175 }
1176
1177 #[test]
1178 fn minibar_custom_thresholds_and_colors() {
1179 let colors = MiniBarColors::new(
1180 PackedRgba::rgb(1, 1, 1),
1181 PackedRgba::rgb(2, 2, 2),
1182 PackedRgba::rgb(3, 3, 3),
1183 PackedRgba::rgb(4, 4, 4),
1184 );
1185 let thresholds = MiniBarThresholds {
1186 high: 0.9,
1187 mid: 0.5,
1188 low: 0.1,
1189 };
1190 let bar = MiniBar::new(0.95, 10).colors(colors).thresholds(thresholds);
1191 let c = bar.color_for_value_with_palette(0.95);
1192 assert_eq!(c, PackedRgba::rgb(1, 1, 1));
1193 }
1194
1195 #[test]
1196 fn minibar_clone_and_debug() {
1197 let bar = MiniBar::new(0.5, 10).show_percent(true);
1198 let cloned = bar.clone();
1199 assert_eq!(cloned.render_string(), bar.render_string());
1200 let dbg = format!("{:?}", bar);
1201 assert!(dbg.contains("MiniBar"));
1202 }
1203
1204 #[test]
1205 fn minibar_render_zero_area() {
1206 let bar = MiniBar::new(0.5, 10);
1207 let mut pool = GraphemePool::new();
1208 let mut frame = Frame::new(10, 1, &mut pool);
1209 let area = Rect::new(0, 0, 0, 0);
1210 Widget::render(&bar, area, &mut frame); }
1212
1213 #[test]
1214 fn minibar_render_with_percent_narrow() {
1215 let bar = MiniBar::new(0.5, 10).show_percent(true);
1216 let mut pool = GraphemePool::new();
1217 let mut frame = Frame::new(5, 1, &mut pool);
1218 let area = Rect::new(0, 0, 5, 1);
1220 Widget::render(&bar, area, &mut frame); }
1222
1223 #[test]
1224 fn minibar_render_percent_only_no_bar_room() {
1225 let bar = MiniBar::new(0.5, 10).show_percent(true);
1226 let mut pool = GraphemePool::new();
1227 let mut frame = Frame::new(5, 1, &mut pool);
1228 let area = Rect::new(0, 0, 5, 1);
1230 Widget::render(&bar, area, &mut frame);
1231 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('5'));
1232 }
1233
1234 #[test]
1235 fn minibar_render_percent_only_starts_with_digits_in_tight_widths() {
1236 let bar = MiniBar::new(0.5, 10).show_percent(true);
1237 let mut pool = GraphemePool::new();
1238 let mut frame = Frame::new(2, 1, &mut pool);
1239 Widget::render(&bar, Rect::new(0, 0, 2, 1), &mut frame);
1240
1241 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('5'));
1242 assert_eq!(cell_at(&frame, 1, 0).content.as_char(), Some('0'));
1243 }
1244
1245 #[test]
1246 fn minibar_essential_only_percent_starts_with_digits_in_tight_widths() {
1247 use ftui_render::budget::DegradationLevel;
1248
1249 let bar = MiniBar::new(0.5, 10).show_percent(true);
1250 let mut pool = GraphemePool::new();
1251 let mut frame = Frame::new(2, 1, &mut pool);
1252 frame.buffer.degradation = DegradationLevel::EssentialOnly;
1253 Widget::render(&bar, Rect::new(0, 0, 2, 1), &mut frame);
1254
1255 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('5'));
1256 assert_eq!(cell_at(&frame, 1, 0).content.as_char(), Some('0'));
1257 }
1258
1259 #[test]
1260 fn minibar_essential_only_right_aligns_percent_when_width_allows() {
1261 use ftui_render::budget::DegradationLevel;
1262
1263 let bar = MiniBar::new(0.5, 10).show_percent(true);
1264 let mut pool = GraphemePool::new();
1265 let mut frame = Frame::new(7, 1, &mut pool);
1266 frame.buffer.degradation = DegradationLevel::EssentialOnly;
1267 Widget::render(&bar, Rect::new(0, 0, 7, 1), &mut frame);
1268
1269 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some(' '));
1270 assert_eq!(cell_at(&frame, 1, 0).content.as_char(), Some(' '));
1271 assert_eq!(cell_at(&frame, 2, 0).content.as_char(), Some(' '));
1272 assert_eq!(cell_at(&frame, 3, 0).content.as_char(), Some(' '));
1273 assert_eq!(cell_at(&frame, 4, 0).content.as_char(), Some('5'));
1274 assert_eq!(cell_at(&frame, 5, 0).content.as_char(), Some('0'));
1275 assert_eq!(cell_at(&frame, 6, 0).content.as_char(), Some('%'));
1276 }
1277
1278 #[test]
1279 fn minibar_essential_only_clears_previous_bar_content() {
1280 use ftui_render::budget::DegradationLevel;
1281
1282 let bar = MiniBar::new(0.5, 10).show_percent(true);
1283 let area = Rect::new(0, 0, 7, 1);
1284 let mut pool = GraphemePool::new();
1285 let mut frame = Frame::new(7, 1, &mut pool);
1286
1287 Widget::render(&bar, area, &mut frame);
1288 frame.buffer.degradation = DegradationLevel::EssentialOnly;
1289 Widget::render(&bar, area, &mut frame);
1290
1291 assert_eq!(raw_row_text(&frame, 0, 7), " 50%");
1292 }
1293
1294 #[test]
1295 fn minibar_thresholds_default_values() {
1296 let t = MiniBarThresholds::default();
1297 assert!((t.high - 0.75).abs() < f64::EPSILON);
1298 assert!((t.mid - 0.50).abs() < f64::EPSILON);
1299 assert!((t.low - 0.25).abs() < f64::EPSILON);
1300 }
1301
1302 #[test]
1303 fn minibar_colors_default_not_all_same() {
1304 let c = MiniBarColors::default();
1305 assert_ne!(c.high, c.mid);
1306 assert_ne!(c.mid, c.low);
1307 assert_ne!(c.low, c.critical);
1308 }
1309
1310 #[test]
1311 fn minibar_colors_copy() {
1312 let c = MiniBarColors::default();
1313 let c2 = c; assert_eq!(c.high, c2.high);
1315 }
1316
1317 #[test]
1318 fn minibar_thresholds_copy() {
1319 let t = MiniBarThresholds::default();
1320 let t2 = t; assert!((t.high - t2.high).abs() < f64::EPSILON);
1322 }
1323
1324 #[test]
1325 fn minibar_style_setter() {
1326 let bar = MiniBar::new(0.5, 10).style(Style::new().bold());
1327 let dbg = format!("{:?}", bar);
1328 assert!(dbg.contains("MiniBar"));
1329 }
1330}