use ratatui::style::Color;
pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = (53, 120, 229); pub const DEEPSEEK_SKY_RGB: (u8, u8, u8) = (106, 174, 242);
#[allow(dead_code)]
pub const DEEPSEEK_AQUA_RGB: (u8, u8, u8) = (54, 187, 212);
#[allow(dead_code)]
pub const DEEPSEEK_NAVY_RGB: (u8, u8, u8) = (24, 63, 138);
pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = (11, 21, 38);
pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = (18, 28, 46);
pub const DEEPSEEK_RED_RGB: (u8, u8, u8) = (226, 80, 96);
pub const LIGHT_SURFACE_RGB: (u8, u8, u8) = (246, 248, 251); pub const LIGHT_PANEL_RGB: (u8, u8, u8) = (236, 242, 248); pub const LIGHT_ELEVATED_RGB: (u8, u8, u8) = (219, 229, 240); pub const LIGHT_REASONING_RGB: (u8, u8, u8) = (255, 246, 214); pub const LIGHT_SUCCESS_RGB: (u8, u8, u8) = (223, 247, 231); pub const LIGHT_ERROR_RGB: (u8, u8, u8) = (254, 229, 229); pub const LIGHT_TEXT_BODY_RGB: (u8, u8, u8) = (15, 23, 42); pub const LIGHT_TEXT_MUTED_RGB: (u8, u8, u8) = (51, 65, 85); pub const LIGHT_TEXT_HINT_RGB: (u8, u8, u8) = (100, 116, 139); pub const LIGHT_TEXT_SOFT_RGB: (u8, u8, u8) = (30, 41, 59); pub const LIGHT_BORDER_RGB: (u8, u8, u8) = (139, 161, 184); pub const LIGHT_SELECTION_RGB: (u8, u8, u8) = (207, 224, 247); pub const GRAYSCALE_SURFACE_RGB: (u8, u8, u8) = (10, 10, 10); pub const GRAYSCALE_PANEL_RGB: (u8, u8, u8) = (18, 18, 18); pub const GRAYSCALE_ELEVATED_RGB: (u8, u8, u8) = (31, 31, 31); pub const GRAYSCALE_REASONING_RGB: (u8, u8, u8) = (38, 38, 38); pub const GRAYSCALE_SUCCESS_RGB: (u8, u8, u8) = (34, 34, 34); pub const GRAYSCALE_ERROR_RGB: (u8, u8, u8) = (42, 42, 42); pub const GRAYSCALE_TEXT_BODY_RGB: (u8, u8, u8) = (236, 236, 236); pub const GRAYSCALE_TEXT_MUTED_RGB: (u8, u8, u8) = (180, 180, 180); pub const GRAYSCALE_TEXT_HINT_RGB: (u8, u8, u8) = (138, 138, 138); pub const GRAYSCALE_TEXT_SOFT_RGB: (u8, u8, u8) = (220, 220, 220); pub const GRAYSCALE_BORDER_RGB: (u8, u8, u8) = (96, 96, 96); pub const GRAYSCALE_SELECTION_RGB: (u8, u8, u8) = (62, 62, 62);
pub const BORDER_COLOR_RGB: (u8, u8, u8) = (42, 74, 127);
pub const DEEPSEEK_BLUE: Color = Color::Rgb(
DEEPSEEK_BLUE_RGB.0,
DEEPSEEK_BLUE_RGB.1,
DEEPSEEK_BLUE_RGB.2,
);
pub const DEEPSEEK_SKY: Color =
Color::Rgb(DEEPSEEK_SKY_RGB.0, DEEPSEEK_SKY_RGB.1, DEEPSEEK_SKY_RGB.2);
#[allow(dead_code)]
pub const DEEPSEEK_AQUA: Color = Color::Rgb(
DEEPSEEK_AQUA_RGB.0,
DEEPSEEK_AQUA_RGB.1,
DEEPSEEK_AQUA_RGB.2,
);
#[allow(dead_code)]
pub const DEEPSEEK_NAVY: Color = Color::Rgb(
DEEPSEEK_NAVY_RGB.0,
DEEPSEEK_NAVY_RGB.1,
DEEPSEEK_NAVY_RGB.2,
);
pub const DEEPSEEK_INK: Color =
Color::Rgb(DEEPSEEK_INK_RGB.0, DEEPSEEK_INK_RGB.1, DEEPSEEK_INK_RGB.2);
pub const DEEPSEEK_SLATE: Color = Color::Rgb(
DEEPSEEK_SLATE_RGB.0,
DEEPSEEK_SLATE_RGB.1,
DEEPSEEK_SLATE_RGB.2,
);
pub const DEEPSEEK_RED: Color =
Color::Rgb(DEEPSEEK_RED_RGB.0, DEEPSEEK_RED_RGB.1, DEEPSEEK_RED_RGB.2);
pub const LIGHT_SURFACE: Color = Color::Rgb(
LIGHT_SURFACE_RGB.0,
LIGHT_SURFACE_RGB.1,
LIGHT_SURFACE_RGB.2,
);
pub const LIGHT_PANEL: Color = Color::Rgb(LIGHT_PANEL_RGB.0, LIGHT_PANEL_RGB.1, LIGHT_PANEL_RGB.2);
pub const LIGHT_ELEVATED: Color = Color::Rgb(
LIGHT_ELEVATED_RGB.0,
LIGHT_ELEVATED_RGB.1,
LIGHT_ELEVATED_RGB.2,
);
pub const LIGHT_REASONING: Color = Color::Rgb(
LIGHT_REASONING_RGB.0,
LIGHT_REASONING_RGB.1,
LIGHT_REASONING_RGB.2,
);
pub const LIGHT_SUCCESS: Color = Color::Rgb(
LIGHT_SUCCESS_RGB.0,
LIGHT_SUCCESS_RGB.1,
LIGHT_SUCCESS_RGB.2,
);
pub const LIGHT_ERROR: Color = Color::Rgb(LIGHT_ERROR_RGB.0, LIGHT_ERROR_RGB.1, LIGHT_ERROR_RGB.2);
pub const LIGHT_TEXT_BODY: Color = Color::Rgb(
LIGHT_TEXT_BODY_RGB.0,
LIGHT_TEXT_BODY_RGB.1,
LIGHT_TEXT_BODY_RGB.2,
);
pub const LIGHT_TEXT_MUTED: Color = Color::Rgb(
LIGHT_TEXT_MUTED_RGB.0,
LIGHT_TEXT_MUTED_RGB.1,
LIGHT_TEXT_MUTED_RGB.2,
);
pub const LIGHT_TEXT_HINT: Color = Color::Rgb(
LIGHT_TEXT_HINT_RGB.0,
LIGHT_TEXT_HINT_RGB.1,
LIGHT_TEXT_HINT_RGB.2,
);
pub const LIGHT_TEXT_SOFT: Color = Color::Rgb(
LIGHT_TEXT_SOFT_RGB.0,
LIGHT_TEXT_SOFT_RGB.1,
LIGHT_TEXT_SOFT_RGB.2,
);
pub const LIGHT_BORDER: Color =
Color::Rgb(LIGHT_BORDER_RGB.0, LIGHT_BORDER_RGB.1, LIGHT_BORDER_RGB.2);
pub const LIGHT_SELECTION_BG: Color = Color::Rgb(
LIGHT_SELECTION_RGB.0,
LIGHT_SELECTION_RGB.1,
LIGHT_SELECTION_RGB.2,
);
pub const GRAYSCALE_SURFACE: Color = Color::Rgb(
GRAYSCALE_SURFACE_RGB.0,
GRAYSCALE_SURFACE_RGB.1,
GRAYSCALE_SURFACE_RGB.2,
);
pub const GRAYSCALE_PANEL: Color = Color::Rgb(
GRAYSCALE_PANEL_RGB.0,
GRAYSCALE_PANEL_RGB.1,
GRAYSCALE_PANEL_RGB.2,
);
pub const GRAYSCALE_ELEVATED: Color = Color::Rgb(
GRAYSCALE_ELEVATED_RGB.0,
GRAYSCALE_ELEVATED_RGB.1,
GRAYSCALE_ELEVATED_RGB.2,
);
pub const GRAYSCALE_REASONING: Color = Color::Rgb(
GRAYSCALE_REASONING_RGB.0,
GRAYSCALE_REASONING_RGB.1,
GRAYSCALE_REASONING_RGB.2,
);
pub const GRAYSCALE_SUCCESS: Color = Color::Rgb(
GRAYSCALE_SUCCESS_RGB.0,
GRAYSCALE_SUCCESS_RGB.1,
GRAYSCALE_SUCCESS_RGB.2,
);
pub const GRAYSCALE_ERROR: Color = Color::Rgb(
GRAYSCALE_ERROR_RGB.0,
GRAYSCALE_ERROR_RGB.1,
GRAYSCALE_ERROR_RGB.2,
);
pub const GRAYSCALE_TEXT_BODY: Color = Color::Rgb(
GRAYSCALE_TEXT_BODY_RGB.0,
GRAYSCALE_TEXT_BODY_RGB.1,
GRAYSCALE_TEXT_BODY_RGB.2,
);
pub const GRAYSCALE_TEXT_MUTED: Color = Color::Rgb(
GRAYSCALE_TEXT_MUTED_RGB.0,
GRAYSCALE_TEXT_MUTED_RGB.1,
GRAYSCALE_TEXT_MUTED_RGB.2,
);
pub const GRAYSCALE_TEXT_HINT: Color = Color::Rgb(
GRAYSCALE_TEXT_HINT_RGB.0,
GRAYSCALE_TEXT_HINT_RGB.1,
GRAYSCALE_TEXT_HINT_RGB.2,
);
pub const GRAYSCALE_TEXT_SOFT: Color = Color::Rgb(
GRAYSCALE_TEXT_SOFT_RGB.0,
GRAYSCALE_TEXT_SOFT_RGB.1,
GRAYSCALE_TEXT_SOFT_RGB.2,
);
pub const GRAYSCALE_BORDER: Color = Color::Rgb(
GRAYSCALE_BORDER_RGB.0,
GRAYSCALE_BORDER_RGB.1,
GRAYSCALE_BORDER_RGB.2,
);
pub const GRAYSCALE_SELECTION_BG: Color = Color::Rgb(
GRAYSCALE_SELECTION_RGB.0,
GRAYSCALE_SELECTION_RGB.1,
GRAYSCALE_SELECTION_RGB.2,
);
pub const TEXT_BODY: Color = Color::Rgb(226, 232, 240); pub const TEXT_SECONDARY: Color = Color::Rgb(177, 190, 207); pub const TEXT_HINT: Color = Color::Rgb(135, 151, 171); pub const TEXT_ACCENT: Color = DEEPSEEK_SKY;
pub const SELECTION_TEXT: Color = Color::White;
pub const TEXT_SOFT: Color = Color::Rgb(217, 226, 238); pub const TEXT_REASONING: Color = Color::Rgb(211, 170, 112);
pub const TEXT_PRIMARY: Color = TEXT_BODY;
pub const TEXT_MUTED: Color = TEXT_SECONDARY;
pub const TEXT_DIM: Color = TEXT_HINT;
pub const USER_BODY: Color = Color::Rgb(74, 222, 128); pub const LIGHT_USER_BODY: Color = Color::Rgb(21, 128, 61);
pub const BORDER_COLOR: Color =
Color::Rgb(BORDER_COLOR_RGB.0, BORDER_COLOR_RGB.1, BORDER_COLOR_RGB.2);
#[allow(dead_code)]
pub const ACCENT_PRIMARY: Color = DEEPSEEK_BLUE; #[allow(dead_code)]
pub const ACCENT_SECONDARY: Color = TEXT_ACCENT; #[allow(dead_code)]
pub const BACKGROUND_DARK: Color = Color::Rgb(13, 26, 48); #[allow(dead_code)]
pub const STATUS_NEUTRAL: Color = Color::Rgb(160, 160, 160); #[allow(dead_code)]
pub const SURFACE_PANEL: Color = Color::Rgb(21, 33, 52); #[allow(dead_code)]
pub const SURFACE_ELEVATED: Color = Color::Rgb(28, 42, 64); pub const SURFACE_REASONING: Color = Color::Rgb(54, 44, 26); pub const SURFACE_REASONING_TINT: Color = Color::Rgb(16, 24, 37); #[allow(dead_code)]
pub const SURFACE_REASONING_ACTIVE: Color = Color::Rgb(68, 53, 28); #[allow(dead_code)]
pub const SURFACE_TOOL: Color = Color::Rgb(24, 39, 60); #[allow(dead_code)]
pub const SURFACE_TOOL_ACTIVE: Color = Color::Rgb(29, 48, 73); #[allow(dead_code)]
pub const SURFACE_SUCCESS: Color = Color::Rgb(22, 56, 63); #[allow(dead_code)]
pub const SURFACE_ERROR: Color = Color::Rgb(63, 27, 36); pub const DIFF_ADDED_BG: Color = Color::Rgb(18, 52, 38); pub const DIFF_DELETED_BG: Color = Color::Rgb(52, 22, 28); pub const DIFF_ADDED: Color = Color::Rgb(87, 199, 133); pub const ACCENT_REASONING_LIVE: Color = Color::Rgb(224, 153, 72); pub const ACCENT_TOOL_LIVE: Color = Color::Rgb(133, 184, 234); pub const ACCENT_TOOL_ISSUE: Color = Color::Rgb(192, 143, 153); pub const TEXT_TOOL_OUTPUT: Color = Color::Rgb(191, 205, 220);
pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY;
pub const STATUS_WARNING: Color = Color::Rgb(255, 170, 60); pub const STATUS_ERROR: Color = DEEPSEEK_RED;
#[allow(dead_code)]
pub const STATUS_INFO: Color = DEEPSEEK_BLUE;
pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60);
pub const SELECTION_BG: Color = Color::Rgb(26, 44, 74);
#[allow(dead_code)]
pub const COMPOSER_BG: Color = DEEPSEEK_SLATE;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaletteMode {
Dark,
Light,
Grayscale,
}
impl PaletteMode {
#[must_use]
pub fn from_colorfgbg(value: &str) -> Option<Self> {
let bg = value
.split(';')
.rev()
.find_map(|part| part.parse::<u16>().ok())?;
Some(if bg >= 8 { Self::Light } else { Self::Dark })
}
#[must_use]
pub fn detect() -> Self {
std::env::var("COLORFGBG")
.ok()
.and_then(|value| Self::from_colorfgbg(&value))
.unwrap_or(Self::Dark)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UiTheme {
pub name: &'static str,
pub mode: PaletteMode,
pub surface_bg: Color,
pub panel_bg: Color,
pub elevated_bg: Color,
pub composer_bg: Color,
pub selection_bg: Color,
pub header_bg: Color,
pub footer_bg: Color,
pub mode_agent: Color,
pub mode_yolo: Color,
pub mode_plan: Color,
pub status_ready: Color,
pub status_working: Color,
pub status_warning: Color,
pub text_dim: Color,
pub text_hint: Color,
pub text_muted: Color,
pub text_body: Color,
pub text_soft: Color,
pub border: Color,
}
pub const UI_THEME: UiTheme = UiTheme {
name: "whale",
mode: PaletteMode::Dark,
surface_bg: DEEPSEEK_INK,
panel_bg: DEEPSEEK_SLATE,
elevated_bg: SURFACE_ELEVATED,
composer_bg: DEEPSEEK_SLATE,
selection_bg: SELECTION_BG,
header_bg: DEEPSEEK_INK,
footer_bg: DEEPSEEK_INK,
mode_agent: MODE_AGENT,
mode_yolo: MODE_YOLO,
mode_plan: MODE_PLAN,
status_ready: TEXT_MUTED,
status_working: DEEPSEEK_SKY,
status_warning: STATUS_WARNING,
text_dim: TEXT_DIM,
text_hint: TEXT_HINT,
text_muted: TEXT_MUTED,
text_body: TEXT_BODY,
text_soft: TEXT_SOFT,
border: BORDER_COLOR,
};
pub const LIGHT_UI_THEME: UiTheme = UiTheme {
name: "whale-light",
mode: PaletteMode::Light,
surface_bg: LIGHT_SURFACE,
panel_bg: LIGHT_PANEL,
elevated_bg: LIGHT_ELEVATED,
composer_bg: LIGHT_PANEL,
selection_bg: LIGHT_SELECTION_BG,
header_bg: LIGHT_SURFACE,
footer_bg: LIGHT_SURFACE,
mode_agent: DEEPSEEK_BLUE,
mode_yolo: DEEPSEEK_RED,
mode_plan: Color::Rgb(180, 83, 9),
status_ready: LIGHT_TEXT_MUTED,
status_working: DEEPSEEK_BLUE,
status_warning: Color::Rgb(180, 83, 9),
text_dim: LIGHT_TEXT_HINT,
text_hint: LIGHT_TEXT_HINT,
text_muted: LIGHT_TEXT_MUTED,
text_body: LIGHT_TEXT_BODY,
text_soft: LIGHT_TEXT_SOFT,
border: LIGHT_BORDER,
};
pub const GRAYSCALE_UI_THEME: UiTheme = UiTheme {
name: "grayscale",
mode: PaletteMode::Grayscale,
surface_bg: GRAYSCALE_SURFACE,
panel_bg: GRAYSCALE_PANEL,
elevated_bg: GRAYSCALE_ELEVATED,
composer_bg: GRAYSCALE_PANEL,
selection_bg: GRAYSCALE_SELECTION_BG,
header_bg: GRAYSCALE_SURFACE,
footer_bg: GRAYSCALE_SURFACE,
mode_agent: GRAYSCALE_TEXT_SOFT,
mode_yolo: GRAYSCALE_TEXT_BODY,
mode_plan: GRAYSCALE_TEXT_MUTED,
status_ready: GRAYSCALE_TEXT_MUTED,
status_working: GRAYSCALE_TEXT_SOFT,
status_warning: GRAYSCALE_TEXT_BODY,
text_dim: GRAYSCALE_TEXT_HINT,
text_hint: GRAYSCALE_TEXT_HINT,
text_muted: GRAYSCALE_TEXT_MUTED,
text_body: GRAYSCALE_TEXT_BODY,
text_soft: GRAYSCALE_TEXT_SOFT,
border: GRAYSCALE_BORDER,
};
pub const CATPPUCCIN_MOCHA_UI_THEME: UiTheme = UiTheme {
name: "catppuccin-mocha",
mode: PaletteMode::Dark,
surface_bg: Color::Rgb(0x1e, 0x1e, 0x2e), panel_bg: Color::Rgb(0x18, 0x18, 0x25), elevated_bg: Color::Rgb(0x31, 0x32, 0x44), composer_bg: Color::Rgb(0x18, 0x18, 0x25),
selection_bg: Color::Rgb(0x45, 0x47, 0x5a), header_bg: Color::Rgb(0x11, 0x11, 0x1b), footer_bg: Color::Rgb(0x11, 0x11, 0x1b),
mode_agent: Color::Rgb(0x89, 0xb4, 0xfa), mode_yolo: Color::Rgb(0xf3, 0x8b, 0xa8), mode_plan: Color::Rgb(0xfa, 0xb3, 0x87), status_ready: Color::Rgb(0x7f, 0x84, 0x9c), status_working: Color::Rgb(0x74, 0xc7, 0xec), status_warning: Color::Rgb(0xf9, 0xe2, 0xaf), text_dim: Color::Rgb(0x6c, 0x70, 0x86), text_hint: Color::Rgb(0x7f, 0x84, 0x9c), text_muted: Color::Rgb(0xa6, 0xad, 0xc8), text_body: Color::Rgb(0xcd, 0xd6, 0xf4), text_soft: Color::Rgb(0xba, 0xc2, 0xde), border: Color::Rgb(0x45, 0x47, 0x5a), };
pub const TOKYO_NIGHT_UI_THEME: UiTheme = UiTheme {
name: "tokyo-night",
mode: PaletteMode::Dark,
surface_bg: Color::Rgb(0x1a, 0x1b, 0x26), panel_bg: Color::Rgb(0x16, 0x16, 0x1e), elevated_bg: Color::Rgb(0x29, 0x2e, 0x42), composer_bg: Color::Rgb(0x16, 0x16, 0x1e),
selection_bg: Color::Rgb(0x28, 0x34, 0x57), header_bg: Color::Rgb(0x16, 0x16, 0x1e),
footer_bg: Color::Rgb(0x16, 0x16, 0x1e),
mode_agent: Color::Rgb(0x7a, 0xa2, 0xf7), mode_yolo: Color::Rgb(0xf7, 0x76, 0x8e), mode_plan: Color::Rgb(0xff, 0x9e, 0x64), status_ready: Color::Rgb(0x56, 0x5f, 0x89), status_working: Color::Rgb(0x7d, 0xcf, 0xff), status_warning: Color::Rgb(0xe0, 0xaf, 0x68), text_dim: Color::Rgb(0x56, 0x5f, 0x89), text_hint: Color::Rgb(0x73, 0x7a, 0xa2), text_muted: Color::Rgb(0xa9, 0xb1, 0xd6), text_body: Color::Rgb(0xc0, 0xca, 0xf5), text_soft: Color::Rgb(0xbb, 0xc2, 0xe0),
border: Color::Rgb(0x41, 0x48, 0x68), };
pub const DRACULA_UI_THEME: UiTheme = UiTheme {
name: "dracula",
mode: PaletteMode::Dark,
surface_bg: Color::Rgb(0x28, 0x2a, 0x36), panel_bg: Color::Rgb(0x21, 0x22, 0x2c),
elevated_bg: Color::Rgb(0x34, 0x37, 0x46),
composer_bg: Color::Rgb(0x21, 0x22, 0x2c),
selection_bg: Color::Rgb(0x44, 0x47, 0x5a), header_bg: Color::Rgb(0x21, 0x22, 0x2c),
footer_bg: Color::Rgb(0x21, 0x22, 0x2c),
mode_agent: Color::Rgb(0xbd, 0x93, 0xf9), mode_yolo: Color::Rgb(0xff, 0x55, 0x55), mode_plan: Color::Rgb(0xff, 0xb8, 0x6c), status_ready: Color::Rgb(0x62, 0x72, 0xa4), status_working: Color::Rgb(0x8b, 0xe9, 0xfd), status_warning: Color::Rgb(0xf1, 0xfa, 0x8c), text_dim: Color::Rgb(0x62, 0x72, 0xa4),
text_hint: Color::Rgb(0x8a, 0x8e, 0xaa),
text_muted: Color::Rgb(0xc0, 0xc4, 0xd6),
text_body: Color::Rgb(0xf8, 0xf8, 0xf2), text_soft: Color::Rgb(0xe2, 0xe2, 0xdc),
border: Color::Rgb(0x44, 0x47, 0x5a),
};
pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme {
name: "gruvbox-dark",
mode: PaletteMode::Dark,
surface_bg: Color::Rgb(0x28, 0x28, 0x28), panel_bg: Color::Rgb(0x3c, 0x38, 0x36), elevated_bg: Color::Rgb(0x50, 0x49, 0x45), composer_bg: Color::Rgb(0x3c, 0x38, 0x36),
selection_bg: Color::Rgb(0x66, 0x5c, 0x54), header_bg: Color::Rgb(0x1d, 0x20, 0x21), footer_bg: Color::Rgb(0x1d, 0x20, 0x21),
mode_agent: Color::Rgb(0x83, 0xa5, 0x98), mode_yolo: Color::Rgb(0xfb, 0x49, 0x34), mode_plan: Color::Rgb(0xfe, 0x80, 0x19), status_ready: Color::Rgb(0x92, 0x83, 0x74), status_working: Color::Rgb(0x8e, 0xc0, 0x7c), status_warning: Color::Rgb(0xfa, 0xbd, 0x2f), text_dim: Color::Rgb(0x92, 0x83, 0x74), text_hint: Color::Rgb(0xa8, 0x99, 0x84), text_muted: Color::Rgb(0xbd, 0xae, 0x93), text_body: Color::Rgb(0xeb, 0xdb, 0xb2), text_soft: Color::Rgb(0xd5, 0xc4, 0xa1), border: Color::Rgb(0x66, 0x5c, 0x54), };
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemeId {
System,
Whale,
WhaleLight,
Grayscale,
CatppuccinMocha,
TokyoNight,
Dracula,
GruvboxDark,
}
impl ThemeId {
#[must_use]
pub fn from_name(value: &str) -> Option<Self> {
match normalize_theme_name(value)? {
"system" => Some(Self::System),
"dark" => Some(Self::Whale),
"light" => Some(Self::WhaleLight),
"grayscale" => Some(Self::Grayscale),
"catppuccin-mocha" => Some(Self::CatppuccinMocha),
"tokyo-night" => Some(Self::TokyoNight),
"dracula" => Some(Self::Dracula),
"gruvbox-dark" => Some(Self::GruvboxDark),
_ => None,
}
}
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::System => "system",
Self::Whale => "dark",
Self::WhaleLight => "light",
Self::Grayscale => "grayscale",
Self::CatppuccinMocha => "catppuccin-mocha",
Self::TokyoNight => "tokyo-night",
Self::Dracula => "dracula",
Self::GruvboxDark => "gruvbox-dark",
}
}
#[must_use]
pub const fn display_name(self) -> &'static str {
match self {
Self::System => "System",
Self::Whale => "Whale (Dark)",
Self::WhaleLight => "Whale Light",
Self::Grayscale => "Grayscale",
Self::CatppuccinMocha => "Catppuccin Mocha",
Self::TokyoNight => "Tokyo Night",
Self::Dracula => "Dracula",
Self::GruvboxDark => "Gruvbox Dark",
}
}
#[must_use]
pub const fn tagline(self) -> &'static str {
match self {
Self::System => "Follow terminal background (COLORFGBG)",
Self::Whale => "Default DeepSeek dark blue",
Self::WhaleLight => "DeepSeek light, paper-ish",
Self::Grayscale => "Color-minimal high contrast",
Self::CatppuccinMocha => "Soft pastels on warm dark",
Self::TokyoNight => "Deep blue/violet night palette",
Self::Dracula => "Classic high-contrast purple",
Self::GruvboxDark => "Vintage warm earth tones",
}
}
#[must_use]
pub fn ui_theme(self) -> UiTheme {
match self {
Self::System => UiTheme::detect(),
Self::Whale => UI_THEME,
Self::WhaleLight => LIGHT_UI_THEME,
Self::Grayscale => GRAYSCALE_UI_THEME,
Self::CatppuccinMocha => CATPPUCCIN_MOCHA_UI_THEME,
Self::TokyoNight => TOKYO_NIGHT_UI_THEME,
Self::Dracula => DRACULA_UI_THEME,
Self::GruvboxDark => GRUVBOX_DARK_UI_THEME,
}
}
}
pub const SELECTABLE_THEMES: &[ThemeId] = &[
ThemeId::System,
ThemeId::Whale,
ThemeId::WhaleLight,
ThemeId::Grayscale,
ThemeId::CatppuccinMocha,
ThemeId::TokyoNight,
ThemeId::Dracula,
ThemeId::GruvboxDark,
];
impl UiTheme {
#[must_use]
pub fn for_mode(mode: PaletteMode) -> Self {
match mode {
PaletteMode::Dark => UI_THEME,
PaletteMode::Light => LIGHT_UI_THEME,
PaletteMode::Grayscale => GRAYSCALE_UI_THEME,
}
}
#[must_use]
pub fn detect() -> Self {
Self::for_mode(PaletteMode::detect())
}
#[must_use]
pub fn from_setting(value: &str) -> Option<Self> {
ThemeId::from_name(value).map(ThemeId::ui_theme)
}
#[must_use]
pub fn with_background_color(mut self, color: Color) -> Self {
self.surface_bg = color;
self.header_bg = color;
self.footer_bg = color;
self
}
}
#[must_use]
pub fn normalize_theme_name(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"" | "auto" | "system" | "default" => Some("system"),
"dark" | "whale" | "whale-dark" => Some("dark"),
"light" | "whale-light" => Some("light"),
"grayscale" | "greyscale" | "gray" | "grey" | "mono" | "monochrome" | "black-white"
| "black_and_white" | "blackwhite" | "bw" | "b&w" => Some("grayscale"),
"catppuccin-mocha" | "catppuccin" | "mocha" => Some("catppuccin-mocha"),
"tokyo-night" | "tokyonight" | "tokyo" => Some("tokyo-night"),
"dracula" => Some("dracula"),
"gruvbox-dark" | "gruvbox" => Some("gruvbox-dark"),
_ => None,
}
}
#[must_use]
pub fn theme_label_for_mode(mode: PaletteMode) -> &'static str {
match mode {
PaletteMode::Dark => "dark",
PaletteMode::Light => "light",
PaletteMode::Grayscale => "grayscale",
}
}
#[must_use]
pub fn ui_theme_from_settings(theme: &str, background_color: Option<&str>) -> UiTheme {
let mut ui_theme = UiTheme::from_setting(theme).unwrap_or_else(UiTheme::detect);
if let Some(background) = background_color.and_then(parse_hex_rgb_color) {
ui_theme = ui_theme.with_background_color(background);
}
ui_theme
}
#[must_use]
pub fn parse_hex_rgb_color(value: &str) -> Option<Color> {
let hex = value.trim().strip_prefix('#').unwrap_or(value.trim());
if hex.len() != 6 || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Color::Rgb(r, g, b))
}
#[must_use]
pub fn normalize_hex_rgb_color(value: &str) -> Option<String> {
hex_rgb_string(parse_hex_rgb_color(value)?)
}
#[must_use]
pub fn hex_rgb_string(color: Color) -> Option<String> {
let Color::Rgb(r, g, b) = color else {
return None;
};
Some(format!("#{r:02x}{g:02x}{b:02x}"))
}
#[must_use]
pub fn adapt_fg_for_palette_mode(color: Color, _bg: Color, mode: PaletteMode) -> Color {
match mode {
PaletteMode::Dark => color,
PaletteMode::Light => adapt_fg_for_light_palette(color),
PaletteMode::Grayscale => adapt_fg_for_grayscale_palette(color),
}
}
#[must_use]
pub fn adapt_bg_for_palette_mode(color: Color, mode: PaletteMode) -> Color {
match mode {
PaletteMode::Dark => color,
PaletteMode::Light => adapt_bg_for_light_palette(color),
PaletteMode::Grayscale => adapt_bg_for_grayscale_palette(color),
}
}
fn adapt_fg_for_light_palette(color: Color) -> Color {
if color == TEXT_BODY || color == SELECTION_TEXT || color == Color::White {
LIGHT_TEXT_BODY
} else if color == TEXT_SECONDARY || color == TEXT_MUTED {
LIGHT_TEXT_MUTED
} else if color == TEXT_HINT || color == TEXT_DIM {
LIGHT_TEXT_HINT
} else if color == TEXT_SOFT || color == TEXT_TOOL_OUTPUT {
LIGHT_TEXT_SOFT
} else if color == BORDER_COLOR {
LIGHT_BORDER
} else if color == TEXT_ACCENT || color == DEEPSEEK_SKY || color == ACCENT_TOOL_LIVE {
DEEPSEEK_BLUE
} else if color == TEXT_REASONING || color == ACCENT_REASONING_LIVE {
Color::Rgb(146, 64, 14)
} else if color == ACCENT_TOOL_ISSUE {
Color::Rgb(159, 18, 57)
} else if color == DIFF_ADDED {
Color::Rgb(22, 101, 52)
} else if color == USER_BODY {
LIGHT_USER_BODY
} else {
color
}
}
fn adapt_bg_for_light_palette(color: Color) -> Color {
if color == DEEPSEEK_INK || color == BACKGROUND_DARK {
LIGHT_SURFACE
} else if color == DEEPSEEK_SLATE
|| color == COMPOSER_BG
|| color == SURFACE_PANEL
|| color == SURFACE_TOOL
{
LIGHT_PANEL
} else if color == SURFACE_ELEVATED || color == SURFACE_TOOL_ACTIVE {
LIGHT_ELEVATED
} else if color == SURFACE_REASONING
|| color == SURFACE_REASONING_TINT
|| color == SURFACE_REASONING_ACTIVE
{
LIGHT_REASONING
} else if color == SURFACE_SUCCESS {
LIGHT_SUCCESS
} else if color == SURFACE_ERROR {
LIGHT_ERROR
} else if color == DIFF_ADDED_BG {
LIGHT_SUCCESS
} else if color == DIFF_DELETED_BG {
LIGHT_ERROR
} else if color == SELECTION_BG {
LIGHT_SELECTION_BG
} else {
color
}
}
#[must_use]
const fn theme_green(theme: ThemeId) -> Color {
match theme {
ThemeId::CatppuccinMocha => Color::Rgb(0xa6, 0xe3, 0xa1),
ThemeId::TokyoNight => Color::Rgb(0x9e, 0xce, 0x6a),
ThemeId::Dracula => Color::Rgb(0x50, 0xfa, 0x7b),
ThemeId::GruvboxDark => Color::Rgb(0xb8, 0xbb, 0x26),
_ => USER_BODY,
}
}
#[must_use]
const fn theme_red(theme: ThemeId) -> Color {
match theme {
ThemeId::CatppuccinMocha => Color::Rgb(0xf3, 0x8b, 0xa8),
ThemeId::TokyoNight => Color::Rgb(0xf7, 0x76, 0x8e),
ThemeId::Dracula => Color::Rgb(0xff, 0x55, 0x55),
ThemeId::GruvboxDark => Color::Rgb(0xfb, 0x49, 0x34),
_ => DEEPSEEK_RED,
}
}
#[must_use]
const fn theme_diff_added_bg(theme: ThemeId) -> Color {
match theme {
ThemeId::CatppuccinMocha => Color::Rgb(0x1f, 0x33, 0x29),
ThemeId::TokyoNight => Color::Rgb(0x1b, 0x2b, 0x1f),
ThemeId::Dracula => Color::Rgb(0x21, 0x3a, 0x2a),
ThemeId::GruvboxDark => Color::Rgb(0x29, 0x32, 0x16),
_ => DIFF_ADDED_BG,
}
}
#[must_use]
const fn theme_diff_deleted_bg(theme: ThemeId) -> Color {
match theme {
ThemeId::CatppuccinMocha => Color::Rgb(0x3a, 0x1f, 0x2a),
ThemeId::TokyoNight => Color::Rgb(0x33, 0x1c, 0x24),
ThemeId::Dracula => Color::Rgb(0x3a, 0x1f, 0x22),
ThemeId::GruvboxDark => Color::Rgb(0x35, 0x1c, 0x18),
_ => DIFF_DELETED_BG,
}
}
#[inline]
#[must_use]
pub const fn theme_remap_active(theme: ThemeId) -> bool {
matches!(
theme,
ThemeId::CatppuccinMocha | ThemeId::TokyoNight | ThemeId::Dracula | ThemeId::GruvboxDark
)
}
#[must_use]
pub fn adapt_fg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color {
if !theme_remap_active(theme) {
return color;
}
if color == TEXT_BODY || color == SELECTION_TEXT || color == Color::White {
ui.text_body
} else if color == TEXT_SECONDARY || color == TEXT_MUTED {
ui.text_muted
} else if color == TEXT_HINT || color == TEXT_DIM {
ui.text_hint
} else if color == TEXT_SOFT || color == TEXT_TOOL_OUTPUT {
ui.text_soft
} else if color == BORDER_COLOR {
ui.border
} else if color == TEXT_ACCENT || color == DEEPSEEK_SKY || color == ACCENT_TOOL_LIVE {
ui.status_working
} else if color == TEXT_REASONING || color == ACCENT_REASONING_LIVE {
ui.mode_plan
} else if color == ACCENT_TOOL_ISSUE {
ui.mode_yolo
} else if color == STATUS_WARNING {
ui.status_warning
} else if color == DEEPSEEK_RED {
theme_red(theme)
} else if color == DIFF_ADDED || color == USER_BODY {
theme_green(theme)
} else if color == DEEPSEEK_BLUE {
ui.mode_agent
} else {
color
}
}
#[must_use]
pub fn adapt_bg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color {
if !theme_remap_active(theme) {
return color;
}
if color == DEEPSEEK_INK || color == BACKGROUND_DARK {
ui.surface_bg
} else if color == DEEPSEEK_SLATE
|| color == COMPOSER_BG
|| color == SURFACE_PANEL
|| color == SURFACE_TOOL
{
ui.panel_bg
} else if color == SURFACE_ELEVATED || color == SURFACE_TOOL_ACTIVE {
ui.elevated_bg
} else if color == SURFACE_REASONING
|| color == SURFACE_REASONING_TINT
|| color == SURFACE_REASONING_ACTIVE
|| color == SURFACE_SUCCESS
|| color == SURFACE_ERROR
{
ui.panel_bg
} else if color == SELECTION_BG {
ui.selection_bg
} else if color == DIFF_ADDED_BG {
theme_diff_added_bg(theme)
} else if color == DIFF_DELETED_BG {
theme_diff_deleted_bg(theme)
} else {
color
}
}
fn adapt_fg_for_grayscale_palette(color: Color) -> Color {
if color == Color::Reset {
return color;
}
if color == TEXT_BODY
|| color == SELECTION_TEXT
|| color == LIGHT_TEXT_BODY
|| color == Color::White
|| color == DEEPSEEK_RED
|| color == STATUS_ERROR
|| color == MODE_YOLO
{
GRAYSCALE_TEXT_BODY
} else if color == TEXT_SOFT
|| color == TEXT_TOOL_OUTPUT
|| color == LIGHT_TEXT_SOFT
|| color == TEXT_ACCENT
|| color == DEEPSEEK_SKY
|| color == DEEPSEEK_BLUE
|| color == ACCENT_TOOL_LIVE
|| color == STATUS_SUCCESS
|| color == STATUS_INFO
|| color == MODE_AGENT
{
GRAYSCALE_TEXT_SOFT
} else if color == TEXT_SECONDARY
|| color == TEXT_MUTED
|| color == LIGHT_TEXT_MUTED
|| color == TEXT_REASONING
|| color == ACCENT_REASONING_LIVE
|| color == STATUS_WARNING
|| color == MODE_PLAN
|| color == USER_BODY
|| color == LIGHT_USER_BODY
|| color == DIFF_ADDED
{
GRAYSCALE_TEXT_MUTED
} else if color == TEXT_HINT
|| color == TEXT_DIM
|| color == LIGHT_TEXT_HINT
|| color == BORDER_COLOR
|| color == LIGHT_BORDER
|| color == ACCENT_TOOL_ISSUE
{
GRAYSCALE_TEXT_HINT
} else {
match color {
Color::Black => GRAYSCALE_TEXT_BODY,
Color::Gray | Color::DarkGray => GRAYSCALE_TEXT_HINT,
Color::Red
| Color::LightRed
| Color::Green
| Color::LightGreen
| Color::Yellow
| Color::LightYellow
| Color::Blue
| Color::LightBlue
| Color::Magenta
| Color::LightMagenta
| Color::Cyan
| Color::LightCyan => GRAYSCALE_TEXT_SOFT,
Color::Rgb(r, g, b) => grayscale_fg_from_luma(luma(r, g, b)),
Color::Indexed(_) => color,
_ => color,
}
}
}
fn adapt_bg_for_grayscale_palette(color: Color) -> Color {
if color == Color::Reset {
return color;
}
if color == DEEPSEEK_INK || color == BACKGROUND_DARK || color == LIGHT_SURFACE {
GRAYSCALE_SURFACE
} else if color == DEEPSEEK_SLATE
|| color == COMPOSER_BG
|| color == SURFACE_PANEL
|| color == SURFACE_TOOL
|| color == LIGHT_PANEL
{
GRAYSCALE_PANEL
} else if color == SURFACE_ELEVATED
|| color == SURFACE_TOOL_ACTIVE
|| color == LIGHT_ELEVATED
|| color == SELECTION_BG
|| color == LIGHT_SELECTION_BG
{
GRAYSCALE_ELEVATED
} else if color == SURFACE_REASONING
|| color == SURFACE_REASONING_TINT
|| color == SURFACE_REASONING_ACTIVE
|| color == LIGHT_REASONING
{
GRAYSCALE_REASONING
} else if color == SURFACE_SUCCESS || color == DIFF_ADDED_BG || color == LIGHT_SUCCESS {
GRAYSCALE_SUCCESS
} else if color == SURFACE_ERROR || color == DIFF_DELETED_BG || color == LIGHT_ERROR {
GRAYSCALE_ERROR
} else {
match color {
Color::Black => GRAYSCALE_SURFACE,
Color::White | Color::Gray => GRAYSCALE_ELEVATED,
Color::DarkGray => GRAYSCALE_PANEL,
Color::Red
| Color::LightRed
| Color::Green
| Color::LightGreen
| Color::Yellow
| Color::LightYellow
| Color::Blue
| Color::LightBlue
| Color::Magenta
| Color::LightMagenta
| Color::Cyan
| Color::LightCyan => GRAYSCALE_ELEVATED,
Color::Rgb(r, g, b) => grayscale_bg_from_luma(luma(r, g, b)),
Color::Indexed(_) => color,
_ => color,
}
}
}
fn grayscale_fg_from_luma(luma: u8) -> Color {
match luma {
0..=95 => GRAYSCALE_TEXT_HINT,
96..=155 => GRAYSCALE_TEXT_MUTED,
156..=215 => GRAYSCALE_TEXT_SOFT,
_ => GRAYSCALE_TEXT_BODY,
}
}
fn grayscale_bg_from_luma(luma: u8) -> Color {
match luma {
0..=28 => GRAYSCALE_SURFACE,
29..=95 => GRAYSCALE_PANEL,
96..=185 => GRAYSCALE_ELEVATED,
_ => GRAYSCALE_REASONING,
}
}
fn luma(r: u8, g: u8, b: u8) -> u8 {
(((u16::from(r) * 299) + (u16::from(g) * 587) + (u16::from(b) * 114)) / 1000) as u8
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorDepth {
Ansi16,
Ansi256,
TrueColor,
}
impl ColorDepth {
#[must_use]
pub fn detect() -> Self {
if let Ok(ct) = std::env::var("COLORTERM") {
let ct = ct.to_ascii_lowercase();
if ct.contains("truecolor") || ct.contains("24bit") {
return Self::TrueColor;
}
}
if std::env::var_os("WT_SESSION").is_some() {
return Self::TrueColor;
}
if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
let term_program = term_program.to_ascii_lowercase();
if term_program.contains("iterm")
|| term_program.contains("wezterm")
|| term_program.contains("vscode")
|| term_program.contains("warp")
{
return Self::TrueColor;
}
}
let term = std::env::var("TERM").unwrap_or_default();
let term = term.to_ascii_lowercase();
if term.contains("truecolor") || term.contains("24bit") {
Self::TrueColor
} else if term.contains("256") {
Self::Ansi256
} else if term.is_empty() || term == "dumb" {
Self::Ansi16
} else {
Self::Ansi256
}
}
}
#[allow(dead_code)]
#[must_use]
pub fn adapt_color(color: Color, depth: ColorDepth) -> Color {
match (color, depth) {
(_, ColorDepth::TrueColor) => color,
(Color::Rgb(r, g, b), ColorDepth::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)),
(Color::Rgb(r, g, b), ColorDepth::Ansi16) => nearest_ansi16(r, g, b),
_ => color,
}
}
#[allow(dead_code)]
#[must_use]
pub fn adapt_bg(color: Color, depth: ColorDepth) -> Color {
match (color, depth) {
(_, ColorDepth::TrueColor) => color,
(Color::Rgb(r, g, b), ColorDepth::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)),
(_, ColorDepth::Ansi256) => color,
(_, ColorDepth::Ansi16) => Color::Reset,
}
}
#[allow(dead_code)]
#[must_use]
pub fn blend(fg: Color, bg: Color, alpha: f32) -> Color {
let alpha = alpha.clamp(0.0, 1.0);
match (fg, bg) {
(Color::Rgb(fr, fg_, fb), Color::Rgb(br, bg_, bb)) => {
let mix = |a: u8, b: u8| -> u8 {
let a = f32::from(a);
let b = f32::from(b);
(b + (a - b) * alpha).round().clamp(0.0, 255.0) as u8
};
Color::Rgb(mix(fr, br), mix(fg_, bg_), mix(fb, bb))
}
_ => fg,
}
}
#[must_use]
pub fn reasoning_surface_tint(depth: ColorDepth) -> Option<Color> {
match depth {
ColorDepth::Ansi16 => None,
_ => Some(adapt_bg(SURFACE_REASONING_TINT, depth)),
}
}
#[must_use]
pub fn pulse_brightness(color: Color, now_ms: u64) -> Color {
let phase = (now_ms % 2000) as f32 / 2000.0;
let t = (phase * std::f32::consts::TAU).sin() * 0.5 + 0.5; let alpha = 0.30 + t * 0.70; match color {
Color::Rgb(r, g, b) => {
let s = |c: u8| -> u8 { ((f32::from(c)) * alpha).round().clamp(0.0, 255.0) as u8 };
Color::Rgb(s(r), s(g), s(b))
}
other => other,
}
}
#[allow(dead_code)]
fn nearest_ansi16(r: u8, g: u8, b: u8) -> Color {
let lum = (u16::from(r) + u16::from(g) + u16::from(b)) / 3;
if lum < 24 {
return Color::Black;
}
if r > 220 && g > 220 && b > 220 {
return Color::White;
}
let bright = lum > 144;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
if max.saturating_sub(min) < 16 {
return if bright { Color::Gray } else { Color::DarkGray };
}
if r >= g && r >= b {
if g > b + 24 {
if bright {
Color::LightYellow
} else {
Color::Yellow
}
} else if b > r.saturating_sub(24) {
if bright {
Color::LightMagenta
} else {
Color::Magenta
}
} else if bright {
Color::LightRed
} else {
Color::Red
}
} else if g >= r && g >= b {
if b > r + 24 {
if bright {
Color::LightCyan
} else {
Color::Cyan
}
} else if bright {
Color::LightGreen
} else {
Color::Green
}
} else if r.saturating_add(48) >= b && r > g + 24 {
if bright {
Color::LightMagenta
} else {
Color::Magenta
}
} else if g.saturating_add(48) >= b && g > r + 24 {
if bright {
Color::LightCyan
} else {
Color::Cyan
}
} else if bright {
Color::LightBlue
} else {
Color::Blue
}
}
#[allow(dead_code)]
fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
fn nearest_cube_level(channel: u8) -> usize {
CUBE_LEVELS
.iter()
.enumerate()
.min_by_key(|(_, level)| channel.abs_diff(**level))
.map(|(idx, _)| idx)
.unwrap_or(0)
}
fn dist_sq(a: (u8, u8, u8), b: (u8, u8, u8)) -> u32 {
let dr = i32::from(a.0) - i32::from(b.0);
let dg = i32::from(a.1) - i32::from(b.1);
let db = i32::from(a.2) - i32::from(b.2);
(dr * dr + dg * dg + db * db) as u32
}
let ri = nearest_cube_level(r);
let gi = nearest_cube_level(g);
let bi = nearest_cube_level(b);
let cube_rgb = (CUBE_LEVELS[ri], CUBE_LEVELS[gi], CUBE_LEVELS[bi]);
let cube_index = 16 + (36 * ri) as u8 + (6 * gi) as u8 + bi as u8;
let avg = ((u16::from(r) + u16::from(g) + u16::from(b)) / 3) as u8;
let gray_i = if avg <= 8 {
0
} else if avg >= 238 {
23
} else {
((u16::from(avg) - 8 + 5) / 10).min(23) as u8
};
let gray = 8 + 10 * gray_i;
let gray_index = 232 + gray_i;
if dist_sq((r, g, b), (gray, gray, gray)) < dist_sq((r, g, b), cube_rgb) {
gray_index
} else {
cube_index
}
}
#[cfg(test)]
mod tests {
use super::{
ACCENT_REASONING_LIVE, ColorDepth, DEEPSEEK_INK, DEEPSEEK_RED, DEEPSEEK_SKY,
DEEPSEEK_SLATE, GRAYSCALE_BORDER, GRAYSCALE_ELEVATED, GRAYSCALE_PANEL, GRAYSCALE_REASONING,
GRAYSCALE_SURFACE, GRAYSCALE_TEXT_BODY, GRAYSCALE_TEXT_HINT, GRAYSCALE_TEXT_SOFT,
GRAYSCALE_UI_THEME, LIGHT_BORDER, LIGHT_ELEVATED, LIGHT_PANEL, LIGHT_REASONING,
LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_HINT, LIGHT_UI_THEME, PaletteMode,
SURFACE_REASONING, SURFACE_REASONING_TINT, TEXT_BODY, TEXT_HINT, TEXT_REASONING,
TEXT_TOOL_OUTPUT, UI_THEME, adapt_bg, adapt_bg_for_palette_mode, adapt_color,
adapt_fg_for_palette_mode, blend, nearest_ansi16, normalize_hex_rgb_color,
normalize_theme_name, parse_hex_rgb_color, pulse_brightness, reasoning_surface_tint,
rgb_to_ansi256, theme_label_for_mode, ui_theme_from_settings,
};
use ratatui::style::Color;
#[test]
fn palette_mode_parses_colorfgbg_background_slot() {
assert_eq!(
PaletteMode::from_colorfgbg("0;15"),
Some(PaletteMode::Light)
);
assert_eq!(PaletteMode::from_colorfgbg("15;0"), Some(PaletteMode::Dark));
assert_eq!(
PaletteMode::from_colorfgbg("7;default;15"),
Some(PaletteMode::Light)
);
assert_eq!(PaletteMode::from_colorfgbg("not-a-color"), None);
}
#[test]
fn ui_theme_selects_light_variant() {
let theme = super::UiTheme::for_mode(PaletteMode::Light);
assert_eq!(theme, LIGHT_UI_THEME);
assert_eq!(theme.surface_bg, LIGHT_SURFACE);
assert_eq!(theme.text_body, LIGHT_TEXT_BODY);
}
#[test]
fn ui_theme_selects_grayscale_variant() {
let theme = super::UiTheme::for_mode(PaletteMode::Grayscale);
assert_eq!(theme, GRAYSCALE_UI_THEME);
assert_eq!(theme.surface_bg, GRAYSCALE_SURFACE);
assert_eq!(theme.panel_bg, GRAYSCALE_PANEL);
assert_eq!(theme.text_body, GRAYSCALE_TEXT_BODY);
}
#[test]
fn theme_names_normalize_common_grayscale_aliases() {
assert_eq!(normalize_theme_name("system"), Some("system"));
assert_eq!(normalize_theme_name("default"), Some("system"));
assert_eq!(normalize_theme_name("whale"), Some("dark"));
assert_eq!(normalize_theme_name("black-white"), Some("grayscale"));
assert_eq!(normalize_theme_name("mono"), Some("grayscale"));
assert_eq!(normalize_theme_name("solarized"), None);
assert_eq!(theme_label_for_mode(PaletteMode::Grayscale), "grayscale");
}
#[test]
fn light_palette_has_quiet_layer_separation() {
assert_eq!(LIGHT_SURFACE, Color::Rgb(246, 248, 251));
assert_eq!(LIGHT_PANEL, Color::Rgb(236, 242, 248));
assert_eq!(LIGHT_ELEVATED, Color::Rgb(219, 229, 240));
assert_eq!(LIGHT_BORDER, Color::Rgb(139, 161, 184));
assert_ne!(LIGHT_SURFACE, LIGHT_PANEL);
assert_ne!(LIGHT_PANEL, LIGHT_ELEVATED);
}
#[test]
fn dark_palette_uses_soft_body_text_and_warm_reasoning() {
assert_eq!(TEXT_BODY, Color::Rgb(226, 232, 240));
assert_eq!(TEXT_REASONING, Color::Rgb(211, 170, 112));
assert_eq!(ACCENT_REASONING_LIVE, Color::Rgb(224, 153, 72));
assert_ne!(TEXT_REASONING, TEXT_TOOL_OUTPUT);
assert_ne!(TEXT_BODY, Color::White);
}
#[test]
fn ui_theme_applies_custom_background_to_base_surfaces() {
let custom = Color::Rgb(26, 27, 38);
let theme = super::UiTheme::for_mode(PaletteMode::Dark).with_background_color(custom);
assert_eq!(theme.surface_bg, custom);
assert_eq!(theme.header_bg, custom);
assert_eq!(theme.footer_bg, custom);
assert_eq!(
theme.composer_bg, UI_THEME.composer_bg,
"custom background must not erase panel contrast"
);
}
#[test]
fn hex_rgb_color_parser_accepts_hashless_and_normalizes() {
assert_eq!(parse_hex_rgb_color("#1a1B26"), Some(Color::Rgb(26, 27, 38)));
assert_eq!(parse_hex_rgb_color("1a1b26"), Some(Color::Rgb(26, 27, 38)));
assert_eq!(
normalize_hex_rgb_color("#1A1B26").as_deref(),
Some("#1a1b26")
);
assert_eq!(parse_hex_rgb_color("#123"), None);
assert_eq!(parse_hex_rgb_color("#zzzzzz"), None);
}
#[test]
fn light_palette_maps_dark_surfaces_and_text() {
assert_eq!(
adapt_bg_for_palette_mode(DEEPSEEK_INK, PaletteMode::Light),
LIGHT_SURFACE
);
assert_eq!(
adapt_bg_for_palette_mode(DEEPSEEK_SLATE, PaletteMode::Light),
LIGHT_PANEL
);
assert_eq!(
adapt_fg_for_palette_mode(Color::White, LIGHT_SURFACE, PaletteMode::Light),
LIGHT_TEXT_BODY
);
assert_eq!(
adapt_fg_for_palette_mode(TEXT_HINT, LIGHT_SURFACE, PaletteMode::Light),
LIGHT_TEXT_HINT
);
}
#[test]
fn grayscale_palette_maps_brand_hues_to_neutral_roles() {
assert_eq!(
adapt_bg_for_palette_mode(DEEPSEEK_INK, PaletteMode::Grayscale),
GRAYSCALE_SURFACE
);
assert_eq!(
adapt_bg_for_palette_mode(DEEPSEEK_SLATE, PaletteMode::Grayscale),
GRAYSCALE_PANEL
);
assert_eq!(
adapt_bg_for_palette_mode(SURFACE_REASONING, PaletteMode::Grayscale),
GRAYSCALE_REASONING
);
assert_eq!(
adapt_fg_for_palette_mode(DEEPSEEK_SKY, GRAYSCALE_SURFACE, PaletteMode::Grayscale),
GRAYSCALE_TEXT_SOFT
);
assert_eq!(
adapt_fg_for_palette_mode(DEEPSEEK_RED, GRAYSCALE_SURFACE, PaletteMode::Grayscale),
GRAYSCALE_TEXT_BODY
);
assert_eq!(
adapt_fg_for_palette_mode(TEXT_HINT, GRAYSCALE_SURFACE, PaletteMode::Grayscale),
GRAYSCALE_TEXT_HINT
);
}
#[test]
fn ui_theme_from_settings_applies_theme_and_background() {
let theme = ui_theme_from_settings("grayscale", Some("#111111"));
assert_eq!(theme.mode, PaletteMode::Grayscale);
assert_eq!(theme.surface_bg, Color::Rgb(17, 17, 17));
assert_eq!(theme.header_bg, Color::Rgb(17, 17, 17));
assert_eq!(theme.footer_bg, Color::Rgb(17, 17, 17));
assert_eq!(theme.panel_bg, GRAYSCALE_PANEL);
assert_eq!(theme.elevated_bg, GRAYSCALE_ELEVATED);
assert_eq!(theme.border, GRAYSCALE_BORDER);
}
#[test]
fn adapt_color_passes_through_truecolor() {
let c = Color::Rgb(53, 120, 229);
assert_eq!(adapt_color(c, ColorDepth::TrueColor), c);
}
#[test]
fn adapt_color_maps_rgb_to_indexed_on_ansi256() {
let c = Color::Rgb(53, 120, 229);
assert!(matches!(
adapt_color(c, ColorDepth::Ansi256),
Color::Indexed(_)
));
}
#[test]
fn adapt_bg_maps_rgb_to_indexed_on_ansi256() {
assert!(matches!(
adapt_bg(SURFACE_REASONING, ColorDepth::Ansi256),
Color::Indexed(_)
));
}
#[test]
fn adapt_color_drops_to_named_on_ansi16() {
assert_eq!(
adapt_color(DEEPSEEK_SKY, ColorDepth::Ansi16),
Color::LightBlue
);
assert_eq!(adapt_color(DEEPSEEK_RED, ColorDepth::Ansi16), Color::Red);
}
#[test]
fn adapt_bg_disables_tints_on_ansi16() {
assert_eq!(
adapt_bg(SURFACE_REASONING, ColorDepth::Ansi16),
Color::Reset
);
assert_eq!(
adapt_bg(SURFACE_REASONING, ColorDepth::TrueColor),
SURFACE_REASONING
);
}
#[test]
fn reasoning_tint_is_none_on_ansi16() {
assert!(reasoning_surface_tint(ColorDepth::Ansi16).is_none());
assert!(reasoning_surface_tint(ColorDepth::TrueColor).is_some());
assert!(matches!(
reasoning_surface_tint(ColorDepth::Ansi256),
Some(Color::Indexed(_))
));
}
#[test]
fn light_palette_maps_reasoning_tint_to_light_surface() {
assert_eq!(
blend(SURFACE_REASONING, DEEPSEEK_INK, 0.12),
SURFACE_REASONING_TINT
);
assert_eq!(
adapt_bg_for_palette_mode(SURFACE_REASONING_TINT, PaletteMode::Light),
LIGHT_REASONING
);
assert_eq!(
adapt_bg_for_palette_mode(
reasoning_surface_tint(ColorDepth::TrueColor).expect("truecolor tint"),
PaletteMode::Light,
),
LIGHT_REASONING
);
}
#[test]
fn blend_at_zero_returns_bg_at_one_returns_fg() {
let fg = Color::Rgb(200, 100, 50);
let bg = Color::Rgb(0, 0, 0);
assert_eq!(blend(fg, bg, 0.0), bg);
assert_eq!(blend(fg, bg, 1.0), fg);
}
#[test]
fn blend_at_half_is_midpoint() {
let mid = blend(Color::Rgb(200, 100, 0), Color::Rgb(0, 0, 0), 0.5);
assert_eq!(mid, Color::Rgb(100, 50, 0));
}
#[test]
fn pulse_brightness_swings_within_envelope() {
let src = ACCENT_REASONING_LIVE;
let mut min_r = u8::MAX;
let mut max_r = 0u8;
for ms in (0u64..2000).step_by(50) {
if let Color::Rgb(r, _, _) = pulse_brightness(src, ms) {
min_r = min_r.min(r);
max_r = max_r.max(r);
}
}
let Color::Rgb(src_r, _, _) = src else {
panic!("expected RGB");
};
let lower = (f32::from(src_r) * 0.30).round() as u8;
assert!(min_r <= lower + 2, "trough too high: {min_r}");
assert!(max_r + 2 >= src_r, "crest too low: {max_r}");
}
#[test]
fn pulse_passes_named_colors_unchanged() {
assert_eq!(pulse_brightness(Color::Reset, 0), Color::Reset);
assert_eq!(pulse_brightness(Color::Cyan, 1234), Color::Cyan);
}
#[test]
fn nearest_ansi16_routes_known_brand_colors() {
assert_eq!(nearest_ansi16(53, 120, 229), Color::Blue);
assert_eq!(nearest_ansi16(106, 174, 242), Color::LightBlue);
assert_eq!(nearest_ansi16(42, 74, 127), Color::Blue);
assert_eq!(nearest_ansi16(54, 187, 212), Color::LightCyan);
assert_eq!(nearest_ansi16(226, 80, 96), Color::Red);
assert_eq!(nearest_ansi16(11, 21, 38), Color::Black);
}
#[test]
fn rgb_to_ansi256_uses_stable_extended_palette() {
assert!(rgb_to_ansi256(53, 120, 229) >= 16);
assert!(rgb_to_ansi256(11, 21, 38) >= 16);
}
#[test]
fn color_depth_detect_is_safe_without_env() {
let _ = ColorDepth::detect();
let _ = adapt_color(DEEPSEEK_INK, ColorDepth::detect());
}
}