use crate::status::{Status, Usage};
use crate::theme::{BOLD, RESET, Theme, fg};
const GAUGE_WIDTH: u32 = 5;
const MAX_LEVEL: u8 = 5;
pub fn render(st: &Status, th: &Theme) -> String {
let mut level = 0;
let mut line = build(st, th, level);
if st.cols > 0 {
while visible_len(&line) > st.cols && level < MAX_LEVEL {
level += 1;
line = build(st, th, level);
}
}
line
}
pub fn build(st: &Status, th: &Theme, level: u8) -> String {
let mut model = st.model.clone();
if level >= 1
&& let Some(i) = model.find(" (")
{
model.truncate(i);
}
let mut dir = display_dir(&st.cwd, &st.home);
if level >= 2 {
dir = dir.rsplit('/').next().unwrap_or(&dir).to_string();
}
let show_reset = level < 3;
let user = if level >= 4 { "" } else { st.username.as_str() };
let branch = if level >= 5 {
None
} else {
st.branch.as_deref().map(shorten_branch)
};
let dim = fg(th.dim);
let sep = format!("{dim} · {RESET}");
let mut line = String::new();
if !user.is_empty() {
line.push_str(&format!("{}{user} {RESET}", fg(th.user)));
}
line.push_str(&format!("{}{dir}{}", fg(th.path), fg(th.branch)));
if let Some(b) = branch {
line.push_str(&format!(" ( {b} )"));
}
line.push_str(RESET);
if !model.is_empty() {
line.push_str(&format!("{sep}{BOLD}{}{model}{RESET}", fg(th.model)));
}
if let Some(p) = st.ctx_pct {
line.push_str(&format!("{sep}{dim}ctx {}{p}%{RESET}", th.grad_color(p)));
}
for (label, usage) in [("5h", st.five), ("wk", st.week)] {
let Some(Usage { pct, reset_secs }) = usage else {
continue;
};
line.push_str(&format!(
"{sep}{dim}{label} {RESET}{} {}{pct}%{RESET}",
gauge(th, pct),
th.grad_color(pct)
));
if show_reset && let Some(secs) = reset_secs {
line.push_str(&format!(" {dim}({}){RESET}", format_time_left(secs)));
}
}
line
}
fn display_dir(cwd: &str, home: &str) -> String {
if !home.is_empty() && cwd.starts_with(home) {
cwd.replacen(home, "~", 1)
} else {
cwd.to_string()
}
}
fn shorten_branch(b: &str) -> String {
if b.chars().count() > 15 {
let mut parts = b.split('-');
if let (Some(a), Some(c)) = (parts.next(), parts.next())
&& !c.is_empty()
{
return format!("{a}-{c}");
}
}
b.to_string()
}
fn gauge(th: &Theme, pct: u32) -> String {
let filled = ((pct.min(100) * GAUGE_WIDTH + 50) / 100).min(GAUGE_WIDTH);
let mut out = String::new();
for i in 0..GAUGE_WIDTH {
if i < filled {
out.push_str(&th.grad_color((i * 2 + 1) * 100 / (GAUGE_WIDTH * 2)));
out.push('█');
} else {
out.push_str(&fg(th.dim));
out.push('░');
}
}
out.push_str(RESET);
out
}
pub fn format_time_left(secs: u64) -> String {
let h = secs / 3600;
let m = (secs % 3600) / 60;
if h >= 24 {
format!("{}d{}h", h / 24, h % 24)
} else if h > 0 {
format!("{h}h{m:02}m")
} else {
format!("{m}m")
}
}
pub fn visible_len(s: &str) -> usize {
let mut len = 0;
let mut in_ansi = false;
for c in s.chars() {
if c == '\x1b' {
in_ansi = true;
} else if in_ansi {
if c.is_ascii_alphabetic() {
in_ansi = false;
}
} else {
len += 1;
}
}
len
}
#[cfg(test)]
mod tests {
use super::*;
fn strip(s: &str) -> String {
let mut out = String::new();
let mut in_ansi = false;
for c in s.chars() {
if c == '\x1b' {
in_ansi = true;
} else if in_ansi {
if c.is_ascii_alphabetic() {
in_ansi = false;
}
} else {
out.push(c);
}
}
out
}
fn sample(cols: usize) -> Status {
Status {
username: "ash".into(),
home: "/Users/ash".into(),
cwd: "/Users/ash/repos/grav-bar".into(),
branch: Some("feature-long-branch-name".into()),
model: "Fable 5.1 (1M context)".into(),
ctx_pct: Some(4),
five: Some(Usage {
pct: 37,
reset_secs: Some(8100),
}),
week: Some(Usage {
pct: 62,
reset_secs: Some(200_000),
}),
cols,
}
}
#[test]
fn full_line_matches_bash_layout() {
let line = render(&sample(0), Theme::default_theme());
assert_eq!(
strip(&line),
"ash ~/repos/grav-bar ( feature-long ) · Fable 5.1 (1M context) · ctx 4% · 5h ██░░░ 37% (2h15m) · wk ███░░ 62% (2d7h)"
);
}
#[test]
fn compaction_levels_shrink_monotonically() {
let st = sample(0);
let th = Theme::default_theme();
let mut prev = usize::MAX;
for level in 0..=MAX_LEVEL {
let len = visible_len(&build(&st, th, level));
assert!(len < prev, "level {level} did not shrink: {len} >= {prev}");
prev = len;
}
assert_eq!(
strip(&build(&st, th, MAX_LEVEL)),
"grav-bar · Fable 5.1 · ctx 4% · 5h ██░░░ 37% · wk ███░░ 62%"
);
}
#[test]
fn render_stops_at_first_fitting_level() {
let th = Theme::default_theme();
let wide = render(&sample(200), th);
assert_eq!(visible_len(&wide), visible_len(&build(&sample(0), th, 0)));
let narrow = render(&sample(76), th);
assert_eq!(visible_len(&narrow), 76);
assert_eq!(
strip(&narrow),
"grav-bar ( feature-long ) · Fable 5.1 · ctx 4% · 5h ██░░░ 37% · wk ███░░ 62%"
);
let tiny = render(&sample(10), th);
assert_eq!(strip(&tiny), strip(&build(&sample(0), th, MAX_LEVEL)));
}
#[test]
fn missing_segments_are_omitted() {
let mut st = sample(0);
st.branch = None;
st.ctx_pct = None;
st.five = None;
st.week = None;
st.model = String::new();
assert_eq!(
strip(&render(&st, Theme::default_theme())),
"ash ~/repos/grav-bar"
);
}
#[test]
fn gauge_fill_counts() {
let th = Theme::default_theme();
let cells = |pct| strip(&gauge(th, pct)).matches('█').count();
assert_eq!(cells(0), 0);
assert_eq!(cells(9), 0);
assert_eq!(cells(10), 1);
assert_eq!(cells(50), 3);
assert_eq!(cells(89), 4);
assert_eq!(cells(90), 5);
assert_eq!(cells(100), 5);
assert_eq!(strip(&gauge(th, 100)).chars().count(), 5);
}
#[test]
fn time_left_formats() {
assert_eq!(format_time_left(47 * 60), "47m");
assert_eq!(format_time_left(2 * 3600 + 5 * 60), "2h05m");
assert_eq!(format_time_left(8100), "2h15m");
assert_eq!(format_time_left(200_000), "2d7h");
assert_eq!(format_time_left(24 * 3600), "1d0h");
assert_eq!(format_time_left(30), "0m");
}
#[test]
fn branch_shortening() {
assert_eq!(shorten_branch("main"), "main");
assert_eq!(shorten_branch("feature-long-branch-name"), "feature-long");
assert_eq!(
shorten_branch("averyveryverylongbranch"),
"averyveryverylongbranch"
);
assert_eq!(shorten_branch("exactly-fifteen"), "exactly-fifteen");
}
#[test]
fn visible_len_ignores_truecolor_sequences() {
assert_eq!(visible_len("\x1b[38;2;1;2;3mab\x1b[0m·█"), 4);
}
}