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