Skip to main content

ftui_widgets/
progress.rs

1#![forbid(unsafe_code)]
2
3//! Progress bar widget.
4
5use 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/// A widget to display a progress bar.
16#[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    /// Create a new progress bar with default settings.
27    #[must_use]
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Set the surrounding block.
33    #[must_use]
34    pub fn block(mut self, block: Block<'a>) -> Self {
35        self.block = Some(block);
36        self
37    }
38
39    /// Set the progress ratio (clamped to 0.0..=1.0).
40    #[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    /// Set the centered label text.
51    #[must_use]
52    pub fn label(mut self, label: &'a str) -> Self {
53        self.label = Some(label);
54        self
55    }
56
57    /// Set the base style.
58    #[must_use]
59    pub fn style(mut self, style: Style) -> Self {
60        self.style = style;
61        self
62    }
63
64    /// Set the filled portion style.
65    #[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        // Skeleton+: skip entirely
88        if !deg.render_content() {
89            return;
90        }
91
92        // EssentialOnly: just show percentage text, no bar
93        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        // Draw filled part
128        let gauge_style = if deg.apply_styling() {
129            self.gauge_style
130        } else {
131            // At NoStyling, use '#' as fill char instead of background color
132            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        // Draw label (centered)
148        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        // ProgressBar fills available width, has fixed height of 1 (or block inner height)
175        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        // Minimum: 1 cell for bar + block overhead
187        // Preferred: fills available width, 1 row + block overhead
188        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), // Fills width, so preferred = min
194            max: None,                                   // Can grow to fill available space
195        }
196    }
197
198    fn has_intrinsic_size(&self) -> bool {
199        // ProgressBar fills width, so it doesn't have true intrinsic width
200        // but it does have intrinsic height
201        true
202    }
203}
204
205// ---------------------------------------------------------------------------
206// Accessibility
207// ---------------------------------------------------------------------------
208
209impl 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// ---------------------------------------------------------------------------
237// MiniBar
238// ---------------------------------------------------------------------------
239
240/// Color thresholds for [`MiniBar`].
241#[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/// Thresholds for mapping values to colors.
272#[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/// Compact progress indicator for dashboard-style metrics.
290#[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    /// Create a new MiniBar with value in the 0.0..=1.0 range.
304    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    /// Override the value (clamped to 0.0..=1.0).
318    #[must_use]
319    pub fn value(mut self, value: f64) -> Self {
320        self.value = value;
321        self
322    }
323
324    /// Override the displayed width.
325    #[must_use]
326    pub fn width(mut self, width: u16) -> Self {
327        self.width = width;
328        self
329    }
330
331    /// Enable or disable percentage text.
332    #[must_use]
333    pub fn show_percent(mut self, show: bool) -> Self {
334        self.show_percent = show;
335        self
336    }
337
338    /// Set the base style for the bar.
339    #[must_use]
340    pub fn style(mut self, style: Style) -> Self {
341        self.style = style;
342        self
343    }
344
345    /// Override the filled block character.
346    #[must_use]
347    pub fn filled_char(mut self, ch: char) -> Self {
348        self.filled_char = ch;
349        self
350    }
351
352    /// Override the empty block character.
353    #[must_use]
354    pub fn empty_char(mut self, ch: char) -> Self {
355        self.empty_char = ch;
356        self
357    }
358
359    /// Override the color thresholds.
360    #[must_use]
361    pub fn thresholds(mut self, thresholds: MiniBarThresholds) -> Self {
362        self.thresholds = thresholds;
363        self
364    }
365
366    /// Override the color palette.
367    #[must_use]
368    pub fn colors(mut self, colors: MiniBarColors) -> Self {
369        self.colors = colors;
370        self
371    }
372
373    /// Map a value to a color using default thresholds.
374    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    /// Render the bar as a string (for testing/debugging).
391    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        // MiniBar has fixed dimensions
571        let percent_width = if self.show_percent { 5 } else { 0 }; // " XXX%"
572        let total_width = self.width.saturating_add(percent_width);
573
574        SizeConstraints {
575            min: Size::new(1, 1), // At least show something
576            preferred: Size::new(total_width, 1),
577            max: Some(Size::new(total_width, 1)), // Fixed size
578        }
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    // --- Builder tests ---
611
612    #[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    // --- Rendering tests ---
645
646    #[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        // Should not panic
654    }
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        // No cells should have the gauge style bg
666        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        // All cells should have gauge bg
686        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        // About 5 cells should be filled (10 * 0.5 = 5)
706        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        // All 3 rows should be filled
722        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        // Label "50%" is 3 chars wide, centered in 10 = starts at x=3
743        // (10 - 3) / 2 = 3
744        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        // Block::bordered() includes 1 cell padding on each side, so we need
759        // at least 5 rows to have a 1-row inner content area.
760        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        // Inner area is 6x1 (borders + padding take 2 on each side)
766        // All inner cells should have gauge bg
767        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    // --- Degradation tests ---
778
779    #[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        // Nothing should be rendered
793        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        // Should show "50%" text, no gauge bar
815        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        // No gauge background color
819        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        // All cells should have gauge bg
836        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    // --- MiniBar tests ---
877
878    #[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    // --- MeasurableWidget tests ---
919
920    #[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()); // Fills available width
934    }
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        // Block adds chrome (borders + padding) = 4 on each axis.
942        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        // Width = 10 + 5 (" XXX%") = 15
962        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    // ── Edge-case tests (bd-3b82x) ──────────────────────────
976
977    #[test]
978    fn ratio_nan_clamped_to_zero() {
979        let pb = ProgressBar::new().ratio(f64::NAN);
980        // We now safely handle NaN in the ratio setter to avoid clamping panic.
981        // The render path uses floor() which handles NaN → 0
982        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); // Should not panic, truncated
1006    }
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        // label_y = top + height/2 = 0 + 2 = 2
1016        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); // Should not panic
1027    }
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        // floor(100 * 0.01) = 1 cell filled
1068        assert_eq!(cell_at(&frame, 0, 0).bg, PackedRgba::GREEN);
1069        assert_ne!(cell_at(&frame, 1, 0).bg, PackedRgba::GREEN);
1070    }
1071
1072    // --- MiniBar edge cases ---
1073
1074    #[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); // NaN/Inf → normalized_value returns 0.0
1086    }
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); // clamped to 1.0
1100    }
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        // Default high threshold is 0.75; at exactly 0.75, value is NOT > 0.75
1136        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); // not above high threshold
1141    }
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); // not above mid threshold
1148    }
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); // not above low threshold
1155    }
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); // NaN → 0.0 → critical
1162    }
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); // Should not panic
1211    }
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        // Area smaller than bar_width + percent_width
1219        let area = Rect::new(0, 0, 5, 1);
1220        Widget::render(&bar, area, &mut frame); // Should adapt or truncate
1221    }
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        // Area of width 5, percent takes 5 (" XXX%"), bar_width gets 0
1229        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; // Copy
1314        assert_eq!(c.high, c2.high);
1315    }
1316
1317    #[test]
1318    fn minibar_thresholds_copy() {
1319        let t = MiniBarThresholds::default();
1320        let t2 = t; // Copy
1321        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}