diff options
Diffstat (limited to 'crates/turtle/src/atuin_client/theme.rs')
| -rw-r--r-- | crates/turtle/src/atuin_client/theme.rs | 846 |
1 files changed, 28 insertions, 818 deletions
diff --git a/crates/turtle/src/atuin_client/theme.rs b/crates/turtle/src/atuin_client/theme.rs index 21bbe07c..ec0538e9 100644 --- a/crates/turtle/src/atuin_client/theme.rs +++ b/crates/turtle/src/atuin_client/theme.rs @@ -1,831 +1,41 @@ -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(crate) enum Meaning { - AlertInfo, - AlertWarn, - AlertError, - Annotation, - Base, - Guidance, - Important, - Title, - Muted, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub(crate) struct ThemeConfig { - // Definition of the theme - pub(crate) theme: ThemeDefinitionConfigBlock, - - // Colors - pub(crate) colors: HashMap<Meaning, String>, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub(crate) struct ThemeDefinitionConfigBlock { - /// Name of theme ("default" for base) - pub(crate) name: String, - - /// Whether any theme should be treated as a parent _if available_ - pub(crate) 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(crate) struct Theme { - pub(crate) name: String, - pub(crate) parent: Option<String>, - pub(crate) styles: HashMap<Meaning, ContentStyle>, +pub(crate) fn style_base() -> ContentStyle { + ContentStyle::default() } - -// 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(crate) fn get_base(&self) -> ContentStyle { - self.styles[&Meaning::Base] - } - - pub(crate) fn get_info(&self) -> ContentStyle { - self.get_alert(log::Level::Info) - } - - pub(crate) fn get_warning(&self) -> ContentStyle { - self.get_alert(log::Level::Warn) - } - - pub(crate) 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(crate) fn get_alert(&self, severity: log::Level) -> ContentStyle { - self.styles[ALERT_TYPES.get(&severity).unwrap()] - } - - pub(crate) fn new( - name: String, - parent: Option<String>, - styles: HashMap<Meaning, ContentStyle>, - ) -> Theme { - Theme { - name, - parent, - styles, - } - } - - pub(crate) 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(crate) 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(crate) 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) +pub(crate) fn style_annotation() -> ContentStyle { + ContentStyle { + foreground_color: Some(Color::DarkGrey), + ..ContentStyle::default() } } - -// 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(crate) fn style_important() -> ContentStyle { + ContentStyle { + foreground_color: Some(Color::White), + attributes: Attributes::from(Attribute::Bold), + ..ContentStyle::default() } } - -pub(crate) 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() - } +pub(crate) fn style_guidance() -> ContentStyle { + ContentStyle { + foreground_color: Some(Color::DarkBlue), + ..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(crate) struct ThemeManager { - loaded_themes: HashMap<String, Theme>, - debug: bool, - override_theme_dir: Option<String>, -} - -// Theme-loading logic -impl ThemeManager { - pub(crate) 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(crate) 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(crate) 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(crate) 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() - } - }, - } +pub(crate) fn style_alerterror() -> ContentStyle { + ContentStyle { + foreground_color: Some(Color::DarkRed), + ..ContentStyle::default() } } - -#[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() - ); +pub(crate) fn style_alertinfo() -> ContentStyle { + ContentStyle { + foreground_color: Some(Color::DarkGreen), + ..ContentStyle::default() } - - #[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" - )) - ); - }); +} +pub(crate) fn style_alertwarn() -> ContentStyle { + ContentStyle { + foreground_color: Some(Color::DarkYellow), + ..ContentStyle::default() } } |
