use ratatui::style::{Color, Modifier, Style};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorSupport {
TrueColor256,
Basic8,
None,
}
pub fn detect_color_support(no_color: bool) -> ColorSupport {
if no_color {
return ColorSupport::None;
}
if std::env::var("NO_COLOR").is_ok() {
return ColorSupport::None;
}
if let Ok(colorterm) = std::env::var("COLORTERM") {
let ct = colorterm.to_lowercase();
if ct.contains("truecolor") || ct.contains("24bit") {
return ColorSupport::TrueColor256;
}
}
if let Ok(term) = std::env::var("TERM") {
let t = term.to_lowercase();
if t.contains("256color") {
return ColorSupport::TrueColor256;
}
if t == "dumb" || t.is_empty() {
return ColorSupport::None;
}
}
ColorSupport::Basic8
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemeMode {
Dark,
Light,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum ThemePreference {
Auto,
Dark,
Light,
}
pub fn parse_colorfgbg(value: &str) -> Option<ThemeMode> {
let bg_idx: u8 = value
.rsplit(';')
.find_map(|f| f.trim().parse::<u8>().ok())?;
Some(if bg_idx >= 7 {
ThemeMode::Light
} else {
ThemeMode::Dark
})
}
fn classify_theme(luma: Option<f32>, colorfgbg: Option<&str>) -> ThemeMode {
if let Some(l) = luma {
return if l > 0.6 {
ThemeMode::Light
} else {
ThemeMode::Dark
};
}
if let Some(value) = colorfgbg {
if let Some(mode) = parse_colorfgbg(value) {
return mode;
}
}
ThemeMode::Dark
}
pub fn detect_system_theme() -> ThemeMode {
let luma = terminal_light::luma().ok();
let colorfgbg = std::env::var("COLORFGBG").ok();
classify_theme(luma, colorfgbg.as_deref())
}
pub fn resolve_theme(preference: ThemePreference, is_headless: bool) -> ThemeMode {
match preference {
ThemePreference::Dark => ThemeMode::Dark,
ThemePreference::Light => ThemeMode::Light,
ThemePreference::Auto => {
if is_headless {
ThemeMode::Dark
} else {
detect_system_theme()
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Palette {
pub fg: Color,
pub fg_dim: Color,
pub bg: Option<Color>,
pub bg_alt: Option<Color>,
pub accent: Color,
pub accent_dim: Color,
pub success: Color,
pub warning: Color,
pub error: Color,
pub info: Color,
pub border: Color,
pub border_focused: Color,
pub selection_fg: Color,
pub selection_bg: Color,
}
pub fn dark_palette() -> Palette {
Palette {
fg: Color::White,
fg_dim: Color::DarkGray,
bg: None,
bg_alt: Some(Color::White),
accent: Color::Cyan,
accent_dim: Color::DarkGray,
success: Color::Green,
warning: Color::Yellow,
error: Color::Red,
info: Color::Magenta,
border: Color::Gray,
border_focused: Color::Cyan,
selection_fg: Color::Black,
selection_bg: Color::LightBlue,
}
}
pub fn light_palette() -> Palette {
Palette {
fg: Color::Black,
fg_dim: Color::DarkGray,
bg: None,
bg_alt: Some(Color::Black),
accent: Color::Blue,
accent_dim: Color::Gray,
success: Color::Green,
warning: Color::Indexed(208), error: Color::Red,
info: Color::Magenta,
border: Color::Gray,
border_focused: Color::Blue,
selection_fg: Color::White,
selection_bg: Color::Blue,
}
}
fn palette_for(mode: ThemeMode) -> Palette {
match mode {
ThemeMode::Dark => dark_palette(),
ThemeMode::Light => light_palette(),
}
}
fn to_basic8(c: Color) -> Color {
match c {
Color::LightRed => Color::Red,
Color::LightGreen => Color::Green,
Color::LightYellow => Color::Yellow,
Color::LightBlue => Color::Blue,
Color::LightMagenta => Color::Magenta,
Color::LightCyan => Color::Cyan,
Color::Indexed(208) => Color::Yellow, other => other,
}
}
fn downgrade_palette(p: Palette) -> Palette {
Palette {
fg: to_basic8(p.fg),
fg_dim: to_basic8(p.fg_dim),
bg: p.bg.map(to_basic8),
bg_alt: p.bg_alt.map(to_basic8),
accent: to_basic8(p.accent),
accent_dim: to_basic8(p.accent_dim),
success: to_basic8(p.success),
warning: to_basic8(p.warning),
error: to_basic8(p.error),
info: to_basic8(p.info),
border: to_basic8(p.border),
border_focused: to_basic8(p.border_focused),
selection_fg: to_basic8(p.selection_fg),
selection_bg: to_basic8(p.selection_bg),
}
}
fn build_palette(mode: ThemeMode, color_support: ColorSupport) -> Palette {
let palette = palette_for(mode);
if color_support == ColorSupport::Basic8 {
downgrade_palette(palette)
} else {
palette
}
}
pub struct Theme {
pub no_color: bool,
pub color_support: ColorSupport,
pub mode: ThemeMode,
palette: Palette,
}
impl Theme {
pub fn new(no_color: bool, mode: ThemeMode) -> Self {
let color_support = detect_color_support(no_color);
Self {
no_color,
color_support,
mode,
palette: build_palette(mode, color_support),
}
}
pub fn with_color_support(color_support: ColorSupport) -> Self {
Self::with_color_support_mode(color_support, ThemeMode::Dark)
}
pub fn with_color_support_mode(color_support: ColorSupport, mode: ThemeMode) -> Self {
Self {
no_color: color_support == ColorSupport::None,
color_support,
mode,
palette: build_palette(mode, color_support),
}
}
pub fn toggle_mode(&mut self) {
self.mode = match self.mode {
ThemeMode::Dark => ThemeMode::Light,
ThemeMode::Light => ThemeMode::Dark,
};
self.palette = build_palette(self.mode, self.color_support);
}
fn has_color(&self) -> bool {
self.color_support != ColorSupport::None
}
pub fn sidebar_style(&self) -> Style {
if !self.has_color() {
Style::default()
} else {
Style::default().fg(self.palette.fg)
}
}
pub fn sidebar_selected(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
.fg(self.palette.selection_fg)
.bg(self.palette.accent)
.add_modifier(Modifier::BOLD)
}
}
pub fn list_item(&self) -> Style {
if !self.has_color() {
Style::default()
} else {
Style::default().fg(self.palette.fg)
}
}
pub fn list_selected(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
.fg(self.palette.selection_fg)
.bg(self.palette.selection_bg)
.add_modifier(Modifier::BOLD)
}
}
pub fn detail_key(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(self.palette.warning)
.add_modifier(Modifier::BOLD)
}
}
pub fn detail_value(&self) -> Style {
if !self.has_color() {
Style::default()
} else {
Style::default().fg(self.palette.fg)
}
}
pub fn status_bar(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::REVERSED)
} else {
let mut style = Style::default().fg(self.palette.selection_fg);
if let Some(bg) = self.palette.bg_alt {
style = style.bg(bg);
}
style
}
}
pub fn help_bar(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::DIM)
} else {
Style::default().fg(self.palette.accent_dim)
}
}
pub fn error_style(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(self.palette.error)
.add_modifier(Modifier::BOLD)
}
}
pub fn search_style(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::UNDERLINED)
} else {
Style::default()
.fg(self.palette.success)
.add_modifier(Modifier::BOLD)
}
}
pub fn border_style(&self) -> Style {
if !self.has_color() {
Style::default()
} else {
Style::default().fg(self.palette.border)
}
}
pub fn border_focused(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(self.palette.border_focused)
.add_modifier(Modifier::BOLD)
}
}
pub fn border_unfocused(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::DIM)
} else {
Style::default().fg(self.palette.accent_dim)
}
}
pub fn gate_not_run(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::DIM)
} else {
Style::default().fg(self.palette.accent_dim)
}
}
pub fn gate_running(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(self.palette.warning)
.add_modifier(Modifier::BOLD)
}
}
pub fn gate_passed(&self) -> Style {
if !self.has_color() {
Style::default()
} else {
Style::default().fg(self.palette.success)
}
}
pub fn gate_failed(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(self.palette.error)
.add_modifier(Modifier::BOLD)
}
}
pub fn gate_timed_out(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(self.palette.info)
.add_modifier(Modifier::BOLD)
}
}
pub fn filter_chip(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
.fg(self.palette.selection_fg)
.bg(self.palette.accent)
}
}
pub fn sparkline_filled(&self) -> Style {
if !self.has_color() {
Style::default()
} else {
Style::default().fg(self.palette.accent)
}
}
pub fn sparkline_empty(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::DIM)
} else {
Style::default().fg(self.palette.accent_dim)
}
}
pub fn help_overlay_title(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(self.palette.accent)
.add_modifier(Modifier::BOLD)
}
}
pub fn help_overlay_section(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(self.palette.warning)
.add_modifier(Modifier::BOLD)
}
}
pub fn completion_highlight(&self) -> Style {
if !self.has_color() {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
.fg(self.palette.selection_fg)
.bg(self.palette.success)
}
}
pub fn completion_normal(&self) -> Style {
if !self.has_color() {
Style::default()
} else {
Style::default().fg(self.palette.fg)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn no_color_mode_uses_no_fg_color() {
let theme = Theme::with_color_support(ColorSupport::None);
assert_eq!(theme.sidebar_style().fg, None);
assert_eq!(theme.list_item().fg, None);
assert_eq!(theme.detail_value().fg, None);
}
#[test]
fn color_mode_has_colors() {
let theme = Theme::with_color_support(ColorSupport::TrueColor256);
assert!(theme.sidebar_style().fg.is_some());
assert!(theme.list_selected().bg.is_some());
assert!(theme.detail_key().fg.is_some());
}
#[test]
fn eight_color_mode_has_colors() {
let theme = Theme::with_color_support(ColorSupport::Basic8);
assert!(theme.sidebar_style().fg.is_some());
assert!(theme.list_selected().bg.is_some());
}
#[test]
fn basic8_downgrades_bright_selection_bg() {
let basic8 = Theme::with_color_support_mode(ColorSupport::Basic8, ThemeMode::Dark);
assert_eq!(basic8.list_selected().bg, Some(Color::Blue));
let rich = Theme::with_color_support_mode(ColorSupport::TrueColor256, ThemeMode::Dark);
assert_eq!(rich.list_selected().bg, Some(Color::LightBlue));
}
#[test]
fn basic8_downgrades_indexed_warning() {
let basic8 = Theme::with_color_support_mode(ColorSupport::Basic8, ThemeMode::Light);
assert_eq!(basic8.detail_key().fg, Some(Color::Yellow));
let rich = Theme::with_color_support_mode(ColorSupport::TrueColor256, ThemeMode::Light);
assert_eq!(rich.detail_key().fg, Some(Color::Indexed(208)));
}
#[test]
fn dark_and_light_palettes_differ_in_color_mode() {
for support in [ColorSupport::TrueColor256, ColorSupport::Basic8] {
let dark = Theme::with_color_support_mode(support, ThemeMode::Dark);
let light = Theme::with_color_support_mode(support, ThemeMode::Light);
assert_ne!(
dark.sidebar_style().fg,
light.sidebar_style().fg,
"dark and light must differ for {support:?}"
);
assert_ne!(
dark.border_focused().fg,
light.border_focused().fg,
"dark and light accents must differ for {support:?}"
);
}
}
#[test]
fn focused_border_differs_from_unfocused() {
let theme = Theme::with_color_support(ColorSupport::TrueColor256);
let focused = theme.border_focused();
let unfocused = theme.border_unfocused();
assert_ne!(focused.fg, unfocused.fg);
}
#[test]
fn no_color_focused_uses_modifiers() {
let theme = Theme::with_color_support(ColorSupport::None);
let focused = theme.border_focused();
let unfocused = theme.border_unfocused();
assert!(focused.add_modifier.contains(Modifier::BOLD));
assert!(unfocused.add_modifier.contains(Modifier::DIM));
}
#[test]
fn color_support_detection_no_color_flag() {
assert_eq!(detect_color_support(true), ColorSupport::None);
}
#[test]
fn theme_deterministic_same_support_level() {
let theme1 = Theme::with_color_support(ColorSupport::TrueColor256);
let theme2 = Theme::with_color_support(ColorSupport::TrueColor256);
assert_eq!(theme1.sidebar_style(), theme2.sidebar_style());
assert_eq!(theme1.list_selected(), theme2.list_selected());
assert_eq!(theme1.error_style(), theme2.error_style());
assert_eq!(theme1.border_focused(), theme2.border_focused());
}
#[test]
fn with_color_support_sets_no_color_correctly() {
let theme_none = Theme::with_color_support(ColorSupport::None);
assert!(theme_none.no_color);
let theme_256 = Theme::with_color_support(ColorSupport::TrueColor256);
assert!(!theme_256.no_color);
let theme_8 = Theme::with_color_support(ColorSupport::Basic8);
assert!(!theme_8.no_color);
}
#[test]
fn gate_styles_have_color_in_color_mode() {
let theme = Theme::with_color_support(ColorSupport::TrueColor256);
assert!(theme.gate_passed().fg.is_some());
assert!(theme.gate_failed().fg.is_some());
assert!(theme.gate_running().fg.is_some());
assert!(theme.gate_timed_out().fg.is_some());
}
#[test]
fn no_color_help_bar_uses_dim() {
let theme = Theme::with_color_support(ColorSupport::None);
let style = theme.help_bar();
assert!(style.add_modifier.contains(Modifier::DIM));
}
#[test]
fn classify_falls_back_to_dark_when_detection_fails() {
assert_eq!(classify_theme(None, None), ThemeMode::Dark);
}
#[test]
fn classify_uses_luma_threshold() {
assert_eq!(classify_theme(Some(0.9), Some("15;0")), ThemeMode::Light);
assert_eq!(classify_theme(Some(0.61), None), ThemeMode::Light);
assert_eq!(classify_theme(Some(0.6), None), ThemeMode::Dark);
assert_eq!(classify_theme(Some(0.1), Some("0;15")), ThemeMode::Dark);
}
#[test]
fn colorfgbg_parsing() {
assert_eq!(parse_colorfgbg("0;15"), Some(ThemeMode::Light));
assert_eq!(parse_colorfgbg("15;0"), Some(ThemeMode::Dark));
assert_eq!(parse_colorfgbg("15;default;0"), Some(ThemeMode::Dark));
assert_eq!(parse_colorfgbg("0;default;15"), Some(ThemeMode::Light));
assert_eq!(parse_colorfgbg("0;7"), Some(ThemeMode::Light));
assert_eq!(parse_colorfgbg("0;6"), Some(ThemeMode::Dark));
assert_eq!(parse_colorfgbg("15;"), Some(ThemeMode::Light));
assert_eq!(parse_colorfgbg("0;15;"), Some(ThemeMode::Light));
assert_eq!(parse_colorfgbg(""), None);
assert_eq!(parse_colorfgbg("not;a;number"), None);
}
#[test]
fn colorfgbg_used_when_luma_missing() {
assert_eq!(classify_theme(None, Some("0;15")), ThemeMode::Light);
assert_eq!(classify_theme(None, Some("15;0")), ThemeMode::Dark);
assert_eq!(classify_theme(None, Some("garbage")), ThemeMode::Dark);
}
#[test]
fn resolve_theme_explicit_overrides_detection() {
assert_eq!(resolve_theme(ThemePreference::Dark, false), ThemeMode::Dark);
assert_eq!(
resolve_theme(ThemePreference::Light, false),
ThemeMode::Light
);
assert_eq!(resolve_theme(ThemePreference::Dark, true), ThemeMode::Dark);
assert_eq!(
resolve_theme(ThemePreference::Light, true),
ThemeMode::Light
);
}
#[test]
fn resolve_theme_auto_headless_defaults_dark() {
assert_eq!(resolve_theme(ThemePreference::Auto, true), ThemeMode::Dark);
}
#[test]
fn light_mode_uses_light_palette_colors() {
let theme = Theme::with_color_support_mode(ColorSupport::TrueColor256, ThemeMode::Light);
assert_eq!(theme.mode, ThemeMode::Light);
assert_eq!(theme.sidebar_style().fg, Some(Color::Black));
assert_eq!(theme.border_focused().fg, Some(Color::Blue));
assert_eq!(theme.list_selected().bg, Some(Color::Blue));
}
#[test]
fn dark_mode_uses_dark_palette_colors() {
let theme = Theme::with_color_support_mode(ColorSupport::TrueColor256, ThemeMode::Dark);
assert_eq!(theme.mode, ThemeMode::Dark);
assert_eq!(theme.sidebar_style().fg, Some(Color::White));
assert_eq!(theme.border_focused().fg, Some(Color::Cyan));
assert_eq!(theme.list_selected().bg, Some(Color::LightBlue));
}
#[test]
fn no_color_ignores_mode() {
for mode in [ThemeMode::Dark, ThemeMode::Light] {
let theme = Theme::with_color_support_mode(ColorSupport::None, mode);
assert_eq!(theme.sidebar_style().fg, None);
assert_eq!(theme.list_selected().fg, None);
assert_eq!(theme.list_selected().bg, None);
assert_eq!(theme.border_focused().fg, None);
assert_eq!(theme.gate_passed().fg, None);
}
}
#[test]
fn runtime_toggle_flips_mode_and_styles() {
let mut theme = Theme::with_color_support_mode(ColorSupport::TrueColor256, ThemeMode::Dark);
let dark_fg = theme.sidebar_style().fg;
let dark_border = theme.border_focused().fg;
theme.toggle_mode();
assert_eq!(theme.mode, ThemeMode::Light);
assert_ne!(theme.sidebar_style().fg, dark_fg);
assert_ne!(theme.border_focused().fg, dark_border);
theme.toggle_mode();
assert_eq!(theme.mode, ThemeMode::Dark);
assert_eq!(theme.sidebar_style().fg, dark_fg);
assert_eq!(theme.border_focused().fg, dark_border);
}
fn all_styles(theme: &Theme) -> Vec<Style> {
vec![
theme.sidebar_style(),
theme.sidebar_selected(),
theme.list_item(),
theme.list_selected(),
theme.detail_key(),
theme.detail_value(),
theme.status_bar(),
theme.help_bar(),
theme.error_style(),
theme.search_style(),
theme.border_style(),
theme.border_focused(),
theme.border_unfocused(),
theme.gate_not_run(),
theme.gate_running(),
theme.gate_passed(),
theme.gate_failed(),
theme.gate_timed_out(),
theme.filter_chip(),
theme.sparkline_filled(),
theme.sparkline_empty(),
theme.help_overlay_title(),
theme.help_overlay_section(),
theme.completion_highlight(),
theme.completion_normal(),
]
}
fn mode_strategy() -> impl Strategy<Value = ThemeMode> {
prop_oneof![Just(ThemeMode::Dark), Just(ThemeMode::Light)]
}
fn support_strategy() -> impl Strategy<Value = ColorSupport> {
prop_oneof![
Just(ColorSupport::TrueColor256),
Just(ColorSupport::Basic8),
Just(ColorSupport::None),
]
}
proptest! {
#[test]
fn prop34_theme_styles_deterministic(
mode in mode_strategy(),
support in support_strategy(),
) {
let a = Theme::with_color_support_mode(support, mode);
let b = Theme::with_color_support_mode(support, mode);
prop_assert_eq!(all_styles(&a), all_styles(&b));
if support == ColorSupport::None {
for style in all_styles(&a) {
prop_assert_eq!(style.fg, None);
prop_assert_eq!(style.bg, None);
}
}
}
}
}