use ratatui::style::Color;
#[cfg(target_os = "macos")]
use std::process::Command;
pub const WHALE_BG_RGB: (u8, u8, u8) = (13, 21, 37); pub const WHALE_PANEL_RGB: (u8, u8, u8) = (19, 29, 48); pub const WHALE_ELEVATED_RGB: (u8, u8, u8) = (26, 40, 64); pub const WHALE_SELECTION_RGB: (u8, u8, u8) = (30, 50, 82); pub const WHALE_TEXT_BODY_RGB: (u8, u8, u8) = (246, 242, 232); pub const WHALE_TEXT_SOFT_RGB: (u8, u8, u8) = (217, 224, 234); pub const WHALE_TEXT_MUTED_RGB: (u8, u8, u8) = (169, 180, 199); pub const WHALE_TEXT_HINT_RGB: (u8, u8, u8) = (122, 134, 158); #[allow(dead_code)]
pub const WHALE_TEXT_DIM_RGB: (u8, u8, u8) = (107, 120, 146); pub const WHALE_ACCENT_PRIMARY_RGB: (u8, u8, u8) = (246, 196, 83); pub const WHALE_ACCENT_SECONDARY_RGB: (u8, u8, u8) = (79, 209, 197); pub const WHALE_ACCENT_ACTION_RGB: (u8, u8, u8) = (255, 122, 89); pub const WHALE_ERROR_RGB: (u8, u8, u8) = (255, 92, 122); pub const WHALE_ERROR_HOVER_RGB: (u8, u8, u8) = (255, 120, 144); pub const WHALE_ERROR_SURFACE_RGB: (u8, u8, u8) = (42, 18, 26); pub const WHALE_ERROR_BORDER_RGB: (u8, u8, u8) = (255, 138, 160); pub const WHALE_ERROR_TEXT_RGB: (u8, u8, u8) = (255, 214, 222); pub const WHALE_WARNING_RGB: (u8, u8, u8) = (240, 160, 48); pub const WHALE_SUCCESS_RGB: (u8, u8, u8) = (79, 209, 197); pub const WHALE_INFO_RGB: (u8, u8, u8) = (106, 174, 242); pub const WHALE_BORDER_RGB: (u8, u8, u8) = (42, 74, 127); pub const WHALE_REASONING_TEXT_RGB: (u8, u8, u8) = (224, 153, 72); pub const WHALE_REASONING_SURFACE_RGB: (u8, u8, u8) = (42, 34, 24); pub const WHALE_REASONING_TINT_RGB: (u8, u8, u8) = (20, 30, 42); pub const WHALE_DIFF_ADDED_RGB: (u8, u8, u8) = (87, 199, 133); #[allow(dead_code)]
pub const WHALE_DIFF_DELETED_RGB: (u8, u8, u8) = (255, 92, 122); pub const WHALE_DIFF_ADDED_BG_RGB: (u8, u8, u8) = (18, 42, 34); pub const WHALE_DIFF_DELETED_BG_RGB: (u8, u8, u8) = (42, 18, 26); pub const WHALE_MODE_AGENT_RGB: (u8, u8, u8) = (80, 150, 255); pub const WHALE_MODE_YOLO_RGB: (u8, u8, u8) = (255, 100, 100); pub const WHALE_MODE_PLAN_RGB: (u8, u8, u8) = (246, 196, 83); pub const WHALE_MODE_GOAL_RGB: (u8, u8, u8) = (100, 220, 160); pub const WHALE_TOOL_LIVE_RGB: (u8, u8, u8) = (133, 184, 234); pub const WHALE_TOOL_ISSUE_RGB: (u8, u8, u8) = (192, 143, 153); pub const WHALE_TOOL_OUTPUT_RGB: (u8, u8, u8) = (194, 208, 224); pub const WHALE_TOOL_SURFACE_RGB: (u8, u8, u8) = (24, 34, 53); pub const WHALE_TOOL_ACTIVE_RGB: (u8, u8, u8) = (31, 45, 69);
pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = WHALE_ACCENT_PRIMARY_RGB;
pub const DEEPSEEK_SKY_RGB: (u8, u8, u8) = WHALE_INFO_RGB;
#[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) = WHALE_BG_RGB;
pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = WHALE_PANEL_RGB;
pub const DEEPSEEK_RED_RGB: (u8, u8, u8) = WHALE_ERROR_RGB;
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) = WHALE_BORDER_RGB;
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(
WHALE_TEXT_BODY_RGB.0,
WHALE_TEXT_BODY_RGB.1,
WHALE_TEXT_BODY_RGB.2,
);
pub const TEXT_SECONDARY: Color = Color::Rgb(
WHALE_TEXT_MUTED_RGB.0,
WHALE_TEXT_MUTED_RGB.1,
WHALE_TEXT_MUTED_RGB.2,
);
pub const TEXT_HINT: Color = Color::Rgb(
WHALE_TEXT_HINT_RGB.0,
WHALE_TEXT_HINT_RGB.1,
WHALE_TEXT_HINT_RGB.2,
);
pub const TEXT_ACCENT: Color = Color::Rgb(
WHALE_ACCENT_SECONDARY_RGB.0,
WHALE_ACCENT_SECONDARY_RGB.1,
WHALE_ACCENT_SECONDARY_RGB.2,
);
pub const SELECTION_TEXT: Color = Color::White;
pub const TEXT_SOFT: Color = Color::Rgb(
WHALE_TEXT_SOFT_RGB.0,
WHALE_TEXT_SOFT_RGB.1,
WHALE_TEXT_SOFT_RGB.2,
);
pub const TEXT_REASONING: Color = Color::Rgb(
WHALE_REASONING_TEXT_RGB.0,
WHALE_REASONING_TEXT_RGB.1,
WHALE_REASONING_TEXT_RGB.2,
);
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 = Color::Rgb(
WHALE_ACCENT_PRIMARY_RGB.0,
WHALE_ACCENT_PRIMARY_RGB.1,
WHALE_ACCENT_PRIMARY_RGB.2,
);
#[allow(dead_code)]
pub const ACCENT_SECONDARY: Color = Color::Rgb(
WHALE_ACCENT_SECONDARY_RGB.0,
WHALE_ACCENT_SECONDARY_RGB.1,
WHALE_ACCENT_SECONDARY_RGB.2,
);
#[allow(dead_code)]
pub const BACKGROUND_DARK: Color = Color::Rgb(WHALE_BG_RGB.0, WHALE_BG_RGB.1, WHALE_BG_RGB.2);
#[allow(dead_code)]
pub const STATUS_NEUTRAL: Color = TEXT_MUTED;
#[allow(dead_code)]
pub const SURFACE_PANEL: Color =
Color::Rgb(WHALE_PANEL_RGB.0, WHALE_PANEL_RGB.1, WHALE_PANEL_RGB.2);
#[allow(dead_code)]
pub const SURFACE_ELEVATED: Color = Color::Rgb(
WHALE_ELEVATED_RGB.0,
WHALE_ELEVATED_RGB.1,
WHALE_ELEVATED_RGB.2,
);
pub const SURFACE_REASONING: Color = Color::Rgb(
WHALE_REASONING_SURFACE_RGB.0,
WHALE_REASONING_SURFACE_RGB.1,
WHALE_REASONING_SURFACE_RGB.2,
);
pub const SURFACE_REASONING_TINT: Color = Color::Rgb(
WHALE_REASONING_TINT_RGB.0,
WHALE_REASONING_TINT_RGB.1,
WHALE_REASONING_TINT_RGB.2,
);
#[allow(dead_code)]
pub const SURFACE_REASONING_ACTIVE: Color = Color::Rgb(58, 46, 32);
#[allow(dead_code)]
pub const SURFACE_TOOL: Color = Color::Rgb(
WHALE_TOOL_SURFACE_RGB.0,
WHALE_TOOL_SURFACE_RGB.1,
WHALE_TOOL_SURFACE_RGB.2,
);
#[allow(dead_code)]
pub const SURFACE_TOOL_ACTIVE: Color = Color::Rgb(
WHALE_TOOL_ACTIVE_RGB.0,
WHALE_TOOL_ACTIVE_RGB.1,
WHALE_TOOL_ACTIVE_RGB.2,
);
#[allow(dead_code)]
pub const SURFACE_SUCCESS: Color = Color::Rgb(18, 42, 37); #[allow(dead_code)]
pub const SURFACE_ERROR: Color = Color::Rgb(
WHALE_ERROR_SURFACE_RGB.0,
WHALE_ERROR_SURFACE_RGB.1,
WHALE_ERROR_SURFACE_RGB.2,
);
pub const DIFF_ADDED_BG: Color = Color::Rgb(
WHALE_DIFF_ADDED_BG_RGB.0,
WHALE_DIFF_ADDED_BG_RGB.1,
WHALE_DIFF_ADDED_BG_RGB.2,
);
pub const DIFF_DELETED_BG: Color = Color::Rgb(
WHALE_DIFF_DELETED_BG_RGB.0,
WHALE_DIFF_DELETED_BG_RGB.1,
WHALE_DIFF_DELETED_BG_RGB.2,
);
pub const DIFF_ADDED: Color = Color::Rgb(
WHALE_DIFF_ADDED_RGB.0,
WHALE_DIFF_ADDED_RGB.1,
WHALE_DIFF_ADDED_RGB.2,
);
pub const ACCENT_REASONING_LIVE: Color = Color::Rgb(
WHALE_REASONING_TEXT_RGB.0,
WHALE_REASONING_TEXT_RGB.1,
WHALE_REASONING_TEXT_RGB.2,
);
pub const ACCENT_TOOL_LIVE: Color = Color::Rgb(
WHALE_TOOL_LIVE_RGB.0,
WHALE_TOOL_LIVE_RGB.1,
WHALE_TOOL_LIVE_RGB.2,
);
pub const ACCENT_TOOL_ISSUE: Color = Color::Rgb(
WHALE_TOOL_ISSUE_RGB.0,
WHALE_TOOL_ISSUE_RGB.1,
WHALE_TOOL_ISSUE_RGB.2,
);
pub const TEXT_TOOL_OUTPUT: Color = Color::Rgb(
WHALE_TOOL_OUTPUT_RGB.0,
WHALE_TOOL_OUTPUT_RGB.1,
WHALE_TOOL_OUTPUT_RGB.2,
);
pub const STATUS_SUCCESS: Color = Color::Rgb(
WHALE_SUCCESS_RGB.0,
WHALE_SUCCESS_RGB.1,
WHALE_SUCCESS_RGB.2,
);
pub const STATUS_WARNING: Color = Color::Rgb(
WHALE_WARNING_RGB.0,
WHALE_WARNING_RGB.1,
WHALE_WARNING_RGB.2,
);
pub const STATUS_ERROR: Color = Color::Rgb(WHALE_ERROR_RGB.0, WHALE_ERROR_RGB.1, WHALE_ERROR_RGB.2);
#[allow(dead_code)]
pub const STATUS_INFO: Color = Color::Rgb(WHALE_INFO_RGB.0, WHALE_INFO_RGB.1, WHALE_INFO_RGB.2);
pub const MODE_AGENT: Color = Color::Rgb(
WHALE_MODE_AGENT_RGB.0,
WHALE_MODE_AGENT_RGB.1,
WHALE_MODE_AGENT_RGB.2,
);
pub const MODE_YOLO: Color = Color::Rgb(
WHALE_MODE_YOLO_RGB.0,
WHALE_MODE_YOLO_RGB.1,
WHALE_MODE_YOLO_RGB.2,
);
pub const MODE_PLAN: Color = Color::Rgb(
WHALE_MODE_PLAN_RGB.0,
WHALE_MODE_PLAN_RGB.1,
WHALE_MODE_PLAN_RGB.2,
);
pub const MODE_GOAL: Color = Color::Rgb(
WHALE_MODE_GOAL_RGB.0,
WHALE_MODE_GOAL_RGB.1,
WHALE_MODE_GOAL_RGB.2,
);
pub const SELECTION_BG: Color = Color::Rgb(
WHALE_SELECTION_RGB.0,
WHALE_SELECTION_RGB.1,
WHALE_SELECTION_RGB.2,
);
#[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 {
Self::detect_from_sources(
std::env::var("COLORFGBG").ok().as_deref(),
detect_macos_palette_mode(),
)
}
#[must_use]
fn detect_from_sources(colorfgbg: Option<&str>, macos_fallback: Option<Self>) -> Self {
colorfgbg
.and_then(Self::from_colorfgbg)
.or(macos_fallback)
.unwrap_or(Self::Dark)
}
}
#[cfg(target_os = "macos")]
fn detect_macos_palette_mode() -> Option<PaletteMode> {
let output = Command::new("defaults")
.args(["read", "-g", "AppleInterfaceStyle"])
.output()
.ok()?;
if output.status.success() {
Some(palette_mode_from_apple_interface_style(
&String::from_utf8_lossy(&output.stdout),
))
} else {
Some(PaletteMode::Light)
}
}
#[cfg(not(target_os = "macos"))]
fn detect_macos_palette_mode() -> Option<PaletteMode> {
None
}
#[cfg(any(target_os = "macos", test))]
fn palette_mode_from_apple_interface_style(value: &str) -> PaletteMode {
if value.trim().eq_ignore_ascii_case("dark") {
PaletteMode::Dark
} else {
PaletteMode::Light
}
}
#[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 text_dim: Color,
pub text_hint: Color,
pub text_muted: Color,
pub text_body: Color,
pub text_soft: Color,
pub border: Color,
pub accent_primary: Color,
pub accent_secondary: Color,
pub accent_action: Color,
pub error_fg: Color,
pub error_hover: Color,
pub error_surface: Color,
pub error_border: Color,
pub error_text: Color,
pub warning: Color,
pub success: Color,
pub info: Color,
pub mode_agent: Color,
pub mode_yolo: Color,
pub mode_plan: Color,
pub mode_goal: Color,
pub status_ready: Color,
pub status_working: Color,
pub status_warning: Color,
pub diff_added_fg: Color,
pub diff_deleted_fg: Color,
pub diff_added_bg: Color,
pub diff_deleted_bg: Color,
pub tool_running: Color,
pub tool_success: Color,
pub tool_failed: 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,
text_dim: TEXT_DIM,
text_hint: TEXT_HINT,
text_muted: TEXT_MUTED,
text_body: TEXT_BODY,
text_soft: TEXT_SOFT,
border: BORDER_COLOR,
accent_primary: Color::Rgb(
WHALE_ACCENT_PRIMARY_RGB.0,
WHALE_ACCENT_PRIMARY_RGB.1,
WHALE_ACCENT_PRIMARY_RGB.2,
),
accent_secondary: Color::Rgb(
WHALE_ACCENT_SECONDARY_RGB.0,
WHALE_ACCENT_SECONDARY_RGB.1,
WHALE_ACCENT_SECONDARY_RGB.2,
),
accent_action: Color::Rgb(
WHALE_ACCENT_ACTION_RGB.0,
WHALE_ACCENT_ACTION_RGB.1,
WHALE_ACCENT_ACTION_RGB.2,
),
error_fg: Color::Rgb(WHALE_ERROR_RGB.0, WHALE_ERROR_RGB.1, WHALE_ERROR_RGB.2),
error_hover: Color::Rgb(
WHALE_ERROR_HOVER_RGB.0,
WHALE_ERROR_HOVER_RGB.1,
WHALE_ERROR_HOVER_RGB.2,
),
error_surface: Color::Rgb(
WHALE_ERROR_SURFACE_RGB.0,
WHALE_ERROR_SURFACE_RGB.1,
WHALE_ERROR_SURFACE_RGB.2,
),
error_border: Color::Rgb(
WHALE_ERROR_BORDER_RGB.0,
WHALE_ERROR_BORDER_RGB.1,
WHALE_ERROR_BORDER_RGB.2,
),
error_text: Color::Rgb(
WHALE_ERROR_TEXT_RGB.0,
WHALE_ERROR_TEXT_RGB.1,
WHALE_ERROR_TEXT_RGB.2,
),
warning: Color::Rgb(
WHALE_WARNING_RGB.0,
WHALE_WARNING_RGB.1,
WHALE_WARNING_RGB.2,
),
success: Color::Rgb(
WHALE_SUCCESS_RGB.0,
WHALE_SUCCESS_RGB.1,
WHALE_SUCCESS_RGB.2,
),
info: Color::Rgb(WHALE_INFO_RGB.0, WHALE_INFO_RGB.1, WHALE_INFO_RGB.2),
mode_agent: MODE_AGENT,
mode_yolo: MODE_YOLO,
mode_plan: MODE_PLAN,
mode_goal: MODE_GOAL,
status_ready: TEXT_MUTED,
status_working: DEEPSEEK_SKY,
status_warning: STATUS_WARNING,
diff_added_fg: DIFF_ADDED,
diff_deleted_fg: Color::Rgb(WHALE_ERROR_RGB.0, WHALE_ERROR_RGB.1, WHALE_ERROR_RGB.2),
diff_added_bg: DIFF_ADDED_BG,
diff_deleted_bg: DIFF_DELETED_BG,
tool_running: ACCENT_TOOL_LIVE,
tool_success: TEXT_DIM,
tool_failed: ACCENT_TOOL_ISSUE,
};
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,
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,
accent_primary: Color::Rgb(53, 120, 229), accent_secondary: Color::Rgb(79, 180, 160), accent_action: Color::Rgb(220, 90, 60), error_fg: Color::Rgb(200, 40, 60), error_hover: Color::Rgb(220, 70, 85),
error_surface: Color::Rgb(254, 229, 229),
error_border: Color::Rgb(240, 120, 130),
error_text: Color::Rgb(120, 20, 30),
warning: Color::Rgb(180, 83, 9), success: Color::Rgb(21, 128, 61), info: Color::Rgb(53, 120, 229), mode_agent: Color::Rgb(53, 120, 229), mode_yolo: Color::Rgb(200, 40, 60), mode_plan: Color::Rgb(180, 83, 9), mode_goal: Color::Rgb(80, 180, 130), status_ready: LIGHT_TEXT_MUTED,
status_working: Color::Rgb(53, 120, 229), status_warning: Color::Rgb(180, 83, 9), diff_added_fg: Color::Rgb(22, 101, 52), diff_deleted_fg: Color::Rgb(200, 40, 60), diff_added_bg: Color::Rgb(223, 247, 231), diff_deleted_bg: Color::Rgb(254, 229, 229), tool_running: Color::Rgb(53, 120, 229), tool_success: LIGHT_TEXT_HINT,
tool_failed: Color::Rgb(200, 40, 60), };
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,
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,
accent_primary: GRAYSCALE_TEXT_SOFT,
accent_secondary: GRAYSCALE_TEXT_MUTED,
accent_action: Color::Rgb(210, 210, 210),
error_fg: GRAYSCALE_TEXT_BODY,
error_hover: GRAYSCALE_TEXT_SOFT,
error_surface: GRAYSCALE_ERROR,
error_border: GRAYSCALE_BORDER,
error_text: GRAYSCALE_TEXT_SOFT,
warning: GRAYSCALE_TEXT_MUTED,
success: GRAYSCALE_TEXT_SOFT,
info: GRAYSCALE_TEXT_MUTED,
mode_agent: Color::Rgb(200, 200, 200),
mode_yolo: GRAYSCALE_TEXT_BODY,
mode_plan: GRAYSCALE_TEXT_MUTED,
mode_goal: GRAYSCALE_TEXT_SOFT,
status_ready: GRAYSCALE_TEXT_MUTED,
status_working: GRAYSCALE_TEXT_SOFT,
status_warning: GRAYSCALE_TEXT_BODY,
diff_added_fg: GRAYSCALE_TEXT_SOFT,
diff_deleted_fg: GRAYSCALE_TEXT_BODY,
diff_added_bg: GRAYSCALE_SUCCESS,
diff_deleted_bg: GRAYSCALE_ERROR,
tool_running: GRAYSCALE_TEXT_SOFT,
tool_success: GRAYSCALE_TEXT_HINT,
tool_failed: GRAYSCALE_TEXT_BODY,
};
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),
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), accent_primary: Color::Rgb(0x89, 0xb4, 0xfa), accent_secondary: Color::Rgb(0x74, 0xc7, 0xec), accent_action: Color::Rgb(0xfa, 0xb3, 0x87), error_fg: Color::Rgb(0xf3, 0x8b, 0xa8), error_hover: Color::Rgb(0xf5, 0xa2, 0xbc),
error_surface: Color::Rgb(0x3a, 0x1f, 0x2a),
error_border: Color::Rgb(0xf3, 0x8b, 0xa8),
error_text: Color::Rgb(0xf5, 0xc2, 0xd0),
warning: Color::Rgb(0xf9, 0xe2, 0xaf), success: Color::Rgb(0xa6, 0xe3, 0xa1), info: Color::Rgb(0x89, 0xd9, 0xeb), mode_agent: Color::Rgb(0x89, 0xb4, 0xfa), mode_yolo: Color::Rgb(0xf3, 0x8b, 0xa8), mode_plan: Color::Rgb(0xfa, 0xb3, 0x87), mode_goal: Color::Rgb(0xa6, 0xe3, 0xa1), status_ready: Color::Rgb(0x7f, 0x84, 0x9c), status_working: Color::Rgb(0x74, 0xc7, 0xec), status_warning: Color::Rgb(0xf9, 0xe2, 0xaf), diff_added_fg: Color::Rgb(0xa6, 0xe3, 0xa1), diff_deleted_fg: Color::Rgb(0xf3, 0x8b, 0xa8), diff_added_bg: Color::Rgb(0x1f, 0x33, 0x29),
diff_deleted_bg: Color::Rgb(0x3a, 0x1f, 0x2a),
tool_running: Color::Rgb(0x74, 0xc7, 0xec), tool_success: Color::Rgb(0x7f, 0x84, 0x9c), tool_failed: Color::Rgb(0xf3, 0x8b, 0xa8), };
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),
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), accent_primary: Color::Rgb(0x7a, 0xa2, 0xf7), accent_secondary: Color::Rgb(0x7d, 0xcf, 0xff), accent_action: Color::Rgb(0xff, 0x9e, 0x64), error_fg: Color::Rgb(0xf7, 0x76, 0x8e), error_hover: Color::Rgb(0xf9, 0x92, 0xa4),
error_surface: Color::Rgb(0x33, 0x1c, 0x24),
error_border: Color::Rgb(0xf7, 0x76, 0x8e),
error_text: Color::Rgb(0xfa, 0xcc, 0xd4),
warning: Color::Rgb(0xe0, 0xaf, 0x68), success: Color::Rgb(0x9e, 0xce, 0x6a), info: Color::Rgb(0x7d, 0xcf, 0xff), mode_agent: Color::Rgb(0x7a, 0xa2, 0xf7), mode_yolo: Color::Rgb(0xf7, 0x76, 0x8e), mode_plan: Color::Rgb(0xff, 0x9e, 0x64), mode_goal: Color::Rgb(0x9e, 0xce, 0x6a), status_ready: Color::Rgb(0x56, 0x5f, 0x89), status_working: Color::Rgb(0x7d, 0xcf, 0xff), status_warning: Color::Rgb(0xe0, 0xaf, 0x68), diff_added_fg: Color::Rgb(0x9e, 0xce, 0x6a), diff_deleted_fg: Color::Rgb(0xf7, 0x76, 0x8e), diff_added_bg: Color::Rgb(0x1b, 0x2b, 0x1f),
diff_deleted_bg: Color::Rgb(0x33, 0x1c, 0x24),
tool_running: Color::Rgb(0x7d, 0xcf, 0xff), tool_success: Color::Rgb(0x56, 0x5f, 0x89), tool_failed: Color::Rgb(0xf7, 0x76, 0x8e), };
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),
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),
accent_primary: Color::Rgb(0xbd, 0x93, 0xf9), accent_secondary: Color::Rgb(0x8b, 0xe9, 0xfd), accent_action: Color::Rgb(0xff, 0xb8, 0x6c), error_fg: Color::Rgb(0xff, 0x55, 0x55), error_hover: Color::Rgb(0xff, 0x7c, 0x7c),
error_surface: Color::Rgb(0x3a, 0x1f, 0x22),
error_border: Color::Rgb(0xff, 0x55, 0x55),
error_text: Color::Rgb(0xff, 0xbb, 0xbb),
warning: Color::Rgb(0xf1, 0xfa, 0x8c), success: Color::Rgb(0x50, 0xfa, 0x7b), info: Color::Rgb(0x8b, 0xe9, 0xfd), mode_agent: Color::Rgb(0xbd, 0x93, 0xf9), mode_yolo: Color::Rgb(0xff, 0x55, 0x55), mode_plan: Color::Rgb(0xff, 0xb8, 0x6c), mode_goal: Color::Rgb(0x50, 0xfa, 0x7b), status_ready: Color::Rgb(0x62, 0x72, 0xa4), status_working: Color::Rgb(0x8b, 0xe9, 0xfd), status_warning: Color::Rgb(0xf1, 0xfa, 0x8c), diff_added_fg: Color::Rgb(0x50, 0xfa, 0x7b), diff_deleted_fg: Color::Rgb(0xff, 0x55, 0x55), diff_added_bg: Color::Rgb(0x21, 0x3a, 0x2a),
diff_deleted_bg: Color::Rgb(0x3a, 0x1f, 0x22),
tool_running: Color::Rgb(0x8b, 0xe9, 0xfd), tool_success: Color::Rgb(0x62, 0x72, 0xa4), tool_failed: Color::Rgb(0xff, 0x55, 0x55), };
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),
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), accent_primary: Color::Rgb(0x83, 0xa5, 0x98), accent_secondary: Color::Rgb(0x8e, 0xc0, 0x7c), accent_action: Color::Rgb(0xfe, 0x80, 0x19), error_fg: Color::Rgb(0xfb, 0x49, 0x34), error_hover: Color::Rgb(0xfc, 0x7c, 0x6b),
error_surface: Color::Rgb(0x35, 0x1c, 0x18),
error_border: Color::Rgb(0xfb, 0x49, 0x34),
error_text: Color::Rgb(0xfc, 0xc4, 0xb8),
warning: Color::Rgb(0xfa, 0xbd, 0x2f), success: Color::Rgb(0x8e, 0xc0, 0x7c), info: Color::Rgb(0x83, 0xa5, 0x98), mode_agent: Color::Rgb(0x83, 0xa5, 0x98), mode_yolo: Color::Rgb(0xfb, 0x49, 0x34), mode_plan: Color::Rgb(0xfe, 0x80, 0x19), mode_goal: Color::Rgb(0x8e, 0xc0, 0x7c), status_ready: Color::Rgb(0x92, 0x83, 0x74), status_working: Color::Rgb(0x8e, 0xc0, 0x7c), status_warning: Color::Rgb(0xfa, 0xbd, 0x2f), diff_added_fg: Color::Rgb(0x8e, 0xc0, 0x7c), diff_deleted_fg: Color::Rgb(0xfb, 0x49, 0x34), diff_added_bg: Color::Rgb(0x29, 0x32, 0x16),
diff_deleted_bg: Color::Rgb(0x35, 0x1c, 0x18),
tool_running: Color::Rgb(0x8e, 0xc0, 0x7c), tool_success: Color::Rgb(0x92, 0x83, 0x74), tool_failed: Color::Rgb(0xfb, 0x49, 0x34), };
#[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 / macOS appearance)",
Self::Whale => "Whale dark — deep navy & gold",
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(ui: &UiTheme) -> Color {
ui.diff_added_fg
}
#[must_use]
#[allow(dead_code)]
const fn theme_red(ui: &UiTheme) -> Color {
ui.diff_deleted_fg
}
#[must_use]
const fn theme_diff_added_bg(ui: &UiTheme) -> Color {
ui.diff_added_bg
}
#[must_use]
const fn theme_diff_deleted_bg(ui: &UiTheme) -> Color {
ui.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.warning
} else if color == STATUS_ERROR || color == DEEPSEEK_RED {
ui.error_fg
} else if color == DIFF_ADDED || color == USER_BODY {
theme_green(ui)
} 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
{
ui.panel_bg
} else if color == SURFACE_SUCCESS {
ui.diff_added_bg
} else if color == SURFACE_ERROR {
ui.error_surface
} else if color == SELECTION_BG {
ui.selection_bg
} else if color == DIFF_ADDED_BG {
theme_diff_added_bg(ui)
} else if color == DIFF_DELETED_BG {
theme_diff_deleted_bg(ui)
} 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 {
((u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114 + 500) / 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, WHALE_REASONING_TEXT_RGB, WHALE_REASONING_TINT_RGB,
WHALE_TEXT_BODY_RGB, adapt_bg, adapt_bg_for_palette_mode, adapt_color,
adapt_fg_for_palette_mode, blend, luma, 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 palette_mode_detect_prefers_colorfgbg_over_macos_fallback() {
assert_eq!(
PaletteMode::detect_from_sources(Some("0;15"), Some(PaletteMode::Dark)),
PaletteMode::Light
);
assert_eq!(
PaletteMode::detect_from_sources(Some("15;0"), Some(PaletteMode::Light)),
PaletteMode::Dark
);
}
#[test]
fn palette_mode_detect_uses_macos_fallback_when_colorfgbg_missing_or_invalid() {
assert_eq!(
PaletteMode::detect_from_sources(None, Some(PaletteMode::Light)),
PaletteMode::Light
);
assert_eq!(
PaletteMode::detect_from_sources(Some("not-a-color"), Some(PaletteMode::Light)),
PaletteMode::Light
);
assert_eq!(
PaletteMode::detect_from_sources(None, None),
PaletteMode::Dark
);
}
#[test]
fn apple_interface_style_maps_dark_and_missing_key_to_expected_modes() {
assert_eq!(
super::palette_mode_from_apple_interface_style("Dark\n"),
PaletteMode::Dark
);
assert_eq!(
super::palette_mode_from_apple_interface_style("Light\n"),
PaletteMode::Light
);
assert_eq!(
super::palette_mode_from_apple_interface_style(""),
PaletteMode::Light
);
}
#[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(
WHALE_TEXT_BODY_RGB.0,
WHALE_TEXT_BODY_RGB.1,
WHALE_TEXT_BODY_RGB.2
)
);
assert_eq!(
TEXT_REASONING,
Color::Rgb(
WHALE_REASONING_TEXT_RGB.0,
WHALE_REASONING_TEXT_RGB.1,
WHALE_REASONING_TEXT_RGB.2
)
);
assert_eq!(
ACCENT_REASONING_LIVE,
Color::Rgb(
WHALE_REASONING_TEXT_RGB.0,
WHALE_REASONING_TEXT_RGB.1,
WHALE_REASONING_TEXT_RGB.2
)
);
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 grayscale_luma_handles_bright_rgb_without_overflow() {
assert_eq!(luma(255, 255, 255), 255);
assert_eq!(
adapt_fg_for_palette_mode(
Color::Rgb(255, 255, 255),
GRAYSCALE_SURFACE,
PaletteMode::Grayscale
),
GRAYSCALE_TEXT_BODY
);
}
#[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::LightRed
);
}
#[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!(
SURFACE_REASONING_TINT,
Color::Rgb(
WHALE_REASONING_TINT_RGB.0,
WHALE_REASONING_TINT_RGB.1,
WHALE_REASONING_TINT_RGB.2
)
);
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(246, 196, 83), Color::LightYellow); assert_eq!(nearest_ansi16(79, 209, 197), Color::LightCyan); assert_eq!(nearest_ansi16(42, 74, 127), Color::Blue); assert_eq!(nearest_ansi16(54, 187, 212), Color::LightCyan); assert_eq!(nearest_ansi16(255, 92, 122), Color::LightRed); assert_eq!(nearest_ansi16(13, 21, 37), 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());
}
}