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;
let mut rest = s;
while let Some(ch) = rest.chars().next() {
let step = match ch {
'<' => {
depth += 1;
ch.len_utf8()
}
'>' if depth > 0 => {
depth -= 1;
ch.len_utf8()
}
'&' if depth == 0 => {
count += 1;
entity_len(rest).unwrap_or(ch.len_utf8())
}
_ => {
if depth == 0 {
count += 1;
}
ch.len_utf8()
}
};
rest = &rest[step..];
}
count
}
fn entity_len(s: &str) -> Option<usize> {
let body = s.strip_prefix('&')?;
let end = body.find(';')?;
if end == 0 {
return None;
}
let name = &body[..end];
let named = matches!(name, "amp" | "lt" | "gt" | "quot" | "apos");
let numeric = name
.strip_prefix("#x")
.and_then(|digits| parse_xml_char(digits, 16))
.or_else(|| {
name.strip_prefix('#')
.and_then(|digits| parse_xml_char(digits, 10))
})
.is_some();
(named || numeric).then_some(1 + end + 1)
}
fn parse_xml_char(digits: &str, radix: u32) -> Option<u32> {
if digits.is_empty() {
return None;
}
let value = u32::from_str_radix(digits, radix).ok()?;
matches!(
value,
0x9 | 0xA | 0xD | 0x20..=0xD7FF | 0xE000..=0xFFFD | 0x10000..=0x10FFFF
)
.then_some(value)
}
#[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);
}
#[test]
fn visible_width_counts_an_entity_as_one_glyph() {
assert_eq!(visible_width("&"), 1);
assert_eq!(visible_width("Claude & GPT"), 12);
assert_eq!(visible_width(escape("Claude & GPT").as_str()), 12);
assert_eq!(visible_width("<>"'"), 4);
assert_eq!(visible_width("&"), 1);
assert_eq!(visible_width("&"), 1);
assert_eq!(visible_width("&"), 1);
assert_eq!(visible_width("<span>a & b</span>"), 5);
}
#[test]
fn visible_width_treats_a_bare_ampersand_as_one_glyph() {
assert_eq!(visible_width("a & b"), 5);
assert_eq!(visible_width("&"), 1);
assert_eq!(visible_width("&;"), 2);
assert_eq!(visible_width("&nope;"), 6);
assert_eq!(visible_width("&"), 5);
assert_eq!(visible_width("&#GG;"), 5);
assert_eq!(visible_width("�"), 4);
assert_eq!(visible_width("�"), 10);
assert_eq!(visible_width("¬anentityatall;"), 18);
}
#[test]
fn escaped_and_plain_labels_measure_the_same() {
for label in ["Claude & GPT (weekly)", "a<b>c", "quote\"d", "plain"] {
assert_eq!(
visible_width(escape(label).as_str()),
label.chars().count(),
"{label}"
);
}
}
}