aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle/src/atuin_client/theme.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/turtle/src/atuin_client/theme.rs')
-rw-r--r--crates/turtle/src/atuin_client/theme.rs831
1 files changed, 831 insertions, 0 deletions
diff --git a/crates/turtle/src/atuin_client/theme.rs b/crates/turtle/src/atuin_client/theme.rs
new file mode 100644
index 00000000..1d9c0b9e
--- /dev/null
+++ b/crates/turtle/src/atuin_client/theme.rs
@@ -0,0 +1,831 @@
+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;
+
+// Collection of settable "meanings" that can have colors set.
+// NOTE: You can add a new meaning here without breaking backwards compatibility but please:
+// - update the atuin/docs repository, which has a list of available meanings
+// - add a fallback in the MEANING_FALLBACKS below, so that themes which do not have it
+// get a sensible fallback (see Title as an example)
+#[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 {
+ // Definition of the theme
+ pub theme: ThemeDefinitionConfigBlock,
+
+ // Colors
+ pub colors: HashMap<Meaning, String>,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct ThemeDefinitionConfigBlock {
+ /// Name of theme ("default" for base)
+ pub name: String,
+
+ /// Whether any theme should be treated as a parent _if available_
+ pub parent: Option<String>,
+}
+
+use crossterm::style::{Attribute, Attributes, Color, ContentStyle};
+
+// For now, a theme is loaded as a mapping of meanings to colors, but it may be desirable to
+// expand that in the future to general styles, so we populate a Meaning->ContentStyle hashmap.
+pub struct Theme {
+ pub name: String,
+ pub parent: Option<String>,
+ pub styles: HashMap<Meaning, ContentStyle>,
+}
+
+// Themes have a number of convenience functions for the most commonly used meanings.
+// The general purpose `as_style` routine gives back a style, but for ease-of-use and to keep
+// theme-related boilerplate minimal, the convenience functions give a color.
+impl Theme {
+ // This is the base "default" color, for general text
+ 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)
+ }
+
+ // The alert meanings may be chosen by the Level enum, rather than the methods above
+ // or the full Meaning enum, to simplify programmatic selection of a log-level.
+ 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
+ }
+ }
+
+ // General access - if you have a meaning, this will give you a (crossterm) style
+ pub fn as_style(&self, meaning: Meaning) -> ContentStyle {
+ self.styles[self.closest_meaning(&meaning)]
+ }
+
+ // Turns a map of meanings to colornames into a theme
+ // If theme-debug is on, then we will print any colornames that we cannot load,
+ // but we do not have this on in general, as it could print unfiltered text to the terminal
+ // from a theme TOML file. However, it will always return a theme, falling back to
+ // defaults on error, so that a TOML file does not break loading
+ 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)
+ }
+
+ // Boil down a meaning-color hashmap into a theme, by taking the defaults
+ // for any unknown colors
+ 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)
+ }
+}
+
+// Use palette to get a color from a string name, if possible
+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],
+ })
+ }
+ '@' => {
+ // For full flexibility, we need to use serde_json, given
+ // crossterm's approach.
+ 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),
+ }
+ }
+
+ // For succinctness, if we are confident that the name will be known,
+ // this routine is available to keep the code readable
+ 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()
+ }
+ }
+}
+
+// Built-in themes. Rather than having extra files added before any theming
+// is available, this gives a couple of basic options, demonstrating the use
+// of themes: autumn and marine
+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()
+});
+
+// To avoid themes being repeatedly loaded, we store them in a theme manager
+pub struct ThemeManager {
+ loaded_themes: HashMap<String, Theme>,
+ debug: bool,
+ override_theme_dir: Option<String>,
+}
+
+// Theme-loading logic
+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(),
+ },
+ }
+ }
+
+ // Try to load a theme from a `{name}.toml` file in the theme directory. If an override is set
+ // for the theme dir (via ATUIN_THEME_DIR env) we should load the theme from there
+ 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 = crate::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)
+ }
+
+ // Check if the requested theme is loaded and, if not, then attempt to get it
+ // from the builtins or, if not there, from file
+ 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()));
+
+ // We use title as an example of a meaning that is not defined
+ // even in the base theme.
+ 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();
+
+ // Correctly picks overridden color.
+ assert_eq!(
+ theme.as_style(Meaning::Guidance).foreground_color,
+ from_string("white").ok()
+ );
+
+ // Does not fall back to any color.
+ assert_eq!(theme.as_style(Meaning::AlertInfo).foreground_color, None);
+
+ // Even for the base.
+ assert_eq!(theme.as_style(Meaning::Base).foreground_color, None);
+
+ // Falls back to red as meaning missing from theme, so picks base default.
+ assert_eq!(
+ theme.as_style(Meaning::AlertError).foreground_color,
+ Some(Color::DarkRed)
+ );
+
+ // Falls back to Important as Title not available.
+ 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()));
+
+ // First, we introduce a base theme
+ 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()
+ );
+
+ // Then we introduce a derived theme
+ 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();
+
+ // It will take its own values
+ assert_eq!(
+ unsolarized_theme
+ .as_style(Meaning::AlertInfo)
+ .foreground_color,
+ from_string("red").ok()
+ );
+
+ // ...or fall back to the parent
+ 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));
+
+ // If the parent is not found, we end up with the no theme colors or styling
+ // as this is considered a (soft) error state.
+ 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"
+ ))
+ );
+ });
+ }
+}