use std::sync::{OnceLock, RwLock};
use ratatui::style::Color;
#[derive(Debug, Clone, Copy)]
pub struct Theme {
pub name: &'static str,
pub bg: Color, pub bg2: Color, pub bg3: Color, pub bg_dark: Color, pub bg_darker: Color, pub statusline: Color, pub line: Color, pub lightbg: Color, pub fg: Color, pub comment: Color, pub grey: Color,
pub grey_fg: Color,
pub red: Color,
pub pink: Color,
pub green: Color,
pub vibrant_green: Color,
pub yellow: Color,
pub sun: Color,
pub orange: Color,
pub blue: Color,
pub nord_blue: Color,
pub teal: Color,
pub cyan: Color,
pub purple: Color,
pub dark_purple: Color,
pub base16: [Color; 16],
}
const fn rgb(hex: u32) -> Color {
Color::Rgb(
((hex >> 16) & 0xff) as u8,
((hex >> 8) & 0xff) as u8,
(hex & 0xff) as u8,
)
}
pub const fn onedark() -> Theme {
Theme {
name: "onedark",
bg: rgb(0x282c34),
bg2: rgb(0x353b45),
bg3: rgb(0x373b43),
bg_dark: rgb(0x1e222a),
bg_darker: rgb(0x1b1f27),
statusline: rgb(0x22262e),
line: rgb(0x31353d),
lightbg: rgb(0x2d3139),
fg: rgb(0xabb2bf),
comment: rgb(0x80848d),
grey: rgb(0x42464e),
grey_fg: rgb(0x565c64),
red: rgb(0xe06c75),
pink: rgb(0xff75a0),
green: rgb(0x98c379),
vibrant_green: rgb(0x7eca9c),
yellow: rgb(0xe7c787),
sun: rgb(0xebcb8b),
orange: rgb(0xfca2aa),
blue: rgb(0x61afef),
nord_blue: rgb(0x81a1c1),
teal: rgb(0x519aba),
cyan: rgb(0xa3b8ef),
purple: rgb(0xde98fd),
dark_purple: rgb(0xc882e7),
base16: [
rgb(0x1e222a), rgb(0x353b45), rgb(0x3e4451), rgb(0x545862), rgb(0x565c64), rgb(0xabb2bf), rgb(0xb6bdca), rgb(0xc8ccd4), rgb(0xe06c75), rgb(0xd19a66), rgb(0xe5c07b), rgb(0x98c379), rgb(0x56b6c2), rgb(0x61afef), rgb(0xc678dd), rgb(0xbe5046), ],
}
}
include!(concat!(env!("OUT_DIR"), "/theme_sources.rs"));
fn parse_hex(s: &str) -> Option<[u8; 3]> {
let s = s.trim().strip_prefix('#')?;
let h = |x: &str| u8::from_str_radix(x, 16).ok();
match s.len() {
6 => Some([h(&s[0..2])?, h(&s[2..4])?, h(&s[4..6])?]),
3 => {
let d = |i: usize| h(&s[i..i + 1]).map(|v| v * 17);
Some([d(0)?, d(1)?, d(2)?])
}
_ => None,
}
}
#[derive(serde::Deserialize)]
struct RawTheme {
#[serde(default)]
base_30: std::collections::HashMap<String, String>,
#[serde(default)]
base_16: std::collections::HashMap<String, String>,
}
fn parse_theme(name: &'static str, src: &str) -> Option<Theme> {
let raw: RawTheme = toml::from_str(src).ok()?;
if raw.base_30.is_empty() {
return None;
}
let col = |k: &str| raw.base_30.get(k).and_then(|s| parse_hex(s));
let rgb_of = |[r, g, b]: [u8; 3]| Color::Rgb(r, g, b);
let pick = |keys: &[&str], default: Color| {
keys.iter()
.find_map(|k| col(k))
.map(rgb_of)
.unwrap_or(default)
};
let od = onedark();
let white = pick(&["white"], od.fg);
let black = pick(&["black"], od.bg_dark);
let mut base16 = od.base16;
for (i, slot) in base16.iter_mut().enumerate() {
if let Some(rgb) = raw
.base_16
.get(&format!("base{i:02X}"))
.or_else(|| raw.base_16.get(&format!("base{i:02x}")))
.and_then(|s| parse_hex(s))
{
*slot = rgb_of(rgb);
}
}
Some(Theme {
name,
bg: pick(&["one_bg", "black"], black),
bg2: pick(&["one_bg2", "one_bg"], black),
bg3: pick(&["one_bg3", "one_bg2"], black),
bg_dark: black,
bg_darker: pick(&["darker_black"], black),
statusline: pick(&["statusline_bg", "black2"], black),
line: pick(&["line", "one_bg3"], black),
lightbg: pick(&["lightbg", "one_bg"], black),
fg: white,
comment: pick(&["light_grey", "grey_fg2", "grey_fg", "grey"], white),
grey: pick(&["grey", "grey_fg"], white),
grey_fg: pick(&["grey_fg", "grey"], white),
red: pick(&["red"], white),
pink: pick(&["pink", "baby_pink"], white),
green: pick(&["green"], white),
vibrant_green: pick(&["vibrant_green", "green"], white),
yellow: pick(&["yellow"], white),
sun: pick(&["sun", "yellow"], white),
orange: pick(&["orange"], white),
blue: pick(&["blue"], white),
nord_blue: pick(&["nord_blue", "blue"], white),
teal: pick(&["teal"], white),
cyan: pick(&["cyan", "blue"], white),
purple: pick(&["purple"], white),
dark_purple: pick(&["dark_purple", "purple"], white),
base16,
})
}
fn themes() -> &'static [Theme] {
static THEMES: OnceLock<Vec<Theme>> = OnceLock::new();
THEMES.get_or_init(|| {
let mut v: Vec<Theme> = THEME_SOURCES
.iter()
.filter_map(|&(name, src)| parse_theme(name, src))
.collect();
if !v.iter().any(|t| t.name == "onedark") {
v.insert(0, onedark());
}
v
})
}
pub fn lookup(name: &str) -> Option<Theme> {
let name = name.trim();
themes()
.iter()
.find(|t| t.name.eq_ignore_ascii_case(name))
.copied()
}
pub fn names() -> Vec<&'static str> {
themes().iter().map(|t| t.name).collect()
}
fn active() -> &'static RwLock<Theme> {
static ACTIVE: OnceLock<RwLock<Theme>> = OnceLock::new();
ACTIVE.get_or_init(|| RwLock::new(lookup("onedark").unwrap_or_else(onedark)))
}
pub fn cur() -> Theme {
*active().read().expect("theme lock poisoned")
}
pub fn ai_chip_parts(kind: &str, t: &Theme) -> (&'static str, &'static str, ratatui::style::Color) {
match kind {
"codex" => ("\u{F8B1}", "C", t.cyan),
_ => (
"\u{F8B0}",
"*",
brand_color_for_builtin("claude_code").unwrap_or(t.orange),
),
}
}
pub fn brand_color_for_builtin(id: &str) -> Option<ratatui::style::Color> {
use ratatui::style::Color;
match id {
"claude_code" => Some(Color::Rgb(0xD1, 0x6D, 0x51)),
"codex" => Some(cur().cyan),
_ => None,
}
}
pub fn ai_chip_parts_for(
kind: &str,
t: &Theme,
use_mnml: bool,
) -> (&'static str, &'static str, ratatui::style::Color) {
if use_mnml {
match kind {
"codex" => ("\u{F1E01}", "C", t.cyan),
_ => (
"\u{F1E00}",
"*",
brand_color_for_builtin("claude_code").unwrap_or(t.orange),
),
}
} else {
ai_chip_parts(kind, t)
}
}
pub fn color_from_slot(name: &str, t: &Theme) -> ratatui::style::Color {
match name {
"orange" => t.orange,
"cyan" => t.cyan,
"blue" => t.blue,
"green" => t.green,
"yellow" => t.yellow,
"purple" => t.purple,
"red" => t.red,
"teal" => t.teal,
"magenta" => ratatui::style::Color::Magenta,
"pink" => ratatui::style::Color::Rgb(0xE7, 0x15, 0x7B),
"fg" => t.fg,
"comment" => t.comment,
"bg" => t.bg,
"bg2" => t.bg2,
"white" => ratatui::style::Color::White,
"black" => ratatui::style::Color::Black,
hex if hex.starts_with('#') && hex.len() == 7 => {
let parse = |s: Option<&str>| {
s.filter(|s| s.chars().all(|c| c.is_ascii_hexdigit()))
.and_then(|s| u8::from_str_radix(s, 16).ok())
};
match (
parse(hex.get(1..3)),
parse(hex.get(3..5)),
parse(hex.get(5..7)),
) {
(Some(r), Some(g), Some(b)) => ratatui::style::Color::Rgb(r, g, b),
_ => t.bg2,
}
}
_ => t.bg2,
}
}
pub fn set(name: &str) -> Option<Theme> {
let t = lookup(name)?;
*active().write().expect("theme lock poisoned") = t;
Some(t)
}
pub fn detect_system_dark() -> bool {
use std::process::Command;
#[cfg(target_os = "macos")]
{
Command::new("defaults")
.args(["read", "-g", "AppleInterfaceStyle"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.trim()
.eq_ignore_ascii_case("Dark")
})
.unwrap_or(false)
}
#[cfg(target_os = "linux")]
{
let gnome = Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.to_ascii_lowercase()
.contains("dark")
});
if let Some(dark) = gnome {
return dark;
}
for bin in ["kreadconfig6", "kreadconfig5"] {
let kde = Command::new(bin)
.args(["--group", "General", "--key", "ColorScheme"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.to_ascii_lowercase()
.contains("dark")
});
if let Some(dark) = kde {
return dark;
}
}
false
}
#[cfg(target_os = "windows")]
{
Command::new("reg")
.args([
"query",
r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
"/v",
"AppsUseLightTheme",
])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).contains("0x0"))
.unwrap_or(false)
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
false
}
}
pub fn current_theme_path() -> Option<std::path::PathBuf> {
if crate::data_root::data_root_kind() == crate::data_root::DataRootKind::Portable {
return Some(crate::data_root::data_root().join("current-theme.toml"));
}
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
&& !xdg.is_empty()
{
return Some(
std::path::PathBuf::from(xdg)
.join("mnml")
.join("current-theme.toml"),
);
}
std::env::var_os("HOME").map(|h| {
std::path::PathBuf::from(h)
.join(".config")
.join("mnml")
.join("current-theme.toml")
})
}
fn hex(c: Color) -> String {
match c {
Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
_ => "#000000".to_string(),
}
}
pub fn to_toml(t: &Theme) -> String {
let mut s = String::with_capacity(1024);
s.push_str(
"# Written by mnml — the resolved active theme. Family apps (mixr,\n\
# mnml-* integrations) read this to follow mnml's colours. Regenerated on\n\
# launch and on every theme switch; do not hand-edit.\n",
);
s.push_str(&format!("name = \"{}\"\n\n[base_30]\n", t.name));
let row = |s: &mut String, k: &str, c: Color| s.push_str(&format!("{k} = \"{}\"\n", hex(c)));
row(&mut s, "white", t.fg);
row(&mut s, "black", t.bg_dark);
row(&mut s, "darker_black", t.bg_darker);
row(&mut s, "black2", t.statusline);
row(&mut s, "one_bg", t.bg);
row(&mut s, "one_bg2", t.bg2);
row(&mut s, "one_bg3", t.bg3);
row(&mut s, "statusline_bg", t.statusline);
row(&mut s, "line", t.line);
row(&mut s, "lightbg", t.lightbg);
row(&mut s, "light_grey", t.comment);
row(&mut s, "grey_fg2", t.comment);
row(&mut s, "grey", t.grey);
row(&mut s, "grey_fg", t.grey_fg);
row(&mut s, "red", t.red);
row(&mut s, "pink", t.pink);
row(&mut s, "green", t.green);
row(&mut s, "vibrant_green", t.vibrant_green);
row(&mut s, "yellow", t.yellow);
row(&mut s, "sun", t.sun);
row(&mut s, "orange", t.orange);
row(&mut s, "blue", t.blue);
row(&mut s, "nord_blue", t.nord_blue);
row(&mut s, "teal", t.teal);
row(&mut s, "cyan", t.cyan);
row(&mut s, "purple", t.purple);
row(&mut s, "dark_purple", t.dark_purple);
s.push_str("\n[base_16]\n");
for (i, c) in t.base16.iter().enumerate() {
s.push_str(&format!("base{i:02X} = \"{}\"\n", hex(*c)));
}
s
}
pub fn write_current(t: &Theme) {
let Some(path) = current_theme_path() else {
return;
};
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = crate::app::backup::write_toml_with_backup(&path, &to_toml(t), "theme");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgb_unpacks() {
assert_eq!(rgb(0x1e222a), Color::Rgb(0x1e, 0x22, 0x2a));
}
#[test]
fn bundled_themes_load() {
let all = names();
assert!(
all.len() > 50,
"expected the bundled themes, got {}",
all.len()
);
assert!(all.contains(&"onedark"));
assert!(all.contains(&"gruvbox"));
assert!(all.contains(&"catppuccin"));
assert!(lookup("ONEDARK").is_some()); assert!(lookup("nope").is_none());
assert_eq!(onedark().base16.len(), 16);
}
#[test]
fn parse_theme_extracts_colours() {
let src = r##"
name = "demo"
type = "dark"
[base_30]
white = "#abcdef"
black = "#111213"
one_bg = "#222324"
blue = "#3456ef"
[base_16]
base00 = "#010203"
base0E = "#c678dd"
"##;
let t = parse_theme("demo", src).unwrap();
assert_eq!(t.fg, Color::Rgb(0xab, 0xcd, 0xef));
assert_eq!(t.bg_dark, Color::Rgb(0x11, 0x12, 0x13));
assert_eq!(t.bg, Color::Rgb(0x22, 0x23, 0x24));
assert_eq!(t.blue, Color::Rgb(0x34, 0x56, 0xef));
assert_eq!(t.base16[0x00], Color::Rgb(0x01, 0x02, 0x03));
assert_eq!(t.base16[0x0e], Color::Rgb(0xc6, 0x78, 0xdd));
assert_eq!(t.red, Color::Rgb(0xab, 0xcd, 0xef));
assert!(parse_theme("x", "name = \"x\"").is_none());
}
#[test]
fn to_toml_round_trips_through_the_parser() {
let src = onedark();
let toml = to_toml(&src);
let back = parse_theme("onedark", &toml).expect("written theme re-parses");
assert_eq!(back.fg, src.fg);
assert_eq!(back.bg, src.bg);
assert_eq!(back.bg_dark, src.bg_dark);
assert_eq!(back.bg_darker, src.bg_darker);
assert_eq!(back.comment, src.comment);
assert_eq!(back.blue, src.blue);
assert_eq!(back.red, src.red);
assert_eq!(back.base16, src.base16);
}
#[test]
fn color_from_slot_multibyte_hex_falls_back_without_panic() {
let t = *active().read().unwrap();
let c = color_from_slot("#12\u{4E2D}4", &t);
assert_eq!(c, t.bg2, "non-ASCII hex must fall back to bg2");
let c2 = color_from_slot("#123\u{00E9}7", &t);
assert_eq!(c2, t.bg2);
let c3 = color_from_slot("#1234\u{00E9}", &t);
assert_eq!(c3, t.bg2);
let c4 = color_from_slot("#D16D51", &t);
assert_eq!(
c4,
ratatui::style::Color::Rgb(0xD1, 0x6D, 0x51),
"valid hex must still parse"
);
}
#[test]
fn set_and_cur_roundtrip() {
let restore = cur().name;
assert!(set("gruvbox").is_some());
assert_eq!(cur().name, "gruvbox");
assert!(set("does-not-exist").is_none());
assert_eq!(cur().name, "gruvbox"); set(restore); }
}