use config::{Config, File as ConfigFile, FileFormat};
use log;
use palette::named;
use serde::{Deserialize, Serialize};
use serde_json;
use std::collections::HashMap;
use std::error;
use std::io::{Error, ErrorKind};
use std::path::PathBuf;
use std::sync::LazyLock;
use strum_macros;
static DEFAULT_MAX_DEPTH: u8 = 10;
#[derive(
Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display,
)]
#[strum(serialize_all = "camel_case")]
pub enum Meaning {
AlertInfo,
AlertWarn,
AlertError,
Annotation,
Base,
Guidance,
Important,
Title,
Muted,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ThemeConfig {
pub theme: ThemeDefinitionConfigBlock,
pub colors: HashMap<Meaning, String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ThemeDefinitionConfigBlock {
pub name: String,
pub parent: Option<String>,
}
use crossterm::style::{Attribute, Attributes, Color, ContentStyle};
pub struct Theme {
pub name: String,
pub parent: Option<String>,
pub styles: HashMap<Meaning, ContentStyle>,
}
impl Theme {
pub fn get_base(&self) -> ContentStyle {
self.styles[&Meaning::Base]
}
pub fn get_info(&self) -> ContentStyle {
self.get_alert(log::Level::Info)
}
pub fn get_warning(&self) -> ContentStyle {
self.get_alert(log::Level::Warn)
}
pub fn get_error(&self) -> ContentStyle {
self.get_alert(log::Level::Error)
}
pub fn get_alert(&self, severity: log::Level) -> ContentStyle {
self.styles[ALERT_TYPES.get(&severity).unwrap()]
}
pub fn new(
name: String,
parent: Option<String>,
styles: HashMap<Meaning, ContentStyle>,
) -> Theme {
Theme {
name,
parent,
styles,
}
}
pub fn closest_meaning<'a>(&self, meaning: &'a Meaning) -> &'a Meaning {
if self.styles.contains_key(meaning) {
meaning
} else if MEANING_FALLBACKS.contains_key(meaning) {
self.closest_meaning(&MEANING_FALLBACKS[meaning])
} else {
&Meaning::Base
}
}
pub fn as_style(&self, meaning: Meaning) -> ContentStyle {
self.styles[self.closest_meaning(&meaning)]
}
pub fn from_foreground_colors(
name: String,
parent: Option<&Theme>,
foreground_colors: HashMap<Meaning, String>,
debug: bool,
) -> Theme {
let styles: HashMap<Meaning, ContentStyle> = foreground_colors
.iter()
.map(|(name, color)| {
(
*name,
StyleFactory::from_fg_string(color).unwrap_or_else(|err| {
if debug {
log::warn!("Tried to load string as a color unsuccessfully: ({name}={color}) {err}");
}
ContentStyle::default()
}),
)
})
.collect();
Theme::from_map(name, parent, &styles)
}
fn from_map(
name: String,
parent: Option<&Theme>,
overrides: &HashMap<Meaning, ContentStyle>,
) -> Theme {
let styles = match parent {
Some(theme) => Box::new(theme.styles.clone()),
None => Box::new(DEFAULT_THEME.styles.clone()),
}
.iter()
.map(|(name, color)| match overrides.get(name) {
Some(value) => (*name, *value),
None => (*name, *color),
})
.collect();
Theme::new(name, parent.map(|p| p.name.clone()), styles)
}
}
fn from_string(name: &str) -> Result<Color, String> {
if name.is_empty() {
return Err("Empty string".into());
}
let first_char = name.chars().next().unwrap();
match first_char {
'#' => {
let hexcode = &name[1..];
let vec: Vec<u8> = hexcode
.chars()
.collect::<Vec<char>>()
.chunks(2)
.map(|pair| u8::from_str_radix(pair.iter().collect::<String>().as_str(), 16))
.filter_map(|n| n.ok())
.collect();
if vec.len() != 3 {
return Err("Could not parse 3 hex values from string".into());
}
Ok(Color::Rgb {
r: vec[0],
g: vec[1],
b: vec[2],
})
}
'@' => {
serde_json::from_str::<Color>(format!("\"{}\"", &name[1..]).as_str())
.map_err(|_| format!("Could not convert color name {name} to Crossterm color"))
}
_ => {
let srgb = named::from_str(name).ok_or("No such color in palette")?;
Ok(Color::Rgb {
r: srgb.red,
g: srgb.green,
b: srgb.blue,
})
}
}
}
pub struct StyleFactory {}
impl StyleFactory {
fn from_fg_string(name: &str) -> Result<ContentStyle, String> {
match from_string(name) {
Ok(color) => Ok(Self::from_fg_color(color)),
Err(err) => Err(err),
}
}
fn known_fg_string(name: &str) -> ContentStyle {
Self::from_fg_string(name).unwrap()
}
fn from_fg_color(color: Color) -> ContentStyle {
ContentStyle {
foreground_color: Some(color),
..ContentStyle::default()
}
}
fn from_fg_color_and_attributes(color: Color, attributes: Attributes) -> ContentStyle {
ContentStyle {
foreground_color: Some(color),
attributes,
..ContentStyle::default()
}
}
}
static ALERT_TYPES: LazyLock<HashMap<log::Level, Meaning>> = LazyLock::new(|| {
HashMap::from([
(log::Level::Info, Meaning::AlertInfo),
(log::Level::Warn, Meaning::AlertWarn),
(log::Level::Error, Meaning::AlertError),
])
});
static MEANING_FALLBACKS: LazyLock<HashMap<Meaning, Meaning>> = LazyLock::new(|| {
HashMap::from([
(Meaning::Guidance, Meaning::AlertInfo),
(Meaning::Annotation, Meaning::AlertInfo),
(Meaning::Title, Meaning::Important),
])
});
static DEFAULT_THEME: LazyLock<Theme> = LazyLock::new(|| {
Theme::new(
"default".to_string(),
None,
HashMap::from([
(
Meaning::AlertError,
StyleFactory::from_fg_color(Color::DarkRed),
),
(
Meaning::AlertWarn,
StyleFactory::from_fg_color(Color::DarkYellow),
),
(
Meaning::AlertInfo,
StyleFactory::from_fg_color(Color::DarkGreen),
),
(
Meaning::Annotation,
StyleFactory::from_fg_color(Color::DarkGrey),
),
(
Meaning::Guidance,
StyleFactory::from_fg_color(Color::DarkBlue),
),
(
Meaning::Important,
StyleFactory::from_fg_color_and_attributes(
Color::White,
Attributes::from(Attribute::Bold),
),
),
(Meaning::Muted, StyleFactory::from_fg_color(Color::Grey)),
(Meaning::Base, ContentStyle::default()),
]),
)
});
static BUILTIN_THEMES: LazyLock<HashMap<&'static str, Theme>> = LazyLock::new(|| {
HashMap::from([
("default", HashMap::new()),
(
"(none)",
HashMap::from([
(Meaning::AlertError, ContentStyle::default()),
(Meaning::AlertWarn, ContentStyle::default()),
(Meaning::AlertInfo, ContentStyle::default()),
(Meaning::Annotation, ContentStyle::default()),
(Meaning::Guidance, ContentStyle::default()),
(Meaning::Important, ContentStyle::default()),
(Meaning::Muted, ContentStyle::default()),
(Meaning::Base, ContentStyle::default()),
]),
),
(
"autumn",
HashMap::from([
(
Meaning::AlertError,
StyleFactory::known_fg_string("saddlebrown"),
),
(
Meaning::AlertWarn,
StyleFactory::known_fg_string("darkorange"),
),
(Meaning::AlertInfo, StyleFactory::known_fg_string("gold")),
(
Meaning::Annotation,
StyleFactory::from_fg_color(Color::DarkGrey),
),
(Meaning::Guidance, StyleFactory::known_fg_string("brown")),
]),
),
(
"marine",
HashMap::from([
(
Meaning::AlertError,
StyleFactory::known_fg_string("yellowgreen"),
),
(Meaning::AlertWarn, StyleFactory::known_fg_string("cyan")),
(
Meaning::AlertInfo,
StyleFactory::known_fg_string("turquoise"),
),
(
Meaning::Annotation,
StyleFactory::known_fg_string("steelblue"),
),
(
Meaning::Base,
StyleFactory::known_fg_string("lightsteelblue"),
),
(Meaning::Guidance, StyleFactory::known_fg_string("teal")),
]),
),
])
.iter()
.map(|(name, theme)| (*name, Theme::from_map(name.to_string(), None, theme)))
.collect()
});
pub struct ThemeManager {
loaded_themes: HashMap<String, Theme>,
debug: bool,
override_theme_dir: Option<String>,
}
impl ThemeManager {
pub fn new(debug: Option<bool>, theme_dir: Option<String>) -> Self {
Self {
loaded_themes: HashMap::new(),
debug: debug.unwrap_or(false),
override_theme_dir: match theme_dir {
Some(theme_dir) => Some(theme_dir),
None => std::env::var("ATUIN_THEME_DIR").ok(),
},
}
}
pub fn load_theme_from_file(
&mut self,
name: &str,
max_depth: u8,
) -> Result<&Theme, Box<dyn error::Error>> {
let mut theme_file = if let Some(p) = &self.override_theme_dir {
if p.is_empty() {
return Err(Box::new(Error::new(
ErrorKind::NotFound,
"Empty theme directory override and could not find theme elsewhere",
)));
}
PathBuf::from(p)
} else {
let config_dir = atuin_common::utils::config_dir();
let mut theme_file = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") {
PathBuf::from(p)
} else {
let mut theme_file = PathBuf::new();
theme_file.push(config_dir);
theme_file
};
theme_file.push("themes");
theme_file
};
let theme_toml = format!["{name}.toml"];
theme_file.push(theme_toml);
let mut config_builder = Config::builder();
config_builder = config_builder.add_source(ConfigFile::new(
theme_file.to_str().unwrap(),
FileFormat::Toml,
));
let config = config_builder.build()?;
self.load_theme_from_config(name, config, max_depth)
}
pub fn load_theme_from_config(
&mut self,
name: &str,
config: Config,
max_depth: u8,
) -> Result<&Theme, Box<dyn error::Error>> {
let debug = self.debug;
let theme_config: ThemeConfig = match config.try_deserialize() {
Ok(tc) => tc,
Err(e) => {
return Err(Box::new(Error::new(
ErrorKind::InvalidInput,
format!(
"Failed to deserialize theme: {}",
if debug {
e.to_string()
} else {
"set theme debug on for more info".to_string()
}
),
)));
}
};
let colors: HashMap<Meaning, String> = theme_config.colors;
let parent: Option<&Theme> = match theme_config.theme.parent {
Some(parent_name) => {
if max_depth == 0 {
return Err(Box::new(Error::new(
ErrorKind::InvalidInput,
"Parent requested but we hit the recursion limit",
)));
}
Some(self.load_theme(parent_name.as_str(), Some(max_depth - 1)))
}
None => Some(self.load_theme("default", Some(max_depth - 1))),
};
if debug && name != theme_config.theme.name {
log::warn!(
"Your theme config name is not the name of your loaded theme {} != {}",
name,
theme_config.theme.name
);
}
let theme = Theme::from_foreground_colors(theme_config.theme.name, parent, colors, debug);
let name = name.to_string();
self.loaded_themes.insert(name.clone(), theme);
let theme = self.loaded_themes.get(&name).unwrap();
Ok(theme)
}
pub fn load_theme(&mut self, name: &str, max_depth: Option<u8>) -> &Theme {
if self.loaded_themes.contains_key(name) {
return self.loaded_themes.get(name).unwrap();
}
let built_ins = &BUILTIN_THEMES;
match built_ins.get(name) {
Some(theme) => theme,
None => match self.load_theme_from_file(name, max_depth.unwrap_or(DEFAULT_MAX_DEPTH)) {
Ok(theme) => theme,
Err(err) => {
log::warn!("Could not load theme {name}: {err}");
built_ins.get("(none)").unwrap()
}
},
}
}
}
#[cfg(test)]
mod theme_tests {
use super::*;
#[test]
fn test_can_load_builtin_theme() {
let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
let theme = manager.load_theme("autumn", None);
assert_eq!(
theme.as_style(Meaning::Guidance).foreground_color,
from_string("brown").ok()
);
}
#[test]
fn test_can_create_theme() {
let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
let mytheme = Theme::new(
"mytheme".to_string(),
None,
HashMap::from([(
Meaning::AlertError,
StyleFactory::known_fg_string("yellowgreen"),
)]),
);
manager.loaded_themes.insert("mytheme".to_string(), mytheme);
let theme = manager.load_theme("mytheme", None);
assert_eq!(
theme.as_style(Meaning::AlertError).foreground_color,
from_string("yellowgreen").ok()
);
}
#[test]
fn test_can_fallback_when_meaning_missing() {
let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
assert!(!DEFAULT_THEME.styles.contains_key(&Meaning::Title));
let config = Config::builder()
.add_source(ConfigFile::from_str(
"
[theme]
name = \"title_theme\"
[colors]
Guidance = \"white\"
AlertInfo = \"zomp\"
",
FileFormat::Toml,
))
.build()
.unwrap();
let theme = manager
.load_theme_from_config("config_theme", config, 1)
.unwrap();
assert_eq!(
theme.as_style(Meaning::Guidance).foreground_color,
from_string("white").ok()
);
assert_eq!(theme.as_style(Meaning::AlertInfo).foreground_color, None);
assert_eq!(theme.as_style(Meaning::Base).foreground_color, None);
assert_eq!(
theme.as_style(Meaning::AlertError).foreground_color,
Some(Color::DarkRed)
);
assert_eq!(
theme.as_style(Meaning::Title).foreground_color,
theme.as_style(Meaning::Important).foreground_color,
);
let title_config = Config::builder()
.add_source(ConfigFile::from_str(
"
[theme]
name = \"title_theme\"
[colors]
Title = \"white\"
AlertInfo = \"zomp\"
",
FileFormat::Toml,
))
.build()
.unwrap();
let title_theme = manager
.load_theme_from_config("title_theme", title_config, 1)
.unwrap();
assert_eq!(
title_theme.as_style(Meaning::Title).foreground_color,
Some(Color::White)
);
}
#[test]
fn test_no_fallbacks_are_circular() {
let mytheme = Theme::new("mytheme".to_string(), None, HashMap::from([]));
MEANING_FALLBACKS
.iter()
.for_each(|pair| assert_eq!(mytheme.closest_meaning(pair.0), &Meaning::Base))
}
#[test]
fn test_can_get_colors_via_convenience_functions() {
let mut manager = ThemeManager::new(Some(true), Some("".to_string()));
let theme = manager.load_theme("default", None);
assert_eq!(theme.get_error().foreground_color.unwrap(), Color::DarkRed);
assert_eq!(
theme.get_warning().foreground_color.unwrap(),
Color::DarkYellow
);
assert_eq!(theme.get_info().foreground_color.unwrap(), Color::DarkGreen);
assert_eq!(theme.get_base().foreground_color, None);
assert_eq!(
theme.get_alert(log::Level::Error).foreground_color.unwrap(),
Color::DarkRed
)
}
#[test]
fn test_can_use_parent_theme_for_fallbacks() {
testing_logger::setup();
let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
let solarized = Config::builder()
.add_source(ConfigFile::from_str(
"
[theme]
name = \"solarized\"
[colors]
Guidance = \"white\"
AlertInfo = \"pink\"
",
FileFormat::Toml,
))
.build()
.unwrap();
let solarized_theme = manager
.load_theme_from_config("solarized", solarized, 1)
.unwrap();
assert_eq!(
solarized_theme
.as_style(Meaning::AlertInfo)
.foreground_color,
from_string("pink").ok()
);
let unsolarized = Config::builder()
.add_source(ConfigFile::from_str(
"
[theme]
name = \"unsolarized\"
parent = \"solarized\"
[colors]
AlertInfo = \"red\"
",
FileFormat::Toml,
))
.build()
.unwrap();
let unsolarized_theme = manager
.load_theme_from_config("unsolarized", unsolarized, 1)
.unwrap();
assert_eq!(
unsolarized_theme
.as_style(Meaning::AlertInfo)
.foreground_color,
from_string("red").ok()
);
assert_eq!(
unsolarized_theme
.as_style(Meaning::Guidance)
.foreground_color,
from_string("white").ok()
);
testing_logger::validate(|captured_logs| assert_eq!(captured_logs.len(), 0));
let nunsolarized = Config::builder()
.add_source(ConfigFile::from_str(
"
[theme]
name = \"nunsolarized\"
parent = \"nonsolarized\"
[colors]
AlertInfo = \"red\"
",
FileFormat::Toml,
))
.build()
.unwrap();
let nunsolarized_theme = manager
.load_theme_from_config("nunsolarized", nunsolarized, 1)
.unwrap();
assert_eq!(
nunsolarized_theme
.as_style(Meaning::Guidance)
.foreground_color,
None
);
testing_logger::validate(|captured_logs| {
assert_eq!(captured_logs.len(), 1);
assert_eq!(
captured_logs[0].body,
"Could not load theme nonsolarized: Empty theme directory override and could not find theme elsewhere"
);
assert_eq!(captured_logs[0].level, log::Level::Warn)
});
}
#[test]
fn test_can_debug_theme() {
testing_logger::setup();
[true, false].iter().for_each(|debug| {
let mut manager = ThemeManager::new(Some(*debug), Some("".to_string()));
let config = Config::builder()
.add_source(ConfigFile::from_str(
"
[theme]
name = \"mytheme\"
[colors]
Guidance = \"white\"
AlertInfo = \"xinetic\"
",
FileFormat::Toml,
))
.build()
.unwrap();
manager
.load_theme_from_config("config_theme", config, 1)
.unwrap();
testing_logger::validate(|captured_logs| {
if *debug {
assert_eq!(captured_logs.len(), 2);
assert_eq!(
captured_logs[0].body,
"Your theme config name is not the name of your loaded theme config_theme != mytheme"
);
assert_eq!(captured_logs[0].level, log::Level::Warn);
assert_eq!(
captured_logs[1].body,
"Tried to load string as a color unsuccessfully: (AlertInfo=xinetic) No such color in palette"
);
assert_eq!(captured_logs[1].level, log::Level::Warn)
} else {
assert_eq!(captured_logs.len(), 0)
}
})
})
}
#[test]
fn test_can_parse_color_strings_correctly() {
assert_eq!(
from_string("brown").unwrap(),
Color::Rgb {
r: 165,
g: 42,
b: 42
}
);
assert_eq!(from_string(""), Err("Empty string".into()));
["manatee", "caput mortuum", "123456"]
.iter()
.for_each(|inp| {
assert_eq!(from_string(inp), Err("No such color in palette".into()));
});
assert_eq!(
from_string("#ff1122").unwrap(),
Color::Rgb {
r: 255,
g: 17,
b: 34
}
);
["#1122", "#ffaa112", "#brown"].iter().for_each(|inp| {
assert_eq!(
from_string(inp),
Err("Could not parse 3 hex values from string".into())
);
});
assert_eq!(from_string("@dark_grey").unwrap(), Color::DarkGrey);
assert_eq!(
from_string("@rgb_(255,255,255)").unwrap(),
Color::Rgb {
r: 255,
g: 255,
b: 255
}
);
assert_eq!(from_string("@ansi_(255)").unwrap(), Color::AnsiValue(255));
["@", "@DarkGray", "@Dark 4ay", "@ansi(256)"]
.iter()
.for_each(|inp| {
assert_eq!(
from_string(inp),
Err(format!(
"Could not convert color name {inp} to Crossterm color"
))
);
});
}
}