use super::config::{THEME_SKELETON_CONTENT, try_load_theme_with_diagnostics};
use super::paths::{config_dir, resolve_theme_config_path};
use super::settings::settings;
use super::terminal_detect::is_supported_terminal_for_theme;
use super::terminal_query::{query_terminal_colors, theme_from_fg_bg};
use super::types::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemeSource {
File,
Terminal,
Default,
}
#[derive(Debug)]
pub struct ResolvedTheme {
pub theme: Theme,
pub source: ThemeSource,
}
#[must_use]
pub fn resolve_theme() -> ResolvedTheme {
let prefs = settings();
let use_terminal = prefs.use_terminal_theme;
let file_theme = try_load_file_theme();
match file_theme {
Some(theme) if !use_terminal => {
tracing::info!("Using theme from theme.conf");
ResolvedTheme {
theme,
source: ThemeSource::File,
}
}
Some(file_theme) => {
if is_supported_terminal_for_theme() {
if let Some(resolved) = try_terminal_theme() {
tracing::info!(
"Using terminal theme (use_terminal_theme=true, supported terminal)"
);
return resolved;
}
tracing::info!("Terminal theme query failed, falling back to theme.conf");
} else {
tracing::info!(
"use_terminal_theme=true but terminal not supported, using theme.conf"
);
}
ResolvedTheme {
theme: file_theme,
source: ThemeSource::File,
}
}
None => {
if is_supported_terminal_for_theme() {
if let Some(resolved) = try_terminal_theme() {
tracing::info!(
"No valid theme.conf, using terminal theme (supported terminal)"
);
return resolved;
}
tracing::info!(
"No valid theme.conf and terminal query failed, using codebase default"
);
} else {
tracing::info!(
"No valid theme.conf and terminal not supported, using codebase default"
);
}
ResolvedTheme {
theme: default_theme(),
source: ThemeSource::Default,
}
}
}
}
fn try_load_file_theme() -> Option<Theme> {
let path = resolve_theme_config_path().or_else(|| {
let target = config_dir().join("theme.conf");
if target.exists() { Some(target) } else { None }
})?;
let meta = std::fs::metadata(&path).ok()?;
if meta.len() == 0 {
return None;
}
match try_load_theme_with_diagnostics(&path) {
Ok(theme) => Some(theme),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "Failed to load theme.conf");
None
}
}
}
fn try_terminal_theme() -> Option<ResolvedTheme> {
let (fg, bg) = query_terminal_colors()?;
let theme = theme_from_fg_bg(fg, bg);
Some(ResolvedTheme {
theme,
source: ThemeSource::Terminal,
})
}
fn default_theme() -> Theme {
parse_skeleton_theme().unwrap_or_else(hardcoded_default_theme)
}
fn parse_skeleton_theme() -> Option<Theme> {
use ratatui::style::Color;
use std::collections::HashMap;
let mut map: HashMap<String, Color> = HashMap::new();
for line in THEME_SKELETON_CONTENT.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if !trimmed.contains('=') {
continue;
}
let mut parts = trimmed.splitn(2, '=');
let key = parts.next()?.trim();
let val = parts.next()?.trim();
if let Some(color) = parse_color(val) {
let norm = normalize_key(key);
map.insert(norm, color);
}
}
let required = [
"base", "mantle", "crust", "surface1", "surface2", "overlay1", "overlay2", "text",
"subtext0", "subtext1", "sapphire", "mauve", "green", "yellow", "red", "lavender",
];
for k in required {
if !map.contains_key(k) {
return None;
}
}
Some(Theme {
base: map["base"],
mantle: map["mantle"],
crust: map["crust"],
surface1: map["surface1"],
surface2: map["surface2"],
overlay1: map["overlay1"],
overlay2: map["overlay2"],
text: map["text"],
subtext0: map["subtext0"],
subtext1: map["subtext1"],
sapphire: map["sapphire"],
mauve: map["mauve"],
green: map["green"],
yellow: map["yellow"],
red: map["red"],
lavender: map["lavender"],
})
}
fn normalize_key(key: &str) -> String {
let norm = key.to_lowercase().replace(['.', '-', ' '], "_");
match norm.as_str() {
"background_base" => "base".to_string(),
"background_mantle" => "mantle".to_string(),
"background_crust" => "crust".to_string(),
"surface_level1" => "surface1".to_string(),
"surface_level2" => "surface2".to_string(),
"overlay_primary" => "overlay1".to_string(),
"overlay_secondary" => "overlay2".to_string(),
"text_primary" => "text".to_string(),
"text_secondary" => "subtext0".to_string(),
"text_tertiary" => "subtext1".to_string(),
"accent_interactive" => "sapphire".to_string(),
"accent_heading" => "mauve".to_string(),
"accent_emphasis" => "lavender".to_string(),
"semantic_success" => "green".to_string(),
"semantic_warning" => "yellow".to_string(),
"semantic_error" => "red".to_string(),
_ => norm,
}
}
fn parse_color(val: &str) -> Option<ratatui::style::Color> {
let val = val.trim();
if val.starts_with('#') && val.len() == 7 {
let r = u8::from_str_radix(&val[1..3], 16).ok()?;
let g = u8::from_str_radix(&val[3..5], 16).ok()?;
let b = u8::from_str_radix(&val[5..7], 16).ok()?;
return Some(ratatui::style::Color::Rgb(r, g, b));
}
if val.contains(',') {
let parts: Vec<&str> = val.split(',').collect();
if parts.len() >= 3 {
let r: u8 = parts[0].trim().parse().ok()?;
let g: u8 = parts[1].trim().parse().ok()?;
let b: u8 = parts[2].trim().parse().ok()?;
return Some(ratatui::style::Color::Rgb(r, g, b));
}
}
None
}
const fn hardcoded_default_theme() -> Theme {
use ratatui::style::Color;
Theme {
base: Color::Rgb(30, 30, 46), mantle: Color::Rgb(24, 24, 37), crust: Color::Rgb(17, 17, 27), surface1: Color::Rgb(69, 71, 90), surface2: Color::Rgb(88, 91, 112), overlay1: Color::Rgb(127, 132, 156), overlay2: Color::Rgb(147, 153, 178), text: Color::Rgb(205, 214, 244), subtext0: Color::Rgb(166, 173, 200), subtext1: Color::Rgb(186, 194, 222), sapphire: Color::Rgb(116, 199, 236), mauve: Color::Rgb(203, 166, 247), green: Color::Rgb(166, 227, 161), yellow: Color::Rgb(249, 226, 175), red: Color::Rgb(243, 139, 168), lavender: Color::Rgb(180, 190, 254), }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_color_hex() {
let color = parse_color("#1e1e2e");
let color = color.expect("hex color should parse");
assert!(matches!(color, ratatui::style::Color::Rgb(30, 30, 46)));
}
#[test]
fn test_parse_color_decimal() {
let color = parse_color("205, 214, 244");
let color = color.expect("decimal color should parse");
assert!(matches!(color, ratatui::style::Color::Rgb(205, 214, 244)));
}
#[test]
fn test_normalize_key() {
assert_eq!(normalize_key("background_base"), "base");
assert_eq!(normalize_key("text_primary"), "text");
assert_eq!(normalize_key("accent_interactive"), "sapphire");
assert_eq!(normalize_key("base"), "base"); }
#[test]
fn test_hardcoded_default_theme() {
let theme = hardcoded_default_theme();
assert!(matches!(theme.base, ratatui::style::Color::Rgb(30, 30, 46)));
}
#[test]
fn test_parse_skeleton_theme() {
let theme = parse_skeleton_theme();
let theme = theme.expect("Should be able to parse skeleton theme");
assert!(matches!(theme.base, ratatui::style::Color::Rgb(30, 30, 46)));
assert!(matches!(
theme.text,
ratatui::style::Color::Rgb(205, 214, 244)
));
}
}