use ratatui::style::{Color, Style};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ColorSupport {
TrueColor,
Ansi256,
Ansi16,
}
impl ColorSupport {
pub fn detect() -> Self {
if let Some(value) = std::env::var_os("COLORTERM") {
let value = value.to_string_lossy();
if value.contains("truecolor") || value.contains("24bit") {
return Self::TrueColor;
}
}
if let Some(value) = std::env::var_os("TERM") {
let value = value.to_string_lossy();
if value.contains("256color") {
return Self::Ansi256;
}
if value.contains("direct") {
return Self::TrueColor;
}
}
match ratatui::crossterm::style::available_color_count() {
u16::MAX => Self::TrueColor,
256 => Self::Ansi256,
_ => Self::Ansi16,
}
}
pub const NAMES: &'static str = "auto, truecolor, 256, 16";
pub fn parse(value: &str) -> Option<Self> {
match value.to_ascii_lowercase().as_str() {
"auto" => Some(Self::detect()),
"truecolor" | "24bit" => Some(Self::TrueColor),
"256" | "ansi256" => Some(Self::Ansi256),
"16" | "ansi16" => Some(Self::Ansi16),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Palette {
pub dim: Color,
pub dim_selected: Color,
pub highlight_bg: Color,
pub accent: Color,
pub bar: Color,
pub ok: Color,
pub warn: Color,
pub crit: Color,
pub focus: Color,
pub anon: Color,
pub file: Color,
pub shmem: Color,
pub swap: Color,
pub swap_pss: Color,
pub tile_fg: Color,
pub treemap: [Color; 6],
}
impl Palette {
pub const DEFAULT: Self = Self {
dim: Color::DarkGray,
dim_selected: Color::Gray,
highlight_bg: Color::Rgb(38, 79, 120),
accent: Color::Cyan,
bar: Color::Cyan,
ok: Color::Green,
warn: Color::Yellow,
crit: Color::Red,
focus: Color::Yellow,
anon: Color::Magenta,
file: Color::Cyan,
shmem: Color::Yellow,
swap: Color::LightBlue,
swap_pss: Color::LightBlue,
tile_fg: Color::Gray,
treemap: [
Color::Rgb(46, 64, 94),
Color::Rgb(60, 47, 82),
Color::Rgb(36, 72, 72),
Color::Rgb(82, 62, 42),
Color::Rgb(52, 72, 46),
Color::Rgb(78, 47, 58),
],
};
pub fn highlight(&self) -> Style {
Style::new().bg(self.highlight_bg)
}
pub fn dim(&self, selected: bool) -> Color {
if selected {
self.dim_selected
} else {
self.dim
}
}
pub fn rendered(self, support: ColorSupport) -> Self {
Self {
dim: render_color(self.dim, support),
dim_selected: render_color(self.dim_selected, support),
highlight_bg: render_color(self.highlight_bg, support),
accent: render_color(self.accent, support),
bar: render_color(self.bar, support),
ok: render_color(self.ok, support),
warn: render_color(self.warn, support),
crit: render_color(self.crit, support),
focus: render_color(self.focus, support),
anon: render_color(self.anon, support),
file: render_color(self.file, support),
shmem: render_color(self.shmem, support),
swap: render_color(self.swap, support),
swap_pss: render_color(self.swap_pss, support),
tile_fg: render_color(self.tile_fg, support),
treemap: self.treemap.map(|color| render_color(color, support)),
}
}
}
const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::Rgb(r, g, b)
}
fn render_color(color: Color, support: ColorSupport) -> Color {
match (support, color) {
(ColorSupport::TrueColor, color) => color,
(ColorSupport::Ansi256, Color::Rgb(r, g, b)) => Color::Indexed(rgb_to_256(r, g, b)),
(ColorSupport::Ansi16, Color::Rgb(r, g, b)) => nearest_ansi16(r, g, b),
(_, color) => color,
}
}
fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
let (rf, gf, bf) = (r as f64, g as f64, b as f64);
if (rf / 11.0).round() == (gf / 11.0).round() && (gf / 11.0).round() == (bf / 11.0).round() {
(232.0 + (rf / 11.0).round()).min(255.0) as u8
} else {
(16.0 + (rf / 51.0).round() * 36.0 + (gf / 51.0).round() * 6.0 + (bf / 51.0).round()) as u8
}
}
#[rustfmt::skip]
const ANSI16_RGB: [(u8, u8, u8); 16] = [
(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
(0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
(85, 85, 85), (255, 0, 0), (0, 255, 0), (255, 255, 0),
(0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
];
#[rustfmt::skip]
const ANSI16_COLORS: [Color; 16] = [
Color::Black, Color::Red, Color::Green, Color::Yellow,
Color::Blue, Color::Magenta, Color::Cyan, Color::Gray,
Color::DarkGray, Color::LightRed, Color::LightGreen, Color::LightYellow,
Color::LightBlue, Color::LightMagenta, Color::LightCyan, Color::White,
];
fn nearest_ansi16(r: u8, g: u8, b: u8) -> Color {
let mut best = 0;
let mut best_distance = u32::MAX;
for (index, (r2, g2, b2)) in ANSI16_RGB.into_iter().enumerate() {
let distance = (i32::from(r) - i32::from(r2)).pow(2) as u32
+ (i32::from(g) - i32::from(g2)).pow(2) as u32
+ (i32::from(b) - i32::from(b2)).pow(2) as u32;
if distance < best_distance {
best_distance = distance;
best = index;
}
}
ANSI16_COLORS[best]
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Theme {
pub name: &'static str,
pub palette: Palette,
}
impl Theme {
pub const DEFAULT: Self = Self {
name: "default",
palette: Palette::DEFAULT,
};
pub const BTOP: Self = Self {
name: "btop",
palette: Palette {
dim: rgb(0x40, 0x40, 0x40),
dim_selected: rgb(0xee, 0xee, 0xee),
highlight_bg: rgb(0x6a, 0x2f, 0x2f),
accent: rgb(0xee, 0xee, 0xee),
bar: rgb(0x80, 0xd0, 0xa3),
ok: rgb(0x77, 0xca, 0x9b),
warn: rgb(0xcb, 0xc0, 0x6c),
crit: rgb(0xdc, 0x4c, 0x4c),
focus: rgb(0xb5, 0x40, 0x40),
anon: rgb(0xff, 0x47, 0x69),
file: rgb(0x26, 0xc5, 0xff),
shmem: rgb(0xff, 0xb8, 0x14),
swap: rgb(0xb0, 0xa9, 0xde),
swap_pss: rgb(0xb0, 0xa9, 0xde),
tile_fg: rgb(0x22, 0x22, 0x22),
treemap: [
rgb(0xff, 0x47, 0x69),
rgb(0x26, 0xc5, 0xff),
rgb(0xff, 0xb8, 0x14),
rgb(0xdc, 0xff, 0x85),
rgb(0xb0, 0xa9, 0xde),
rgb(0xdc, 0xaf, 0xde),
],
},
};
pub const DRACULA: Self = Self {
name: "dracula",
palette: Palette {
dim: rgb(0x44, 0x47, 0x5a),
dim_selected: rgb(0xf8, 0xf8, 0xf2),
highlight_bg: rgb(0xff, 0x79, 0xc6),
accent: rgb(0xf8, 0xf8, 0xf2),
bar: rgb(0x50, 0xfa, 0x7b),
ok: rgb(0x50, 0xfa, 0x7b),
warn: rgb(0xf1, 0xfa, 0x8c),
crit: rgb(0xff, 0x55, 0x55),
focus: rgb(0x62, 0x72, 0xa4),
anon: rgb(0x0d, 0xfa, 0x49),
file: rgb(0x26, 0xd7, 0xfd),
shmem: rgb(0xff, 0x9c, 0x33),
swap: rgb(0xbd, 0x93, 0xf9),
swap_pss: rgb(0xbd, 0x93, 0xf9),
tile_fg: rgb(0x28, 0x2a, 0x36),
treemap: [
rgb(0x50, 0xfa, 0x7b),
rgb(0xbd, 0x93, 0xf9),
rgb(0xff, 0x79, 0xc6),
rgb(0x8b, 0xe9, 0xfd),
rgb(0xff, 0x9c, 0x33),
rgb(0xff, 0x33, 0xa8),
],
},
};
pub const GRUVBOX: Self = Self {
name: "gruvbox-dark",
palette: Palette {
dim: rgb(0x7c, 0x6f, 0x64),
dim_selected: rgb(0xfa, 0xbd, 0x2f),
highlight_bg: rgb(0x3c, 0x38, 0x36),
accent: rgb(0xeb, 0xdb, 0xb2),
bar: rgb(0xb8, 0xbb, 0x26),
ok: rgb(0xb8, 0xbb, 0x26),
warn: rgb(0xfa, 0xbd, 0x2f),
crit: rgb(0xfb, 0x49, 0x34),
focus: rgb(0xd7, 0x99, 0x21),
anon: rgb(0xfb, 0x49, 0x34),
file: rgb(0x83, 0xa5, 0x98),
shmem: rgb(0xfa, 0xbd, 0x2f),
swap: rgb(0xa3, 0xa8, 0xf7),
swap_pss: rgb(0xa3, 0xa8, 0xf7),
tile_fg: rgb(0x1d, 0x20, 0x21),
treemap: [
rgb(0xb8, 0xbb, 0x26),
rgb(0x83, 0xa5, 0x98),
rgb(0xfa, 0xbd, 0x2f),
rgb(0x98, 0x97, 0x1a),
rgb(0xa3, 0xa8, 0xf7),
rgb(0xd3, 0x86, 0x9b),
],
},
};
pub const KANAGAWA: Self = Self {
name: "kanagawa-wave",
palette: Palette {
dim: rgb(0x72, 0x71, 0x69),
dim_selected: rgb(0xdc, 0xa5, 0x61),
highlight_bg: rgb(0x22, 0x32, 0x49),
accent: rgb(0xdc, 0xd7, 0xba),
bar: rgb(0x98, 0xbb, 0x6c),
ok: rgb(0x98, 0xbb, 0x6c),
warn: rgb(0xdc, 0xa5, 0x61),
crit: rgb(0xe8, 0x24, 0x24),
focus: rgb(0xc3, 0x40, 0x43),
anon: rgb(0x7f, 0xb4, 0xca),
file: rgb(0xff, 0x9e, 0x3b),
shmem: rgb(0x98, 0xbb, 0x6c),
swap: rgb(0x95, 0x7f, 0xbb),
swap_pss: rgb(0x95, 0x7f, 0xbb),
tile_fg: rgb(0x16, 0x16, 0x1d),
treemap: [
rgb(0x98, 0xbb, 0x6c),
rgb(0xc0, 0xa3, 0x6e),
rgb(0x93, 0x8a, 0xa9),
rgb(0xe8, 0x24, 0x24),
rgb(0x7e, 0x9c, 0xdb),
rgb(0xdc, 0xa5, 0x61),
],
},
};
pub const LIGHT: Self = Self {
name: "light",
palette: Palette {
dim: Color::DarkGray,
dim_selected: Color::Black,
highlight_bg: Color::Rgb(191, 219, 254),
accent: Color::Blue,
bar: Color::Blue,
ok: Color::Green,
warn: Color::Rgb(146, 64, 14),
crit: Color::Red,
focus: Color::Blue,
anon: Color::Magenta,
file: Color::Blue,
shmem: Color::Rgb(146, 64, 14),
swap: Color::Rgb(0x8e, 0x45, 0x85),
swap_pss: Color::Rgb(0x8e, 0x45, 0x85),
tile_fg: Color::Black,
treemap: [
Color::Rgb(219, 234, 254),
Color::Rgb(237, 233, 254),
Color::Rgb(204, 251, 241),
Color::Rgb(254, 243, 199),
Color::Rgb(220, 252, 231),
Color::Rgb(254, 226, 226),
],
},
};
pub const ANSI16: Self = Self {
name: "ansi16",
palette: Palette {
dim: Color::DarkGray,
dim_selected: Color::Gray,
highlight_bg: Color::Blue,
accent: Color::Cyan,
bar: Color::Cyan,
ok: Color::Green,
warn: Color::Yellow,
crit: Color::Red,
focus: Color::Yellow,
anon: Color::Blue,
file: Color::Cyan,
shmem: Color::Yellow,
swap: Color::Magenta,
swap_pss: Color::Magenta,
tile_fg: Color::Gray,
treemap: [
Color::Blue,
Color::Magenta,
Color::Cyan,
Color::Yellow,
Color::Green,
Color::Red,
],
},
};
pub fn rendered(self, support: ColorSupport) -> Self {
Self {
name: self.name,
palette: self.palette.rendered(support),
}
}
pub const ALL: [Self; 7] = [
Self::DEFAULT,
Self::BTOP,
Self::DRACULA,
Self::GRUVBOX,
Self::KANAGAWA,
Self::LIGHT,
Self::ANSI16,
];
pub const NAMES: &'static str =
"default, btop, dracula, gruvbox-dark, kanagawa-wave, light, ansi16";
pub fn by_name(name: &str) -> Option<Self> {
Self::ALL
.iter()
.copied()
.find(|theme| theme.name.eq_ignore_ascii_case(name))
}
pub fn next(self) -> Self {
let index = Self::ALL
.iter()
.position(|theme| theme.name == self.name)
.unwrap_or(0);
Self::ALL[(index + 1) % Self::ALL.len()]
}
}
#[cfg(test)]
mod tests {
use super::{Palette, Theme};
use ratatui::style::Color;
#[test]
fn default_theme_keeps_the_shipped_chrome_colours() {
let p = Theme::DEFAULT.palette;
assert_eq!(p, Palette::DEFAULT);
assert_eq!(p.highlight_bg, Color::Rgb(38, 79, 120));
assert_eq!(p.dim, Color::DarkGray);
assert_eq!(p.dim_selected, Color::Gray);
assert_eq!(p.accent, Color::Cyan);
assert_eq!(p.bar, Color::Cyan);
assert_eq!(p.focus, Color::Yellow);
assert_eq!(p.treemap[0], Color::Rgb(46, 64, 94));
assert_eq!(p.treemap[5], Color::Rgb(78, 47, 58));
}
#[test]
fn theme_lookup_is_case_insensitive_and_rejects_unknown_names() {
assert_eq!(Theme::by_name("default"), Some(Theme::DEFAULT));
assert_eq!(Theme::by_name("BTOP"), Some(Theme::BTOP));
assert_eq!(Theme::by_name("Dracula"), Some(Theme::DRACULA));
assert_eq!(Theme::by_name("gruvbox-dark"), Some(Theme::GRUVBOX));
assert_eq!(Theme::by_name("Kanagawa-Wave"), Some(Theme::KANAGAWA));
assert_eq!(Theme::by_name("Light"), Some(Theme::LIGHT));
assert_eq!(Theme::by_name("ANSI16"), Some(Theme::ANSI16));
assert_eq!(Theme::by_name("solarized"), None);
}
#[test]
fn t_cycles_through_every_builtin_and_wraps() {
for pair in Theme::ALL.windows(2) {
assert_eq!(pair[0].next(), pair[1], "{} ->", pair[0].name);
}
assert_eq!(Theme::ALL[Theme::ALL.len() - 1].next(), Theme::ALL[0]);
}
#[test]
fn names_lists_every_theme_in_order() {
let joined = Theme::ALL
.iter()
.map(|theme| theme.name)
.collect::<Vec<_>>()
.join(", ");
assert_eq!(joined, Theme::NAMES);
}
#[test]
fn btop_theme_matches_the_btop_default_palette() {
let p = Theme::BTOP.palette;
assert_eq!(p.highlight_bg, Color::Rgb(0x6a, 0x2f, 0x2f));
assert_eq!(p.ok, Color::Rgb(0x77, 0xca, 0x9b));
assert_eq!(p.warn, Color::Rgb(0xcb, 0xc0, 0x6c));
assert_eq!(p.crit, Color::Rgb(0xdc, 0x4c, 0x4c));
assert_eq!(p.treemap[1], Color::Rgb(0x26, 0xc5, 0xff));
}
#[test]
fn ansi256_conversion_uses_the_cube_and_grey_ramp() {
assert_eq!(super::rgb_to_256(255, 0, 0), 196);
assert_eq!(super::rgb_to_256(0, 255, 0), 46);
assert_eq!(super::rgb_to_256(0, 0, 255), 21);
assert_eq!(super::rgb_to_256(0, 0, 0), 232);
assert_eq!(super::rgb_to_256(255, 255, 255), 255);
assert_eq!(super::rgb_to_256(128, 128, 128), 244);
}
#[test]
fn rendered_256_maps_rgb_to_indexed_and_keeps_the_name() {
let theme = Theme::BTOP.rendered(super::ColorSupport::Ansi256);
assert_eq!(theme.name, "btop");
assert_eq!(
theme.palette.highlight_bg,
Color::Indexed(super::rgb_to_256(0x6a, 0x2f, 0x2f))
);
assert!(matches!(theme.palette.treemap[0], Color::Indexed(_)));
}
#[test]
fn rendered_16_uses_named_ansi_colours() {
use super::ColorSupport;
assert_eq!(
super::render_color(Color::Rgb(255, 0, 0), ColorSupport::Ansi16),
Color::LightRed
);
assert_eq!(
super::render_color(Color::Rgb(0, 0, 0), ColorSupport::Ansi16),
Color::Black
);
assert_eq!(
super::render_color(Color::Rgb(255, 255, 255), ColorSupport::Ansi16),
Color::White
);
assert_eq!(
super::render_color(Color::Rgb(0x44, 0x47, 0x5a), ColorSupport::Ansi16),
Color::DarkGray
);
assert_eq!(
super::render_color(Color::Cyan, ColorSupport::Ansi16),
Color::Cyan
);
}
#[test]
fn truecolor_rendering_is_a_no_op() {
assert_eq!(
Theme::DRACULA.rendered(super::ColorSupport::TrueColor),
Theme::DRACULA
);
}
#[test]
fn pss_mix_colours_are_theme_derived_and_distinct() {
for theme in Theme::ALL {
let palette = theme.palette;
let distinct: std::collections::HashSet<Color> =
[palette.anon, palette.file, palette.shmem, palette.swap]
.into_iter()
.collect();
assert_eq!(distinct.len(), 4, "{} reuses a PSS mix colour", theme.name);
assert_eq!(palette.swap_pss, palette.swap, "{}", theme.name);
}
let p = Theme::DEFAULT.palette;
assert_eq!(p.anon, Color::Magenta);
assert_eq!(p.file, Color::Cyan);
assert_eq!(p.shmem, Color::Yellow);
assert_eq!(p.swap, Color::LightBlue);
}
#[test]
fn every_theme_has_distinct_fills_and_no_tile_fg_collision() {
for theme in Theme::ALL {
let palette = theme.palette;
let distinct: std::collections::HashSet<Color> =
palette.treemap.iter().copied().collect();
assert_eq!(distinct.len(), 6, "{} reuses a tile fill", theme.name);
assert!(
!distinct.contains(&palette.tile_fg),
"{} tile_fg is also a tile fill",
theme.name
);
}
}
#[test]
fn ansi16_uses_no_rgb_or_indexed_colours() {
let p = Theme::ANSI16.palette;
let slots = [
p.dim,
p.dim_selected,
p.highlight_bg,
p.accent,
p.bar,
p.ok,
p.warn,
p.crit,
p.focus,
p.anon,
p.file,
p.shmem,
p.swap,
p.swap_pss,
p.tile_fg,
];
for color in slots.into_iter().chain(p.treemap) {
assert!(
!matches!(color, Color::Rgb(..) | Color::Indexed(_)),
"ansi16 theme leaked {color:?}"
);
}
}
}