termusiclib/config/v2/tui/theme/
mod.rs

1#![allow(clippy::module_name_repetitions)]
2
3use std::{fs::File, io::BufReader, num::ParseIntError, path::Path};
4
5use serde::{Deserialize, Serialize};
6use tuirealm::props::Color;
7
8use crate::config::{
9    v1::AlacrittyColor,
10    yaml_theme::{YAMLTheme, YAMLThemeBright, YAMLThemeCursor, YAMLThemeNormal, YAMLThemePrimary},
11};
12
13use styles::ColorTermusic;
14
15pub mod styles;
16
17// TODO: combine Theme & Color?
18
19#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
20#[serde(default)] // allow missing fields and fill them with the `..Self::default()` in this struct
21pub struct ThemeWrap {
22    pub style: styles::Styles,
23    // On full-on default, also set the names to "Termusic Default"
24    // this function is only used if this property does not exist at all
25    #[serde(default = "ThemeColors::full_default")]
26    pub theme: ThemeColors,
27}
28
29impl ThemeWrap {
30    #[must_use]
31    pub fn get_color_from_theme(&self, color: ColorTermusic) -> Color {
32        match color {
33            ColorTermusic::Reset => Color::Reset,
34            ColorTermusic::Foreground => self.theme.primary.foreground.into(),
35            ColorTermusic::Background => self.theme.primary.background.into(),
36            ColorTermusic::Black => self.theme.normal.black.into(),
37            ColorTermusic::Red => self.theme.normal.red.into(),
38            ColorTermusic::Green => self.theme.normal.green.into(),
39            ColorTermusic::Yellow => self.theme.normal.yellow.into(),
40            ColorTermusic::Blue => self.theme.normal.blue.into(),
41            ColorTermusic::Magenta => self.theme.normal.magenta.into(),
42            ColorTermusic::Cyan => self.theme.normal.cyan.into(),
43            ColorTermusic::White => self.theme.normal.white.into(),
44            ColorTermusic::LightBlack => self.theme.bright.black.into(),
45            ColorTermusic::LightRed => self.theme.bright.red.into(),
46            ColorTermusic::LightGreen => self.theme.bright.green.into(),
47            ColorTermusic::LightYellow => self.theme.bright.yellow.into(),
48            ColorTermusic::LightBlue => self.theme.bright.blue.into(),
49            ColorTermusic::LightMagenta => self.theme.bright.magenta.into(),
50            ColorTermusic::LightCyan => self.theme.bright.cyan.into(),
51            ColorTermusic::LightWhite => self.theme.bright.white.into(),
52        }
53    }
54
55    #[inline]
56    #[must_use]
57    pub fn library_foreground(&self) -> Color {
58        self.get_color_from_theme(self.style.library.foreground_color)
59    }
60
61    #[inline]
62    #[must_use]
63    pub fn library_background(&self) -> Color {
64        self.get_color_from_theme(self.style.library.background_color)
65    }
66
67    #[inline]
68    #[must_use]
69    pub fn library_highlight(&self) -> Color {
70        self.get_color_from_theme(self.style.library.highlight_color)
71    }
72
73    #[inline]
74    #[must_use]
75    pub fn library_border(&self) -> Color {
76        self.get_color_from_theme(self.style.library.border_color)
77    }
78
79    #[inline]
80    #[must_use]
81    pub fn playlist_foreground(&self) -> Color {
82        self.get_color_from_theme(self.style.playlist.foreground_color)
83    }
84
85    #[inline]
86    #[must_use]
87    pub fn playlist_background(&self) -> Color {
88        self.get_color_from_theme(self.style.playlist.background_color)
89    }
90
91    #[inline]
92    #[must_use]
93    pub fn playlist_highlight(&self) -> Color {
94        self.get_color_from_theme(self.style.playlist.highlight_color)
95    }
96
97    #[inline]
98    #[must_use]
99    pub fn playlist_border(&self) -> Color {
100        self.get_color_from_theme(self.style.playlist.border_color)
101    }
102
103    #[inline]
104    #[must_use]
105    pub fn progress_foreground(&self) -> Color {
106        self.get_color_from_theme(self.style.progress.foreground_color)
107    }
108
109    #[inline]
110    #[must_use]
111    pub fn progress_background(&self) -> Color {
112        self.get_color_from_theme(self.style.progress.background_color)
113    }
114
115    #[inline]
116    #[must_use]
117    pub fn progress_border(&self) -> Color {
118        self.get_color_from_theme(self.style.progress.border_color)
119    }
120
121    #[inline]
122    #[must_use]
123    pub fn lyric_foreground(&self) -> Color {
124        self.get_color_from_theme(self.style.lyric.foreground_color)
125    }
126
127    #[inline]
128    #[must_use]
129    pub fn lyric_background(&self) -> Color {
130        self.get_color_from_theme(self.style.lyric.background_color)
131    }
132
133    #[inline]
134    #[must_use]
135    pub fn lyric_border(&self) -> Color {
136        self.get_color_from_theme(self.style.lyric.border_color)
137    }
138
139    #[inline]
140    #[must_use]
141    pub fn important_popup_foreground(&self) -> Color {
142        self.get_color_from_theme(self.style.important_popup.foreground_color)
143    }
144
145    #[inline]
146    #[must_use]
147    pub fn important_popup_background(&self) -> Color {
148        self.get_color_from_theme(self.style.important_popup.background_color)
149    }
150
151    #[inline]
152    #[must_use]
153    pub fn important_popup_border(&self) -> Color {
154        self.get_color_from_theme(self.style.important_popup.border_color)
155    }
156
157    #[inline]
158    #[must_use]
159    pub fn fallback_foreground(&self) -> Color {
160        self.get_color_from_theme(self.style.fallback.foreground_color)
161    }
162
163    #[inline]
164    #[must_use]
165    pub fn fallback_background(&self) -> Color {
166        self.get_color_from_theme(self.style.fallback.background_color)
167    }
168
169    #[inline]
170    #[must_use]
171    pub fn fallback_highlight(&self) -> Color {
172        self.get_color_from_theme(self.style.fallback.highlight_color)
173    }
174
175    #[inline]
176    #[must_use]
177    pub fn fallback_border(&self) -> Color {
178        self.get_color_from_theme(self.style.fallback.border_color)
179    }
180}
181
182/// Error for when [`ThemeColor`] parsing fails
183#[derive(Debug, Clone, PartialEq, thiserror::Error)]
184pub enum ThemeColorParseError {
185    #[error("Failed to parse color because of {0}")]
186    ParseIntError(#[from] ParseIntError),
187    #[error("Failed to parse color because of incorrect length {0}, expected prefix \"#\" or \"0x\" and length 6")]
188    IncorrectLength(usize),
189    #[error("Failed to parse color becazse of unknown prefix \"{0}\", expected \"#\" or \"0x\"")]
190    UnknownPrefix(String),
191}
192
193/// The rgb colors
194#[derive(Debug, Copy, Clone, Deserialize, Serialize, PartialEq, Eq)]
195#[serde(try_from = "String")]
196#[serde(into = "String")]
197pub struct ThemeColor {
198    pub r: u8,
199    pub g: u8,
200    pub b: u8,
201}
202
203impl ThemeColor {
204    /// Create a new instance with those values
205    #[must_use]
206    pub const fn new(r: u8, g: u8, b: u8) -> Self {
207        Self { r, g, b }
208    }
209
210    /// Convert from a prefix + 6 length string
211    pub fn from_hex(val: &str) -> Result<Self, ThemeColorParseError> {
212        let Some(without_prefix) = val.strip_prefix('#').or(val.strip_prefix("0x")) else {
213            return Err(ThemeColorParseError::UnknownPrefix(val.to_string()));
214        };
215
216        // not in a format we support
217        if without_prefix.len() != 6 {
218            return Err(ThemeColorParseError::IncorrectLength(without_prefix.len()));
219        }
220
221        let r = u8::from_str_radix(&without_prefix[0..=1], 16)
222            .map_err(ThemeColorParseError::ParseIntError)?;
223        let g = u8::from_str_radix(&without_prefix[2..=3], 16)
224            .map_err(ThemeColorParseError::ParseIntError)?;
225        let b = u8::from_str_radix(&without_prefix[4..=5], 16)
226            .map_err(ThemeColorParseError::ParseIntError)?;
227
228        Ok(Self { r, g, b })
229    }
230
231    /// Convert to hex prefix + 6 length string
232    #[inline]
233    #[must_use]
234    pub fn to_hex(&self) -> String {
235        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
236    }
237}
238
239impl TryFrom<String> for ThemeColor {
240    type Error = ThemeColorParseError;
241
242    fn try_from(value: String) -> Result<Self, Self::Error> {
243        Self::from_hex(&value)
244    }
245}
246
247impl TryFrom<&str> for ThemeColor {
248    type Error = ThemeColorParseError;
249
250    fn try_from(value: &str) -> Result<Self, Self::Error> {
251        Self::from_hex(value)
252    }
253}
254
255impl From<AlacrittyColor> for ThemeColor {
256    fn from(value: AlacrittyColor) -> Self {
257        Self {
258            r: value.r,
259            g: value.g,
260            b: value.b,
261        }
262    }
263}
264
265impl From<ThemeColor> for String {
266    fn from(val: ThemeColor) -> Self {
267        ThemeColor::to_hex(&val)
268    }
269}
270
271impl From<ThemeColor> for Color {
272    fn from(val: ThemeColor) -> Self {
273        Color::Rgb(val.r, val.g, val.b)
274    }
275}
276
277#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
278#[serde(default)] // allow missing fields and fill them with the `..Self::default()` in this struct
279pub struct ThemeColors {
280    /// The Filename of the current theme, if a file is used.
281    /// This value is skipped if empty.
282    ///
283    /// This is used for example to pre-select in the config editor if available.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub file_name: Option<String>,
286    pub name: String,
287    pub author: String,
288    pub primary: ThemePrimary,
289    pub cursor: ThemeCursor,
290    pub normal: ThemeNormal,
291    pub bright: ThemeBright,
292}
293
294impl Default for ThemeColors {
295    fn default() -> Self {
296        Self {
297            file_name: None,
298            name: default_name(),
299            author: default_author(),
300            primary: ThemePrimary::default(),
301            cursor: ThemeCursor::default(),
302            normal: ThemeNormal::default(),
303            bright: ThemeBright::default(),
304        }
305    }
306}
307
308impl ThemeColors {
309    /// Get the full default theme, including names.
310    ///
311    /// This function is different from [`Self::default`] as the trait impl is also used for filling empty places
312    #[must_use]
313    pub fn full_default() -> Self {
314        Self {
315            name: "Termusic Default".to_string(),
316            author: "Termusic Developers".to_string(),
317            ..Default::default()
318        }
319    }
320}
321
322/// Error for when [`ThemeColors`] parsing fails
323#[derive(Debug, Clone, PartialEq, thiserror::Error)]
324pub enum ThemeColorsParseError {
325    #[error("Failed to parse Theme: {0}")]
326    ThemeColor(#[from] ThemeColorParseError),
327}
328
329impl TryFrom<YAMLTheme> for ThemeColors {
330    type Error = ThemeColorsParseError;
331
332    fn try_from(value: YAMLTheme) -> Result<Self, Self::Error> {
333        let colors = value.colors;
334        Ok(Self {
335            file_name: None,
336            name: colors.name.unwrap_or_else(default_name),
337            author: colors.author.unwrap_or_else(default_author),
338            primary: colors.primary.try_into()?,
339            cursor: colors.cursor.try_into()?,
340            normal: colors.normal.try_into()?,
341            bright: colors.bright.try_into()?,
342        })
343    }
344}
345
346impl ThemeColors {
347    /// Load a YAML Theme and then convert it to a [`Alacritty`] instance
348    pub fn from_yaml_file(path: &Path) -> anyhow::Result<Self> {
349        let parsed: YAMLTheme = serde_yaml::from_reader(BufReader::new(File::open(path)?))?;
350
351        let mut theme = Self::try_from(parsed)?;
352
353        let file_name = path.file_stem();
354        theme.file_name = file_name.map(|v| v.to_string_lossy().to_string());
355
356        Ok(theme)
357    }
358}
359
360#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
361pub struct ThemePrimary {
362    pub background: ThemeColor,
363    pub foreground: ThemeColor,
364}
365
366impl Default for ThemePrimary {
367    fn default() -> Self {
368        Self {
369            background: ThemeColor::new(0x10, 0x14, 0x21),
370            foreground: ThemeColor::new(0xff, 0xfb, 0xf6),
371        }
372    }
373}
374
375impl TryFrom<YAMLThemePrimary> for ThemePrimary {
376    type Error = ThemeColorsParseError;
377
378    fn try_from(value: YAMLThemePrimary) -> Result<Self, Self::Error> {
379        Ok(Self {
380            background: value.background.try_into()?,
381            foreground: value.foreground.try_into()?,
382        })
383    }
384}
385
386#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
387#[serde(default)] // allow missing fields and fill them with the `..Self::default()` in this struct
388pub struct ThemeCursor {
389    pub text: ThemeColor,
390    pub cursor: ThemeColor,
391}
392
393impl Default for ThemeCursor {
394    fn default() -> Self {
395        Self {
396            text: ThemeColor::new(0x1e, 0x1e, 0x1e),
397            cursor: default_fff(),
398        }
399    }
400}
401
402impl TryFrom<YAMLThemeCursor> for ThemeCursor {
403    type Error = ThemeColorsParseError;
404
405    fn try_from(value: YAMLThemeCursor) -> Result<Self, Self::Error> {
406        Ok(Self {
407            text: value.text.try_into()?,
408            cursor: value.cursor.try_into()?,
409        })
410    }
411}
412
413#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
414#[serde(default)] // allow missing fields and fill them with the `..Self::default()` in this struct
415pub struct ThemeNormal {
416    pub black: ThemeColor,
417    pub red: ThemeColor,
418    pub green: ThemeColor,
419    pub yellow: ThemeColor,
420    pub blue: ThemeColor,
421    pub magenta: ThemeColor,
422    pub cyan: ThemeColor,
423    pub white: ThemeColor,
424}
425
426impl Default for ThemeNormal {
427    fn default() -> Self {
428        Self {
429            black: ThemeColor::new(0x2e, 0x2e, 0x2e),
430            red: ThemeColor::new(0xeb, 0x41, 0x29),
431            green: ThemeColor::new(0xab, 0xe0, 0x47),
432            yellow: ThemeColor::new(0xf6, 0xc7, 0x44),
433            blue: ThemeColor::new(0x47, 0xa0, 0xf3),
434            magenta: ThemeColor::new(0x7b, 0x5c, 0xb0),
435            cyan: ThemeColor::new(0x64, 0xdb, 0xed),
436            white: ThemeColor::new(0xe5, 0xe9, 0xf0),
437        }
438    }
439}
440
441impl TryFrom<YAMLThemeNormal> for ThemeNormal {
442    type Error = ThemeColorsParseError;
443
444    fn try_from(value: YAMLThemeNormal) -> Result<Self, Self::Error> {
445        Ok(Self {
446            black: value.black.try_into()?,
447            red: value.red.try_into()?,
448            green: value.green.try_into()?,
449            yellow: value.yellow.try_into()?,
450            blue: value.blue.try_into()?,
451            magenta: value.magenta.try_into()?,
452            cyan: value.cyan.try_into()?,
453            white: value.white.try_into()?,
454        })
455    }
456}
457
458#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
459#[serde(default)] // allow missing fields and fill them with the `..Self::default()` in this struct
460pub struct ThemeBright {
461    pub black: ThemeColor,
462    pub red: ThemeColor,
463    pub green: ThemeColor,
464    pub yellow: ThemeColor,
465    pub blue: ThemeColor,
466    pub magenta: ThemeColor,
467    pub cyan: ThemeColor,
468    pub white: ThemeColor,
469}
470
471impl Default for ThemeBright {
472    fn default() -> Self {
473        Self {
474            black: ThemeColor::new(0x56, 0x56, 0x56),
475            red: ThemeColor::new(0xec, 0x53, 0x57),
476            green: ThemeColor::new(0xc0, 0xe1, 0x7d),
477            yellow: ThemeColor::new(0xf9, 0xda, 0x6a),
478            blue: ThemeColor::new(0x49, 0xa4, 0xf8),
479            magenta: ThemeColor::new(0xa4, 0x7d, 0xe9),
480            cyan: ThemeColor::new(0x99, 0xfa, 0xf2),
481            white: default_fff(),
482        }
483    }
484}
485
486impl TryFrom<YAMLThemeBright> for ThemeBright {
487    type Error = ThemeColorsParseError;
488
489    fn try_from(value: YAMLThemeBright) -> Result<Self, Self::Error> {
490        Ok(Self {
491            black: value.black.try_into()?,
492            red: value.red.try_into()?,
493            green: value.green.try_into()?,
494            yellow: value.yellow.try_into()?,
495            blue: value.blue.try_into()?,
496            magenta: value.magenta.try_into()?,
497            cyan: value.cyan.try_into()?,
498            white: value.white.try_into()?,
499        })
500    }
501}
502
503#[inline]
504fn default_name() -> String {
505    "empty name".to_string()
506}
507
508#[inline]
509fn default_author() -> String {
510    "empty author".to_string()
511}
512
513#[inline]
514fn default_fff() -> ThemeColor {
515    ThemeColor::new(0xFF, 0xFF, 0xFF)
516}
517
518mod v1_interop {
519    use super::{ThemeBright, ThemeColors, ThemeCursor, ThemeNormal, ThemePrimary, ThemeWrap};
520    use crate::config::v1;
521
522    impl From<&v1::Alacritty> for ThemeColors {
523        fn from(value: &v1::Alacritty) -> Self {
524            Self {
525                file_name: None,
526                name: value.name.clone(),
527                author: value.author.clone(),
528                primary: ThemePrimary {
529                    background: value.background.into(),
530                    foreground: value.foreground.into(),
531                },
532                cursor: ThemeCursor {
533                    text: value.text.into(),
534                    cursor: value.cursor.into(),
535                },
536                normal: ThemeNormal {
537                    black: value.black.into(),
538                    red: value.red.into(),
539                    green: value.green.into(),
540                    yellow: value.yellow.into(),
541                    blue: value.blue.into(),
542                    magenta: value.magenta.into(),
543                    cyan: value.cyan.into(),
544                    white: value.white.into(),
545                },
546                bright: ThemeBright {
547                    black: value.light_black.into(),
548                    red: value.light_red.into(),
549                    green: value.light_green.into(),
550                    yellow: value.light_yellow.into(),
551                    blue: value.light_blue.into(),
552                    magenta: value.light_magenta.into(),
553                    cyan: value.light_cyan.into(),
554                    white: value.light_white.into(),
555                },
556            }
557        }
558    }
559
560    impl From<&v1::Settings> for ThemeWrap {
561        fn from(value: &v1::Settings) -> Self {
562            Self {
563                theme: (&value.style_color_symbol.alacritty_theme).into(),
564                style: value.into(),
565            }
566        }
567    }
568
569    #[cfg(test)]
570    mod tests {
571        use super::*;
572
573        #[test]
574        fn should_convert_default_without_error() {
575            let converted: ThemeColors = (&v1::StyleColorSymbol::default().alacritty_theme).into();
576
577            assert_eq!(
578                converted,
579                ThemeColors {
580                    file_name: None,
581                    name: "default".into(),
582                    author: "Larry Hao".into(),
583                    primary: ThemePrimary {
584                        background: "#101421".try_into().unwrap(),
585                        foreground: "#fffbf6".try_into().unwrap()
586                    },
587                    cursor: ThemeCursor {
588                        text: "#1E1E1E".try_into().unwrap(),
589                        cursor: "#FFFFFF".try_into().unwrap()
590                    },
591                    normal: ThemeNormal {
592                        black: "#2e2e2e".try_into().unwrap(),
593                        red: "#eb4129".try_into().unwrap(),
594                        green: "#abe047".try_into().unwrap(),
595                        yellow: "#f6c744".try_into().unwrap(),
596                        blue: "#47a0f3".try_into().unwrap(),
597                        magenta: "#7b5cb0".try_into().unwrap(),
598                        cyan: "#64dbed".try_into().unwrap(),
599                        white: "#e5e9f0".try_into().unwrap()
600                    },
601                    bright: ThemeBright {
602                        black: "#565656".try_into().unwrap(),
603                        red: "#ec5357".try_into().unwrap(),
604                        green: "#c0e17d".try_into().unwrap(),
605                        yellow: "#f9da6a".try_into().unwrap(),
606                        blue: "#49a4f8".try_into().unwrap(),
607                        magenta: "#a47de9".try_into().unwrap(),
608                        cyan: "#99faf2".try_into().unwrap(),
609                        white: "#ffffff".try_into().unwrap()
610                    }
611                }
612            );
613        }
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::ThemeColors;
620
621    mod theme_color {
622        use super::super::ThemeColor;
623
624        #[test]
625        fn should_parse_hashtag() {
626            assert_eq!(
627                ThemeColor::new(1, 2, 3),
628                ThemeColor::from_hex("#010203").unwrap()
629            );
630            assert_eq!(
631                ThemeColor::new(255, 255, 255),
632                ThemeColor::from_hex("#ffffff").unwrap()
633            );
634            assert_eq!(
635                ThemeColor::new(0, 0, 0),
636                ThemeColor::from_hex("#000000").unwrap()
637            );
638        }
639
640        #[test]
641        fn should_parse_0x() {
642            assert_eq!(
643                ThemeColor::new(1, 2, 3),
644                ThemeColor::from_hex("0x010203").unwrap()
645            );
646            assert_eq!(
647                ThemeColor::new(255, 255, 255),
648                ThemeColor::from_hex("0xffffff").unwrap()
649            );
650            assert_eq!(
651                ThemeColor::new(0, 0, 0),
652                ThemeColor::from_hex("0x000000").unwrap()
653            );
654        }
655    }
656
657    #[test]
658    fn should_default() {
659        // Test that there are no panics in the defaults, this should be able to be omitted once it is const
660        let _ = ThemeColors::default();
661    }
662}