#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Appearance {
Dark,
Light,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ThemeChoice {
#[default]
Auto,
Dark,
Light,
}
impl ThemeChoice {
pub fn from_config_str(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"dark" => Self::Dark,
"light" => Self::Light,
_ => Self::Auto,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Dark => "dark",
Self::Light => "light",
}
}
pub fn resolve(self, os_hint: Appearance) -> Appearance {
match self {
Self::Dark => Appearance::Dark,
Self::Light => Appearance::Light,
Self::Auto => match os_hint {
Appearance::Dark | Appearance::Light => os_hint,
Appearance::Unknown => Appearance::Dark,
},
}
}
}
pub fn detect() -> Appearance {
if std::env::var_os("NO_COLOR").is_some() {
return Appearance::Unknown;
}
if let Some(a) = from_colorfgbg() {
return a;
}
#[cfg(target_os = "windows")]
{
if let Some(a) = windows::detect() {
return a;
}
}
#[cfg(target_os = "macos")]
{
if let Some(a) = macos::detect() {
return a;
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
if let Some(a) = linux::detect() {
return a;
}
}
Appearance::Unknown
}
fn from_colorfgbg() -> Option<Appearance> {
let raw = std::env::var("COLORFGBG").ok()?;
let bg: u8 = raw.rsplit(';').next()?.trim().parse().ok()?;
Some(if matches!(bg, 0..=6 | 8) {
Appearance::Dark
} else {
Appearance::Light
})
}
#[cfg(target_os = "windows")]
mod windows {
use super::Appearance;
use std::ffi::{c_void, OsStr};
use std::os::windows::ffi::OsStrExt;
type Hkey = *mut c_void;
const HKEY_CURRENT_USER: Hkey = 0x8000_0001 as Hkey;
const RRF_RT_REG_DWORD: u32 = 0x0000_0010;
const ERROR_SUCCESS: i32 = 0;
#[link(name = "advapi32")]
unsafe extern "system" {
fn RegGetValueW(
hkey: Hkey,
lp_subkey: *const u16,
lp_value: *const u16,
dw_flags: u32,
pdw_type: *mut u32,
pv_data: *mut c_void,
pcb_data: *mut u32,
) -> i32;
}
fn wide(s: &str) -> Vec<u16> {
OsStr::new(s).encode_wide().chain(Some(0)).collect()
}
pub fn detect() -> Option<Appearance> {
let subkey = wide(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
let value = wide("AppsUseLightTheme");
let mut data: u32 = 0;
let mut data_len: u32 = std::mem::size_of::<u32>() as u32;
let rc = unsafe {
RegGetValueW(
HKEY_CURRENT_USER,
subkey.as_ptr(),
value.as_ptr(),
RRF_RT_REG_DWORD,
std::ptr::null_mut(),
&mut data as *mut u32 as *mut c_void,
&mut data_len,
)
};
if rc != ERROR_SUCCESS {
return None;
}
Some(if data == 1 {
Appearance::Light
} else {
Appearance::Dark
})
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::Appearance;
use std::process::Command;
pub fn detect() -> Option<Appearance> {
let out = Command::new("defaults")
.args(["read", "-g", "AppleInterfaceStyle"])
.output()
.ok()?;
if out.status.success() && String::from_utf8_lossy(&out.stdout).contains("Dark") {
Some(Appearance::Dark)
} else {
Some(Appearance::Light)
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
mod linux {
use super::Appearance;
use std::process::Command;
pub fn detect() -> Option<Appearance> {
if let Some(a) = gsettings("org.gnome.desktop.interface", "color-scheme") {
return Some(a);
}
if let Some(a) = kde_kdeglobals() {
return Some(a);
}
None
}
fn gsettings(schema: &str, key: &str) -> Option<Appearance> {
let out = Command::new("gsettings")
.args(["get", schema, key])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).to_lowercase();
if s.contains("dark") {
Some(Appearance::Dark)
} else if s.contains("light") || s.contains("default") {
Some(Appearance::Light)
} else {
None
}
}
fn kde_kdeglobals() -> Option<Appearance> {
let home = dirs::config_dir()?;
let path = home.join("kdeglobals");
let text = std::fs::read_to_string(path).ok()?;
for line in text.lines() {
if let Some(v) = line.strip_prefix("ColorScheme=") {
let v = v.trim().to_lowercase();
return Some(if v.contains("dark") {
Appearance::Dark
} else {
Appearance::Light
});
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn theme_choice_parses_and_resolves() {
assert_eq!(ThemeChoice::from_config_str("dark"), ThemeChoice::Dark);
assert_eq!(ThemeChoice::from_config_str("LIGHT"), ThemeChoice::Light);
assert_eq!(ThemeChoice::from_config_str("auto"), ThemeChoice::Auto);
assert_eq!(ThemeChoice::from_config_str(""), ThemeChoice::Auto);
assert_eq!(
ThemeChoice::from_config_str("high-contrast"),
ThemeChoice::Auto
);
assert_eq!(
ThemeChoice::Auto.resolve(Appearance::Dark),
Appearance::Dark
);
assert_eq!(
ThemeChoice::Auto.resolve(Appearance::Light),
Appearance::Light
);
assert_eq!(
ThemeChoice::Auto.resolve(Appearance::Unknown),
Appearance::Dark
);
assert_eq!(
ThemeChoice::Dark.resolve(Appearance::Light),
Appearance::Dark
);
assert_eq!(
ThemeChoice::Light.resolve(Appearance::Dark),
Appearance::Light
);
for choice in [ThemeChoice::Auto, ThemeChoice::Dark, ThemeChoice::Light] {
assert_eq!(ThemeChoice::from_config_str(choice.as_str()), choice);
}
}
#[test]
fn colorfgbg_parsing() {
unsafe {
std::env::set_var("COLORFGBG", "15;0");
assert_eq!(from_colorfgbg(), Some(Appearance::Dark), "bg=0 -> dark");
std::env::set_var("COLORFGBG", "0;15");
assert_eq!(from_colorfgbg(), Some(Appearance::Light), "bg=15 -> light");
std::env::set_var("COLORFGBG", "7;8");
assert_eq!(from_colorfgbg(), Some(Appearance::Dark), "bg=8 -> dark");
std::env::set_var("COLORFGBG", "nonsense");
assert_eq!(from_colorfgbg(), None);
std::env::remove_var("COLORFGBG");
assert_eq!(from_colorfgbg(), None);
}
}
}