use ratatui::style::{Color, Modifier, Style};
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Variant {
Dark,
Light,
Mono,
}
#[derive(Debug, Clone, Copy)]
pub struct Palette {
pub variant: Variant,
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 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,
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),
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,
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(249),
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),
selected_bg: Color::Indexed(253),
failed_bg: Color::Indexed(224),
header_bg: Color::Indexed(253),
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,
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,
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,
}
};
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 init_from_env() {
let _ = PALETTE.set(select(
std::env::var("NO_COLOR").ok().as_deref(),
std::env::var("CCTOP_THEME").ok().as_deref(),
std::env::var("COLORFGBG").ok().as_deref(),
));
}
fn select(no_color: Option<&str>, theme: Option<&str>, colorfgbg: Option<&str>) -> Palette {
if no_color.is_some_and(|v| !v.is_empty()) {
return MONO;
}
match theme.map(str::trim).unwrap_or("auto") {
"light" => LIGHT,
"dark" => DARK,
"none" | "mono" => MONO,
_ => match detect_light(colorfgbg) {
true => LIGHT,
false => 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 {
match variant() {
Variant::Dark => Color::Indexed(dark_index),
Variant::Mono => Color::Reset,
Variant::Light if (232..=255).contains(&dark_index) => {
Color::Indexed(232 + (255 - dark_index))
}
Variant::Light => Color::Indexed(dark_index),
}
}
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 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::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));
Color::Indexed(hues[(hash as usize) % hues.len()])
}
#[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::*;
#[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!(select(None, None, None).variant, Variant::Dark);
assert_eq!(select(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 no_color_beats_an_explicit_theme() {
assert_eq!(
select(Some("1"), Some("light"), None).variant,
Variant::Mono
);
assert_eq!(
select(Some(""), Some("light"), None).variant,
Variant::Light
);
}
#[test]
fn theme_env_selects_and_tolerates_nonsense() {
assert_eq!(select(None, Some("light"), None).variant, Variant::Light);
assert_eq!(select(None, Some(" dark "), None).variant, Variant::Dark);
assert_eq!(select(None, Some("lihgt"), None).variant, Variant::Dark);
assert_eq!(
select(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));
}
}