Skip to main content

ftui_widgets/
progress.rs

1#![forbid(unsafe_code)]
2
3//! Progress bar widget.
4
5use 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/// A widget to display a progress bar.
14#[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    /// Create a new progress bar with default settings.
25    #[must_use]
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    /// Set the surrounding block.
31    #[must_use]
32    pub fn block(mut self, block: Block<'a>) -> Self {
33        self.block = Some(block);
34        self
35    }
36
37    /// Set the progress ratio (clamped to 0.0..=1.0).
38    #[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    /// Set the centered label text.
45    #[must_use]
46    pub fn label(mut self, label: &'a str) -> Self {
47        self.label = Some(label);
48        self
49    }
50
51    /// Set the base style.
52    #[must_use]
53    pub fn style(mut self, style: Style) -> Self {
54        self.style = style;
55        self
56    }
57
58    /// Set the filled portion style.
59    #[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        // Skeleton+: skip entirely
82        if !deg.render_content() {
83            return;
84        }
85
86        // EssentialOnly: just show percentage text, no bar
87        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        // Draw filled part
117        let gauge_style = if deg.apply_styling() {
118            self.gauge_style
119        } else {
120            // At NoStyling, use '#' as fill char instead of background color
121            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        // Draw label (centered)
137        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        // ProgressBar fills available width, has fixed height of 1 (or block inner height)
164        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        // Minimum: 1 cell for bar + block overhead
176        // Preferred: fills available width, 1 row + block overhead
177        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), // Fills width, so preferred = min
183            max: None,                                   // Can grow to fill available space
184        }
185    }
186
187    fn has_intrinsic_size(&self) -> bool {
188        // ProgressBar fills width, so it doesn't have true intrinsic width
189        // but it does have intrinsic height
190        true
191    }
192}
193
194// ---------------------------------------------------------------------------
195// MiniBar
196// ---------------------------------------------------------------------------
197
198/// Color thresholds for [`MiniBar`].
199#[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/// Thresholds for mapping values to colors.
230#[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/// Compact progress indicator for dashboard-style metrics.
248#[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    /// Create a new MiniBar with value in the 0.0..=1.0 range.
262    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    /// Override the value (clamped to 0.0..=1.0).
276    #[must_use]
277    pub fn value(mut self, value: f64) -> Self {
278        self.value = value;
279        self
280    }
281
282    /// Override the displayed width.
283    #[must_use]
284    pub fn width(mut self, width: u16) -> Self {
285        self.width = width;
286        self
287    }
288
289    /// Enable or disable percentage text.
290    #[must_use]
291    pub fn show_percent(mut self, show: bool) -> Self {
292        self.show_percent = show;
293        self
294    }
295
296    /// Set the base style for the bar.
297    #[must_use]
298    pub fn style(mut self, style: Style) -> Self {
299        self.style = style;
300        self
301    }
302
303    /// Override the filled block character.
304    #[must_use]
305    pub fn filled_char(mut self, ch: char) -> Self {
306        self.filled_char = ch;
307        self
308    }
309
310    /// Override the empty block character.
311    #[must_use]
312    pub fn empty_char(mut self, ch: char) -> Self {
313        self.empty_char = ch;
314        self
315    }
316
317    /// Override the color thresholds.
318    #[must_use]
319    pub fn thresholds(mut self, thresholds: MiniBarThresholds) -> Self {
320        self.thresholds = thresholds;
321        self
322    }
323
324    /// Override the color palette.
325    #[must_use]
326    pub fn colors(mut self, colors: MiniBarColors) -> Self {
327        self.colors = colors;
328        self
329    }
330
331    /// Map a value to a color using default thresholds.
332    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    /// Render the bar as a string (for testing/debugging).
349    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        // MiniBar has fixed dimensions
500        let percent_width = if self.show_percent { 5 } else { 0 }; // " XXX%"
501        let total_width = self.width.saturating_add(percent_width);
502
503        SizeConstraints {
504            min: Size::new(1, 1), // At least show something
505            preferred: Size::new(total_width, 1),
506            max: Some(Size::new(total_width, 1)), // Fixed size
507        }
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    // --- Builder tests ---
528
529    #[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    // --- Rendering tests ---
562
563    #[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        // Should not panic
571    }
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        // No cells should have the gauge style bg
583        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        // All cells should have gauge bg
603        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        // About 5 cells should be filled (10 * 0.5 = 5)
623        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        // All 3 rows should be filled
639        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        // Label "50%" is 3 chars wide, centered in 10 = starts at x=3
660        // (10 - 3) / 2 = 3
661        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        // Inner area is 8x1 (border takes 1 on each side)
681        // All inner cells should have gauge bg
682        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    // --- Degradation tests ---
693
694    #[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        // Nothing should be rendered
708        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        // Should show "50%" text, no gauge bar
730        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        // No gauge background color
734        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        // All cells should have gauge bg
751        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    // --- MiniBar tests ---
761
762    #[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    // --- MeasurableWidget tests ---
803
804    #[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()); // Fills available width
818    }
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        // Block adds 2 (border on each side)
826        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        // Width = 10 + 5 (" XXX%") = 15
846        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    // ── Edge-case tests (bd-3b82x) ──────────────────────────
860
861    #[test]
862    fn ratio_nan_clamped_to_zero() {
863        let pb = ProgressBar::new().ratio(f64::NAN);
864        // NaN.clamp(0.0, 1.0) returns NaN in Rust, but check it doesn't panic
865        // The render path uses floor() which handles NaN → 0
866        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); // Should not panic, truncated
890    }
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        // label_y = top + height/2 = 0 + 2 = 2
900        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); // Should not panic
911    }
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        // floor(100 * 0.01) = 1 cell filled
952        assert_eq!(cell_at(&frame, 0, 0).bg, PackedRgba::GREEN);
953        assert_ne!(cell_at(&frame, 1, 0).bg, PackedRgba::GREEN);
954    }
955
956    // --- MiniBar edge cases ---
957
958    #[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); // NaN/Inf → normalized_value returns 0.0
970    }
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); // clamped to 1.0
984    }
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        // Default high threshold is 0.75; at exactly 0.75, value is NOT > 0.75
1020        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); // not above high threshold
1025    }
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); // not above mid threshold
1032    }
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); // not above low threshold
1039    }
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); // NaN → 0.0 → critical
1046    }
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); // Should not panic
1095    }
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        // Area smaller than bar_width + percent_width
1103        let area = Rect::new(0, 0, 5, 1);
1104        Widget::render(&bar, area, &mut frame); // Should adapt or truncate
1105    }
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        // Area of width 5, percent takes 5 (" XXX%"), bar_width gets 0
1113        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; // Copy
1137        assert_eq!(c.high, c2.high);
1138    }
1139
1140    #[test]
1141    fn minibar_thresholds_copy() {
1142        let t = MiniBarThresholds::default();
1143        let t2 = t; // Copy
1144        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}