diff options
Diffstat (limited to 'crates/atuin-client/src')
| -rw-r--r-- | crates/atuin-client/src/settings.rs | 4 | ||||
| -rw-r--r-- | crates/atuin-client/src/theme.rs | 311 |
2 files changed, 212 insertions, 103 deletions
diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs index a83f36bb..64cb4f3a 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -340,7 +340,7 @@ pub struct Preview { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Theme { - /// Name of desired theme ("" for base) + /// Name of desired theme ("default" for base) pub name: String, /// Whether any available additional theme debug should be shown @@ -756,7 +756,7 @@ impl Settings { .set_default("daemon.socket_path", socket_path.to_str())? .set_default("daemon.systemd_socket", false)? .set_default("daemon.tcp_port", 8889)? - .set_default("theme.name", "")? + .set_default("theme.name", "default")? .set_default("theme.debug", None::<bool>)? .set_default( "prefers_reduced_motion", diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index f0e51cd7..1f9bce7e 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -3,6 +3,7 @@ use lazy_static::lazy_static; use log; use palette::named; use serde::{Deserialize, Serialize}; +use serde_json; use std::collections::HashMap; use std::error; use std::io::{Error, ErrorKind}; @@ -29,6 +30,7 @@ pub enum Meaning { Guidance, Important, Title, + Muted, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -42,7 +44,7 @@ pub struct ThemeConfig { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct ThemeDefinitionConfigBlock { - /// Name of theme ("" for base) + /// Name of theme ("default" for base) pub name: String, /// Whether any theme should be treated as a parent _if available_ @@ -56,7 +58,7 @@ use crossterm::style::{Color, ContentStyle}; pub struct Theme { pub name: String, pub parent: Option<String>, - pub colors: HashMap<Meaning, Color>, + pub styles: HashMap<Meaning, ContentStyle>, } // Themes have a number of convenience functions for the most commonly used meanings. @@ -64,38 +66,42 @@ pub struct Theme { // 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) -> Color { - self.colors[&Meaning::Base] + pub fn get_base(&self) -> ContentStyle { + self.styles[&Meaning::Base] } - pub fn get_info(&self) -> Color { + pub fn get_info(&self) -> ContentStyle { self.get_alert(log::Level::Info) } - pub fn get_warning(&self) -> Color { + pub fn get_warning(&self) -> ContentStyle { self.get_alert(log::Level::Warn) } - pub fn get_error(&self) -> Color { + 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) -> Color { - self.colors[ALERT_TYPES.get(&severity).unwrap()] + pub fn get_alert(&self, severity: log::Level) -> ContentStyle { + self.styles[ALERT_TYPES.get(&severity).unwrap()] } - pub fn new(name: String, parent: Option<String>, colors: HashMap<Meaning, Color>) -> Theme { + pub fn new( + name: String, + parent: Option<String>, + styles: HashMap<Meaning, ContentStyle>, + ) -> Theme { Theme { name, parent, - colors, + styles, } } pub fn closest_meaning<'a>(&self, meaning: &'a Meaning) -> &'a Meaning { - if self.colors.contains_key(meaning) { + if self.styles.contains_key(meaning) { meaning } else if MEANING_FALLBACKS.contains_key(meaning) { self.closest_meaning(&MEANING_FALLBACKS[meaning]) @@ -106,10 +112,7 @@ impl Theme { // General access - if you have a meaning, this will give you a (crossterm) style pub fn as_style(&self, meaning: Meaning) -> ContentStyle { - ContentStyle { - foreground_color: Some(self.colors[self.closest_meaning(&meaning)]), - ..ContentStyle::default() - } + self.styles[self.closest_meaning(&meaning)] } // Turns a map of meanings to colornames into a theme @@ -117,27 +120,52 @@ impl Theme { // 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_map( + pub fn from_foreground_colors( name: String, parent: Option<&Theme>, - colors: HashMap<Meaning, String>, + foreground_colors: HashMap<Meaning, String>, debug: bool, ) -> Theme { - let colors: HashMap<Meaning, Color> = colors + let styles: HashMap<Meaning, ContentStyle> = foreground_colors .iter() .map(|(name, color)| { ( *name, - from_string(color).unwrap_or_else(|msg: String| { + StyleFactory::from_fg_string(color).unwrap_or_else(|err| { if debug { - log::warn!("Could not load theme color: {} -> {}", msg, color); + log::warn!( + "Tried to load string as a color unsuccessfully: ({}={}) {}", + name, + color, + err + ); } - Color::Grey + ContentStyle::default() }), ) }) .collect(); - make_theme(name, parent, &colors) + 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) } } @@ -146,61 +174,65 @@ fn from_string(name: &str) -> Result<Color, String> { if name.is_empty() { return Err("Empty string".into()); } - if let Some(name) = name.strip_prefix('#') { - let hexcode = name; - 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()); + 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 fleixibility, 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 {} to Crossterm color", name)) + } + _ => { + 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, + }) } - Ok(Color::Rgb { - r: vec[0], - g: vec[1], - b: vec[2], - }) - } else { - 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, - }) } } -// For succinctness, if we are confident that the name will be known, -// this routine is available to keep the code readable -fn _from_known(name: &str) -> Color { - from_string(name).unwrap() -} +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), + } + } -// Boil down a meaning-color hashmap into a theme, by taking the defaults -// for any unknown colors -fn make_theme(name: String, parent: Option<&Theme>, overrides: &HashMap<Meaning, Color>) -> Theme { - let colors = match parent { - Some(theme) => Box::new(theme.colors.clone()), - None => Box::new(HashMap::from([ - (Meaning::AlertError, Color::Red), - (Meaning::AlertWarn, Color::Yellow), - (Meaning::AlertInfo, Color::Green), - (Meaning::Annotation, Color::DarkGrey), - (Meaning::Guidance, Color::Blue), - (Meaning::Important, Color::White), - (Meaning::Base, Color::Grey), - ])), + // 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() + } } - .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()), colors) } // Built-in themes. Rather than having extra files added before any theming @@ -221,33 +253,82 @@ lazy_static! { (Meaning::Title, Meaning::Important), ]) }; + static ref DEFAULT_THEME: Theme = { + Theme::new( + "default".to_string(), + None, + HashMap::from([ + (Meaning::AlertError, StyleFactory::from_fg_color(Color::Red)), + ( + Meaning::AlertWarn, + StyleFactory::from_fg_color(Color::Yellow), + ), + ( + Meaning::AlertInfo, + StyleFactory::from_fg_color(Color::Green), + ), + ( + Meaning::Annotation, + StyleFactory::from_fg_color(Color::DarkGrey), + ), + (Meaning::Guidance, StyleFactory::from_fg_color(Color::Blue)), + ( + Meaning::Important, + StyleFactory::from_fg_color(Color::White), + ), + (Meaning::Muted, StyleFactory::from_fg_color(Color::Grey)), + (Meaning::Base, ContentStyle::default()), + ]), + ) + }; static ref BUILTIN_THEMES: HashMap<&'static str, Theme> = { HashMap::from([ - ("", HashMap::new()), + ("default", HashMap::new()), ( "autumn", HashMap::from([ - (Meaning::AlertError, _from_known("saddlebrown")), - (Meaning::AlertWarn, _from_known("darkorange")), - (Meaning::AlertInfo, _from_known("gold")), - (Meaning::Annotation, Color::DarkGrey), - (Meaning::Guidance, _from_known("brown")), + ( + 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, _from_known("yellowgreen")), - (Meaning::AlertWarn, _from_known("cyan")), - (Meaning::AlertInfo, _from_known("turquoise")), - (Meaning::Annotation, _from_known("steelblue")), - (Meaning::Base, _from_known("lightsteelblue")), - (Meaning::Guidance, _from_known("teal")), + ( + 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, make_theme(name.to_string(), None, theme))) + .map(|(name, theme)| (*name, Theme::from_map(name.to_string(), None, theme))) .collect() }; } @@ -354,7 +435,7 @@ impl ThemeManager { ); } - let theme = Theme::from_map(theme_config.theme.name, parent, colors, debug); + 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(); @@ -374,7 +455,7 @@ impl ThemeManager { Ok(theme) => theme, Err(err) => { log::warn!("Could not load theme {}: {}", name, err); - built_ins.get("").unwrap() + built_ins.get("default").unwrap() } }, } @@ -401,7 +482,10 @@ mod theme_tests { let mytheme = Theme::new( "mytheme".to_string(), None, - HashMap::from([(Meaning::AlertError, _from_known("yellowgreen"))]), + HashMap::from([( + Meaning::AlertError, + StyleFactory::known_fg_string("yellowgreen"), + )]), ); manager.loaded_themes.insert("mytheme".to_string(), mytheme); let theme = manager.load_theme("mytheme", None); @@ -417,7 +501,7 @@ mod theme_tests { // We use title as an example of a meaning that is not defined // even in the base theme. - assert!(!BUILTIN_THEMES[""].colors.contains_key(&Meaning::Title)); + assert!(!DEFAULT_THEME.styles.contains_key(&Meaning::Title)); let config = Config::builder() .add_source(ConfigFile::from_str( @@ -443,11 +527,11 @@ mod theme_tests { from_string("white").ok() ); - // Falls back to grey as general "unknown" color. - assert_eq!( - theme.as_style(Meaning::AlertInfo).foreground_color, - Some(Color::Grey) - ); + // 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!( @@ -496,12 +580,15 @@ mod theme_tests { #[test] fn test_can_get_colors_via_convenience_functions() { let mut manager = ThemeManager::new(Some(true), Some("".to_string())); - let theme = manager.load_theme("", None); - assert_eq!(theme.get_error(), Color::Red); - assert_eq!(theme.get_warning(), Color::Yellow); - assert_eq!(theme.get_info(), Color::Green); - assert_eq!(theme.get_base(), Color::Grey); - assert_eq!(theme.get_alert(log::Level::Error), Color::Red) + let theme = manager.load_theme("default", None); + assert_eq!(theme.get_error().foreground_color.unwrap(), Color::Red); + assert_eq!(theme.get_warning().foreground_color.unwrap(), Color::Yellow); + assert_eq!(theme.get_info().foreground_color.unwrap(), Color::Green); + assert_eq!(theme.get_base().foreground_color, None); + assert_eq!( + theme.get_alert(log::Level::Error).foreground_color.unwrap(), + Color::Red + ) } #[test] @@ -640,7 +727,7 @@ mod theme_tests { assert_eq!(captured_logs[0].level, log::Level::Warn); assert_eq!( captured_logs[1].body, - "Could not load theme color: No such color in palette -> xinetic" + "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 { @@ -683,5 +770,27 @@ mod theme_tests { 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 {} to Crossterm color", + inp + )) + ); + }); } } |
