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