atuin_client/
theme.rs

1use config::{Config, File as ConfigFile, FileFormat};
2use lazy_static::lazy_static;
3use log;
4use palette::named;
5use serde::{Deserialize, Serialize};
6use serde_json;
7use std::collections::HashMap;
8use std::error;
9use std::io::{Error, ErrorKind};
10use std::path::PathBuf;
11use strum_macros;
12
13static DEFAULT_MAX_DEPTH: u8 = 10;
14
15// Collection of settable "meanings" that can have colors set.
16// NOTE: You can add a new meaning here without breaking backwards compatibility but please:
17//     - update the atuin/docs repository, which has a list of available meanings
18//     - add a fallback in the MEANING_FALLBACKS below, so that themes which do not have it
19//       get a sensible fallback (see Title as an example)
20#[derive(
21    Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display,
22)]
23#[strum(serialize_all = "camel_case")]
24pub enum Meaning {
25    AlertInfo,
26    AlertWarn,
27    AlertError,
28    Annotation,
29    Base,
30    Guidance,
31    Important,
32    Title,
33    Muted,
34}
35
36#[derive(Clone, Debug, Deserialize, Serialize)]
37pub struct ThemeConfig {
38    // Definition of the theme
39    pub theme: ThemeDefinitionConfigBlock,
40
41    // Colors
42    pub colors: HashMap<Meaning, String>,
43}
44
45#[derive(Clone, Debug, Deserialize, Serialize)]
46pub struct ThemeDefinitionConfigBlock {
47    /// Name of theme ("default" for base)
48    pub name: String,
49
50    /// Whether any theme should be treated as a parent _if available_
51    pub parent: Option<String>,
52}
53
54use crossterm::style::{Color, ContentStyle};
55
56// For now, a theme is loaded as a mapping of meanings to colors, but it may be desirable to
57// expand that in the future to general styles, so we populate a Meaning->ContentStyle hashmap.
58pub struct Theme {
59    pub name: String,
60    pub parent: Option<String>,
61    pub styles: HashMap<Meaning, ContentStyle>,
62}
63
64// Themes have a number of convenience functions for the most commonly used meanings.
65// The general purpose `as_style` routine gives back a style, but for ease-of-use and to keep
66// theme-related boilerplate minimal, the convenience functions give a color.
67impl Theme {
68    // This is the base "default" color, for general text
69    pub fn get_base(&self) -> ContentStyle {
70        self.styles[&Meaning::Base]
71    }
72
73    pub fn get_info(&self) -> ContentStyle {
74        self.get_alert(log::Level::Info)
75    }
76
77    pub fn get_warning(&self) -> ContentStyle {
78        self.get_alert(log::Level::Warn)
79    }
80
81    pub fn get_error(&self) -> ContentStyle {
82        self.get_alert(log::Level::Error)
83    }
84
85    // The alert meanings may be chosen by the Level enum, rather than the methods above
86    // or the full Meaning enum, to simplify programmatic selection of a log-level.
87    pub fn get_alert(&self, severity: log::Level) -> ContentStyle {
88        self.styles[ALERT_TYPES.get(&severity).unwrap()]
89    }
90
91    pub fn new(
92        name: String,
93        parent: Option<String>,
94        styles: HashMap<Meaning, ContentStyle>,
95    ) -> Theme {
96        Theme {
97            name,
98            parent,
99            styles,
100        }
101    }
102
103    pub fn closest_meaning<'a>(&self, meaning: &'a Meaning) -> &'a Meaning {
104        if self.styles.contains_key(meaning) {
105            meaning
106        } else if MEANING_FALLBACKS.contains_key(meaning) {
107            self.closest_meaning(&MEANING_FALLBACKS[meaning])
108        } else {
109            &Meaning::Base
110        }
111    }
112
113    // General access - if you have a meaning, this will give you a (crossterm) style
114    pub fn as_style(&self, meaning: Meaning) -> ContentStyle {
115        self.styles[self.closest_meaning(&meaning)]
116    }
117
118    // Turns a map of meanings to colornames into a theme
119    // If theme-debug is on, then we will print any colornames that we cannot load,
120    // but we do not have this on in general, as it could print unfiltered text to the terminal
121    // from a theme TOML file. However, it will always return a theme, falling back to
122    // defaults on error, so that a TOML file does not break loading
123    pub fn from_foreground_colors(
124        name: String,
125        parent: Option<&Theme>,
126        foreground_colors: HashMap<Meaning, String>,
127        debug: bool,
128    ) -> Theme {
129        let styles: HashMap<Meaning, ContentStyle> = foreground_colors
130            .iter()
131            .map(|(name, color)| {
132                (
133                    *name,
134                    StyleFactory::from_fg_string(color).unwrap_or_else(|err| {
135                        if debug {
136                            log::warn!(
137                                "Tried to load string as a color unsuccessfully: ({}={}) {}",
138                                name,
139                                color,
140                                err
141                            );
142                        }
143                        ContentStyle::default()
144                    }),
145                )
146            })
147            .collect();
148        Theme::from_map(name, parent, &styles)
149    }
150
151    // Boil down a meaning-color hashmap into a theme, by taking the defaults
152    // for any unknown colors
153    fn from_map(
154        name: String,
155        parent: Option<&Theme>,
156        overrides: &HashMap<Meaning, ContentStyle>,
157    ) -> Theme {
158        let styles = match parent {
159            Some(theme) => Box::new(theme.styles.clone()),
160            None => Box::new(DEFAULT_THEME.styles.clone()),
161        }
162        .iter()
163        .map(|(name, color)| match overrides.get(name) {
164            Some(value) => (*name, *value),
165            None => (*name, *color),
166        })
167        .collect();
168        Theme::new(name, parent.map(|p| p.name.clone()), styles)
169    }
170}
171
172// Use palette to get a color from a string name, if possible
173fn from_string(name: &str) -> Result<Color, String> {
174    if name.is_empty() {
175        return Err("Empty string".into());
176    }
177    let first_char = name.chars().next().unwrap();
178    match first_char {
179        '#' => {
180            let hexcode = &name[1..];
181            let vec: Vec<u8> = hexcode
182                .chars()
183                .collect::<Vec<char>>()
184                .chunks(2)
185                .map(|pair| u8::from_str_radix(pair.iter().collect::<String>().as_str(), 16))
186                .filter_map(|n| n.ok())
187                .collect();
188            if vec.len() != 3 {
189                return Err("Could not parse 3 hex values from string".into());
190            }
191            Ok(Color::Rgb {
192                r: vec[0],
193                g: vec[1],
194                b: vec[2],
195            })
196        }
197        '@' => {
198            // For full flexibility, we need to use serde_json, given
199            // crossterm's approach.
200            serde_json::from_str::<Color>(format!("\"{}\"", &name[1..]).as_str())
201                .map_err(|_| format!("Could not convert color name {} to Crossterm color", name))
202        }
203        _ => {
204            let srgb = named::from_str(name).ok_or("No such color in palette")?;
205            Ok(Color::Rgb {
206                r: srgb.red,
207                g: srgb.green,
208                b: srgb.blue,
209            })
210        }
211    }
212}
213
214pub struct StyleFactory {}
215
216impl StyleFactory {
217    fn from_fg_string(name: &str) -> Result<ContentStyle, String> {
218        match from_string(name) {
219            Ok(color) => Ok(Self::from_fg_color(color)),
220            Err(err) => Err(err),
221        }
222    }
223
224    // For succinctness, if we are confident that the name will be known,
225    // this routine is available to keep the code readable
226    fn known_fg_string(name: &str) -> ContentStyle {
227        Self::from_fg_string(name).unwrap()
228    }
229
230    fn from_fg_color(color: Color) -> ContentStyle {
231        ContentStyle {
232            foreground_color: Some(color),
233            ..ContentStyle::default()
234        }
235    }
236}
237
238// Built-in themes. Rather than having extra files added before any theming
239// is available, this gives a couple of basic options, demonstrating the use
240// of themes: autumn and marine
241lazy_static! {
242    static ref ALERT_TYPES: HashMap<log::Level, Meaning> = {
243        HashMap::from([
244            (log::Level::Info, Meaning::AlertInfo),
245            (log::Level::Warn, Meaning::AlertWarn),
246            (log::Level::Error, Meaning::AlertError),
247        ])
248    };
249    static ref MEANING_FALLBACKS: HashMap<Meaning, Meaning> = {
250        HashMap::from([
251            (Meaning::Guidance, Meaning::AlertInfo),
252            (Meaning::Annotation, Meaning::AlertInfo),
253            (Meaning::Title, Meaning::Important),
254        ])
255    };
256    static ref DEFAULT_THEME: Theme = {
257        Theme::new(
258            "default".to_string(),
259            None,
260            HashMap::from([
261                (
262                    Meaning::AlertError,
263                    StyleFactory::from_fg_color(Color::DarkRed),
264                ),
265                (
266                    Meaning::AlertWarn,
267                    StyleFactory::from_fg_color(Color::DarkYellow),
268                ),
269                (
270                    Meaning::AlertInfo,
271                    StyleFactory::from_fg_color(Color::DarkGreen),
272                ),
273                (
274                    Meaning::Annotation,
275                    StyleFactory::from_fg_color(Color::DarkGrey),
276                ),
277                (
278                    Meaning::Guidance,
279                    StyleFactory::from_fg_color(Color::DarkBlue),
280                ),
281                (
282                    Meaning::Important,
283                    StyleFactory::from_fg_color(Color::White),
284                ),
285                (Meaning::Muted, StyleFactory::from_fg_color(Color::Grey)),
286                (Meaning::Base, ContentStyle::default()),
287            ]),
288        )
289    };
290    static ref BUILTIN_THEMES: HashMap<&'static str, Theme> = {
291        HashMap::from([
292            ("default", HashMap::new()),
293            (
294                "autumn",
295                HashMap::from([
296                    (
297                        Meaning::AlertError,
298                        StyleFactory::known_fg_string("saddlebrown"),
299                    ),
300                    (
301                        Meaning::AlertWarn,
302                        StyleFactory::known_fg_string("darkorange"),
303                    ),
304                    (Meaning::AlertInfo, StyleFactory::known_fg_string("gold")),
305                    (
306                        Meaning::Annotation,
307                        StyleFactory::from_fg_color(Color::DarkGrey),
308                    ),
309                    (Meaning::Guidance, StyleFactory::known_fg_string("brown")),
310                ]),
311            ),
312            (
313                "marine",
314                HashMap::from([
315                    (
316                        Meaning::AlertError,
317                        StyleFactory::known_fg_string("yellowgreen"),
318                    ),
319                    (Meaning::AlertWarn, StyleFactory::known_fg_string("cyan")),
320                    (
321                        Meaning::AlertInfo,
322                        StyleFactory::known_fg_string("turquoise"),
323                    ),
324                    (
325                        Meaning::Annotation,
326                        StyleFactory::known_fg_string("steelblue"),
327                    ),
328                    (
329                        Meaning::Base,
330                        StyleFactory::known_fg_string("lightsteelblue"),
331                    ),
332                    (Meaning::Guidance, StyleFactory::known_fg_string("teal")),
333                ]),
334            ),
335        ])
336        .iter()
337        .map(|(name, theme)| (*name, Theme::from_map(name.to_string(), None, theme)))
338        .collect()
339    };
340}
341
342// To avoid themes being repeatedly loaded, we store them in a theme manager
343pub struct ThemeManager {
344    loaded_themes: HashMap<String, Theme>,
345    debug: bool,
346    override_theme_dir: Option<String>,
347}
348
349// Theme-loading logic
350impl ThemeManager {
351    pub fn new(debug: Option<bool>, theme_dir: Option<String>) -> Self {
352        Self {
353            loaded_themes: HashMap::new(),
354            debug: debug.unwrap_or(false),
355            override_theme_dir: match theme_dir {
356                Some(theme_dir) => Some(theme_dir),
357                None => std::env::var("ATUIN_THEME_DIR").ok(),
358            },
359        }
360    }
361
362    // Try to load a theme from a `{name}.toml` file in the theme directory. If an override is set
363    // for the theme dir (via ATUIN_THEME_DIR env) we should load the theme from there
364    pub fn load_theme_from_file(
365        &mut self,
366        name: &str,
367        max_depth: u8,
368    ) -> Result<&Theme, Box<dyn error::Error>> {
369        let mut theme_file = if let Some(p) = &self.override_theme_dir {
370            if p.is_empty() {
371                return Err(Box::new(Error::new(
372                    ErrorKind::NotFound,
373                    "Empty theme directory override and could not find theme elsewhere",
374                )));
375            }
376            PathBuf::from(p)
377        } else {
378            let config_dir = atuin_common::utils::config_dir();
379            let mut theme_file = PathBuf::new();
380            theme_file.push(config_dir);
381            theme_file.push("themes");
382            theme_file
383        };
384
385        let theme_toml = format!["{}.toml", name];
386        theme_file.push(theme_toml);
387
388        let mut config_builder = Config::builder();
389
390        config_builder = config_builder.add_source(ConfigFile::new(
391            theme_file.to_str().unwrap(),
392            FileFormat::Toml,
393        ));
394
395        let config = config_builder.build()?;
396        self.load_theme_from_config(name, config, max_depth)
397    }
398
399    pub fn load_theme_from_config(
400        &mut self,
401        name: &str,
402        config: Config,
403        max_depth: u8,
404    ) -> Result<&Theme, Box<dyn error::Error>> {
405        let debug = self.debug;
406        let theme_config: ThemeConfig = match config.try_deserialize() {
407            Ok(tc) => tc,
408            Err(e) => {
409                return Err(Box::new(Error::new(
410                    ErrorKind::InvalidInput,
411                    format!(
412                        "Failed to deserialize theme: {}",
413                        if debug {
414                            e.to_string()
415                        } else {
416                            "set theme debug on for more info".to_string()
417                        }
418                    ),
419                )))
420            }
421        };
422        let colors: HashMap<Meaning, String> = theme_config.colors;
423        let parent: Option<&Theme> = match theme_config.theme.parent {
424            Some(parent_name) => {
425                if max_depth == 0 {
426                    return Err(Box::new(Error::new(
427                        ErrorKind::InvalidInput,
428                        "Parent requested but we hit the recursion limit",
429                    )));
430                }
431                Some(self.load_theme(parent_name.as_str(), Some(max_depth - 1)))
432            }
433            None => None,
434        };
435
436        if debug && name != theme_config.theme.name {
437            log::warn!(
438                "Your theme config name is not the name of your loaded theme {} != {}",
439                name,
440                theme_config.theme.name
441            );
442        }
443
444        let theme = Theme::from_foreground_colors(theme_config.theme.name, parent, colors, debug);
445        let name = name.to_string();
446        self.loaded_themes.insert(name.clone(), theme);
447        let theme = self.loaded_themes.get(&name).unwrap();
448        Ok(theme)
449    }
450
451    // Check if the requested theme is loaded and, if not, then attempt to get it
452    // from the builtins or, if not there, from file
453    pub fn load_theme(&mut self, name: &str, max_depth: Option<u8>) -> &Theme {
454        if self.loaded_themes.contains_key(name) {
455            return self.loaded_themes.get(name).unwrap();
456        }
457        let built_ins = &BUILTIN_THEMES;
458        match built_ins.get(name) {
459            Some(theme) => theme,
460            None => match self.load_theme_from_file(name, max_depth.unwrap_or(DEFAULT_MAX_DEPTH)) {
461                Ok(theme) => theme,
462                Err(err) => {
463                    log::warn!("Could not load theme {}: {}", name, err);
464                    built_ins.get("default").unwrap()
465                }
466            },
467        }
468    }
469}
470
471#[cfg(test)]
472mod theme_tests {
473    use super::*;
474
475    #[test]
476    fn test_can_load_builtin_theme() {
477        let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
478        let theme = manager.load_theme("autumn", None);
479        assert_eq!(
480            theme.as_style(Meaning::Guidance).foreground_color,
481            from_string("brown").ok()
482        );
483    }
484
485    #[test]
486    fn test_can_create_theme() {
487        let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
488        let mytheme = Theme::new(
489            "mytheme".to_string(),
490            None,
491            HashMap::from([(
492                Meaning::AlertError,
493                StyleFactory::known_fg_string("yellowgreen"),
494            )]),
495        );
496        manager.loaded_themes.insert("mytheme".to_string(), mytheme);
497        let theme = manager.load_theme("mytheme", None);
498        assert_eq!(
499            theme.as_style(Meaning::AlertError).foreground_color,
500            from_string("yellowgreen").ok()
501        );
502    }
503
504    #[test]
505    fn test_can_fallback_when_meaning_missing() {
506        let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
507
508        // We use title as an example of a meaning that is not defined
509        // even in the base theme.
510        assert!(!DEFAULT_THEME.styles.contains_key(&Meaning::Title));
511
512        let config = Config::builder()
513            .add_source(ConfigFile::from_str(
514                "
515        [theme]
516        name = \"title_theme\"
517
518        [colors]
519        Guidance = \"white\"
520        AlertInfo = \"zomp\"
521        ",
522                FileFormat::Toml,
523            ))
524            .build()
525            .unwrap();
526        let theme = manager
527            .load_theme_from_config("config_theme", config, 1)
528            .unwrap();
529
530        // Correctly picks overridden color.
531        assert_eq!(
532            theme.as_style(Meaning::Guidance).foreground_color,
533            from_string("white").ok()
534        );
535
536        // Does not fall back to any color.
537        assert_eq!(theme.as_style(Meaning::AlertInfo).foreground_color, None);
538
539        // Even for the base.
540        assert_eq!(theme.as_style(Meaning::Base).foreground_color, None);
541
542        // Falls back to red as meaning missing from theme, so picks base default.
543        assert_eq!(
544            theme.as_style(Meaning::AlertError).foreground_color,
545            Some(Color::DarkRed)
546        );
547
548        // Falls back to Important as Title not available.
549        assert_eq!(
550            theme.as_style(Meaning::Title).foreground_color,
551            theme.as_style(Meaning::Important).foreground_color,
552        );
553
554        let title_config = Config::builder()
555            .add_source(ConfigFile::from_str(
556                "
557        [theme]
558        name = \"title_theme\"
559
560        [colors]
561        Title = \"white\"
562        AlertInfo = \"zomp\"
563        ",
564                FileFormat::Toml,
565            ))
566            .build()
567            .unwrap();
568        let title_theme = manager
569            .load_theme_from_config("title_theme", title_config, 1)
570            .unwrap();
571
572        assert_eq!(
573            title_theme.as_style(Meaning::Title).foreground_color,
574            Some(Color::White)
575        );
576    }
577
578    #[test]
579    fn test_no_fallbacks_are_circular() {
580        let mytheme = Theme::new("mytheme".to_string(), None, HashMap::from([]));
581        MEANING_FALLBACKS
582            .iter()
583            .for_each(|pair| assert_eq!(mytheme.closest_meaning(pair.0), &Meaning::Base))
584    }
585
586    #[test]
587    fn test_can_get_colors_via_convenience_functions() {
588        let mut manager = ThemeManager::new(Some(true), Some("".to_string()));
589        let theme = manager.load_theme("default", None);
590        assert_eq!(theme.get_error().foreground_color.unwrap(), Color::DarkRed);
591        assert_eq!(
592            theme.get_warning().foreground_color.unwrap(),
593            Color::DarkYellow
594        );
595        assert_eq!(theme.get_info().foreground_color.unwrap(), Color::DarkGreen);
596        assert_eq!(theme.get_base().foreground_color, None);
597        assert_eq!(
598            theme.get_alert(log::Level::Error).foreground_color.unwrap(),
599            Color::DarkRed
600        )
601    }
602
603    #[test]
604    fn test_can_use_parent_theme_for_fallbacks() {
605        testing_logger::setup();
606
607        let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
608
609        // First, we introduce a base theme
610        let solarized = Config::builder()
611            .add_source(ConfigFile::from_str(
612                "
613        [theme]
614        name = \"solarized\"
615
616        [colors]
617        Guidance = \"white\"
618        AlertInfo = \"pink\"
619        ",
620                FileFormat::Toml,
621            ))
622            .build()
623            .unwrap();
624        let solarized_theme = manager
625            .load_theme_from_config("solarized", solarized, 1)
626            .unwrap();
627
628        assert_eq!(
629            solarized_theme
630                .as_style(Meaning::AlertInfo)
631                .foreground_color,
632            from_string("pink").ok()
633        );
634
635        // Then we introduce a derived theme
636        let unsolarized = Config::builder()
637            .add_source(ConfigFile::from_str(
638                "
639        [theme]
640        name = \"unsolarized\"
641        parent = \"solarized\"
642
643        [colors]
644        AlertInfo = \"red\"
645        ",
646                FileFormat::Toml,
647            ))
648            .build()
649            .unwrap();
650        let unsolarized_theme = manager
651            .load_theme_from_config("unsolarized", unsolarized, 1)
652            .unwrap();
653
654        // It will take its own values
655        assert_eq!(
656            unsolarized_theme
657                .as_style(Meaning::AlertInfo)
658                .foreground_color,
659            from_string("red").ok()
660        );
661
662        // ...or fall back to the parent
663        assert_eq!(
664            unsolarized_theme
665                .as_style(Meaning::Guidance)
666                .foreground_color,
667            from_string("white").ok()
668        );
669
670        testing_logger::validate(|captured_logs| assert_eq!(captured_logs.len(), 0));
671
672        // If the parent is not found, we end up with the base theme colors
673        let nunsolarized = Config::builder()
674            .add_source(ConfigFile::from_str(
675                "
676        [theme]
677        name = \"nunsolarized\"
678        parent = \"nonsolarized\"
679
680        [colors]
681        AlertInfo = \"red\"
682        ",
683                FileFormat::Toml,
684            ))
685            .build()
686            .unwrap();
687        let nunsolarized_theme = manager
688            .load_theme_from_config("nunsolarized", nunsolarized, 1)
689            .unwrap();
690
691        assert_eq!(
692            nunsolarized_theme
693                .as_style(Meaning::Guidance)
694                .foreground_color,
695            Some(Color::DarkBlue)
696        );
697
698        testing_logger::validate(|captured_logs| {
699            assert_eq!(captured_logs.len(), 1);
700            assert_eq!(captured_logs[0].body,
701                "Could not load theme nonsolarized: Empty theme directory override and could not find theme elsewhere"
702            );
703            assert_eq!(captured_logs[0].level, log::Level::Warn)
704        });
705    }
706
707    #[test]
708    fn test_can_debug_theme() {
709        testing_logger::setup();
710        [true, false].iter().for_each(|debug| {
711            let mut manager = ThemeManager::new(Some(*debug), Some("".to_string()));
712            let config = Config::builder()
713                .add_source(ConfigFile::from_str(
714                    "
715            [theme]
716            name = \"mytheme\"
717
718            [colors]
719            Guidance = \"white\"
720            AlertInfo = \"xinetic\"
721            ",
722                    FileFormat::Toml,
723                ))
724                .build()
725                .unwrap();
726            manager
727                .load_theme_from_config("config_theme", config, 1)
728                .unwrap();
729            testing_logger::validate(|captured_logs| {
730                if *debug {
731                    assert_eq!(captured_logs.len(), 2);
732                    assert_eq!(
733                        captured_logs[0].body,
734                        "Your theme config name is not the name of your loaded theme config_theme != mytheme"
735                    );
736                    assert_eq!(captured_logs[0].level, log::Level::Warn);
737                    assert_eq!(
738                        captured_logs[1].body,
739                        "Tried to load string as a color unsuccessfully: (AlertInfo=xinetic) No such color in palette"
740                    );
741                    assert_eq!(captured_logs[1].level, log::Level::Warn)
742                } else {
743                    assert_eq!(captured_logs.len(), 0)
744                }
745            })
746        })
747    }
748
749    #[test]
750    fn test_can_parse_color_strings_correctly() {
751        assert_eq!(
752            from_string("brown").unwrap(),
753            Color::Rgb {
754                r: 165,
755                g: 42,
756                b: 42
757            }
758        );
759
760        assert_eq!(from_string(""), Err("Empty string".into()));
761
762        ["manatee", "caput mortuum", "123456"]
763            .iter()
764            .for_each(|inp| {
765                assert_eq!(from_string(inp), Err("No such color in palette".into()));
766            });
767
768        assert_eq!(
769            from_string("#ff1122").unwrap(),
770            Color::Rgb {
771                r: 255,
772                g: 17,
773                b: 34
774            }
775        );
776        ["#1122", "#ffaa112", "#brown"].iter().for_each(|inp| {
777            assert_eq!(
778                from_string(inp),
779                Err("Could not parse 3 hex values from string".into())
780            );
781        });
782
783        assert_eq!(from_string("@dark_grey").unwrap(), Color::DarkGrey);
784        assert_eq!(
785            from_string("@rgb_(255,255,255)").unwrap(),
786            Color::Rgb {
787                r: 255,
788                g: 255,
789                b: 255
790            }
791        );
792        assert_eq!(from_string("@ansi_(255)").unwrap(), Color::AnsiValue(255));
793        ["@", "@DarkGray", "@Dark 4ay", "@ansi(256)"]
794            .iter()
795            .for_each(|inp| {
796                assert_eq!(
797                    from_string(inp),
798                    Err(format!(
799                        "Could not convert color name {} to Crossterm color",
800                        inp
801                    ))
802                );
803            });
804    }
805}