use ratatui::style::Color;
#[derive(Debug, Clone, Copy)]
pub struct Theme {
pub name: &'static str,
pub header_fg: Color,
pub composer_border: Color,
pub suggestion_border: Color,
pub status_bg: Color,
pub status_fg: Color,
pub user_prefix: Color,
pub assistant_prefix: Color,
pub accents: &'static [Color],
pub thinking: Color,
pub dim: Color,
pub status_busy_bg: Color,
}
pub const DEFAULT: Theme = Theme {
name: "default",
header_fg: Color::White,
composer_border: Color::Cyan,
suggestion_border: Color::Magenta,
status_bg: Color::Rgb(20, 30, 40),
status_fg: Color::Gray,
user_prefix: Color::Green,
assistant_prefix: Color::Magenta,
accents: &[Color::Magenta, Color::Cyan, Color::LightBlue, Color::Cyan],
thinking: Color::Magenta,
dim: Color::DarkGray,
status_busy_bg: Color::Rgb(20, 20, 50),
};
pub const CATPPUCCIN_MOCHA: Theme = Theme {
name: "catppuccin",
header_fg: Color::Rgb(245, 224, 220), composer_border: Color::Rgb(137, 180, 250), suggestion_border: Color::Rgb(203, 166, 247), status_bg: Color::Rgb(24, 24, 37), status_fg: Color::Rgb(186, 194, 222), user_prefix: Color::Rgb(166, 227, 161), assistant_prefix: Color::Rgb(203, 166, 247), accents: &[
Color::Rgb(203, 166, 247), Color::Rgb(137, 180, 250), Color::Rgb(116, 199, 236), Color::Rgb(148, 226, 213), ],
thinking: Color::Rgb(245, 194, 231), dim: Color::Rgb(108, 112, 134), status_busy_bg: Color::Rgb(30, 30, 46), };
pub const DRACULA: Theme = Theme {
name: "dracula",
header_fg: Color::Rgb(248, 248, 242), composer_border: Color::Rgb(139, 233, 253), suggestion_border: Color::Rgb(255, 121, 198), status_bg: Color::Rgb(40, 42, 54), status_fg: Color::Rgb(189, 147, 249), user_prefix: Color::Rgb(80, 250, 123), assistant_prefix: Color::Rgb(189, 147, 249), accents: &[
Color::Rgb(255, 121, 198), Color::Rgb(189, 147, 249), Color::Rgb(139, 233, 253), Color::Rgb(241, 250, 140), ],
thinking: Color::Rgb(255, 121, 198), dim: Color::Rgb(98, 114, 164), status_busy_bg: Color::Rgb(68, 71, 90), };
pub const GRUVBOX_DARK: Theme = Theme {
name: "gruvbox",
header_fg: Color::Rgb(235, 219, 178), composer_border: Color::Rgb(131, 165, 152), suggestion_border: Color::Rgb(211, 134, 155), status_bg: Color::Rgb(40, 40, 40), status_fg: Color::Rgb(168, 153, 132), user_prefix: Color::Rgb(184, 187, 38), assistant_prefix: Color::Rgb(211, 134, 155), accents: &[
Color::Rgb(254, 128, 25), Color::Rgb(250, 189, 47), Color::Rgb(184, 187, 38), Color::Rgb(131, 165, 152), ],
thinking: Color::Rgb(254, 128, 25), dim: Color::Rgb(146, 131, 116), status_busy_bg: Color::Rgb(60, 56, 54), };
const ALL: &[Theme] = &[DEFAULT, CATPPUCCIN_MOCHA, DRACULA, GRUVBOX_DARK];
pub const EDITABLE_FIELDS: [&str; 10] = [
"header_fg",
"composer_border",
"suggestion_border",
"status_bg",
"status_fg",
"user_prefix",
"assistant_prefix",
"thinking",
"dim",
"status_busy_bg",
];
impl Theme {
pub fn field_color(&self, idx: usize) -> Color {
match idx {
0 => self.header_fg,
1 => self.composer_border,
2 => self.suggestion_border,
3 => self.status_bg,
4 => self.status_fg,
5 => self.user_prefix,
6 => self.assistant_prefix,
7 => self.thinking,
8 => self.dim,
9 => self.status_busy_bg,
_ => Color::Reset,
}
}
pub fn set_field(&mut self, idx: usize, c: Color) {
match idx {
0 => self.header_fg = c,
1 => self.composer_border = c,
2 => self.suggestion_border = c,
3 => self.status_bg = c,
4 => self.status_fg = c,
5 => self.user_prefix = c,
6 => self.assistant_prefix = c,
7 => self.thinking = c,
8 => self.dim = c,
9 => self.status_busy_bg = c,
_ => {}
}
}
pub fn editable_rgb(&self) -> [(u8, u8, u8); 10] {
let mut out = [(0u8, 0u8, 0u8); 10];
for (i, slot) in out.iter_mut().enumerate() {
*slot = color_to_rgb(self.field_color(i));
}
out
}
}
pub fn from_rgb_fields(fields: &[(u8, u8, u8); 10]) -> Theme {
let c = |i: usize| {
let (r, g, b) = fields[i];
Color::Rgb(r, g, b)
};
let accents: &'static [Color] = Box::leak(vec![c(6), c(2), c(1), c(5)].into_boxed_slice());
Theme {
name: "custom",
header_fg: c(0),
composer_border: c(1),
suggestion_border: c(2),
status_bg: c(3),
status_fg: c(4),
user_prefix: c(5),
assistant_prefix: c(6),
accents,
thinking: c(7),
dim: c(8),
status_busy_bg: c(9),
}
}
pub fn color_to_rgb(c: Color) -> (u8, u8, u8) {
match c {
Color::Rgb(r, g, b) => (r, g, b),
Color::Black => (0, 0, 0),
Color::Red => (205, 0, 0),
Color::Green => (0, 205, 0),
Color::Yellow => (205, 205, 0),
Color::Blue => (0, 0, 238),
Color::Magenta => (205, 0, 205),
Color::Cyan => (0, 205, 205),
Color::Gray => (229, 229, 229),
Color::DarkGray => (127, 127, 127),
Color::LightRed => (255, 0, 0),
Color::LightGreen => (0, 255, 0),
Color::LightYellow => (255, 255, 0),
Color::LightBlue => (92, 92, 255),
Color::LightMagenta => (255, 0, 255),
Color::LightCyan => (0, 255, 255),
Color::White => (255, 255, 255),
_ => (128, 128, 128),
}
}
pub fn available_names() -> Vec<&'static str> {
ALL.iter().map(|t| t.name).collect()
}
pub fn by_name(name: &str) -> Theme {
let lower = name.trim().to_lowercase();
for t in ALL {
if t.name.eq_ignore_ascii_case(&lower) {
return *t;
}
}
DEFAULT
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn available_names_lists_every_theme() {
let names = available_names();
assert!(names.contains(&"default"));
assert!(names.contains(&"catppuccin"));
assert!(names.contains(&"dracula"));
assert!(names.contains(&"gruvbox"));
}
#[test]
fn by_name_resolves_known_themes() {
assert_eq!(by_name("dracula").name, "dracula");
assert_eq!(by_name("DRACULA").name, "dracula");
assert_eq!(by_name(" gruvbox ").name, "gruvbox");
}
#[test]
fn by_name_falls_back_to_default_for_unknown() {
assert_eq!(by_name("nope").name, "default");
}
#[test]
fn color_to_rgb_resolves_named_and_rgb() {
assert_eq!(color_to_rgb(Color::Rgb(1, 2, 3)), (1, 2, 3));
assert_eq!(color_to_rgb(Color::White), (255, 255, 255));
assert_eq!(color_to_rgb(Color::Black), (0, 0, 0));
assert_eq!(color_to_rgb(Color::Indexed(42)), (128, 128, 128));
}
#[test]
fn field_get_set_round_trips() {
let mut t = DEFAULT;
t.set_field(0, Color::Rgb(10, 20, 30));
assert_eq!(t.field_color(0), Color::Rgb(10, 20, 30));
t.set_field(99, Color::Rgb(1, 1, 1));
assert_eq!(t.field_color(99), Color::Reset);
}
#[test]
fn editable_rgb_has_one_entry_per_field() {
let rgb = DEFAULT.editable_rgb();
assert_eq!(rgb.len(), EDITABLE_FIELDS.len());
assert_eq!(rgb[0], (255, 255, 255));
}
#[test]
fn from_rgb_fields_builds_a_custom_theme() {
let fields = [(10, 20, 30); 10];
let t = from_rgb_fields(&fields);
assert_eq!(t.name, "custom");
assert_eq!(t.header_fg, Color::Rgb(10, 20, 30));
assert!(!t.accents.is_empty());
}
}