photon_ui/components/
progress_bar.rs1use crate::{
7 Component,
8 RenderError,
9 Rendered,
10 theme::{
11 Palette,
12 Style,
13 Theme,
14 stylize,
15 },
16};
17
18pub struct ProgressBar {
24 label: String,
25 value: f32,
26 width: u16,
27 show_percent: bool,
28}
29
30impl ProgressBar {
31 pub fn new(label: impl Into<String>, value: f32) -> Self {
35 Self {
36 label: label.into(),
37 value: value.clamp(0.0, 1.0),
38 width: 20,
39 show_percent: true,
40 }
41 }
42
43 pub fn width(mut self, width: u16) -> Self {
45 self.width = width;
46 self
47 }
48
49 pub fn hide_percent(mut self) -> Self {
51 self.show_percent = false;
52 self
53 }
54}
55
56impl Component for ProgressBar {
57 fn render(&self, _width: u16) -> Result<Rendered, RenderError> {
58 let theme = Theme::current();
59 let accent_style = Style::new().fg(theme.accent());
60 let empty_style = Style::new().fg(theme.border_default());
61
62 let inner_width = self.width.saturating_sub(2) as usize;
63 let filled = (self.value * inner_width as f32).round() as usize;
64 let filled = filled.min(inner_width);
65 let empty = inner_width.saturating_sub(filled);
66
67 let filled_str = "█".repeat(filled);
68 let empty_str = "░".repeat(empty);
69
70 let filled_styled = stylize(&filled_str, &accent_style);
71 let empty_styled = stylize(&empty_str, &empty_style);
72
73 let bar = format!("[{}{}]", filled_styled, empty_styled);
74
75 let mut line = if self.label.is_empty() {
76 bar
77 } else {
78 format!("{} {}", self.label, bar)
79 };
80
81 if self.show_percent {
82 let percent = (self.value * 100.0).round() as u8;
83 line.push_str(&format!(" {}%", percent));
84 }
85
86 Ok(Rendered {
87 lines: vec![line],
88 cursor: None,
89 images: Vec::new(),
90 })
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use crate::theme::Theme;
98
99 #[test]
100 fn renders_with_percent() {
101 Theme::with(Theme::Light, || {
102 let pb = ProgressBar::new("", 0.6).width(10);
103 let rendered = pb.render(80).unwrap();
104 assert_eq!(rendered.lines.len(), 1);
105 let line = &rendered.lines[0];
106 assert!(line.contains('['));
107 assert!(line.contains(']'));
108 assert!(line.contains("60%"));
109 });
110 }
111
112 #[test]
113 fn hides_percent() {
114 Theme::with(Theme::Light, || {
115 let pb = ProgressBar::new("", 0.6).width(10).hide_percent();
116 let rendered = pb.render(80).unwrap();
117 assert!(!rendered.lines[0].contains('%'));
118 });
119 }
120
121 #[test]
122 fn label_is_prepended() {
123 Theme::with(Theme::Light, || {
124 let pb = ProgressBar::new("Loading", 0.5).width(10);
125 let rendered = pb.render(80).unwrap();
126 assert!(rendered.lines[0].starts_with("Loading "));
127 });
128 }
129
130 #[test]
131 fn value_is_clamped() {
132 Theme::with(Theme::Light, || {
133 let pb = ProgressBar::new("", 1.5).width(10);
134 let rendered = pb.render(80).unwrap();
135 assert!(rendered.lines[0].contains("100%"));
136 });
137 }
138
139 #[test]
140 fn zero_value_renders_empty() {
141 Theme::with(Theme::Light, || {
142 let pb = ProgressBar::new("", 0.0).width(10);
143 let rendered = pb.render(80).unwrap();
144 let line = &rendered.lines[0];
145 let start = line.find('[').unwrap();
146 let end = line.find(']').unwrap();
147 let inner = &line[start + 1..end];
148 assert!(!inner.contains('█'));
150 });
151 }
152
153 #[test]
154 fn full_value_renders_full() {
155 Theme::with(Theme::Light, || {
156 let pb = ProgressBar::new("", 1.0).width(10);
157 let rendered = pb.render(80).unwrap();
158 let line = &rendered.lines[0];
159 let start = line.find('[').unwrap();
160 let end = line.find(']').unwrap();
161 let inner = &line[start + 1..end];
162 assert!(!inner.contains('░'));
164 });
165 }
166
167 #[test]
168 fn uses_accent_color() {
169 Theme::with(Theme::Light, || {
170 let pb = ProgressBar::new("", 0.5).width(10);
171 let rendered = pb.render(80).unwrap();
172 assert!(rendered.lines[0].contains("\x1b[38;2;250;82;15m"));
174 });
175 }
176}