pub mod color_mode;
pub mod palette;
pub mod presets;
pub use color_mode::{
EffectiveColorMode, apply_mode, detect_unicode_capable, map_color, resolve_color_mode,
};
pub use palette::{ExtendedRoles, Rgb, SemanticPalette};
pub use presets::{Preset, ThemeLoadError, resolve_palette};
use ratatui::style::{Color, Modifier, Style};
use crate::theme::color_mode::EffectiveColorMode as Ecm;
use crate::theme::palette::SemanticPalette as Palette;
pub struct SyntaxTheme {
pub keyword: Style,
pub string: Style,
pub comment: Style,
pub function: Style,
pub r#type: Style,
pub number: Style,
pub operator: Style,
pub variable: Style,
pub attribute: Style,
pub punctuation: Style,
pub constant: Style,
pub default: Style,
}
pub struct Theme {
pub user_message: Style,
pub assistant_message: Style,
pub system_message: Style,
pub input_text: Style,
pub input_cursor: Style,
pub status_bar: Style,
pub header: Style,
pub panel_border: Style,
pub panel_title: Style,
pub highlight: Style,
pub error: Style,
pub thinking_message: Style,
pub code_inline: Style,
pub code_block: Style,
pub streaming_cursor: Style,
pub tool_command: Style,
pub assistant_accent: Style,
pub tool_accent: Style,
pub diff_added_bg: Color,
pub diff_removed_bg: Color,
pub diff_word_added_bg: Color,
pub diff_word_removed_bg: Color,
pub diff_gutter_add: Style,
pub diff_gutter_remove: Style,
pub diff_header: Style,
pub link: Style,
pub table_border: Style,
pub user_message_bg: Color,
pub turn_separator: Style,
pub tool_success: Style,
pub tool_failure: Style,
pub syntax_theme: SyntaxTheme,
}
impl Theme {
#[must_use]
pub fn from_palette(p: &SemanticPalette) -> Self {
Self::from_palette_with_mode(p, Ecm::Truecolor)
}
#[must_use]
pub fn from_palette_with_mode(p: &Palette, mode: Ecm) -> Self {
let am = |style: Style| apply_mode(style, mode);
let mc = |color: Color| map_color(color, mode);
let fg = |c: Rgb| Color::from(c);
Self {
user_message: am(Style::default().fg(fg(p.accent))),
assistant_message: am(Style::default().fg(fg(p.text))),
system_message: am(Style::default().fg(fg(p.muted))),
input_text: am(Style::default().fg(fg(p.accent))),
input_cursor: am(Style::default()
.fg(fg(p.warning))
.add_modifier(Modifier::BOLD)),
status_bar: am(Style::default().fg(fg(p.text)).bg(fg(p.surface))),
header: am(Style::default()
.fg(fg(p.text))
.bg(fg(p.extended.header_bg))
.add_modifier(Modifier::BOLD)),
panel_border: am(Style::default().fg(fg(p.border))),
panel_title: am(Style::default().fg(fg(p.text)).add_modifier(Modifier::BOLD)),
highlight: am(Style::default().fg(fg(p.extended.highlight))),
error: am(Style::default().fg(fg(p.error))),
thinking_message: am(Style::default().fg(fg(p.muted))),
code_inline: am(Style::default()
.fg(fg(p.info))
.bg(fg(p.surface))
.add_modifier(Modifier::BOLD)),
code_block: am(Style::default().fg(fg(p.text)).bg(fg(p.surface))),
streaming_cursor: am(Style::default().fg(fg(p.muted))),
tool_command: am(Style::default()
.fg(fg(p.warning))
.add_modifier(Modifier::BOLD)),
assistant_accent: am(Style::default().fg(fg(p.extended.accent_alt))),
tool_accent: am(Style::default().fg(fg(p.extended.accent_alt))),
diff_added_bg: mc(Color::Rgb(0, 40, 0)),
diff_removed_bg: mc(Color::Rgb(40, 0, 0)),
diff_word_added_bg: mc(Color::Rgb(0, 80, 0)),
diff_word_removed_bg: mc(Color::Rgb(80, 0, 0)),
diff_gutter_add: am(Style::default().fg(fg(p.success))),
diff_gutter_remove: am(Style::default().fg(fg(p.error))),
diff_header: am(Style::default().fg(fg(p.muted))),
link: am(Style::default()
.fg(fg(p.accent))
.add_modifier(Modifier::UNDERLINED)),
table_border: am(Style::default().fg(fg(p.muted))),
user_message_bg: mc(fg(p.surface)),
turn_separator: am(Style::default().fg(fg(p.muted)).add_modifier(Modifier::DIM)),
tool_success: am(Style::default().fg(fg(p.success))),
tool_failure: am(Style::default().fg(fg(p.error))),
syntax_theme: SyntaxTheme::default(),
}
}
}
impl Default for Theme {
fn default() -> Self {
Self {
user_message: Style::default().fg(Color::Cyan),
assistant_message: Style::default().fg(Color::Rgb(200, 200, 210)),
system_message: Style::default().fg(Color::DarkGray),
input_text: Style::default().fg(Color::Cyan),
input_cursor: Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
status_bar: Style::default().fg(Color::White).bg(Color::DarkGray),
header: Style::default()
.fg(Color::Rgb(200, 220, 255))
.bg(Color::Rgb(20, 40, 80))
.add_modifier(Modifier::BOLD),
panel_border: Style::default().fg(Color::Gray),
panel_title: Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
highlight: Style::default().fg(Color::Rgb(215, 150, 60)),
error: Style::default().fg(Color::Red),
thinking_message: Style::default().fg(Color::DarkGray),
code_inline: Style::default()
.fg(Color::Rgb(100, 180, 255))
.bg(Color::Rgb(15, 30, 55))
.add_modifier(Modifier::BOLD),
code_block: Style::default()
.fg(Color::Rgb(190, 175, 145))
.bg(Color::Rgb(20, 25, 35)),
streaming_cursor: Style::default().fg(Color::DarkGray),
tool_command: Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
assistant_accent: Style::default().fg(Color::Rgb(185, 85, 25)),
tool_accent: Style::default().fg(Color::Rgb(140, 120, 50)),
diff_added_bg: Color::Rgb(0, 40, 0),
diff_removed_bg: Color::Rgb(40, 0, 0),
diff_word_added_bg: Color::Rgb(0, 80, 0),
diff_word_removed_bg: Color::Rgb(80, 0, 0),
diff_gutter_add: Style::default().fg(Color::Green),
diff_gutter_remove: Style::default().fg(Color::Red),
diff_header: Style::default().fg(Color::DarkGray),
link: Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::UNDERLINED),
table_border: Style::default().fg(Color::DarkGray),
user_message_bg: Color::Rgb(20, 25, 35),
turn_separator: Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
tool_success: Style::default().fg(Color::Green),
tool_failure: Style::default().fg(Color::Red),
syntax_theme: SyntaxTheme::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_theme_has_distinct_message_styles() {
let theme = Theme::default();
assert_ne!(theme.user_message, theme.assistant_message);
assert_ne!(theme.assistant_message, theme.system_message);
}
#[test]
fn default_theme_status_bar_has_background() {
let theme = Theme::default();
assert_eq!(theme.status_bar.bg, Some(Color::DarkGray));
}
#[test]
fn from_palette_changes_user_message_fg() {
let mut p = SemanticPalette::zephyr();
p.accent = Rgb(0xAB, 0xCD, 0xEF);
let theme = Theme::from_palette(&p);
assert_ne!(
theme.user_message.fg,
Some(Color::Cyan),
"palette-derived theme must differ from Theme::default() user_message"
);
assert_eq!(theme.user_message.fg, Some(Color::Rgb(0xAB, 0xCD, 0xEF)));
}
#[test]
fn from_palette_never_strips_fg() {
let theme = Theme::from_palette_with_mode(&SemanticPalette::zephyr(), Ecm::Never);
assert_eq!(theme.user_message.fg, None);
assert_eq!(theme.status_bar.bg, None);
assert!(theme.header.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn from_palette_ansi256_no_rgb_fg() {
let theme = Theme::from_palette_with_mode(&SemanticPalette::zephyr(), Ecm::Ansi256);
if let Some(fg) = theme.user_message.fg {
assert!(
!matches!(fg, Color::Rgb(..)),
"Ansi256 mode must downgrade Rgb: {fg:?}"
);
}
}
#[test]
fn zephyr_text_bg_contrast_aa() {
let p = SemanticPalette::zephyr();
let text_l = relative_luminance(p.text.0, p.text.1, p.text.2);
let bg_l = relative_luminance(p.bg.0, p.bg.1, p.bg.2);
let ratio = contrast_ratio(text_l, bg_l);
assert!(
ratio >= 4.5,
"zephyr text/bg WCAG contrast {ratio:.2}:1 is below AA threshold 4.5:1"
);
}
#[test]
fn high_contrast_text_bg_contrast_aaa() {
let p = presets::Preset::HighContrast.palette();
let text_l = relative_luminance(p.text.0, p.text.1, p.text.2);
let bg_l = relative_luminance(p.bg.0, p.bg.1, p.bg.2);
let ratio = contrast_ratio(text_l, bg_l);
assert!(
ratio >= 7.0,
"high-contrast text/bg WCAG contrast {ratio:.2}:1 is below AAA threshold 7.0:1"
);
}
fn srgb_linearize(u: u8) -> f64 {
let c = f64::from(u) / 255.0;
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
0.2126 * srgb_linearize(r) + 0.7152 * srgb_linearize(g) + 0.0722 * srgb_linearize(b)
}
fn contrast_ratio(l1: f64, l2: f64) -> f64 {
let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
(lighter + 0.05) / (darker + 0.05)
}
}