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#[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 pub theme: ThemeDefinitionConfigBlock,
40
41 pub colors: HashMap<Meaning, String>,
43}
44
45#[derive(Clone, Debug, Deserialize, Serialize)]
46pub struct ThemeDefinitionConfigBlock {
47 pub name: String,
49
50 pub parent: Option<String>,
52}
53
54use crossterm::style::{Color, ContentStyle};
55
56pub struct Theme {
59 pub name: String,
60 pub parent: Option<String>,
61 pub styles: HashMap<Meaning, ContentStyle>,
62}
63
64impl Theme {
68 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 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 pub fn as_style(&self, meaning: Meaning) -> ContentStyle {
115 self.styles[self.closest_meaning(&meaning)]
116 }
117
118 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 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
172fn 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 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 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
238lazy_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
342pub struct ThemeManager {
344 loaded_themes: HashMap<String, Theme>,
345 debug: bool,
346 override_theme_dir: Option<String>,
347}
348
349impl 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 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 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 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 assert_eq!(
532 theme.as_style(Meaning::Guidance).foreground_color,
533 from_string("white").ok()
534 );
535
536 assert_eq!(theme.as_style(Meaning::AlertInfo).foreground_color, None);
538
539 assert_eq!(theme.as_style(Meaning::Base).foreground_color, None);
541
542 assert_eq!(
544 theme.as_style(Meaning::AlertError).foreground_color,
545 Some(Color::DarkRed)
546 );
547
548 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 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 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 assert_eq!(
656 unsolarized_theme
657 .as_style(Meaning::AlertInfo)
658 .foreground_color,
659 from_string("red").ok()
660 );
661
662 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 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}