1use crate::pacing::PaceSeverity;
13use crate::theme::Theme;
14
15pub const BAR_LEN: u32 = 20;
17
18const FILLED: char = '█';
19const EMPTY: char = '░';
20
21pub fn color_span(color: &str, text: &str) -> String {
26 format!("<span foreground='{color}'>{text}</span>")
27}
28
29pub fn escape(s: &str) -> String {
31 s.replace('&', "&")
32 .replace('<', "<")
33 .replace('>', ">")
34}
35
36pub fn severity_for(pct: i32) -> PaceSeverity {
41 if pct >= 90 {
42 PaceSeverity::Critical
43 } else if pct >= 75 {
44 PaceSeverity::High
45 } else if pct >= 50 {
46 PaceSeverity::Mid
47 } else {
48 PaceSeverity::Low
49 }
50}
51
52pub fn severity_color(sev: PaceSeverity, theme: &Theme) -> &str {
54 match sev {
55 PaceSeverity::Low => &theme.green,
56 PaceSeverity::Mid => &theme.yellow,
57 PaceSeverity::High => &theme.orange,
58 PaceSeverity::Critical => &theme.red,
59 }
60}
61
62pub fn progress_bar(pct: i32, fill_color: &str, theme: &Theme, marker_pct: Option<i32>) -> String {
75 let pct = pct.clamp(0, 100) as u32;
76 let bar_len = BAR_LEN;
77 let filled = (pct * bar_len) / 100;
78
79 let Some(marker) = marker_pct.map(|p| p.clamp(0, 100) as u32) else {
80 let empty = bar_len - filled;
82 return format!(
83 "<span foreground='{fill_color}'>{f}</span><span foreground='{empty_color}'>{e}</span>",
84 f = repeat_char(FILLED, filled),
85 e = repeat_char(EMPTY, empty),
86 empty_color = theme.bar_empty,
87 );
88 };
89
90 let mut m = (marker * bar_len) / 100;
92 if m > bar_len - 1 {
93 m = bar_len - 1;
94 }
95 let pre_f = filled.min(m);
96 let post_f = if filled > m + 1 { filled - m - 1 } else { 0 };
97 let pre_e = m - pre_f;
98 let post_e = bar_len - m - 1 - post_f;
99
100 let mut out = String::with_capacity(256);
101 out.push_str(&format!(
103 "<span foreground='{fill_color}'>{}</span>",
104 repeat_char(FILLED, pre_f)
105 ));
106 out.push_str(&format!(
107 "<span foreground='{}'>{}</span>",
108 theme.bar_empty,
109 repeat_char(EMPTY, pre_e)
110 ));
111 out.push_str(&format!(
113 "<span foreground='{}'>{}</span>",
114 theme.marker, FILLED
115 ));
116 out.push_str(&format!(
118 "<span foreground='{fill_color}'>{}</span>",
119 repeat_char(FILLED, post_f)
120 ));
121 out.push_str(&format!(
122 "<span foreground='{}'>{}</span>",
123 theme.bar_empty,
124 repeat_char(EMPTY, post_e)
125 ));
126 out
127}
128
129fn repeat_char(c: char, n: u32) -> String {
130 std::iter::repeat_n(c, n as usize).collect()
131}
132
133pub fn visible_width(s: &str) -> usize {
137 let mut depth = 0usize;
138 let mut count = 0usize;
139 for ch in s.chars() {
140 match ch {
141 '<' => depth += 1,
142 '>' if depth > 0 => depth = depth.saturating_sub(1),
143 _ if depth == 0 => count += 1,
144 _ => {}
145 }
146 }
147 count
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn theme() -> Theme {
155 Theme::default()
156 }
157
158 #[test]
159 fn severity_thresholds_match_claudebar() {
160 assert_eq!(severity_for(0), PaceSeverity::Low);
161 assert_eq!(severity_for(49), PaceSeverity::Low);
162 assert_eq!(severity_for(50), PaceSeverity::Mid);
163 assert_eq!(severity_for(74), PaceSeverity::Mid);
164 assert_eq!(severity_for(75), PaceSeverity::High);
165 assert_eq!(severity_for(89), PaceSeverity::High);
166 assert_eq!(severity_for(90), PaceSeverity::Critical);
167 assert_eq!(severity_for(100), PaceSeverity::Critical);
168 }
169
170 #[test]
171 fn color_span_wraps_pango() {
172 assert_eq!(
173 color_span("#ff0000", "hi"),
174 "<span foreground='#ff0000'>hi</span>"
175 );
176 }
177
178 #[test]
179 fn escape_handles_markup_chars() {
180 assert_eq!(escape("a < b & c > d"), "a < b & c > d");
182 }
183
184 #[test]
185 fn bar_zero_pct_is_all_empty() {
186 let b = progress_bar(0, "#000000", &theme(), None);
187 assert_eq!(b.matches('░').count(), BAR_LEN as usize);
189 assert_eq!(b.matches('█').count(), 0);
190 }
191
192 #[test]
193 fn bar_hundred_pct_is_all_filled() {
194 let b = progress_bar(100, "#ff0000", &theme(), None);
195 assert_eq!(b.matches('█').count(), BAR_LEN as usize);
196 assert_eq!(b.matches('░').count(), 0);
197 }
198
199 #[test]
200 fn bar_clamps_overflow() {
201 let b = progress_bar(150, "#ff0000", &theme(), None);
202 assert_eq!(b.matches('█').count(), BAR_LEN as usize);
203 }
204
205 #[test]
206 fn bar_fifty_pct_splits_evenly() {
207 let b = progress_bar(50, "#ff0000", &theme(), None);
208 assert_eq!(b.matches('█').count(), 10);
209 assert_eq!(b.matches('░').count(), 10);
210 }
211
212 #[test]
213 fn bar_with_marker_keeps_total_width() {
214 let b = progress_bar(50, "#ff0000", &theme(), Some(50));
218 assert!(b.contains("#ff0000"));
219 assert!(b.contains(&theme().marker));
220 assert_eq!(visible_width(&b), BAR_LEN as usize);
221 }
222
223 #[test]
224 fn bar_marker_at_zero_is_renderable() {
225 let b = progress_bar(0, "#ff0000", &theme(), Some(0));
227 assert_eq!(visible_width(&b), BAR_LEN as usize);
228 }
229
230 #[test]
231 fn bar_marker_at_hundred_is_renderable() {
232 let b = progress_bar(100, "#ff0000", &theme(), Some(100));
235 assert_eq!(visible_width(&b), BAR_LEN as usize);
236 assert_eq!(b.matches('█').count(), BAR_LEN as usize);
238 assert_eq!(b.matches('░').count(), 0);
239 }
240
241 #[test]
242 fn visible_width_strips_tags() {
243 assert_eq!(visible_width("<span foreground='#fff'>hello</span>"), 5);
244 assert_eq!(visible_width("a<x>b</x>c"), 3);
245 assert_eq!(visible_width("plain text"), 10);
246 }
247
248 #[test]
249 fn visible_width_handles_nested_tags() {
250 assert_eq!(visible_width("<a><b>xy</b></a>"), 2);
251 }
252}