use ratatui::style::{Color, Modifier, Style};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use termprofile::TermProfile;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Variant {
Dark,
Light,
Mono,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Depth {
Indexed,
Ansi16,
Colorless,
}
impl Depth {
fn of(profile: TermProfile) -> Depth {
match profile {
TermProfile::NoColor => Depth::Colorless,
TermProfile::Ansi16 => Depth::Ansi16,
TermProfile::NoTty | TermProfile::Ansi256 | TermProfile::TrueColor => Depth::Indexed,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ground {
Light,
Dark,
}
#[derive(Debug, Clone, Copy)]
pub struct Palette {
pub variant: Variant,
pub depth: Depth,
pub border: Color,
pub border_hi: Color,
pub panel_title: Color,
pub label: Color,
pub value: Color,
pub dim: Color,
pub dimmer: Color,
pub accent: Color,
pub on_accent: Color,
pub filter_badge: Color,
pub cost_low: Color,
pub cost_mid: Color,
pub cost_high: Color,
pub claude: Color,
pub openai: Color,
pub cursor: Color,
pub opencode: Color,
pub pi: Color,
pub gemini: Color,
pub windsurf: Color,
pub glm: Color,
pub kimi: Color,
pub deepseek: Color,
pub qwen: Color,
pub grok: Color,
pub desktop_code: Color,
pub desktop_cowork: Color,
pub ground: Color,
pub selected_bg: Color,
pub failed_bg: Color,
pub header_bg: Color,
pub marked_bg: Color,
pub dot_fresh: Color,
pub dot_warm: Color,
pub name_hue: Color,
pub chart_hues: [Color; 2],
pub tool_hues: [u8; 10],
pub spark_cpu: [Color; 6],
pub spark_spend: [Color; 6],
pub spark_accent: [Color; 5],
pub spark_baseline: Color,
}
const DARK: Palette = Palette {
variant: Variant::Dark,
depth: Depth::Indexed,
border: Color::Indexed(60),
border_hi: Color::Indexed(75),
panel_title: Color::Indexed(179),
label: Color::Indexed(75),
value: Color::White,
dim: Color::Indexed(245),
dimmer: Color::Indexed(238),
accent: Color::Indexed(75),
on_accent: Color::Black,
filter_badge: Color::Cyan,
cost_low: Color::Indexed(114),
cost_mid: Color::Indexed(221),
cost_high: Color::Indexed(203),
claude: Color::Indexed(173),
openai: Color::Indexed(110),
cursor: Color::Indexed(141),
opencode: Color::Indexed(117),
pi: Color::Indexed(150),
gemini: Color::Indexed(74),
windsurf: Color::Indexed(80),
glm: Color::Indexed(108),
kimi: Color::Indexed(216),
deepseek: Color::Indexed(105),
qwen: Color::Indexed(180),
grok: Color::Indexed(247),
desktop_code: Color::Indexed(141),
desktop_cowork: Color::Indexed(183),
ground: Color::Reset,
selected_bg: Color::Indexed(236),
failed_bg: Color::Indexed(52),
header_bg: Color::Indexed(236),
marked_bg: Color::Indexed(53),
dot_fresh: Color::Indexed(82),
dot_warm: Color::Indexed(71),
name_hue: Color::Indexed(180),
chart_hues: [Color::Indexed(109), Color::Indexed(139)],
tool_hues: [75, 114, 173, 180, 139, 109, 146, 215, 152, 167],
spark_cpu: [
Color::Indexed(236),
Color::Indexed(71),
Color::Indexed(114),
Color::Indexed(186),
Color::Indexed(221),
Color::Indexed(203),
],
spark_spend: [
Color::Indexed(236),
Color::Indexed(71),
Color::Indexed(114),
Color::Indexed(186),
Color::Indexed(221),
Color::Indexed(203),
],
spark_accent: [
Color::Indexed(238),
Color::Indexed(60),
Color::Indexed(68),
Color::Indexed(75),
Color::Indexed(117),
],
spark_baseline: Color::Indexed(236),
};
const LIGHT: Palette = Palette {
variant: Variant::Light,
depth: Depth::Indexed,
border: Color::Indexed(146),
border_hi: Color::Indexed(25),
panel_title: Color::Indexed(94),
label: Color::Indexed(25),
value: Color::Black,
dim: Color::Indexed(243),
dimmer: Color::Indexed(246),
accent: Color::Indexed(25),
on_accent: Color::White,
filter_badge: Color::Indexed(23),
cost_low: Color::Indexed(28),
cost_mid: Color::Indexed(130),
cost_high: Color::Indexed(124),
claude: Color::Indexed(130),
openai: Color::Indexed(24),
cursor: Color::Indexed(91),
opencode: Color::Indexed(31),
pi: Color::Indexed(64),
gemini: Color::Indexed(26),
windsurf: Color::Indexed(30),
glm: Color::Indexed(65),
kimi: Color::Indexed(166),
deepseek: Color::Indexed(55),
qwen: Color::Indexed(100),
grok: Color::Indexed(238),
desktop_code: Color::Indexed(91),
desktop_cowork: Color::Indexed(97),
ground: Color::White,
selected_bg: Color::Indexed(250),
failed_bg: Color::Indexed(224),
header_bg: Color::Indexed(250),
marked_bg: Color::Indexed(225),
dot_fresh: Color::Indexed(34),
dot_warm: Color::Indexed(22),
name_hue: Color::Indexed(94),
tool_hues: [25, 28, 130, 94, 90, 24, 242, 166, 64, 124],
chart_hues: [Color::Indexed(24), Color::Indexed(91)],
spark_cpu: [
Color::Indexed(252),
Color::Indexed(22),
Color::Indexed(28),
Color::Indexed(136),
Color::Indexed(130),
Color::Indexed(124),
],
spark_spend: [
Color::Indexed(252),
Color::Indexed(22),
Color::Indexed(28),
Color::Indexed(136),
Color::Indexed(130),
Color::Indexed(124),
],
spark_accent: [
Color::Indexed(252),
Color::Indexed(146),
Color::Indexed(67),
Color::Indexed(25),
Color::Indexed(24),
],
spark_baseline: Color::Indexed(252),
};
const MONO: Palette = {
let r = Color::Reset;
Palette {
variant: Variant::Mono,
depth: Depth::Colorless,
border: r,
border_hi: r,
panel_title: r,
label: r,
value: r,
dim: r,
dimmer: r,
accent: r,
on_accent: r,
filter_badge: r,
cost_low: r,
cost_mid: r,
cost_high: r,
claude: r,
openai: r,
cursor: r,
opencode: r,
pi: r,
gemini: r,
windsurf: r,
glm: r,
kimi: r,
deepseek: r,
qwen: r,
grok: r,
desktop_code: r,
desktop_cowork: r,
ground: r,
selected_bg: r,
failed_bg: r,
header_bg: r,
marked_bg: r,
dot_fresh: r,
dot_warm: r,
name_hue: r,
chart_hues: [r; 2],
tool_hues: [0; 10],
spark_cpu: [r; 6],
spark_spend: [r; 6],
spark_accent: [r; 5],
spark_baseline: r,
}
};
impl Palette {
fn map(self, f: impl Fn(Color) -> Color) -> Palette {
Palette {
variant: self.variant,
depth: self.depth,
border: f(self.border),
border_hi: f(self.border_hi),
panel_title: f(self.panel_title),
label: f(self.label),
value: f(self.value),
dim: f(self.dim),
dimmer: f(self.dimmer),
accent: f(self.accent),
on_accent: f(self.on_accent),
filter_badge: f(self.filter_badge),
cost_low: f(self.cost_low),
cost_mid: f(self.cost_mid),
cost_high: f(self.cost_high),
claude: f(self.claude),
openai: f(self.openai),
cursor: f(self.cursor),
opencode: f(self.opencode),
pi: f(self.pi),
gemini: f(self.gemini),
windsurf: f(self.windsurf),
glm: f(self.glm),
kimi: f(self.kimi),
deepseek: f(self.deepseek),
qwen: f(self.qwen),
grok: f(self.grok),
desktop_code: f(self.desktop_code),
desktop_cowork: f(self.desktop_cowork),
ground: f(self.ground),
selected_bg: f(self.selected_bg),
failed_bg: f(self.failed_bg),
header_bg: f(self.header_bg),
marked_bg: f(self.marked_bg),
dot_fresh: f(self.dot_fresh),
dot_warm: f(self.dot_warm),
name_hue: f(self.name_hue),
chart_hues: self.chart_hues.map(&f),
tool_hues: self.tool_hues,
spark_cpu: self.spark_cpu.map(&f),
spark_spend: self.spark_spend.map(&f),
spark_accent: self.spark_accent.map(&f),
spark_baseline: f(self.spark_baseline),
}
}
}
fn fold(color: Color, depth: Depth) -> Color {
match depth {
Depth::Ansi16 => TermProfile::Ansi16.adapt_color(color).unwrap_or(color),
Depth::Indexed | Depth::Colorless => color,
}
}
pub fn adapt(color: Color) -> Color {
adapt_for(color, variant(), colors().depth, truecolor())
}
fn adapt_for(color: Color, variant: Variant, depth: Depth, truecolor: bool) -> Color {
match (variant, color) {
(Variant::Mono, _) => Color::Reset,
(_, Color::Rgb(..)) if depth == Depth::Indexed && !truecolor => {
TermProfile::Ansi256.adapt_color(color).unwrap_or(color)
}
_ => fold(color, depth),
}
}
fn sixteen(palette: Palette) -> Palette {
let mut p = palette.map(|c| fold(c, Depth::Ansi16));
p.depth = Depth::Ansi16;
match p.variant {
Variant::Dark => {
p.cost_low = Color::LightGreen;
p.dot_fresh = Color::LightGreen;
p.dot_warm = Color::Green;
p.selected_bg = Color::Blue;
p.header_bg = Color::Blue;
let heat = [
Color::DarkGray,
Color::Green,
Color::LightGreen,
Color::Yellow,
Color::LightYellow,
Color::LightRed,
];
p.spark_cpu = heat;
p.spark_spend = heat;
p.spark_accent = [
Color::DarkGray,
Color::Blue,
Color::LightBlue,
Color::Cyan,
Color::LightCyan,
];
p.spark_baseline = Color::DarkGray;
}
Variant::Light => {
p.accent = Color::Blue;
p.label = Color::Blue;
p.border_hi = Color::Blue;
p.failed_bg = Color::LightRed;
p.marked_bg = Color::LightMagenta;
p.spark_accent = [
Color::Gray,
Color::Cyan,
Color::Cyan,
Color::Blue,
Color::Blue,
];
}
Variant::Mono => {}
}
p
}
static PALETTE: OnceLock<Palette> = OnceLock::new();
pub fn colors() -> &'static Palette {
PALETTE.get_or_init(|| DARK)
}
pub fn variant() -> Variant {
colors().variant
}
pub fn no_color() -> bool {
variant() == Variant::Mono
}
pub fn truecolor() -> bool {
static TRUECOLOR: OnceLock<bool> = OnceLock::new();
*TRUECOLOR.get_or_init(|| truecolor_from(std::env::var("COLORTERM").ok().as_deref()))
}
fn truecolor_from(colorterm: Option<&str>) -> bool {
matches!(
colorterm
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref(),
Some("truecolor" | "24bit")
)
}
pub fn ground_rgb() -> Color {
match colors().ground {
Color::Indexed(i) if i >= 16 => Color::Indexed(i),
Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
_ => match variant() {
Variant::Light => Color::Indexed(231),
_ => Color::Indexed(234),
},
}
}
pub fn init_from_env(configured: Option<&str>) {
let _ = PALETTE.set(select(
std::env::var("NO_COLOR").ok().as_deref(),
std::env::var("CCTOP_THEME").ok().as_deref().or(configured),
std::env::var("COLORFGBG").ok().as_deref(),
detect_depth(&termprofile::Env, &std::io::stdout()),
query_ground,
));
}
fn detect_depth(
source: &impl termprofile::EnvVarSource,
out: &impl termprofile::IsTerminal,
) -> Depth {
let settings = termprofile::DetectorSettings::new().enable_tmux_info(false);
let vars = termprofile::TermVars::from_source(source, out, settings);
match Depth::of(TermProfile::detect_with_vars(vars)) {
Depth::Ansi16 => {
let term = source.var("TERM").unwrap_or_default();
match SIXTEEN.contains(&term.as_str()) {
true => Depth::Ansi16,
false => Depth::Indexed,
}
}
depth => depth,
}
}
const SIXTEEN: &[&str] = &[
"linux",
"vt100",
"vt102",
"vt220",
"ansi",
"cons25",
"xterm-16color",
"xterm-color",
];
fn select(
no_color: Option<&str>,
theme: Option<&str>,
colorfgbg: Option<&str>,
depth: Depth,
ground: impl FnOnce() -> Option<Ground>,
) -> Palette {
if no_color.is_some_and(|v| !v.is_empty()) || depth == Depth::Colorless {
return MONO;
}
let palette = match theme.map(str::trim).unwrap_or("auto") {
"light" => LIGHT,
"dark" => DARK,
"none" | "mono" => return MONO,
_ => match ground() {
Some(Ground::Light) => LIGHT,
Some(Ground::Dark) => DARK,
None => match detect_light(colorfgbg) {
true => LIGHT,
false => DARK,
},
},
};
match depth {
Depth::Ansi16 => sixteen(palette),
Depth::Indexed | Depth::Colorless => palette,
}
}
const GROUND_TIMEOUT: Duration = Duration::from_millis(500);
fn query_ground() -> Option<Ground> {
use std::io::{IsTerminal, Write};
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return None;
}
if std::env::var("TERM").is_ok_and(|t| t == "dumb") {
return None;
}
crossterm::terminal::enable_raw_mode().ok()?;
let asked = {
let mut out = std::io::stdout();
out.write_all(b"\x1b]11;?\x1b\\\x1b[c")
.and_then(|()| out.flush())
.is_ok()
};
let reply = match asked {
true => read_until_attributes(GROUND_TIMEOUT),
false => Vec::new(),
};
let _ = crossterm::terminal::disable_raw_mode();
parse_ground(&reply)
}
fn read_until_attributes(timeout: Duration) -> Vec<u8> {
let deadline = Instant::now() + timeout;
let mut buf = Vec::new();
while !attributes_arrived(&buf) {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
break;
}
let mut pfd = libc::pollfd {
fd: 0,
events: libc::POLLIN,
revents: 0,
};
let millis = left.as_millis().max(1) as i32;
if unsafe { libc::poll(&mut pfd, 1, millis) } <= 0 {
break;
}
let mut chunk = [0u8; 256];
let n = unsafe { libc::read(0, chunk.as_mut_ptr().cast(), chunk.len()) };
if n <= 0 {
break;
}
buf.extend_from_slice(&chunk[..n as usize]);
}
buf
}
fn attributes_arrived(buf: &[u8]) -> bool {
(0..buf.len()).any(|i| {
let Some(rest) = buf[i..].strip_prefix(b"\x1b[?") else {
return false;
};
rest.iter()
.find(|b| !(b.is_ascii_digit() || **b == b';'))
.is_some_and(|&b| b == b'c')
})
}
fn parse_ground(buf: &[u8]) -> Option<Ground> {
let start = buf.windows(5).position(|w| w == b"\x1b]11;")? + 5;
let body = &buf[start..];
let end = body.iter().position(|&b| b == 0x07 || b == 0x1b)?;
let text = std::str::from_utf8(&body[..end]).ok()?;
let (space, channels) = text.split_once(':')?;
if space != "rgb" && space != "rgba" {
return None;
}
let channel = |hex: &str| -> Option<f64> {
if hex.is_empty() || hex.len() > 4 {
return None;
}
let value = u16::from_str_radix(hex, 16).ok()?;
Some(f64::from(value) / f64::from((1u32 << (4 * hex.len())) - 1))
};
let mut channels = channels.split('/').map(channel);
let (r, g, b) = (channels.next()??, channels.next()??, channels.next()??);
let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
Some(match luminance > 0.5 {
true => Ground::Light,
false => Ground::Dark,
})
}
fn detect_light(colorfgbg: Option<&str>) -> bool {
let Some(bg) = colorfgbg.and_then(|v| {
v.rsplit(';')
.next()
.map(str::trim)
.and_then(|b| b.parse::<u8>().ok())
}) else {
return false;
};
bg == 7 || bg >= 9
}
pub fn gray(dark_index: u8) -> Color {
let index = match variant() {
Variant::Dark => dark_index,
Variant::Mono => return Color::Reset,
Variant::Light if (232..=255).contains(&dark_index) => 232 + (255 - dark_index),
Variant::Light => dark_index,
};
fold(Color::Indexed(index), colors().depth)
}
pub fn label() -> Style {
Style::default()
.fg(colors().label)
.add_modifier(Modifier::BOLD)
}
pub fn value() -> Style {
Style::default()
.fg(colors().value)
.add_modifier(Modifier::BOLD)
}
pub fn dim() -> Style {
let style = Style::default().fg(colors().dim);
match no_color() {
true => style.add_modifier(Modifier::DIM),
false => style,
}
}
pub fn title() -> Style {
Style::default()
.fg(colors().panel_title)
.add_modifier(Modifier::BOLD)
}
pub fn canvas() -> Style {
match colors().ground {
Color::Reset => Style::default(),
bg => Style::default().bg(bg).fg(colors().value),
}
}
pub fn selected() -> Style {
match no_color() {
true => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
false => Style::default()
.bg(colors().selected_bg)
.fg(colors().value)
.add_modifier(Modifier::BOLD),
}
}
pub fn marked() -> Style {
match no_color() {
true => Style::default().add_modifier(Modifier::UNDERLINED),
false => Style::default().bg(colors().marked_bg),
}
}
pub fn header() -> Style {
match no_color() {
true => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
false => Style::default()
.fg(colors().value)
.bg(colors().header_bg)
.add_modifier(Modifier::BOLD),
}
}
pub fn failed() -> Style {
match no_color() {
true => Style::default().add_modifier(Modifier::UNDERLINED),
false => Style::default().bg(colors().failed_bg),
}
}
pub fn attention_lit(hue: Color) -> Style {
match no_color() {
true => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
false => Style::default()
.bg(hue)
.fg(colors().on_accent)
.add_modifier(Modifier::BOLD),
}
}
pub fn key_cap() -> Style {
match no_color() {
true => Style::default().add_modifier(Modifier::REVERSED),
false => Style::default()
.fg(colors().on_accent)
.bg(colors().accent)
.add_modifier(Modifier::BOLD),
}
}
pub fn cost_color(value: f64) -> Color {
let c = colors();
if value < 1.0 {
c.cost_low
} else if value < 10.0 {
c.cost_mid
} else {
c.cost_high
}
}
pub fn model_color(model: &str) -> Color {
let m = model
.rsplit('/')
.next()
.unwrap_or(model)
.to_ascii_lowercase();
if m.starts_with("claude") {
colors().claude
} else if m.starts_with("gpt") || m.starts_with('o') && m.len() > 1 {
colors().openai
} else if m.starts_with("gemini") {
colors().gemini
} else if m.starts_with("glm") {
colors().glm
} else if m.starts_with("kimi") {
colors().kimi
} else if m.starts_with("deepseek") {
colors().deepseek
} else if m.starts_with("qwen") {
colors().qwen
} else if m.starts_with("grok") {
colors().grok
} else {
colors().dim
}
}
pub fn age_color(last_active_secs: Option<i64>, running: bool) -> Color {
if running {
return colors().value;
}
let Some(secs) = last_active_secs else {
return colors().dimmer;
};
let hours = (secs.max(0) as f64) / 3600.0;
let ratio = ((1.0 + hours).ln() / (1.0 + 720.0f64).ln()).clamp(0.0, 1.0);
gray(255 - (ratio * 17.0).round() as u8)
}
pub fn running_dot_color(age_secs: Option<i64>) -> Color {
let c = colors();
match age_secs {
None => c.dot_fresh,
Some(s) if s < 30 => c.dot_fresh,
Some(s) if s < 300 => c.cost_low,
Some(s) if s < 1_800 => c.dot_warm,
Some(s) if s < 7_200 => c.dim,
_ => c.dimmer,
}
}
pub fn signal_color(signal: crate::hook::Signal) -> Color {
use crate::hook::Signal;
match signal {
Signal::NeedsInput => colors().cost_high,
Signal::Idle => colors().cost_mid,
Signal::Busy | Signal::Acting | Signal::Started | Signal::Compacting => colors().cost_low,
Signal::Ended => colors().dimmer,
}
}
pub fn cpu_color(cpu: f32) -> Color {
let c = colors();
if cpu > 80.0 {
c.cost_high
} else if cpu > 40.0 {
c.cost_mid
} else if cpu > 0.0 {
c.cost_low
} else {
c.dim
}
}
pub fn context_color(percent: f64) -> Color {
let c = colors();
if percent > 85.0 {
c.cost_high
} else if percent > 65.0 {
c.cost_mid
} else {
c.cost_low
}
}
pub fn spark_cpu(ratio: f64) -> Color {
let ramp = colors().spark_cpu;
match ratio {
r if r <= 0.01 => ramp[0],
r if r <= 0.30 => ramp[1],
r if r <= 0.50 => ramp[2],
r if r <= 0.70 => ramp[3],
r if r <= 0.85 => ramp[4],
_ => ramp[5],
}
}
pub fn spark_spend(ratio: f64) -> Color {
let ramp = colors().spark_spend;
match ratio {
r if r <= 0.01 => ramp[0],
r if r <= 0.25 => ramp[1],
r if r <= 0.50 => ramp[2],
r if r <= 0.75 => ramp[3],
r if r <= 0.85 => ramp[4],
_ => ramp[5],
}
}
pub fn spark_accent(ratio: f64) -> Color {
let ramp = colors().spark_accent;
match ratio {
r if r <= 0.01 => ramp[0],
r if r <= 0.25 => ramp[1],
r if r <= 0.50 => ramp[2],
r if r <= 0.75 => ramp[3],
_ => ramp[4],
}
}
pub fn tool_color(name: &str) -> Color {
if no_color() {
return Color::Reset;
}
let hues = colors().tool_hues;
let hash = name
.bytes()
.fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32));
fold(
Color::Indexed(hues[(hash as usize) % hues.len()]),
colors().depth,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Hue {
Red,
Orange,
Yellow,
Green,
Cyan,
Blue,
Violet,
Pink,
}
impl Hue {
pub const ALL: [Hue; 8] = [
Hue::Red,
Hue::Orange,
Hue::Yellow,
Hue::Green,
Hue::Cyan,
Hue::Blue,
Hue::Violet,
Hue::Pink,
];
pub fn name(self) -> &'static str {
match self {
Hue::Red => "red",
Hue::Orange => "orange",
Hue::Yellow => "yellow",
Hue::Green => "green",
Hue::Cyan => "cyan",
Hue::Blue => "blue",
Hue::Violet => "violet",
Hue::Pink => "pink",
}
}
pub fn from_name(name: &str) -> Option<Hue> {
Hue::ALL.into_iter().find(|hue| hue.name() == name)
}
pub fn color(self) -> Color {
let (dark, light) = match self {
Hue::Red => (203, 124),
Hue::Orange => (215, 166),
Hue::Yellow => (221, 136),
Hue::Green => (114, 28),
Hue::Cyan => (80, 30),
Hue::Blue => (110, 25),
Hue::Violet => (141, 91),
Hue::Pink => (213, 162),
};
match (variant(), colors().depth) {
(Variant::Mono, _) => Color::Reset,
(Variant::Dark, Depth::Ansi16) => self.ansi(),
(Variant::Light, Depth::Ansi16) => match self.ansi() {
Color::LightYellow => Color::Yellow,
Color::LightMagenta => Color::Magenta,
other => other,
},
(Variant::Dark, _) => Color::Indexed(dark),
(Variant::Light, _) => Color::Indexed(light),
}
}
fn ansi(self) -> Color {
match self {
Hue::Red => Color::Red,
Hue::Orange => Color::Yellow,
Hue::Yellow => Color::LightYellow,
Hue::Green => Color::Green,
Hue::Cyan => Color::Cyan,
Hue::Blue => Color::Blue,
Hue::Violet => Color::Magenta,
Hue::Pink => Color::LightMagenta,
}
}
fn ansi_ink(self) -> Color {
match self {
Hue::Red | Hue::Blue | Hue::Violet => Color::White,
_ => Color::Black,
}
}
pub fn fill(self, strength: Fill) -> Color {
match (variant(), colors().depth) {
(Variant::Mono, _) => Color::Reset,
(_, Depth::Ansi16) => self.ansi(),
(variant, _) => self.fill_in(strength, variant),
}
}
fn fill_in(self, strength: Fill, variant: Variant) -> Color {
let (rest, selected, alert) = match variant {
Variant::Mono => return Color::Reset,
Variant::Dark => match self {
Hue::Red => (95, 131, 203),
Hue::Orange => (137, 173, 208),
Hue::Yellow => (101, 143, 220),
Hue::Green => (65, 71, 77),
Hue::Cyan => (66, 73, 44),
Hue::Blue => (67, 68, 39),
Hue::Violet => (97, 134, 135),
Hue::Pink => (132, 168, 205),
},
Variant::Light => match self {
Hue::Red => (224, 217, 203),
Hue::Orange => (223, 216, 208),
Hue::Yellow => (230, 229, 220),
Hue::Green => (194, 157, 120),
Hue::Cyan => (195, 159, 87),
Hue::Blue => (153, 117, 75),
Hue::Violet => (183, 177, 135),
Hue::Pink => (225, 218, 205),
},
};
Color::Indexed(match strength {
Fill::Rest => rest,
Fill::Selected => selected,
Fill::Alert => alert,
})
}
pub fn wash(self, strength: Fill) -> Style {
match variant() != Variant::Mono && colors().depth == Depth::Ansi16 {
true => self.ansi_wash(strength),
false => Style::default().bg(self.fill(strength)).fg(strength.ink()),
}
}
fn ansi_wash(self, strength: Fill) -> Style {
let wash = Style::default().bg(self.ansi()).fg(self.ansi_ink());
match strength {
Fill::Alert => wash.add_modifier(Modifier::REVERSED),
Fill::Rest | Fill::Selected => wash,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Fill {
Rest,
Selected,
Alert,
}
impl Fill {
pub fn ink(self) -> Color {
fold(self.ink_in(variant()), colors().depth)
}
fn ink_in(self, variant: Variant) -> Color {
match (variant, self) {
(Variant::Dark, Fill::Rest | Fill::Selected) => Color::Indexed(254),
(Variant::Dark, Fill::Alert) => Color::Black,
(Variant::Light, _) => Color::Black,
(Variant::Mono, _) => Color::Reset,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gradient {
Cpu,
Spend,
Accent,
}
impl Gradient {
pub fn color(&self, ratio: f64) -> Color {
match self {
Gradient::Cpu => spark_cpu(ratio),
Gradient::Spend => spark_spend(ratio),
Gradient::Accent => spark_accent(ratio),
}
}
pub fn baseline(&self) -> Color {
colors().spark_baseline
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn plain(no_color: Option<&str>, theme: Option<&str>, colorfgbg: Option<&str>) -> Palette {
select(no_color, theme, colorfgbg, Depth::Indexed, || None)
}
fn unasked() -> Option<Ground> {
panic!("the terminal was asked for its ground when the answer was not needed")
}
#[test]
fn cost_thresholds() {
assert_eq!(cost_color(0.5), colors().cost_low);
assert_eq!(cost_color(5.0), colors().cost_mid);
assert_eq!(cost_color(50.0), colors().cost_high);
}
#[test]
fn age_fades_monotonically() {
let fresh = age_color(Some(0), false);
let old = age_color(Some(30 * 86_400), false);
let (Color::Indexed(a), Color::Indexed(b)) = (fresh, old) else {
panic!("expected indexed colors");
};
assert!(a > b, "older sessions must render dimmer ({a} vs {b})");
assert_eq!(age_color(Some(99_999), true), Color::White);
}
#[test]
fn context_color_escalates() {
assert_eq!(context_color(10.0), colors().cost_low);
assert_eq!(context_color(70.0), colors().cost_mid);
assert_eq!(context_color(95.0), colors().cost_high);
}
#[test]
fn dark_is_the_default() {
assert_eq!(colors().variant, Variant::Dark);
assert_eq!(plain(None, None, None).variant, Variant::Dark);
assert_eq!(plain(None, Some("auto"), None).variant, Variant::Dark);
}
#[test]
fn the_dark_palette_still_matches_the_constants_it_replaced() {
let c = DARK;
for (got, want) in [
(c.border, Color::Indexed(60)),
(c.border_hi, Color::Indexed(75)),
(c.panel_title, Color::Indexed(179)),
(c.label, Color::Indexed(75)),
(c.value, Color::White),
(c.dim, Color::Indexed(245)),
(c.dimmer, Color::Indexed(238)),
(c.accent, Color::Indexed(75)),
(c.cost_low, Color::Indexed(114)),
(c.cost_mid, Color::Indexed(221)),
(c.cost_high, Color::Indexed(203)),
(c.claude, Color::Indexed(173)),
(c.openai, Color::Indexed(110)),
(c.cursor, Color::Indexed(141)),
(c.opencode, Color::Indexed(117)),
(c.pi, Color::Indexed(150)),
(c.gemini, Color::Indexed(74)),
(c.windsurf, Color::Indexed(80)),
(c.desktop_code, Color::Indexed(141)),
(c.desktop_cowork, Color::Indexed(183)),
(c.selected_bg, Color::Indexed(236)),
(c.failed_bg, Color::Indexed(52)),
(c.header_bg, Color::Indexed(236)),
(c.marked_bg, Color::Indexed(53)),
(c.spark_baseline, Color::Indexed(236)),
] {
assert_eq!(got, want, "a dark colour moved");
}
assert_eq!(spark_cpu(0.0), Color::Indexed(236));
assert_eq!(spark_cpu(0.4), Color::Indexed(114));
assert_eq!(spark_cpu(1.0), Color::Indexed(203));
assert_eq!(spark_accent(0.6), Color::Indexed(75));
assert_eq!(running_dot_color(Some(0)), Color::Indexed(82));
assert_eq!(running_dot_color(Some(1_000)), Color::Indexed(71));
assert_eq!(tool_color("Bash"), Color::Indexed(173));
}
#[test]
fn truecolor_is_only_what_colorterm_claims() {
assert!(truecolor_from(Some("truecolor")));
assert!(truecolor_from(Some("24bit")));
assert!(truecolor_from(Some(" TrueColor ")));
assert!(!truecolor_from(None));
assert!(!truecolor_from(Some("")));
assert!(!truecolor_from(Some("256color")));
}
#[test]
fn no_color_beats_an_explicit_theme() {
assert_eq!(plain(Some("1"), Some("light"), None).variant, Variant::Mono);
assert_eq!(plain(Some(""), Some("light"), None).variant, Variant::Light);
}
#[test]
fn theme_env_selects_and_tolerates_nonsense() {
assert_eq!(plain(None, Some("light"), None).variant, Variant::Light);
assert_eq!(plain(None, Some(" dark "), None).variant, Variant::Dark);
assert_eq!(plain(None, Some("lihgt"), None).variant, Variant::Dark);
assert_eq!(
plain(None, Some("lihgt"), Some("0;15")).variant,
Variant::Light
);
}
#[test]
fn colorfgbg_reports_the_background() {
assert!(detect_light(Some("0;15")));
assert!(detect_light(Some("0;default;7")));
assert!(!detect_light(Some("15;0")));
assert!(!detect_light(Some("15;8")));
assert!(!detect_light(Some("nonsense")));
assert!(!detect_light(None));
}
#[test]
fn only_mono_uses_reset() {
for p in [DARK, LIGHT] {
for c in [
p.border,
p.value,
p.dim,
p.accent,
p.cost_low,
p.cost_high,
p.selected_bg,
p.header_bg,
p.spark_baseline,
] {
assert_ne!(c, Color::Reset, "{:?} has an unset slot", p.variant);
}
}
assert_eq!(MONO.value, Color::Reset);
}
#[test]
fn gray_passes_through_on_dark() {
assert_eq!(gray(240), Color::Indexed(240));
assert_eq!(gray(120), Color::Indexed(120));
}
#[test]
fn light_selection_is_not_white_on_white() {
assert_eq!(LIGHT.value, Color::Black);
assert_eq!(LIGHT.ground, Color::White);
assert_ne!(LIGHT.selected_bg, Color::White);
assert_ne!(LIGHT.selected_bg, LIGHT.ground);
assert_ne!(LIGHT.selected_bg, LIGHT.value);
assert_eq!(LIGHT.header_bg, LIGHT.selected_bg);
}
#[test]
fn selected_style_uses_palette_ink() {
let s = selected();
assert_eq!(s.fg, Some(colors().value));
assert_eq!(s.bg, Some(colors().selected_bg));
}
#[test]
fn dark_leaves_the_terminal_ground_alone() {
assert_eq!(DARK.ground, Color::Reset);
assert_eq!(canvas().bg, None);
}
#[test]
fn hue_names_survive_the_round_trip_through_a_session() {
for hue in Hue::ALL {
assert_eq!(Hue::from_name(hue.name()), Some(hue));
}
assert_eq!(Hue::from_name(""), None, "an unset option is no colour");
assert_eq!(
Hue::from_name("puce"),
None,
"a word this cctop does not know was guessed at"
);
}
fn lightness(color: Color) -> f64 {
const LEVEL: [f64; 6] = [0.0, 95.0, 135.0, 175.0, 215.0, 255.0];
let Color::Indexed(i @ 16..=231) = color else {
panic!("{color:?} is not a cube colour");
};
let i = (i - 16) as usize;
let (r, g, b) = (LEVEL[i / 36], LEVEL[(i / 6) % 6], LEVEL[i % 6]);
0.2126 * r + 0.7152 * g + 0.0722 * b
}
#[test]
fn the_watched_tab_stands_further_from_the_ground_on_either_palette() {
for hue in Hue::ALL {
let at = |fill, variant| hue.fill_in(fill, variant);
assert!(
lightness(at(Fill::Selected, Variant::Dark))
> lightness(at(Fill::Rest, Variant::Dark)),
"{hue:?}: the watched tab is no lighter than the rest on dark"
);
assert!(
lightness(at(Fill::Selected, Variant::Light))
< lightness(at(Fill::Rest, Variant::Light)),
"{hue:?}: the watched tab is no deeper than the rest on light"
);
for variant in [Variant::Dark, Variant::Light] {
assert_ne!(at(Fill::Alert, variant), at(Fill::Selected, variant));
}
assert_eq!(at(Fill::Rest, Variant::Mono), Color::Reset);
}
for fill in [Fill::Rest, Fill::Selected, Fill::Alert] {
assert_eq!(fill.ink_in(Variant::Light), Color::Black);
assert_eq!(fill.ink_in(Variant::Mono), Color::Reset);
}
assert_eq!(Fill::Rest.ink_in(Variant::Dark), Color::Indexed(254));
assert_eq!(Fill::Selected.ink_in(Variant::Dark), Color::Indexed(254));
assert_eq!(Fill::Alert.ink_in(Variant::Dark), Color::Black);
}
#[test]
fn every_hue_is_its_own_colour() {
let mut seen = std::collections::HashSet::new();
for hue in Hue::ALL {
let color = hue.color();
assert!(
matches!(color, Color::Indexed(_)),
"{hue:?} resolved to no colour"
);
assert!(
seen.insert(color),
"{hue:?} shares a colour with another hue"
);
}
}
#[test]
fn nothing_asks_the_terminal_when_the_answer_is_already_known() {
let pick = |no_color, theme, depth| select(no_color, theme, None, depth, unasked).variant;
assert_eq!(pick(Some("1"), None, Depth::Indexed), Variant::Mono);
assert_eq!(
pick(Some("1"), Some("light"), Depth::Indexed),
Variant::Mono
);
assert_eq!(pick(None, Some("light"), Depth::Indexed), Variant::Light);
assert_eq!(pick(None, Some("dark"), Depth::Indexed), Variant::Dark);
assert_eq!(pick(None, Some("mono"), Depth::Indexed), Variant::Mono);
assert_eq!(pick(None, Some("light"), Depth::Ansi16), Variant::Light);
assert_eq!(pick(None, None, Depth::Colorless), Variant::Mono);
assert_eq!(pick(None, Some("light"), Depth::Colorless), Variant::Mono);
}
#[test]
fn auto_follows_the_terminal_then_colorfgbg_then_dark() {
let pick = |theme, colorfgbg, ground: Option<Ground>| {
select(None, theme, colorfgbg, Depth::Indexed, || ground).variant
};
assert_eq!(pick(None, None, Some(Ground::Light)), Variant::Light);
assert_eq!(pick(Some("auto"), None, Some(Ground::Dark)), Variant::Dark);
assert_eq!(pick(None, Some("0;15"), Some(Ground::Dark)), Variant::Dark);
assert_eq!(
pick(None, Some("15;0"), Some(Ground::Light)),
Variant::Light
);
assert_eq!(pick(None, Some("0;15"), None), Variant::Light);
assert_eq!(pick(None, Some("15;0"), None), Variant::Dark);
assert_eq!(pick(None, None, None), Variant::Dark);
assert_eq!(
pick(Some("lihgt"), None, Some(Ground::Light)),
Variant::Light
);
}
#[test]
fn a_256_colour_terminal_gets_the_palette_unfolded() {
let dark = select(None, None, None, Depth::Indexed, || None);
assert_eq!(dark.depth, Depth::Indexed);
assert_eq!(dark.cost_low, Color::Indexed(114));
assert_eq!(dark.selected_bg, Color::Indexed(236));
let light = select(None, Some("light"), None, Depth::Indexed, unasked);
assert_eq!(light.marked_bg, LIGHT.marked_bg);
}
#[test]
fn a_tools_colours_are_folded_like_the_palette() {
use Variant::*;
let sixteen = |c| adapt_for(c, Dark, Depth::Ansi16, false);
for c in [
Color::Indexed(196),
Color::Rgb(0, 200, 0),
Color::Indexed(33),
] {
assert!(
!matches!(sixteen(c), Color::Indexed(_) | Color::Rgb(..)),
"{c:?} stayed outside the sixteen"
);
}
assert_eq!(sixteen(Color::Red), Color::Red);
assert!(matches!(
adapt_for(Color::Rgb(255, 0, 0), Dark, Depth::Indexed, false),
Color::Indexed(_)
));
let rgb = Color::Rgb(1, 2, 3);
assert_eq!(adapt_for(rgb, Light, Depth::Indexed, true), rgb);
assert_eq!(
adapt_for(Color::Indexed(196), Dark, Depth::Indexed, false),
Color::Indexed(196)
);
assert_eq!(
adapt_for(Color::Green, Mono, Depth::Indexed, true),
Color::Reset
);
}
#[test]
fn sixteen_colours_fold_every_slot_and_keep_the_meanings() {
for theme in ["dark", "light"] {
let p = select(None, Some(theme), None, Depth::Ansi16, unasked);
assert_eq!(p.depth, Depth::Ansi16);
p.map(|c| {
assert!(
!matches!(c, Color::Indexed(_) | Color::Rgb(..)),
"{theme}: {c:?} is outside the sixteen"
);
c
});
let scale = [p.cost_low, p.cost_mid, p.cost_high];
assert!(
scale[0] != scale[1] && scale[1] != scale[2] && scale[0] != scale[2],
"{theme}: the cost scale collapsed: {scale:?}"
);
assert!(matches!(p.cost_low, Color::Green | Color::LightGreen));
assert!(matches!(p.cost_high, Color::Red | Color::LightRed));
assert_ne!(p.marked_bg, p.failed_bg, "{theme}");
assert_ne!(p.spark_baseline, p.ground, "{theme}");
}
let dark = select(None, None, None, Depth::Ansi16, || None);
assert_eq!(dark.variant, Variant::Dark);
assert!(!matches!(dark.selected_bg, Color::Black | Color::Reset));
assert_ne!(dark.spark_baseline, Color::Black);
let light = select(None, None, None, Depth::Ansi16, || Some(Ground::Light));
assert_eq!(light.variant, Variant::Light);
assert_ne!(light.selected_bg, light.ground);
assert_ne!(light.accent, light.on_accent);
}
#[test]
fn sixteen_colour_tabs_stay_apart_and_still_blink() {
let mut seen = std::collections::HashSet::new();
for hue in Hue::ALL {
assert!(seen.insert(hue.ansi()), "{hue:?} shares a fill");
let rest = hue.ansi_wash(Fill::Rest);
assert_ne!(rest, hue.ansi_wash(Fill::Alert), "{hue:?} does not blink");
assert_ne!(rest.bg, rest.fg, "{hue:?} is unreadable");
}
}
struct Out(bool);
impl termprofile::IsTerminal for Out {
fn is_terminal(&self) -> bool {
self.0
}
}
#[test]
fn depth_is_read_from_the_environment() {
let depth = |vars: &[(&str, &str)], tty: bool| {
detect_depth(&vars.iter().copied().collect::<HashMap<_, _>>(), &Out(tty))
};
assert_eq!(depth(&[("TERM", "xterm-256color")], true), Depth::Indexed);
assert_eq!(
depth(&[("TERM", "xterm"), ("COLORTERM", "truecolor")], true),
Depth::Indexed
);
assert_eq!(
depth(
&[
("TERM", "tmux-256color"),
("TMUX", "/tmp/rmux-1000/default,1,0")
],
true
),
Depth::Indexed
);
assert_eq!(depth(&[("TERM", "linux")], true), Depth::Ansi16);
assert_eq!(depth(&[("TERM", "xterm")], true), Depth::Indexed);
assert_eq!(
depth(&[("TERM", "xterm-256color"), ("CI", "true")], true),
Depth::Indexed
);
assert_eq!(
depth(&[("TERM", "xterm"), ("FORCE_COLOR", "ansi256")], true),
Depth::Indexed
);
assert_eq!(
depth(&[("TERM", "xterm-256color"), ("NO_COLOR", "1")], true),
Depth::Colorless
);
assert_eq!(depth(&[("TERM", "linux")], false), Depth::Indexed);
}
#[test]
fn an_osc_11_reply_says_which_side_of_the_middle_the_ground_is() {
let ground = |reply: &[u8]| parse_ground(reply);
assert_eq!(
ground(b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\"),
Some(Ground::Light)
);
assert_eq!(
ground(b"\x1b]11;rgb:1e1e/1e1e/2e2e\x07"),
Some(Ground::Dark)
);
assert_eq!(ground(b"\x1b]11;rgb:fd/f6/e3\x07"), Some(Ground::Light));
assert_eq!(
ground(b"\x1b]11;rgba:0000/0000/0000/ffff\x1b\\"),
Some(Ground::Dark)
);
assert_eq!(
ground(b"j\x1b]11;rgb:f0f0/f0f0/f0f0\x1b\\\x1b[?62;22c"),
Some(Ground::Light)
);
assert_eq!(ground(b"\x1b[?62;22c"), None);
assert_eq!(ground(b"\x1b]11;rgb:ffff/ff"), None);
assert_eq!(ground(b"\x1b]11;#ffffff\x07"), None);
assert_eq!(ground(b"\x1b]11;rgb:fffff/0/0\x07"), None);
assert_eq!(ground(b""), None);
}
#[test]
fn the_device_attributes_reply_ends_the_exchange() {
assert!(attributes_arrived(b"\x1b[?62;22c"));
assert!(attributes_arrived(b"\x1b]11;rgb:0/0/0\x1b\\\x1b[?1;2c"));
assert!(!attributes_arrived(b"\x1b[?62;22"));
assert!(!attributes_arrived(b"\x1b]11;rgb:0/0/0\x1b\\"));
assert!(!attributes_arrived(b"\x1b[?2004h"));
assert!(!attributes_arrived(b""));
}
}