use crate::claude::ClaudeInput;
use crate::config::Config;
use crate::system::{NetThroughput, SystemSnapshot};
use crate::theme::{band_index, band_tint, parse_hex_pub, pick_emoji, pulse_color, ColorSpec};
struct Segment {
plain: String,
colored: String,
priority: u8,
}
pub fn render(
input: &ClaudeInput,
snap: &SystemSnapshot,
cfg: &Config,
now_ms: u128,
pulse_on: bool,
) -> String {
let color_on = std::env::var_os("NO_COLOR").is_none() && cfg.color.mode != "none";
let segments = collect_segments(input, snap, cfg, now_ms, pulse_on, color_on);
let kept = enforce_width(segments, cfg.display.max_width, &cfg.color.separator);
let separator = render_separator(cfg, color_on);
kept.iter()
.map(|segment| segment.colored.as_str())
.collect::<Vec<_>>()
.join(&separator)
}
fn collect_segments(
input: &ClaudeInput,
snap: &SystemSnapshot,
cfg: &Config,
now_ms: u128,
pulse_on: bool,
color_on: bool,
) -> Vec<Segment> {
let mut segments = Vec::new();
let glyph = pick_emoji(snap.cpu_percent, now_ms, pulse_on, cfg);
let glyph_color = glyph_tint(snap.cpu_percent, now_ms, pulse_on, cfg);
let cpu_value = format!("{:.0}%", snap.cpu_percent);
segments.push(Segment {
plain: format!("{glyph} {cpu_value}"),
colored: format!(
"{} {}",
tinted(&glyph, glyph_color, cfg, color_on),
cpu_value
),
priority: 100,
});
segments.push(label_value_segment(
"mem",
&format!("{:.0}%", snap.mem_percent),
90,
cfg,
color_on,
));
if cfg.display.show_battery {
if let Some(battery) = snap.battery.as_ref() {
segments.push(label_value_segment(
battery_marker(battery.percent, battery.is_charging),
&format!("{:.0}%", battery.percent),
85,
cfg,
color_on,
));
}
}
if cfg.display.show_disk {
if let Some(disk) = snap.disk_percent {
segments.push(label_value_segment(
"disk",
&format!("{disk:.0}%"),
80,
cfg,
color_on,
));
}
}
if cfg.display.show_network {
if let Some(net) = snap.net.as_ref() {
segments.push(net_segment(net, 75, cfg, color_on));
}
}
if cfg.display.show_model {
if let Some(model) = input.model_display_name.as_deref() {
if !model.is_empty() {
segments.push(value_segment(model, 60));
}
}
}
if cfg.display.show_context {
if let Some(context) = input.context_used_percentage {
segments.push(label_value_segment(
"ctx",
&format!("{context:.0}%"),
50,
cfg,
color_on,
));
}
}
if cfg.display.show_git {
if let Some(branch) = input.git_branch.as_deref() {
if !branch.is_empty() {
segments.push(label_value_segment("⎇", branch, 40, cfg, color_on));
}
}
}
if let Some(cwd) = input.cwd.as_deref() {
if let Some(dir) = cwd.rsplit('/').find(|part| !part.is_empty()) {
segments.push(value_segment(dir, 30));
}
}
if cfg.display.show_cost {
if let Some(cost) = input.cost_usd {
segments.push(label_value_segment(
"$",
&format!("{cost:.2}"),
20,
cfg,
color_on,
));
}
}
segments
}
fn glyph_tint(cpu_percent: f64, now_ms: u128, pulse_on: bool, cfg: &Config) -> ColorSpec {
pulse_color(cpu_percent, now_ms, pulse_on, cfg)
.unwrap_or_else(|| band_tint(band_index(cpu_percent, cfg), cfg))
}
fn label_value_segment(
label: &str,
value: &str,
priority: u8,
cfg: &Config,
color_on: bool,
) -> Segment {
Segment {
plain: format!("{label} {value}"),
colored: format!("{} {value}", dim_label(label, cfg, color_on)),
priority,
}
}
fn value_segment(value: &str, priority: u8) -> Segment {
Segment {
plain: value.to_string(),
colored: value.to_string(),
priority,
}
}
fn net_segment(net: &NetThroughput, priority: u8, cfg: &Config, color_on: bool) -> Segment {
let rx = format_bytes_per_sec(net.rx_bps);
let tx = format_bytes_per_sec(net.tx_bps);
Segment {
plain: format!("↓{rx} ↑{tx}"),
colored: format!(
"{}{rx} {}{tx}",
dim_label("↓", cfg, color_on),
dim_label("↑", cfg, color_on)
),
priority,
}
}
fn battery_marker(percent: f64, is_charging: bool) -> &'static str {
if is_charging {
"bat+" } else if percent <= 20.0 {
"bat!" } else {
"bat" }
}
fn format_bytes_per_sec(bps: f64) -> String {
if !bps.is_finite() || bps < 0.0 {
return "0B/s".to_string();
}
const KB: f64 = 1024.0;
const MB: f64 = 1024.0 * 1024.0;
if bps < KB {
format!("{:.0}B/s", bps)
} else if bps < MB {
format!("{:.0}KB/s", bps / KB)
} else {
format!("{:.1}MB/s", bps / MB)
}
}
fn enforce_width(mut segments: Vec<Segment>, max_width: usize, separator: &str) -> Vec<Segment> {
let sep_width = display_width(separator);
while segments.len() > 1 && composed_width(&segments, sep_width) > max_width {
let drop_index = segments
.iter()
.enumerate()
.min_by_key(|(_, segment)| segment.priority)
.map(|(index, _)| index)
.unwrap_or(segments.len() - 1);
segments.remove(drop_index);
}
segments
}
fn composed_width(segments: &[Segment], sep_width: usize) -> usize {
let text_width: usize = segments
.iter()
.map(|segment| display_width(&segment.plain))
.sum();
let separators = segments.len().saturating_sub(1);
text_width + separators * sep_width
}
fn display_width(text: &str) -> usize {
text.chars().map(char_width).sum()
}
fn char_width(c: char) -> usize {
let code = c as u32;
if c == '\u{200d}' || (0xFE00..=0xFE0F).contains(&code) {
return 0;
}
if is_wide(code) {
2
} else {
1
}
}
fn is_wide(code: u32) -> bool {
matches!(code,
0x1100..=0x115F | 0x2600..=0x27BF | 0x2E80..=0x303E | 0x3041..=0x33FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xA000..=0xA4CF | 0xAC00..=0xD7A3 | 0xF900..=0xFAFF | 0xFE30..=0xFE4F | 0xFF00..=0xFF60 | 0xFFE0..=0xFFE6
| 0x1F300..=0x1FAFF | 0x20000..=0x3FFFD )
}
fn tinted(glyph: &str, color: ColorSpec, cfg: &Config, color_on: bool) -> String {
if !color_on {
return glyph.to_string();
}
format!("{}{glyph}\x1b[0m", ansi_fg(color, cfg))
}
fn dim_label(label: &str, cfg: &Config, color_on: bool) -> String {
if !color_on {
return label.to_string();
}
let color = parse_hex_pub(&cfg.color.label_color).unwrap_or(ColorSpec {
r: 0x6b,
g: 0x72,
b: 0x80,
});
format!("{}{label}\x1b[0m", ansi_fg(color, cfg))
}
fn render_separator(cfg: &Config, color_on: bool) -> String {
let separator = &cfg.color.separator;
if !color_on {
return separator.clone();
}
let color = separator_spec(cfg);
format!("{}{separator}\x1b[0m", ansi_fg(color, cfg))
}
fn separator_spec(cfg: &Config) -> ColorSpec {
parse_hex_pub(&cfg.color.separator_color).unwrap_or(ColorSpec {
r: 0x3b,
g: 0x40,
b: 0x48,
})
}
fn ansi_fg(color: ColorSpec, cfg: &Config) -> String {
if use_truecolor(cfg) {
format!("\x1b[38;2;{};{};{}m", color.r, color.g, color.b)
} else {
format!("\x1b[38;5;{}m", nearest_xterm256(color))
}
}
fn use_truecolor(cfg: &Config) -> bool {
match cfg.color.mode.as_str() {
"truecolor" => true,
"256" => false,
_ => std::env::var("COLORTERM")
.map(|value| value == "truecolor" || value == "24bit")
.unwrap_or(false),
}
}
fn nearest_xterm256(color: ColorSpec) -> u8 {
let cube = |value: u8| -> (u8, u8) {
let steps = [0u8, 95, 135, 175, 215, 255];
let mut best_index = 0usize;
let mut best_distance = u16::MAX;
for (index, &step) in steps.iter().enumerate() {
let distance = (step as i16 - value as i16).unsigned_abs();
if distance < best_distance {
best_distance = distance;
best_index = index;
}
}
(best_index as u8, steps[best_index])
};
let (ri, _) = cube(color.r);
let (gi, _) = cube(color.g);
let (bi, _) = cube(color.b);
16 + 36 * ri + 6 * gi + bi
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn compose(self_segment: &str, chain_output: &str, order: &str) -> String {
compose_internal(self_segment, chain_output, order, " ")
}
pub fn compose_with_seam(
self_segment: &str,
chain_output: &str,
order: &str,
cfg: &Config,
color_on: bool,
) -> String {
if chain_output.trim_end_matches(['\n', '\r']).is_empty() {
return self_segment.to_string();
}
let seam = render_seam(cfg, color_on);
let joiner = format!(" {seam} ");
compose_internal(self_segment, chain_output, order, &joiner)
}
fn compose_internal(self_segment: &str, chain_output: &str, order: &str, joiner: &str) -> String {
let chain = chain_output.trim_end_matches(['\n', '\r']);
if chain.is_empty() {
return self_segment.to_string();
}
match order {
"chain_first" => format!("{chain}{joiner}{self_segment}"),
_ => format!("{self_segment}{joiner}{chain}"),
}
}
fn render_seam(cfg: &Config, color_on: bool) -> String {
let seam = &cfg.color.hud_seam;
if !color_on {
return seam.clone();
}
format!("{}{seam}\x1b[0m", ansi_fg(separator_spec(cfg), cfg))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::claude::ClaudeInput;
use crate::config::Config;
use crate::system::{BatteryInfo, NetThroughput, SystemSnapshot};
fn sample_input() -> ClaudeInput {
ClaudeInput {
model_display_name: Some("Opus".to_string()),
context_used_percentage: Some(42.0),
cwd: Some("/Users/dev/proj".to_string()),
git_branch: Some("main".to_string()),
cost_usd: Some(1.23),
session_id: Some("sess-1".to_string()),
}
}
fn sample_snap(cpu: f64) -> SystemSnapshot {
SystemSnapshot {
cpu_percent: cpu,
mem_percent: 55.0,
battery: None,
disk_percent: None,
net: None,
}
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn render_no_color_env_has_no_escape_bytes() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let mut cfg = Config::default();
cfg.color.mode = "truecolor".to_string();
unsafe { std::env::set_var("NO_COLOR", "1") };
let line = render(&sample_input(), &sample_snap(95.0), &cfg, 1_000, true);
unsafe { std::env::remove_var("NO_COLOR") };
assert!(
!line.contains('\x1b'),
"NO_COLOR 설정 시 ANSI ESC 바이트가 없어야 함: {line:?}"
);
assert!(line.contains("95%"));
}
#[test]
fn render_no_color_mode_has_no_escape_bytes() {
let mut cfg = Config::default();
cfg.color.mode = "none".to_string();
let line = render(&sample_input(), &sample_snap(95.0), &cfg, 1_000, true);
assert!(
!line.contains('\x1b'),
"mode=none이면 ANSI ESC 바이트가 없어야 함: {line:?}"
);
assert!(line.contains("95%"));
}
#[test]
fn render_has_no_bold_escape() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
unsafe { std::env::remove_var("NO_COLOR") };
let mut cfg = Config::default();
cfg.color.mode = "truecolor".to_string();
let mut snap = sample_snap(95.0);
snap.battery = Some(BatteryInfo {
percent: 80.0,
is_charging: false,
});
snap.disk_percent = Some(63.0);
snap.net = Some(NetThroughput {
rx_bps: 2048.0,
tx_bps: 512.0,
});
for pulse_on in [false, true] {
let line = render(&sample_input(), &snap, &cfg, 1_000, pulse_on);
assert!(
!line.contains("\x1b[1m"),
"BOLD 이스케이프(\\x1b[1m)가 없어야 함(pulse_on={pulse_on}): {line:?}"
);
}
}
#[test]
fn render_truecolor_has_escape_bytes() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
unsafe { std::env::remove_var("NO_COLOR") };
let mut cfg = Config::default();
cfg.color.mode = "truecolor".to_string();
let line = render(&sample_input(), &sample_snap(95.0), &cfg, 1_000, true);
assert!(
line.contains('\x1b'),
"truecolor 모드는 ANSI ESC 바이트를 포함해야 함: {line:?}"
);
assert!(
line.contains("\x1b[38;2;"),
"truecolor 38;2 시퀀스 필요: {line:?}"
);
assert!(
line.contains("\x1b[0m"),
"색을 입힌 조각은 리셋으로 닫혀야 함: {line:?}"
);
assert!(
line.ends_with("1.23"),
"마지막 값은 색 없이 출력되어야 함: {line:?}"
);
}
#[test]
fn render_color_once_value_has_no_escape() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
unsafe { std::env::remove_var("NO_COLOR") };
let mut cfg = Config::default();
cfg.color.mode = "truecolor".to_string();
let line = render(&sample_input(), &sample_snap(58.0), &cfg, 0, false);
let expected_glyph = "\x1b[38;2;134;160;180m▄\x1b[0m 58%";
assert!(
line.starts_with(expected_glyph),
"글리프엔 틴트, 값(58%)엔 색 없음: {line:?}"
);
assert!(
line.contains("\x1b[0m 58%"),
"CPU% 값은 리셋 직후 색 없이 출력: {line:?}"
);
}
#[test]
fn render_uses_dim_middot_separator() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
unsafe { std::env::remove_var("NO_COLOR") };
let mut cfg = Config::default();
cfg.color.mode = "truecolor".to_string();
let line = render(&sample_input(), &sample_snap(10.0), &cfg, 0, false);
assert!(
line.contains("\x1b[38;2;59;64;72m · \x1b[0m"),
"dim 가운뎃점 구분자 필요: {line:?}"
);
assert!(
!line.contains("% mem"),
"구분자는 가운뎃점이어야 함: {line:?}"
);
}
#[test]
fn render_labels_are_dim_values_are_not() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
unsafe { std::env::remove_var("NO_COLOR") };
let mut cfg = Config::default();
cfg.color.mode = "truecolor".to_string();
let line = render(&sample_input(), &sample_snap(10.0), &cfg, 0, false);
assert!(
line.contains("\x1b[38;2;107;114;128mmem\x1b[0m 55%"),
"라벨 dim + 값 색 없음: {line:?}"
);
}
#[test]
fn render_band_snapshots_glyph_and_tint() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
unsafe { std::env::remove_var("NO_COLOR") };
let mut cfg = Config::default();
cfg.color.mode = "truecolor".to_string();
let cases = [
(10.0, "○", "\x1b[38;2;90;104;120m"), (30.0, "▁", "\x1b[38;2;109;130;150m"), (58.0, "▄", "\x1b[38;2;134;160;180m"), (80.0, "▆", "\x1b[38;2;159;191;206m"), (95.0, "◆", "\x1b[38;2;184;120;72m"), ];
for (cpu, glyph, tint_prefix) in cases {
let line = render(&sample_input(), &sample_snap(cpu), &cfg, 0, false);
let expected = format!("{tint_prefix}{glyph}\x1b[0m {cpu:.0}%");
assert!(
line.starts_with(&expected),
"밴드 {cpu}%: 글리프 {glyph} + 틴트 {tint_prefix:?} 필요\n got: {line:?}"
);
}
}
#[test]
fn render_crit_pulse_breathes_terracotta() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
unsafe { std::env::remove_var("NO_COLOR") };
let mut cfg = Config::default();
cfg.color.mode = "truecolor".to_string();
let high = render(&sample_input(), &sample_snap(95.0), &cfg, 22_500, true);
assert!(
high.starts_with("\x1b[38;2;184;120;72m◆\x1b[0m 95%"),
"펄스 high 끝점 테라코타 + 고정 글리프 ◆: {high:?}"
);
let low = render(&sample_input(), &sample_snap(95.0), &cfg, 7_500, true);
assert!(
low.starts_with("\x1b[38;2;122;80;48m◆\x1b[0m 95%"),
"펄스 low 끝점 dim 테라코타 + 고정 글리프 ◆: {low:?}"
);
}
#[test]
fn render_omits_context_when_null() {
let mut input = sample_input();
input.context_used_percentage = None;
let mut cfg = Config::default();
cfg.color.mode = "none".to_string();
let line = render(&input, &sample_snap(10.0), &cfg, 0, false);
assert!(
!line.contains("ctx"),
"context null이면 ctx 세그먼트 생략: {line:?}"
);
}
#[test]
fn render_enforces_max_width_drops_low_priority() {
let mut cfg = Config::default();
cfg.color.mode = "none".to_string();
cfg.display.max_width = 14; let line = render(&sample_input(), &sample_snap(10.0), &cfg, 0, false);
assert!(
!line.contains('$'),
"max_width 초과 시 cost 세그먼트 제거: {line:?}"
);
assert!(line.contains("10%"), "핵심 CPU 세그먼트는 유지: {line:?}");
}
#[test]
fn compose_self_first() {
assert_eq!(compose("SELF", "CHAIN", "self_first"), "SELF CHAIN");
}
#[test]
fn compose_chain_first() {
assert_eq!(compose("SELF", "CHAIN", "chain_first"), "CHAIN SELF");
}
#[test]
fn compose_empty_chain_returns_self_only() {
assert_eq!(compose("SELF", "", "self_first"), "SELF");
assert_eq!(compose("SELF", "\n", "self_first"), "SELF");
}
#[test]
fn compose_with_seam_inserts_dim_seam() {
let cfg = Config::default();
assert_eq!(
compose_with_seam("SELF", "CHAIN", "self_first", &cfg, false),
"SELF │ CHAIN"
);
assert_eq!(
compose_with_seam("SELF", "CHAIN", "chain_first", &cfg, false),
"CHAIN │ SELF"
);
}
#[test]
fn compose_with_seam_colors_seam_when_color_on() {
let mut cfg = Config::default();
cfg.color.mode = "truecolor".to_string();
let line = compose_with_seam("SELF", "CHAIN", "self_first", &cfg, true);
assert!(
line.contains("\x1b[38;2;59;64;72m│\x1b[0m"),
"dim seam 필요: {line:?}"
);
}
#[test]
fn compose_with_seam_no_trailing_seam_when_empty_chain() {
let cfg = Config::default();
assert_eq!(
compose_with_seam("SELF", "", "self_first", &cfg, true),
"SELF"
);
assert_eq!(
compose_with_seam("SELF", "\n", "self_first", &cfg, true),
"SELF"
);
}
#[test]
fn battery_marker_states() {
assert_eq!(battery_marker(15.0, true), "bat+");
assert_eq!(battery_marker(95.0, true), "bat+");
assert_eq!(battery_marker(20.0, false), "bat!");
assert_eq!(battery_marker(5.0, false), "bat!");
assert_eq!(battery_marker(21.0, false), "bat");
assert_eq!(battery_marker(80.0, false), "bat");
}
#[test]
fn format_bytes_per_sec_units() {
assert_eq!(format_bytes_per_sec(512.0), "512B/s");
assert_eq!(format_bytes_per_sec(2048.0), "2KB/s");
assert_eq!(format_bytes_per_sec(3.0 * 1024.0 * 1024.0), "3.0MB/s");
assert_eq!(format_bytes_per_sec(-1.0), "0B/s");
assert_eq!(format_bytes_per_sec(f64::NAN), "0B/s");
}
#[test]
fn render_shows_battery_when_some_and_toggle_on() {
let mut cfg = Config::default();
cfg.color.mode = "none".to_string();
let mut snap = sample_snap(10.0);
snap.battery = Some(BatteryInfo {
percent: 75.0,
is_charging: true,
});
let line = render(&sample_input(), &snap, &cfg, 0, false);
assert!(line.contains("bat+"), "충전 중 배터리 마커 표시: {line:?}");
assert!(line.contains("75%"), "배터리 잔량 표시: {line:?}");
}
#[test]
fn render_omits_battery_when_none() {
let mut cfg = Config::default();
cfg.color.mode = "none".to_string();
let line = render(&sample_input(), &sample_snap(10.0), &cfg, 0, false);
assert!(!line.contains("bat"), "배터리 None이면 생략: {line:?}");
}
#[test]
fn render_omits_battery_when_toggle_off() {
let mut cfg = Config::default();
cfg.color.mode = "none".to_string();
cfg.display.show_battery = false;
let mut snap = sample_snap(10.0);
snap.battery = Some(BatteryInfo {
percent: 50.0,
is_charging: false,
});
let line = render(&sample_input(), &snap, &cfg, 0, false);
assert!(
!line.contains("bat"),
"show_battery=false면 값이 있어도 생략: {line:?}"
);
}
#[test]
fn render_shows_disk_when_some_and_toggle_on() {
let mut cfg = Config::default();
cfg.color.mode = "none".to_string();
let mut snap = sample_snap(10.0);
snap.disk_percent = Some(63.0);
let line = render(&sample_input(), &snap, &cfg, 0, false);
assert!(line.contains("disk"), "디스크 라벨 표시: {line:?}");
assert!(line.contains("63%"), "디스크 사용률 표시: {line:?}");
}
#[test]
fn render_omits_disk_when_none_or_toggle_off() {
let mut cfg = Config::default();
cfg.color.mode = "none".to_string();
let line_none = render(&sample_input(), &sample_snap(10.0), &cfg, 0, false);
assert!(
!line_none.contains("disk"),
"disk None이면 생략: {line_none:?}"
);
cfg.display.show_disk = false;
let mut snap = sample_snap(10.0);
snap.disk_percent = Some(63.0);
let line_off = render(&sample_input(), &snap, &cfg, 0, false);
assert!(
!line_off.contains("disk"),
"show_disk=false면 생략: {line_off:?}"
);
}
#[test]
fn render_shows_network_when_some_and_toggle_on() {
let mut cfg = Config::default();
cfg.color.mode = "none".to_string();
let mut snap = sample_snap(10.0);
snap.net = Some(NetThroughput {
rx_bps: 2048.0,
tx_bps: 512.0,
});
let line = render(&sample_input(), &snap, &cfg, 0, false);
assert!(line.contains("↓2KB/s"), "수신 속도 표시: {line:?}");
assert!(line.contains("↑512B/s"), "송신 속도 표시: {line:?}");
}
#[test]
fn render_omits_network_when_none_or_toggle_off() {
let mut cfg = Config::default();
cfg.color.mode = "none".to_string();
let line_none = render(&sample_input(), &sample_snap(10.0), &cfg, 0, false);
assert!(!line_none.contains('↓'), "net None이면 생략: {line_none:?}");
cfg.display.show_network = false;
let mut snap = sample_snap(10.0);
snap.net = Some(NetThroughput {
rx_bps: 2048.0,
tx_bps: 512.0,
});
let line_off = render(&sample_input(), &snap, &cfg, 0, false);
assert!(
!line_off.contains('↓'),
"show_network=false면 생략: {line_off:?}"
);
}
#[test]
fn display_width_counts_wide_glyph_as_two() {
assert_eq!(display_width("🔥"), 2);
assert_eq!(display_width("a"), 1);
assert_eq!(display_width("🔥a"), 3);
assert_eq!(display_width(" · "), 3);
}
}