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::{Attribute, Attributes, 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!("Tried to load string as a color unsuccessfully: ({name}={color}) {err}");
137 }
138 ContentStyle::default()
139 }),
140 )
141 })
142 .collect();
143 Theme::from_map(name, parent, &styles)
144 }
145
146 fn from_map(
149 name: String,
150 parent: Option<&Theme>,
151 overrides: &HashMap<Meaning, ContentStyle>,
152 ) -> Theme {
153 let styles = match parent {
154 Some(theme) => Box::new(theme.styles.clone()),
155 None => Box::new(DEFAULT_THEME.styles.clone()),
156 }
157 .iter()
158 .map(|(name, color)| match overrides.get(name) {
159 Some(value) => (*name, *value),
160 None => (*name, *color),
161 })
162 .collect();
163 Theme::new(name, parent.map(|p| p.name.clone()), styles)
164 }
165}
166
167fn from_string(name: &str) -> Result<Color, String> {
169 if name.is_empty() {
170 return Err("Empty string".into());
171 }
172 let first_char = name.chars().next().unwrap();
173 match first_char {
174 '#' => {
175 let hexcode = &name[1..];
176 let vec: Vec<u8> = hexcode
177 .chars()
178 .collect::<Vec<char>>()
179 .chunks(2)
180 .map(|pair| u8::from_str_radix(pair.iter().collect::<String>().as_str(), 16))
181 .filter_map(|n| n.ok())
182 .collect();
183 if vec.len() != 3 {
184 return Err("Could not parse 3 hex values from string".into());
185 }
186 Ok(Color::Rgb {
187 r: vec[0],
188 g: vec[1],
189 b: vec[2],
190 })
191 }
192 '@' => {
193 serde_json::from_str::<Color>(format!("\"{}\"", &name[1..]).as_str())
196 .map_err(|_| format!("Could not convert color name {name} to Crossterm color"))
197 }
198 _ => {
199 let srgb = named::from_str(name).ok_or("No such color in palette")?;
200 Ok(Color::Rgb {
201 r: srgb.red,
202 g: srgb.green,
203 b: srgb.blue,
204 })
205 }
206 }
207}
208
209pub struct StyleFactory {}
210
211impl StyleFactory {
212 fn from_fg_string(name: &str) -> Result<ContentStyle, String> {
213 match from_string(name) {
214 Ok(color) => Ok(Self::from_fg_color(color)),
215 Err(err) => Err(err),
216 }
217 }
218
219 fn known_fg_string(name: &str) -> ContentStyle {
222 Self::from_fg_string(name).unwrap()
223 }
224
225 fn from_fg_color(color: Color) -> ContentStyle {
226 ContentStyle {
227 foreground_color: Some(color),
228 ..ContentStyle::default()
229 }
230 }
231
232 fn from_fg_color_and_attributes(color: Color, attributes: Attributes) -> ContentStyle {
233 ContentStyle {
234 foreground_color: Some(color),
235 attributes,
236 ..ContentStyle::default()
237 }
238 }
239}
240
241lazy_static! {
245 static ref ALERT_TYPES: HashMap<log::Level, Meaning> = {
246 HashMap::from([
247 (log::Level::Info, Meaning::AlertInfo),
248 (log::Level::Warn, Meaning::AlertWarn),
249 (log::Level::Error, Meaning::AlertError),
250 ])
251 };
252 static ref MEANING_FALLBACKS: HashMap<Meaning, Meaning> = {
253 HashMap::from([
254 (Meaning::Guidance, Meaning::AlertInfo),
255 (Meaning::Annotation, Meaning::AlertInfo),
256 (Meaning::Title, Meaning::Important),
257 ])
258 };
259 static ref DEFAULT_THEME: Theme = {
260 Theme::new(
261 "default".to_string(),
262 None,
263 HashMap::from([
264 (
265 Meaning::AlertError,
266 StyleFactory::from_fg_color(Color::DarkRed),
267 ),
268 (
269 Meaning::AlertWarn,
270 StyleFactory::from_fg_color(Color::DarkYellow),
271 ),
272 (
273 Meaning::AlertInfo,
274 StyleFactory::from_fg_color(Color::DarkGreen),
275 ),
276 (
277 Meaning::Annotation,
278 StyleFactory::from_fg_color(Color::DarkGrey),
279 ),
280 (
281 Meaning::Guidance,
282 StyleFactory::from_fg_color(Color::DarkBlue),
283 ),
284 (
285 Meaning::Important,
286 StyleFactory::from_fg_color_and_attributes(
287 Color::White,
288 Attributes::from(Attribute::Bold),
289 ),
290 ),
291 (Meaning::Muted, StyleFactory::from_fg_color(Color::Grey)),
292 (Meaning::Base, ContentStyle::default()),
293 ]),
294 )
295 };
296 static ref BUILTIN_THEMES: HashMap<&'static str, Theme> = {
297 HashMap::from([
298 ("default", HashMap::new()),
299 (
300 "(none)",
301 HashMap::from([
302 (Meaning::AlertError, ContentStyle::default()),
303 (Meaning::AlertWarn, ContentStyle::default()),
304 (Meaning::AlertInfo, ContentStyle::default()),
305 (Meaning::Annotation, ContentStyle::default()),
306 (Meaning::Guidance, ContentStyle::default()),
307 (Meaning::Important, ContentStyle::default()),
308 (Meaning::Muted, ContentStyle::default()),
309 (Meaning::Base, ContentStyle::default()),
310 ]),
311 ),
312 (
313 "autumn",
314 HashMap::from([
315 (
316 Meaning::AlertError,
317 StyleFactory::known_fg_string("saddlebrown"),
318 ),
319 (
320 Meaning::AlertWarn,
321 StyleFactory::known_fg_string("darkorange"),
322 ),
323 (Meaning::AlertInfo, StyleFactory::known_fg_string("gold")),
324 (
325 Meaning::Annotation,
326 StyleFactory::from_fg_color(Color::DarkGrey),
327 ),
328 (Meaning::Guidance, StyleFactory::known_fg_string("brown")),
329 ]),
330 ),
331 (
332 "marine",
333 HashMap::from([
334 (
335 Meaning::AlertError,
336 StyleFactory::known_fg_string("yellowgreen"),
337 ),
338 (Meaning::AlertWarn, StyleFactory::known_fg_string("cyan")),
339 (
340 Meaning::AlertInfo,
341 StyleFactory::known_fg_string("turquoise"),
342 ),
343 (
344 Meaning::Annotation,
345 StyleFactory::known_fg_string("steelblue"),
346 ),
347 (
348 Meaning::Base,
349 StyleFactory::known_fg_string("lightsteelblue"),
350 ),
351 (Meaning::Guidance, StyleFactory::known_fg_string("teal")),
352 ]),
353 ),
354 ])
355 .iter()
356 .map(|(name, theme)| (*name, Theme::from_map(name.to_string(), None, theme)))
357 .collect()
358 };
359}
360
361pub struct ThemeManager {
363 loaded_themes: HashMap<String, Theme>,
364 debug: bool,
365 override_theme_dir: Option<String>,
366}
367
368impl ThemeManager {
370 pub fn new(debug: Option<bool>, theme_dir: Option<String>) -> Self {
371 Self {
372 loaded_themes: HashMap::new(),
373 debug: debug.unwrap_or(false),
374 override_theme_dir: match theme_dir {
375 Some(theme_dir) => Some(theme_dir),
376 None => std::env::var("ATUIN_THEME_DIR").ok(),
377 },
378 }
379 }
380
381 pub fn load_theme_from_file(
384 &mut self,
385 name: &str,
386 max_depth: u8,
387 ) -> Result<&Theme, Box<dyn error::Error>> {
388 let mut theme_file = if let Some(p) = &self.override_theme_dir {
389 if p.is_empty() {
390 return Err(Box::new(Error::new(
391 ErrorKind::NotFound,
392 "Empty theme directory override and could not find theme elsewhere",
393 )));
394 }
395 PathBuf::from(p)
396 } else {
397 let config_dir = atuin_common::utils::config_dir();
398 let mut theme_file = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") {
399 PathBuf::from(p)
400 } else {
401 let mut theme_file = PathBuf::new();
402 theme_file.push(config_dir);
403 theme_file
404 };
405 theme_file.push("themes");
406 theme_file
407 };
408
409 let theme_toml = format!["{name}.toml"];
410 theme_file.push(theme_toml);
411
412 let mut config_builder = Config::builder();
413
414 config_builder = config_builder.add_source(ConfigFile::new(
415 theme_file.to_str().unwrap(),
416 FileFormat::Toml,
417 ));
418
419 let config = config_builder.build()?;
420 self.load_theme_from_config(name, config, max_depth)
421 }
422
423 pub fn load_theme_from_config(
424 &mut self,
425 name: &str,
426 config: Config,
427 max_depth: u8,
428 ) -> Result<&Theme, Box<dyn error::Error>> {
429 let debug = self.debug;
430 let theme_config: ThemeConfig = match config.try_deserialize() {
431 Ok(tc) => tc,
432 Err(e) => {
433 return Err(Box::new(Error::new(
434 ErrorKind::InvalidInput,
435 format!(
436 "Failed to deserialize theme: {}",
437 if debug {
438 e.to_string()
439 } else {
440 "set theme debug on for more info".to_string()
441 }
442 ),
443 )));
444 }
445 };
446 let colors: HashMap<Meaning, String> = theme_config.colors;
447 let parent: Option<&Theme> = match theme_config.theme.parent {
448 Some(parent_name) => {
449 if max_depth == 0 {
450 return Err(Box::new(Error::new(
451 ErrorKind::InvalidInput,
452 "Parent requested but we hit the recursion limit",
453 )));
454 }
455 Some(self.load_theme(parent_name.as_str(), Some(max_depth - 1)))
456 }
457 None => Some(self.load_theme("default", Some(max_depth - 1))),
458 };
459
460 if debug && name != theme_config.theme.name {
461 log::warn!(
462 "Your theme config name is not the name of your loaded theme {} != {}",
463 name,
464 theme_config.theme.name
465 );
466 }
467
468 let theme = Theme::from_foreground_colors(theme_config.theme.name, parent, colors, debug);
469 let name = name.to_string();
470 self.loaded_themes.insert(name.clone(), theme);
471 let theme = self.loaded_themes.get(&name).unwrap();
472 Ok(theme)
473 }
474
475 pub fn load_theme(&mut self, name: &str, max_depth: Option<u8>) -> &Theme {
478 if self.loaded_themes.contains_key(name) {
479 return self.loaded_themes.get(name).unwrap();
480 }
481 let built_ins = &BUILTIN_THEMES;
482 match built_ins.get(name) {
483 Some(theme) => theme,
484 None => match self.load_theme_from_file(name, max_depth.unwrap_or(DEFAULT_MAX_DEPTH)) {
485 Ok(theme) => theme,
486 Err(err) => {
487 log::warn!("Could not load theme {name}: {err}");
488 built_ins.get("(none)").unwrap()
489 }
490 },
491 }
492 }
493}
494
495#[cfg(test)]
496mod theme_tests {
497 use super::*;
498
499 #[test]
500 fn test_can_load_builtin_theme() {
501 let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
502 let theme = manager.load_theme("autumn", None);
503 assert_eq!(
504 theme.as_style(Meaning::Guidance).foreground_color,
505 from_string("brown").ok()
506 );
507 }
508
509 #[test]
510 fn test_can_create_theme() {
511 let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
512 let mytheme = Theme::new(
513 "mytheme".to_string(),
514 None,
515 HashMap::from([(
516 Meaning::AlertError,
517 StyleFactory::known_fg_string("yellowgreen"),
518 )]),
519 );
520 manager.loaded_themes.insert("mytheme".to_string(), mytheme);
521 let theme = manager.load_theme("mytheme", None);
522 assert_eq!(
523 theme.as_style(Meaning::AlertError).foreground_color,
524 from_string("yellowgreen").ok()
525 );
526 }
527
528 #[test]
529 fn test_can_fallback_when_meaning_missing() {
530 let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
531
532 assert!(!DEFAULT_THEME.styles.contains_key(&Meaning::Title));
535
536 let config = Config::builder()
537 .add_source(ConfigFile::from_str(
538 "
539 [theme]
540 name = \"title_theme\"
541
542 [colors]
543 Guidance = \"white\"
544 AlertInfo = \"zomp\"
545 ",
546 FileFormat::Toml,
547 ))
548 .build()
549 .unwrap();
550 let theme = manager
551 .load_theme_from_config("config_theme", config, 1)
552 .unwrap();
553
554 assert_eq!(
556 theme.as_style(Meaning::Guidance).foreground_color,
557 from_string("white").ok()
558 );
559
560 assert_eq!(theme.as_style(Meaning::AlertInfo).foreground_color, None);
562
563 assert_eq!(theme.as_style(Meaning::Base).foreground_color, None);
565
566 assert_eq!(
568 theme.as_style(Meaning::AlertError).foreground_color,
569 Some(Color::DarkRed)
570 );
571
572 assert_eq!(
574 theme.as_style(Meaning::Title).foreground_color,
575 theme.as_style(Meaning::Important).foreground_color,
576 );
577
578 let title_config = Config::builder()
579 .add_source(ConfigFile::from_str(
580 "
581 [theme]
582 name = \"title_theme\"
583
584 [colors]
585 Title = \"white\"
586 AlertInfo = \"zomp\"
587 ",
588 FileFormat::Toml,
589 ))
590 .build()
591 .unwrap();
592 let title_theme = manager
593 .load_theme_from_config("title_theme", title_config, 1)
594 .unwrap();
595
596 assert_eq!(
597 title_theme.as_style(Meaning::Title).foreground_color,
598 Some(Color::White)
599 );
600 }
601
602 #[test]
603 fn test_no_fallbacks_are_circular() {
604 let mytheme = Theme::new("mytheme".to_string(), None, HashMap::from([]));
605 MEANING_FALLBACKS
606 .iter()
607 .for_each(|pair| assert_eq!(mytheme.closest_meaning(pair.0), &Meaning::Base))
608 }
609
610 #[test]
611 fn test_can_get_colors_via_convenience_functions() {
612 let mut manager = ThemeManager::new(Some(true), Some("".to_string()));
613 let theme = manager.load_theme("default", None);
614 assert_eq!(theme.get_error().foreground_color.unwrap(), Color::DarkRed);
615 assert_eq!(
616 theme.get_warning().foreground_color.unwrap(),
617 Color::DarkYellow
618 );
619 assert_eq!(theme.get_info().foreground_color.unwrap(), Color::DarkGreen);
620 assert_eq!(theme.get_base().foreground_color, None);
621 assert_eq!(
622 theme.get_alert(log::Level::Error).foreground_color.unwrap(),
623 Color::DarkRed
624 )
625 }
626
627 #[test]
628 fn test_can_use_parent_theme_for_fallbacks() {
629 testing_logger::setup();
630
631 let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
632
633 let solarized = Config::builder()
635 .add_source(ConfigFile::from_str(
636 "
637 [theme]
638 name = \"solarized\"
639
640 [colors]
641 Guidance = \"white\"
642 AlertInfo = \"pink\"
643 ",
644 FileFormat::Toml,
645 ))
646 .build()
647 .unwrap();
648 let solarized_theme = manager
649 .load_theme_from_config("solarized", solarized, 1)
650 .unwrap();
651
652 assert_eq!(
653 solarized_theme
654 .as_style(Meaning::AlertInfo)
655 .foreground_color,
656 from_string("pink").ok()
657 );
658
659 let unsolarized = Config::builder()
661 .add_source(ConfigFile::from_str(
662 "
663 [theme]
664 name = \"unsolarized\"
665 parent = \"solarized\"
666
667 [colors]
668 AlertInfo = \"red\"
669 ",
670 FileFormat::Toml,
671 ))
672 .build()
673 .unwrap();
674 let unsolarized_theme = manager
675 .load_theme_from_config("unsolarized", unsolarized, 1)
676 .unwrap();
677
678 assert_eq!(
680 unsolarized_theme
681 .as_style(Meaning::AlertInfo)
682 .foreground_color,
683 from_string("red").ok()
684 );
685
686 assert_eq!(
688 unsolarized_theme
689 .as_style(Meaning::Guidance)
690 .foreground_color,
691 from_string("white").ok()
692 );
693
694 testing_logger::validate(|captured_logs| assert_eq!(captured_logs.len(), 0));
695
696 let nunsolarized = Config::builder()
699 .add_source(ConfigFile::from_str(
700 "
701 [theme]
702 name = \"nunsolarized\"
703 parent = \"nonsolarized\"
704
705 [colors]
706 AlertInfo = \"red\"
707 ",
708 FileFormat::Toml,
709 ))
710 .build()
711 .unwrap();
712 let nunsolarized_theme = manager
713 .load_theme_from_config("nunsolarized", nunsolarized, 1)
714 .unwrap();
715
716 assert_eq!(
717 nunsolarized_theme
718 .as_style(Meaning::Guidance)
719 .foreground_color,
720 None
721 );
722
723 testing_logger::validate(|captured_logs| {
724 assert_eq!(captured_logs.len(), 1);
725 assert_eq!(
726 captured_logs[0].body,
727 "Could not load theme nonsolarized: Empty theme directory override and could not find theme elsewhere"
728 );
729 assert_eq!(captured_logs[0].level, log::Level::Warn)
730 });
731 }
732
733 #[test]
734 fn test_can_debug_theme() {
735 testing_logger::setup();
736 [true, false].iter().for_each(|debug| {
737 let mut manager = ThemeManager::new(Some(*debug), Some("".to_string()));
738 let config = Config::builder()
739 .add_source(ConfigFile::from_str(
740 "
741 [theme]
742 name = \"mytheme\"
743
744 [colors]
745 Guidance = \"white\"
746 AlertInfo = \"xinetic\"
747 ",
748 FileFormat::Toml,
749 ))
750 .build()
751 .unwrap();
752 manager
753 .load_theme_from_config("config_theme", config, 1)
754 .unwrap();
755 testing_logger::validate(|captured_logs| {
756 if *debug {
757 assert_eq!(captured_logs.len(), 2);
758 assert_eq!(
759 captured_logs[0].body,
760 "Your theme config name is not the name of your loaded theme config_theme != mytheme"
761 );
762 assert_eq!(captured_logs[0].level, log::Level::Warn);
763 assert_eq!(
764 captured_logs[1].body,
765 "Tried to load string as a color unsuccessfully: (AlertInfo=xinetic) No such color in palette"
766 );
767 assert_eq!(captured_logs[1].level, log::Level::Warn)
768 } else {
769 assert_eq!(captured_logs.len(), 0)
770 }
771 })
772 })
773 }
774
775 #[test]
776 fn test_can_parse_color_strings_correctly() {
777 assert_eq!(
778 from_string("brown").unwrap(),
779 Color::Rgb {
780 r: 165,
781 g: 42,
782 b: 42
783 }
784 );
785
786 assert_eq!(from_string(""), Err("Empty string".into()));
787
788 ["manatee", "caput mortuum", "123456"]
789 .iter()
790 .for_each(|inp| {
791 assert_eq!(from_string(inp), Err("No such color in palette".into()));
792 });
793
794 assert_eq!(
795 from_string("#ff1122").unwrap(),
796 Color::Rgb {
797 r: 255,
798 g: 17,
799 b: 34
800 }
801 );
802 ["#1122", "#ffaa112", "#brown"].iter().for_each(|inp| {
803 assert_eq!(
804 from_string(inp),
805 Err("Could not parse 3 hex values from string".into())
806 );
807 });
808
809 assert_eq!(from_string("@dark_grey").unwrap(), Color::DarkGrey);
810 assert_eq!(
811 from_string("@rgb_(255,255,255)").unwrap(),
812 Color::Rgb {
813 r: 255,
814 g: 255,
815 b: 255
816 }
817 );
818 assert_eq!(from_string("@ansi_(255)").unwrap(), Color::AnsiValue(255));
819 ["@", "@DarkGray", "@Dark 4ay", "@ansi(256)"]
820 .iter()
821 .for_each(|inp| {
822 assert_eq!(
823 from_string(inp),
824 Err(format!(
825 "Could not convert color name {inp} to Crossterm color"
826 ))
827 );
828 });
829 }
830}