1#![warn(missing_docs)]
7#[cfg(feature = "fs")]
8use anyhow::Result;
9use serde::Serialize;
10#[cfg(feature = "fs")]
11use std::path::PathBuf;
12
13use crate::base24::{is_dark, normalize_hex, parse_base24};
14#[cfg(feature = "fs")]
15use crate::user_themes_path;
16
17#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
19pub enum HexColorError {
20 #[error("hex color must be 6 characters (got {0})")]
22 InvalidLength(usize),
23 #[error("invalid hex digit: {0}")]
25 InvalidHex(std::num::ParseIntError),
26}
27
28impl From<std::num::ParseIntError> for HexColorError {
29 fn from(e: std::num::ParseIntError) -> Self {
30 HexColorError::InvalidHex(e)
31 }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
39pub struct Base24Slots {
40 pub base00: String,
42 pub base01: String,
44 pub base02: String,
46 pub base03: String,
48 pub base04: String,
50 pub base05: String,
52 pub base06: String,
54 pub base07: String,
56 pub base08: String,
58 pub base09: String,
60 pub base0a: String,
62 pub base0b: String,
64 pub base0c: String,
66 pub base0d: String,
68 pub base0e: String,
70 pub base0f: String,
72 pub base10: String,
74 pub base11: String,
76 pub base12: String,
78 pub base13: String,
80 pub base14: String,
82 pub base15: String,
84 pub base16: String,
86 pub base17: String,
88}
89
90#[derive(Debug, Clone, Serialize)]
94pub struct Theme {
95 pub meta: Meta,
97 pub ansi: Ansi,
99 pub semantic: Semantic,
101 pub ui: Ui,
103 pub base24: Base24Slots,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
109pub struct Meta {
110 pub name: String,
112 pub author: String,
114 pub dark: bool,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
124pub struct Ansi {
125 pub black: String,
127 pub red: String,
129 pub green: String,
131 pub yellow: String,
133 pub blue: String,
135 pub magenta: String,
137 pub cyan: String,
139 pub white: String,
141 pub bright_black: String,
143 pub bright_red: String,
145 pub bright_green: String,
147 pub bright_yellow: String,
149 pub bright_blue: String,
151 pub bright_magenta: String,
153 pub bright_cyan: String,
155 pub bright_white: String,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
161pub struct Semantic {
162 pub error: String,
164 pub warning: String,
166 pub info: String,
168 pub success: String,
170 pub highlight: String,
172 pub link: String,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
178pub struct UiBg {
179 pub primary: String,
181 pub secondary: String,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
187pub struct UiFg {
188 pub primary: String,
190 pub secondary: String,
192 pub muted: String,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
198pub struct UiBorder {
199 pub primary: String,
201 pub muted: String,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
207pub struct UiCursor {
208 pub primary: String,
210 pub muted: String,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
216pub struct UiSelection {
217 pub bg: String,
219 pub fg: String,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
225pub struct Ui {
226 pub bg: UiBg,
228 pub fg: UiFg,
230 pub border: UiBorder,
232 pub cursor: UiCursor,
234 pub selection: UiSelection,
236}
237
238impl Theme {
239 pub fn from_base24_slots(slots: Base24Slots) -> Self {
241 let dark = is_dark(&slots.base00, &slots.base07);
242 let name = String::new(); Theme {
244 meta: Meta {
245 name,
246 author: String::new(),
247 dark,
248 },
249 ansi: Ansi {
250 black: slots.base00.clone(),
251 red: slots.base08.clone(),
252 green: slots.base0b.clone(),
253 yellow: slots.base0a.clone(),
254 blue: slots.base0d.clone(),
255 magenta: slots.base0e.clone(),
256 cyan: slots.base0c.clone(),
257 white: slots.base05.clone(),
258 bright_black: slots.base03.clone(),
259 bright_red: slots.base12.clone(),
260 bright_green: slots.base14.clone(),
261 bright_yellow: slots.base13.clone(),
262 bright_blue: slots.base16.clone(),
263 bright_magenta: slots.base17.clone(),
264 bright_cyan: slots.base15.clone(),
265 bright_white: slots.base07.clone(),
266 },
267 semantic: Semantic {
268 error: slots.base08.clone(),
269 warning: slots.base09.clone(),
270 info: slots.base0c.clone(),
271 success: slots.base0b.clone(),
272 highlight: slots.base0e.clone(),
273 link: slots.base0d.clone(),
274 },
275 ui: Ui {
276 fg: UiFg {
277 primary: slots.base05.clone(),
278 secondary: slots.base06.clone(),
279 muted: slots.base04.clone(),
280 },
281 bg: UiBg {
282 primary: slots.base00.clone(),
283 secondary: slots.base01.clone(),
284 },
285 border: UiBorder {
286 primary: slots.base02.clone(),
287 muted: slots.base01.clone(),
288 },
289 cursor: UiCursor {
290 primary: slots.base05.clone(),
291 muted: slots.base04.clone(),
292 },
293 selection: UiSelection {
294 bg: slots.base02.clone(),
295 fg: slots.base05.clone(),
296 },
297 },
298 base24: slots,
299 }
300 }
301
302 pub fn from_base24_str(src: &str) -> anyhow::Result<Self> {
307 let slots = parse_base24(src.as_bytes())?;
308 Self::from_raw_slots(&slots)
309 }
310
311 pub fn from_raw_slots(
313 slots: &std::collections::HashMap<String, String>,
314 ) -> anyhow::Result<Self> {
315 use anyhow::anyhow;
316
317 let b = |key: &str| -> anyhow::Result<String> {
318 let raw = slots
319 .get(key)
320 .ok_or_else(|| anyhow!("missing required slot '{key}'"))?;
321 normalize_hex(raw).map_err(|e| anyhow!("slot '{key}': {e}"))
322 };
323
324 let is_base24 = slots.contains_key("base10");
325
326 let base00 = b("base00")?;
327 let base01 = b("base01")?;
328 let base02 = b("base02")?;
329 let base03 = b("base03")?;
330 let base04 = b("base04")?;
331 let base05 = b("base05")?;
332 let base06 = b("base06")?;
333 let base07 = b("base07")?;
334 let base08 = b("base08")?;
335 let base09 = b("base09")?;
336 let base0a = b("base0a")?;
337 let base0b = b("base0b")?;
338 let base0c = b("base0c")?;
339 let base0d = b("base0d")?;
340 let base0e = b("base0e")?;
341 let base0f = b("base0f")?;
342
343 let base10 = if is_base24 {
345 b("base10")?
346 } else {
347 base00.clone()
348 };
349 let base11 = if is_base24 {
350 b("base11")?
351 } else {
352 base00.clone()
353 };
354 let base12 = if is_base24 {
355 b("base12")?
356 } else {
357 base08.clone()
358 };
359 let base13 = if is_base24 {
360 b("base13")?
361 } else {
362 base0a.clone()
363 };
364 let base14 = if is_base24 {
365 b("base14")?
366 } else {
367 base0b.clone()
368 };
369 let base15 = if is_base24 {
370 b("base15")?
371 } else {
372 base0c.clone()
373 };
374 let base16 = if is_base24 {
375 b("base16")?
376 } else {
377 base0d.clone()
378 };
379 let base17 = if is_base24 {
380 b("base17")?
381 } else {
382 base0e.clone()
383 };
384
385 let name = slots
386 .get("scheme")
387 .or_else(|| slots.get("name"))
388 .cloned()
389 .unwrap_or_else(|| "Imported Theme".to_string());
390 let author = slots.get("author").cloned().unwrap_or_default();
391
392 let raw = Base24Slots {
393 base00,
394 base01,
395 base02,
396 base03,
397 base04,
398 base05,
399 base06,
400 base07,
401 base08,
402 base09,
403 base0a,
404 base0b,
405 base0c,
406 base0d,
407 base0e,
408 base0f,
409 base10,
410 base11,
411 base12,
412 base13,
413 base14,
414 base15,
415 base16,
416 base17,
417 };
418
419 let mut theme = Self::from_base24_slots(raw);
420 theme.meta.name = name;
421 theme.meta.author = author;
422 Ok(theme)
423 }
424
425 pub fn from_name(name: &str) -> Theme {
434 use crate::BuiltinTheme;
435 #[cfg(feature = "fs")]
436 {
437 use crate::util::load_theme_file;
438 load_theme_file(name)
439 .ok()
440 .as_deref()
441 .and_then(|s| Theme::from_base24_str(s).ok())
442 .or_else(|| {
443 let slug = heck::AsKebabCase(name).to_string();
444 slug.parse::<BuiltinTheme>().ok().map(|b| b.theme())
445 })
446 .unwrap_or_else(|| BuiltinTheme::default().theme())
447 }
448 #[cfg(not(feature = "fs"))]
449 {
450 let slug = heck::AsKebabCase(name).to_string();
451 slug.parse::<BuiltinTheme>()
452 .map(|b| b.theme())
453 .unwrap_or_else(|_| BuiltinTheme::default().theme())
454 }
455 }
456
457 pub fn from_default_cfg() -> Theme {
466 use crate::BuiltinTheme;
467 #[cfg(feature = "fs")]
468 {
469 use crate::TcaConfig;
470 match TcaConfig::load().tca.default_theme {
471 Some(name) => Theme::from_name(&name),
472 None => BuiltinTheme::default().theme(),
473 }
474 }
475 #[cfg(not(feature = "fs"))]
476 {
477 BuiltinTheme::default().theme()
478 }
479 }
480
481 pub fn from_default_dark_cfg() -> Theme {
487 use crate::BuiltinTheme;
488 #[cfg(feature = "fs")]
489 {
490 use crate::TcaConfig;
491 match TcaConfig::load().tca.default_dark_theme {
492 Some(name) => Theme::from_name(&name),
493 None => BuiltinTheme::default().theme(),
494 }
495 }
496 #[cfg(not(feature = "fs"))]
497 {
498 BuiltinTheme::default().theme()
499 }
500 }
501
502 pub fn from_default_light_cfg() -> Theme {
508 use crate::BuiltinTheme;
509 #[cfg(feature = "fs")]
510 {
511 use crate::TcaConfig;
512 if let Some(name) = TcaConfig::load().tca.default_light_theme {
513 let theme = Theme::from_name(&name);
514 if !theme.meta.dark {
515 return theme;
516 }
517 }
518 BuiltinTheme::default_light().theme()
519 }
520 #[cfg(not(feature = "fs"))]
521 {
522 BuiltinTheme::default_light().theme()
523 }
524 }
525
526 pub fn name_slug(&self) -> String {
530 heck::AsKebabCase(&self.meta.name).to_string()
531 }
532
533 pub fn to_filename(&self) -> String {
537 let slug = self.name_slug();
538 if slug.ends_with(".yaml") {
539 slug
540 } else {
541 format!("{}.yaml", slug)
542 }
543 }
544
545 #[cfg(feature = "fs")]
547 pub fn to_pathbuf(&self) -> Result<PathBuf> {
548 let mut path = user_themes_path()?;
549 path.push(self.to_filename());
550 Ok(path)
551 }
552
553 pub fn to_base24_str(&self) -> String {
559 let h = |s: &str| s.trim_start_matches('#').to_lowercase();
560 let variant = if self.meta.dark { "dark" } else { "light" };
561 let author = &self.meta.author;
562 let s = &self.base24;
563 format!(
564 "scheme: \"{name}\"\nauthor: \"{author}\"\nvariant: \"{variant}\"\n\
565 base00: \"{b00}\"\nbase01: \"{b01}\"\nbase02: \"{b02}\"\nbase03: \"{b03}\"\n\
566 base04: \"{b04}\"\nbase05: \"{b05}\"\nbase06: \"{b06}\"\nbase07: \"{b07}\"\n\
567 base08: \"{b08}\"\nbase09: \"{b09}\"\nbase0A: \"{b0a}\"\nbase0B: \"{b0b}\"\n\
568 base0C: \"{b0c}\"\nbase0D: \"{b0d}\"\nbase0E: \"{b0e}\"\nbase0F: \"{b0f}\"\n\
569 base10: \"{b10}\"\nbase11: \"{b11}\"\nbase12: \"{b12}\"\nbase13: \"{b13}\"\n\
570 base14: \"{b14}\"\nbase15: \"{b15}\"\nbase16: \"{b16}\"\nbase17: \"{b17}\"\n",
571 name = self.meta.name,
572 b00 = h(&s.base00),
573 b01 = h(&s.base01),
574 b02 = h(&s.base02),
575 b03 = h(&s.base03),
576 b04 = h(&s.base04),
577 b05 = h(&s.base05),
578 b06 = h(&s.base06),
579 b07 = h(&s.base07),
580 b08 = h(&s.base08),
581 b09 = h(&s.base09),
582 b0a = h(&s.base0a),
583 b0b = h(&s.base0b),
584 b0c = h(&s.base0c),
585 b0d = h(&s.base0d),
586 b0e = h(&s.base0e),
587 b0f = h(&s.base0f),
588 b10 = h(&s.base10),
589 b11 = h(&s.base11),
590 b12 = h(&s.base12),
591 b13 = h(&s.base13),
592 b14 = h(&s.base14),
593 b15 = h(&s.base15),
594 b16 = h(&s.base16),
595 b17 = h(&s.base17),
596 )
597 }
598}
599
600impl Default for Theme {
601 fn default() -> Self {
606 Theme::from_default_cfg()
607 }
608}
609
610impl PartialEq for Theme {
613 fn eq(&self, other: &Self) -> bool {
614 self.name_slug() == other.name_slug()
615 }
616}
617impl Eq for Theme {}
618
619impl PartialOrd for Theme {
620 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
621 Some(self.cmp(other))
622 }
623}
624
625impl Ord for Theme {
626 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
627 self.name_slug().cmp(&other.name_slug())
628 }
629}
630
631pub fn hex_to_rgb(hex: &str) -> Result<(u8, u8, u8), HexColorError> {
651 let hex = hex.trim_start_matches('#');
652
653 if hex.len() != 6 {
654 return Err(HexColorError::InvalidLength(hex.len()));
655 }
656
657 let r = u8::from_str_radix(&hex[0..2], 16)?;
658 let g = u8::from_str_radix(&hex[2..4], 16)?;
659 let b = u8::from_str_radix(&hex[4..6], 16)?;
660 Ok((r, g, b))
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666
667 const MINIMAL_BASE24: &str = r#"
668scheme: "Test Theme"
669author: "Test Author"
670base00: "1a1a1a"
671base01: "222222"
672base02: "333333"
673base03: "666666"
674base04: "888888"
675base05: "fafafa"
676base06: "e0e0e0"
677base07: "ffffff"
678base08: "cc0000"
679base09: "ff8800"
680base0a: "ffff00"
681base0b: "00ff00"
682base0c: "00ffff"
683base0d: "0000ff"
684base0e: "ff00ff"
685base0f: "aa5500"
686base10: "1a1a1a"
687base11: "000000"
688base12: "ff5555"
689base13: "ffff55"
690base14: "55ff55"
691base15: "55ffff"
692base16: "5555ff"
693base17: "ff55ff"
694"#;
695
696 fn test_theme() -> Theme {
697 Theme::from_base24_str(MINIMAL_BASE24).unwrap()
698 }
699
700 #[test]
701 fn test_from_base24_str_name_and_author() {
702 let t = test_theme();
703 assert_eq!(t.meta.name, "Test Theme");
704 assert_eq!(t.meta.author, "Test Author");
705 }
706
707 #[test]
708 fn test_from_base24_str_dark_detection() {
709 let t = test_theme();
710 assert!(t.meta.dark, "dark bg should be detected as dark theme");
711 }
712
713 #[test]
714 fn test_mapping() {
715 let t = test_theme();
716 assert_eq!(t.ansi.black, "#1a1a1a");
717 assert_eq!(t.ansi.red, "#cc0000");
718 assert_eq!(t.ansi.white, "#fafafa");
719 assert_eq!(t.ansi.bright_black, "#666666"); assert_eq!(t.ansi.bright_red, "#ff5555"); assert_eq!(t.semantic.error, "#cc0000"); assert_eq!(t.semantic.warning, "#ff8800"); assert_eq!(t.semantic.info, "#00ffff"); assert_eq!(t.semantic.success, "#00ff00"); assert_eq!(t.semantic.link, "#0000ff"); assert_eq!(t.ui.bg.primary, "#1a1a1a"); assert_eq!(t.ui.bg.secondary, "#222222"); assert_eq!(t.ui.fg.primary, "#fafafa"); assert_eq!(t.ui.fg.secondary, "#e0e0e0"); assert_eq!(t.ui.fg.muted, "#888888"); assert_eq!(t.ui.border.primary, "#333333"); assert_eq!(t.ui.border.muted, "#222222"); assert_eq!(t.ui.cursor.primary, "#fafafa"); assert_eq!(t.ui.cursor.muted, "#888888"); assert_eq!(t.ui.selection.bg, "#333333"); assert_eq!(t.ui.selection.fg, "#fafafa"); }
738
739 #[test]
740 fn test_base16_fallbacks() {
741 let src = MINIMAL_BASE24
743 .lines()
744 .filter(|l| !l.starts_with("base1"))
745 .collect::<Vec<_>>()
746 .join("\n");
747 let t = Theme::from_base24_str(&src).unwrap();
748 assert_eq!(t.ansi.bright_red, t.ansi.red);
750 }
751
752 #[test]
753 fn test_name_slug_and_filename() {
754 let t = test_theme();
755 assert_eq!(t.name_slug(), "test-theme");
756 assert_eq!(t.to_filename(), "test-theme.yaml");
757 }
758
759 #[test]
760 fn test_to_base24_str_round_trip() {
761 let original = test_theme();
762 let yaml = original.to_base24_str();
763 let reloaded = Theme::from_base24_str(&yaml).unwrap();
764 assert_eq!(reloaded.meta.name, original.meta.name);
765 assert_eq!(reloaded.meta.dark, original.meta.dark);
766 assert_eq!(reloaded.base24.base08, original.base24.base08);
767 assert_eq!(reloaded.ansi.red, original.ansi.red);
768 assert_eq!(reloaded.semantic.error, original.semantic.error);
769 }
770
771 #[test]
772 fn test_to_base24_str_format() {
773 let t = test_theme();
774 let yaml = t.to_base24_str();
775 assert!(yaml.contains("scheme: \"Test Theme\""));
777 assert!(yaml.contains("base00:"));
778 assert!(yaml.contains("base17:"));
779 assert!(!yaml.contains(": \"#"));
781 }
782
783 #[test]
784 fn test_hex_to_rgb_valid() {
785 assert_eq!(hex_to_rgb("#ff5533").unwrap(), (255, 85, 51));
787 assert_eq!(hex_to_rgb("ff5533").unwrap(), (255, 85, 51));
788 assert_eq!(hex_to_rgb("#000000").unwrap(), (0, 0, 0));
790 assert_eq!(hex_to_rgb("#ffffff").unwrap(), (255, 255, 255));
791 }
792
793 #[test]
794 fn test_hex_to_rgb_invalid() {
795 assert!(hex_to_rgb("#fff").is_err());
797 assert!(hex_to_rgb("abc").is_err());
798 assert!(hex_to_rgb("#ff5533aa").is_err());
799 assert!(hex_to_rgb("#gggggg").is_err());
801 assert!(hex_to_rgb("#xyz123").is_err());
802 assert!(hex_to_rgb("").is_err());
804 assert!(hex_to_rgb("#").is_err());
805 }
806}