use crate::pacing::PaceSeverity;
use crate::theme::Theme;
pub const BAR_LEN: u32 = 20;
const FILLED: char = '█';
const EMPTY: char = '░';
pub fn color_span(color: &str, text: &str) -> String {
format!("<span foreground='{color}'>{text}</span>")
}
pub fn escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
pub fn severity_for(pct: i32) -> PaceSeverity {
if pct >= 90 {
PaceSeverity::Critical
} else if pct >= 75 {
PaceSeverity::High
} else if pct >= 50 {
PaceSeverity::Mid
} else {
PaceSeverity::Low
}
}
pub fn severity_color(sev: PaceSeverity, theme: &Theme) -> &str {
match sev {
PaceSeverity::Low => &theme.green,
PaceSeverity::Mid => &theme.yellow,
PaceSeverity::High => &theme.orange,
PaceSeverity::Critical => &theme.red,
}
}
pub fn progress_bar(pct: i32, fill_color: &str, theme: &Theme, marker_pct: Option<i32>) -> String {
let pct = pct.clamp(0, 100) as u32;
let bar_len = BAR_LEN;
let filled = (pct * bar_len) / 100;
let Some(marker) = marker_pct.map(|p| p.clamp(0, 100) as u32) else {
let empty = bar_len - filled;
return format!(
"<span foreground='{fill_color}'>{f}</span><span foreground='{empty_color}'>{e}</span>",
f = repeat_char(FILLED, filled),
e = repeat_char(EMPTY, empty),
empty_color = theme.bar_empty,
);
};
let mut m = (marker * bar_len) / 100;
if m > bar_len - 1 {
m = bar_len - 1;
}
let pre_f = filled.min(m);
let post_f = if filled > m + 1 { filled - m - 1 } else { 0 };
let pre_e = m - pre_f;
let post_e = bar_len - m - 1 - post_f;
let mut out = String::with_capacity(256);
out.push_str(&format!(
"<span foreground='{fill_color}'>{}</span>",
repeat_char(FILLED, pre_f)
));
out.push_str(&format!(
"<span foreground='{}'>{}</span>",
theme.bar_empty,
repeat_char(EMPTY, pre_e)
));
out.push_str(&format!(
"<span foreground='{}'>{}</span>",
theme.marker, FILLED
));
out.push_str(&format!(
"<span foreground='{fill_color}'>{}</span>",
repeat_char(FILLED, post_f)
));
out.push_str(&format!(
"<span foreground='{}'>{}</span>",
theme.bar_empty,
repeat_char(EMPTY, post_e)
));
out
}
fn repeat_char(c: char, n: u32) -> String {
std::iter::repeat_n(c, n as usize).collect()
}
pub fn visible_width(s: &str) -> usize {
let mut depth = 0usize;
let mut count = 0usize;
for ch in s.chars() {
match ch {
'<' => depth += 1,
'>' if depth > 0 => depth = depth.saturating_sub(1),
_ if depth == 0 => count += 1,
_ => {}
}
}
count
}
#[cfg(test)]
mod tests {
use super::*;
fn theme() -> Theme {
Theme::default()
}
#[test]
fn severity_thresholds_match_claudebar() {
assert_eq!(severity_for(0), PaceSeverity::Low);
assert_eq!(severity_for(49), PaceSeverity::Low);
assert_eq!(severity_for(50), PaceSeverity::Mid);
assert_eq!(severity_for(74), PaceSeverity::Mid);
assert_eq!(severity_for(75), PaceSeverity::High);
assert_eq!(severity_for(89), PaceSeverity::High);
assert_eq!(severity_for(90), PaceSeverity::Critical);
assert_eq!(severity_for(100), PaceSeverity::Critical);
}
#[test]
fn color_span_wraps_pango() {
assert_eq!(
color_span("#ff0000", "hi"),
"<span foreground='#ff0000'>hi</span>"
);
}
#[test]
fn escape_handles_markup_chars() {
assert_eq!(escape("a < b & c > d"), "a < b & c > d");
}
#[test]
fn bar_zero_pct_is_all_empty() {
let b = progress_bar(0, "#000000", &theme(), None);
assert_eq!(b.matches('░').count(), BAR_LEN as usize);
assert_eq!(b.matches('█').count(), 0);
}
#[test]
fn bar_hundred_pct_is_all_filled() {
let b = progress_bar(100, "#ff0000", &theme(), None);
assert_eq!(b.matches('█').count(), BAR_LEN as usize);
assert_eq!(b.matches('░').count(), 0);
}
#[test]
fn bar_clamps_overflow() {
let b = progress_bar(150, "#ff0000", &theme(), None);
assert_eq!(b.matches('█').count(), BAR_LEN as usize);
}
#[test]
fn bar_fifty_pct_splits_evenly() {
let b = progress_bar(50, "#ff0000", &theme(), None);
assert_eq!(b.matches('█').count(), 10);
assert_eq!(b.matches('░').count(), 10);
}
#[test]
fn bar_with_marker_keeps_total_width() {
let b = progress_bar(50, "#ff0000", &theme(), Some(50));
assert!(b.contains("#ff0000"));
assert!(b.contains(&theme().marker));
assert_eq!(visible_width(&b), BAR_LEN as usize);
}
#[test]
fn bar_marker_at_zero_is_renderable() {
let b = progress_bar(0, "#ff0000", &theme(), Some(0));
assert_eq!(visible_width(&b), BAR_LEN as usize);
}
#[test]
fn bar_marker_at_hundred_is_renderable() {
let b = progress_bar(100, "#ff0000", &theme(), Some(100));
assert_eq!(visible_width(&b), BAR_LEN as usize);
assert_eq!(b.matches('█').count(), BAR_LEN as usize);
assert_eq!(b.matches('░').count(), 0);
}
#[test]
fn visible_width_strips_tags() {
assert_eq!(visible_width("<span foreground='#fff'>hello</span>"), 5);
assert_eq!(visible_width("a<x>b</x>c"), 3);
assert_eq!(visible_width("plain text"), 10);
}
#[test]
fn visible_width_handles_nested_tags() {
assert_eq!(visible_width("<a><b>xy</b></a>"), 2);
}
}