use std::process::Command;
use super::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, clap::ValueEnum)]
pub enum ThemeArg {
#[default]
Dark,
Light,
#[value(name = "ayu-light")]
AyuLight,
Onedark,
#[value(name = "catppuccin-latte")]
CatppuccinLatte,
#[value(name = "catppuccin-frappe")]
CatppuccinFrappe,
#[value(name = "catppuccin-macchiato")]
CatppuccinMacchiato,
#[value(name = "catppuccin-mocha")]
CatppuccinMocha,
#[value(name = "gruvbox-dark")]
GruvboxDark,
#[value(name = "gruvbox-light")]
GruvboxLight,
#[value(name = "nord-dark")]
NordDark,
#[value(name = "nord-light")]
NordLight,
#[value(name = "nord-dark-high-contrast")]
NordDarkHighContrast,
#[value(name = "nord-light-high-contrast")]
NordLightHighContrast,
#[value(name = "solarized-light")]
SolarizedLight,
#[value(name = "solarized-dark")]
SolarizedDark,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, clap::ValueEnum)]
pub enum AppearanceArg {
Light,
Dark,
System,
}
const THEME_NAMES: [(&str, ThemeArg); 16] = [
("dark", ThemeArg::Dark),
("light", ThemeArg::Light),
("ayu-light", ThemeArg::AyuLight),
("onedark", ThemeArg::Onedark),
("catppuccin-latte", ThemeArg::CatppuccinLatte),
("catppuccin-frappe", ThemeArg::CatppuccinFrappe),
("catppuccin-macchiato", ThemeArg::CatppuccinMacchiato),
("catppuccin-mocha", ThemeArg::CatppuccinMocha),
("gruvbox-dark", ThemeArg::GruvboxDark),
("gruvbox-light", ThemeArg::GruvboxLight),
("nord-dark", ThemeArg::NordDark),
("nord-light", ThemeArg::NordLight),
("nord-dark-high-contrast", ThemeArg::NordDarkHighContrast),
("nord-light-high-contrast", ThemeArg::NordLightHighContrast),
("solarized-light", ThemeArg::SolarizedLight),
("solarized-dark", ThemeArg::SolarizedDark),
];
const APPEARANCE_NAMES: [(&str, AppearanceArg); 3] = [
("light", AppearanceArg::Light),
("dark", AppearanceArg::Dark),
("system", AppearanceArg::System),
];
impl ThemeArg {
pub fn from_str(s: &str) -> Option<Self> {
let normalized = s.trim().to_ascii_lowercase();
THEME_NAMES.iter().find_map(|(name, theme)| {
if *name == normalized {
Some(*theme)
} else {
None
}
})
}
fn valid_values_display() -> String {
THEME_NAMES
.iter()
.map(|(name, _)| *name)
.collect::<Vec<_>>()
.join(", ")
}
}
impl AppearanceArg {
pub fn from_str(s: &str) -> Option<Self> {
let normalized = s.trim().to_ascii_lowercase();
APPEARANCE_NAMES.iter().find_map(|(name, appearance)| {
if *name == normalized {
Some(*appearance)
} else {
None
}
})
}
fn valid_values_display() -> String {
APPEARANCE_NAMES
.iter()
.map(|(name, _)| *name)
.collect::<Vec<_>>()
.join(", ")
}
}
pub fn resolve_theme(arg: ThemeArg) -> Theme {
match arg {
ThemeArg::Dark => Theme::dark(),
ThemeArg::Light => Theme::light(),
ThemeArg::AyuLight => Theme::ayu_light(),
ThemeArg::Onedark => Theme::onedark(),
ThemeArg::CatppuccinLatte => Theme::catppuccin_latte(),
ThemeArg::CatppuccinFrappe => Theme::catppuccin_frappe(),
ThemeArg::CatppuccinMacchiato => Theme::catppuccin_macchiato(),
ThemeArg::CatppuccinMocha => Theme::catppuccin_mocha(),
ThemeArg::GruvboxDark => Theme::gruvbox_dark(),
ThemeArg::GruvboxLight => Theme::gruvbox_light(),
ThemeArg::NordDark => Theme::nord_dark(),
ThemeArg::NordLight => Theme::nord_light(),
ThemeArg::NordDarkHighContrast => Theme::nord_dark_high_contrast(),
ThemeArg::NordLightHighContrast => Theme::nord_light_high_contrast(),
ThemeArg::SolarizedLight => Theme::solarized_light(),
ThemeArg::SolarizedDark => Theme::solarized_dark(),
}
}
fn resolve_appearance(appearance: AppearanceArg) -> ThemeArg {
match appearance {
AppearanceArg::Light => ThemeArg::Light,
AppearanceArg::Dark => ThemeArg::Dark,
AppearanceArg::System => {
if is_system_dark_mode().unwrap_or(true) {
ThemeArg::Dark
} else {
ThemeArg::Light
}
}
}
}
#[cfg(target_os = "macos")]
fn is_system_dark_mode() -> Option<bool> {
let output = Command::new("defaults")
.args(["read", "-g", "AppleInterfaceStyle"])
.output()
.ok()?;
if !output.status.success() {
return Some(false);
}
let value = String::from_utf8_lossy(&output.stdout);
Some(value.trim().eq_ignore_ascii_case("dark"))
}
#[cfg(target_os = "windows")]
fn is_system_dark_mode() -> Option<bool> {
let output = Command::new("reg")
.args([
"query",
r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
"/v",
"AppsUseLightTheme",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8_lossy(&output.stdout);
if value.contains("0x0") {
Some(true)
} else if value.contains("0x1") {
Some(false)
} else {
None
}
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
fn is_system_dark_mode() -> Option<bool> {
let color_scheme = Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
.output()
.ok();
if let Some(output) = color_scheme
&& output.status.success()
{
let value = String::from_utf8_lossy(&output.stdout);
if value.contains("prefer-dark") {
return Some(true);
}
if value.contains("default") || value.contains("prefer-light") {
return Some(false);
}
}
let gtk_theme = Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "gtk-theme"])
.output()
.ok();
if let Some(output) = gtk_theme
&& output.status.success()
{
let value = String::from_utf8_lossy(&output.stdout);
if value.to_ascii_lowercase().contains("dark") {
return Some(true);
}
return Some(false);
}
None
}
pub fn resolve_theme_arg_with_config(
cli_theme: Option<ThemeArg>,
config_theme: Option<&str>,
) -> (Option<ThemeArg>, Vec<String>) {
let mut warnings = Vec::new();
if let Some(theme) = cli_theme {
return (Some(theme), warnings);
}
if let Some(config_theme) = config_theme {
if let Some(theme) = ThemeArg::from_str(config_theme) {
return (Some(theme), warnings);
}
let valid_values = ThemeArg::valid_values_display();
warnings.push(format!(
"Warning: Unknown theme '{config_theme}' in config, using appearance mode. Valid options: {valid_values}"
));
}
(None, warnings)
}
pub fn resolve_appearance_arg_with_config(
cli_appearance: Option<AppearanceArg>,
config_appearance: Option<&str>,
) -> (AppearanceArg, Vec<String>) {
let mut warnings = Vec::new();
if let Some(appearance) = cli_appearance {
return (appearance, warnings);
}
if let Some(config_appearance) = config_appearance {
if let Some(appearance) = AppearanceArg::from_str(config_appearance) {
return (appearance, warnings);
}
let valid_values = AppearanceArg::valid_values_display();
warnings.push(format!(
"Warning: Unknown appearance '{config_appearance}' in config, using system. Valid options: {valid_values}"
));
}
(AppearanceArg::System, warnings)
}
fn parse_theme_variant_from_config(
key: &str,
value: Option<&str>,
) -> (Option<ThemeArg>, Vec<String>) {
let mut warnings = Vec::new();
let Some(value) = value else {
return (None, warnings);
};
if let Some(theme) = ThemeArg::from_str(value) {
return (Some(theme), warnings);
}
let valid_values = ThemeArg::valid_values_display();
warnings.push(format!(
"Warning: Unknown theme '{value}' in config key '{key}', ignoring. Valid options: {valid_values}"
));
(None, warnings)
}
pub fn resolve_theme_with_config(
cli_theme: Option<ThemeArg>,
cli_appearance: Option<AppearanceArg>,
config_theme: Option<&str>,
config_theme_dark: Option<&str>,
config_theme_light: Option<&str>,
config_appearance: Option<&str>,
) -> (Theme, Vec<String>) {
let (theme_arg, mut warnings) = resolve_theme_arg_with_config(cli_theme, config_theme);
let (appearance_arg, appearance_warnings) =
resolve_appearance_arg_with_config(cli_appearance, config_appearance);
warnings.extend(appearance_warnings);
let (theme_dark_arg, dark_warnings) =
parse_theme_variant_from_config("theme_dark", config_theme_dark);
warnings.extend(dark_warnings);
let (theme_light_arg, light_warnings) =
parse_theme_variant_from_config("theme_light", config_theme_light);
warnings.extend(light_warnings);
if let Some(theme_arg) = theme_arg {
if cli_appearance.is_some() || config_appearance.is_some() {
warnings.push(
"Warning: Appearance setting is ignored when theme is explicitly set".to_string(),
);
}
(resolve_theme(theme_arg), warnings)
} else {
match (theme_dark_arg, theme_light_arg) {
(Some(theme_dark), Some(theme_light)) => {
let resolved = match appearance_arg {
AppearanceArg::Dark => theme_dark,
AppearanceArg::Light => theme_light,
AppearanceArg::System => {
if is_system_dark_mode().unwrap_or(true) {
theme_dark
} else {
theme_light
}
}
};
(resolve_theme(resolved), warnings)
}
(Some(theme_dark), None) => {
if cli_appearance.is_some() || config_appearance.is_some() {
warnings.push(
"Warning: Appearance setting is ignored when only theme_dark is configured"
.to_string(),
);
}
(resolve_theme(theme_dark), warnings)
}
(None, Some(theme_light)) => {
if cli_appearance.is_some() || config_appearance.is_some() {
warnings.push(
"Warning: Appearance setting is ignored when only theme_light is configured"
.to_string(),
);
}
(resolve_theme(theme_light), warnings)
}
(None, None) => (resolve_theme(resolve_appearance(appearance_arg)), warnings),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use two_face::theme::EmbeddedThemeName;
#[test]
fn should_roundtrip_all_canonical_theme_values() {
for (name, expected_theme) in &THEME_NAMES {
assert_eq!(ThemeArg::from_str(name), Some(*expected_theme));
}
}
#[test]
fn should_have_unique_theme_names_and_variants() {
let names: HashSet<&str> = THEME_NAMES.iter().map(|(name, _)| *name).collect();
let variants: HashSet<ThemeArg> = THEME_NAMES.iter().map(|(_, t)| *t).collect();
assert_eq!(names.len(), THEME_NAMES.len());
assert_eq!(variants.len(), THEME_NAMES.len());
}
#[test]
fn should_use_cli_theme_over_config_theme() {
let (resolved, warnings) =
resolve_theme_arg_with_config(Some(ThemeArg::Light), Some("dark"));
assert_eq!(resolved, Some(ThemeArg::Light));
assert!(warnings.is_empty());
}
#[test]
fn should_use_config_theme_when_cli_missing() {
let (resolved, warnings) = resolve_theme_arg_with_config(None, Some("light"));
assert_eq!(resolved, Some(ThemeArg::Light));
assert!(warnings.is_empty());
}
#[test]
fn should_fallback_to_appearance_and_warn_for_invalid_config_theme() {
let (resolved, warnings) = resolve_theme_arg_with_config(None, Some("unknown"));
assert_eq!(resolved, None);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("Unknown theme 'unknown'"));
}
#[test]
fn should_fallback_to_appearance_when_no_theme_is_set() {
let (resolved, warnings) = resolve_theme_arg_with_config(None, None);
assert_eq!(resolved, None);
assert!(warnings.is_empty());
}
#[test]
fn should_use_catppuccin_theme_from_config_when_cli_missing() {
let (resolved, warnings) = resolve_theme_arg_with_config(None, Some("catppuccin-fRappe"));
assert_eq!(resolved, Some(ThemeArg::CatppuccinFrappe));
assert!(warnings.is_empty());
}
#[test]
fn should_default_to_system_appearance_when_not_set() {
let (resolved, warnings) = resolve_appearance_arg_with_config(None, None);
assert_eq!(resolved, AppearanceArg::System);
assert!(warnings.is_empty());
}
#[test]
fn should_use_appearance_when_theme_is_not_set() {
let (resolved, warnings) =
resolve_theme_with_config(None, Some(AppearanceArg::Light), None, None, None, None);
assert_eq!(resolved.syntect_theme, EmbeddedThemeName::Base16OceanLight);
assert!(warnings.is_empty());
}
#[test]
fn should_select_variant_theme_by_appearance_when_both_variants_configured() {
let (resolved, warnings) = resolve_theme_with_config(
None,
Some(AppearanceArg::Light),
None,
Some("gruvbox-dark"),
Some("gruvbox-light"),
None,
);
assert_eq!(resolved.syntect_theme, EmbeddedThemeName::GruvboxLight);
assert!(warnings.is_empty());
}
#[test]
fn should_ignore_appearance_when_only_dark_variant_configured() {
let (resolved, warnings) = resolve_theme_with_config(
None,
Some(AppearanceArg::Light),
None,
Some("gruvbox-dark"),
None,
None,
);
assert_eq!(resolved.syntect_theme, EmbeddedThemeName::GruvboxDark);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("only theme_dark is configured"));
}
#[test]
fn should_resolve_catppuccin_mocha_syntect_theme() {
let theme = resolve_theme(ThemeArg::CatppuccinMocha);
assert_eq!(theme.syntect_theme, EmbeddedThemeName::CatppuccinMocha);
}
#[test]
fn should_resolve_catppuccin_latte_syntect_theme() {
let theme = resolve_theme(ThemeArg::CatppuccinLatte);
assert_eq!(theme.syntect_theme, EmbeddedThemeName::CatppuccinLatte);
}
#[test]
fn should_resolve_nord_dark_to_nord_syntect_theme() {
let theme = resolve_theme(ThemeArg::NordDark);
assert_eq!(theme.syntect_theme, EmbeddedThemeName::Nord);
}
#[test]
fn should_resolve_nord_light_to_ocean_light_syntect_theme() {
let theme = resolve_theme(ThemeArg::NordLight);
assert_eq!(theme.syntect_theme, EmbeddedThemeName::Base16OceanLight);
}
#[test]
fn should_resolve_nord_dark_high_contrast_to_nord_syntect_theme() {
let theme = resolve_theme(ThemeArg::NordDarkHighContrast);
assert_eq!(theme.syntect_theme, EmbeddedThemeName::Nord);
}
#[test]
fn should_resolve_nord_light_high_contrast_to_ocean_light_syntect_theme() {
let theme = resolve_theme(ThemeArg::NordLightHighContrast);
assert_eq!(theme.syntect_theme, EmbeddedThemeName::Base16OceanLight);
}
#[test]
fn should_boost_fg_primary_for_nord_dark_high_contrast() {
use ratatui::style::Color;
let dark = resolve_theme(ThemeArg::NordDark);
let hc = resolve_theme(ThemeArg::NordDarkHighContrast);
assert_ne!(dark.fg_primary, hc.fg_primary);
assert_eq!(hc.fg_primary, Color::Rgb(236, 239, 244)); }
#[test]
fn should_deepen_fg_dim_for_nord_light_high_contrast() {
use ratatui::style::Color;
let light = resolve_theme(ThemeArg::NordLight);
let hc = resolve_theme(ThemeArg::NordLightHighContrast);
assert_ne!(light.fg_dim, hc.fg_dim);
assert_eq!(hc.fg_dim, Color::Rgb(67, 76, 94)); }
#[test]
fn should_resolve_gruvbox_dark_to_dark_syntect_theme() {
let theme = resolve_theme(ThemeArg::GruvboxDark);
assert_eq!(theme.syntect_theme, EmbeddedThemeName::GruvboxDark);
}
#[test]
fn should_resolve_gruvbox_light_to_light_syntect_theme() {
let theme = resolve_theme(ThemeArg::GruvboxLight);
assert_eq!(theme.syntect_theme, EmbeddedThemeName::GruvboxLight);
}
#[test]
fn should_resolve_ayu_light_to_onehalf_light_syntect_theme() {
let theme = resolve_theme(ThemeArg::AyuLight);
assert_eq!(theme.syntect_theme, EmbeddedThemeName::OneHalfLight);
}
#[test]
fn should_resolve_onedark_to_onehalf_dark_syntect_theme() {
let theme = resolve_theme(ThemeArg::Onedark);
assert_eq!(theme.syntect_theme, EmbeddedThemeName::OneHalfDark);
}
}