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