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: Option<&str>) -> Theme {
435 use crate::BuiltinTheme;
436 #[cfg(feature = "fs")]
437 {
438 use crate::util::load_theme_file;
439 use terminal_colorsaurus::{theme_mode, QueryOptions, ThemeMode};
440 name.and_then(|n| load_theme_file(n).ok())
441 .as_deref()
442 .and_then(|s| Theme::from_base24_str(s).ok())
443 .or_else(|| {
444 name.and_then(|n| {
445 let slug = heck::AsKebabCase(n).to_string();
446 slug.parse::<BuiltinTheme>().ok().map(|b| b.theme())
447 })
448 })
449 .or_else(|| {
450 crate::util::mode_aware_theme_name()
451 .and_then(|n| n.parse::<BuiltinTheme>().ok().map(|b| b.theme()))
452 })
453 .unwrap_or_else(|| match theme_mode(QueryOptions::default()).ok() {
454 Some(ThemeMode::Light) => BuiltinTheme::default_light().theme(),
455 _ => BuiltinTheme::default().theme(),
456 })
457 }
458 #[cfg(not(feature = "fs"))]
459 {
460 name.and_then(|n| n.parse::<BuiltinTheme>().ok().map(|b| b.theme()))
461 .unwrap_or_else(|| BuiltinTheme::default().theme())
462 }
463 }
464
465 pub fn name_slug(&self) -> String {
469 heck::AsKebabCase(&self.meta.name).to_string()
470 }
471
472 pub fn to_filename(&self) -> String {
476 let slug = self.name_slug();
477 if slug.ends_with(".yaml") {
478 slug
479 } else {
480 format!("{}.yaml", slug)
481 }
482 }
483
484 #[cfg(feature = "fs")]
486 pub fn to_pathbuf(&self) -> Result<PathBuf> {
487 let mut path = user_themes_path()?;
488 path.push(self.to_filename());
489 Ok(path)
490 }
491
492 pub fn to_base24_str(&self) -> String {
498 let h = |s: &str| s.trim_start_matches('#').to_lowercase();
499 let variant = if self.meta.dark { "dark" } else { "light" };
500 let author = &self.meta.author;
501 let s = &self.base24;
502 format!(
503 "scheme: \"{name}\"\nauthor: \"{author}\"\nvariant: \"{variant}\"\n\
504 base00: \"{b00}\"\nbase01: \"{b01}\"\nbase02: \"{b02}\"\nbase03: \"{b03}\"\n\
505 base04: \"{b04}\"\nbase05: \"{b05}\"\nbase06: \"{b06}\"\nbase07: \"{b07}\"\n\
506 base08: \"{b08}\"\nbase09: \"{b09}\"\nbase0A: \"{b0a}\"\nbase0B: \"{b0b}\"\n\
507 base0C: \"{b0c}\"\nbase0D: \"{b0d}\"\nbase0E: \"{b0e}\"\nbase0F: \"{b0f}\"\n\
508 base10: \"{b10}\"\nbase11: \"{b11}\"\nbase12: \"{b12}\"\nbase13: \"{b13}\"\n\
509 base14: \"{b14}\"\nbase15: \"{b15}\"\nbase16: \"{b16}\"\nbase17: \"{b17}\"\n",
510 name = self.meta.name,
511 b00 = h(&s.base00),
512 b01 = h(&s.base01),
513 b02 = h(&s.base02),
514 b03 = h(&s.base03),
515 b04 = h(&s.base04),
516 b05 = h(&s.base05),
517 b06 = h(&s.base06),
518 b07 = h(&s.base07),
519 b08 = h(&s.base08),
520 b09 = h(&s.base09),
521 b0a = h(&s.base0a),
522 b0b = h(&s.base0b),
523 b0c = h(&s.base0c),
524 b0d = h(&s.base0d),
525 b0e = h(&s.base0e),
526 b0f = h(&s.base0f),
527 b10 = h(&s.base10),
528 b11 = h(&s.base11),
529 b12 = h(&s.base12),
530 b13 = h(&s.base13),
531 b14 = h(&s.base14),
532 b15 = h(&s.base15),
533 b16 = h(&s.base16),
534 b17 = h(&s.base17),
535 )
536 }
537}
538
539impl PartialEq for Theme {
540 fn eq(&self, other: &Self) -> bool {
541 self.name_slug() == other.name_slug()
542 }
543}
544impl Eq for Theme {}
545
546impl PartialOrd for Theme {
547 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
548 Some(self.cmp(other))
549 }
550}
551
552impl Ord for Theme {
553 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
554 self.name_slug().cmp(&other.name_slug())
555 }
556}
557
558pub fn hex_to_rgb(hex: &str) -> Result<(u8, u8, u8), HexColorError> {
578 let hex = hex.trim_start_matches('#');
579
580 if hex.len() != 6 {
581 return Err(HexColorError::InvalidLength(hex.len()));
582 }
583
584 let r = u8::from_str_radix(&hex[0..2], 16)?;
585 let g = u8::from_str_radix(&hex[2..4], 16)?;
586 let b = u8::from_str_radix(&hex[4..6], 16)?;
587 Ok((r, g, b))
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 const MINIMAL_BASE24: &str = r#"
595scheme: "Test Theme"
596author: "Test Author"
597base00: "1a1a1a"
598base01: "222222"
599base02: "333333"
600base03: "666666"
601base04: "888888"
602base05: "fafafa"
603base06: "e0e0e0"
604base07: "ffffff"
605base08: "cc0000"
606base09: "ff8800"
607base0a: "ffff00"
608base0b: "00ff00"
609base0c: "00ffff"
610base0d: "0000ff"
611base0e: "ff00ff"
612base0f: "aa5500"
613base10: "1a1a1a"
614base11: "000000"
615base12: "ff5555"
616base13: "ffff55"
617base14: "55ff55"
618base15: "55ffff"
619base16: "5555ff"
620base17: "ff55ff"
621"#;
622
623 fn test_theme() -> Theme {
624 Theme::from_base24_str(MINIMAL_BASE24).unwrap()
625 }
626
627 #[test]
628 fn test_from_base24_str_name_and_author() {
629 let t = test_theme();
630 assert_eq!(t.meta.name, "Test Theme");
631 assert_eq!(t.meta.author, "Test Author");
632 }
633
634 #[test]
635 fn test_from_base24_str_dark_detection() {
636 let t = test_theme();
637 assert!(t.meta.dark, "dark bg should be detected as dark theme");
638 }
639
640 #[test]
641 fn test_mapping() {
642 let t = test_theme();
643 assert_eq!(t.ansi.black, "#1a1a1a");
644 assert_eq!(t.ansi.red, "#cc0000");
645 assert_eq!(t.ansi.white, "#fafafa");
646 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"); }
665
666 #[test]
667 fn test_base16_fallbacks() {
668 let src = MINIMAL_BASE24
670 .lines()
671 .filter(|l| !l.starts_with("base1"))
672 .collect::<Vec<_>>()
673 .join("\n");
674 let t = Theme::from_base24_str(&src).unwrap();
675 assert_eq!(t.ansi.bright_red, t.ansi.red);
677 }
678
679 #[test]
680 fn test_name_slug_and_filename() {
681 let t = test_theme();
682 assert_eq!(t.name_slug(), "test-theme");
683 assert_eq!(t.to_filename(), "test-theme.yaml");
684 }
685
686 #[test]
687 fn test_to_base24_str_round_trip() {
688 let original = test_theme();
689 let yaml = original.to_base24_str();
690 let reloaded = Theme::from_base24_str(&yaml).unwrap();
691 assert_eq!(reloaded.meta.name, original.meta.name);
692 assert_eq!(reloaded.meta.dark, original.meta.dark);
693 assert_eq!(reloaded.base24.base08, original.base24.base08);
694 assert_eq!(reloaded.ansi.red, original.ansi.red);
695 assert_eq!(reloaded.semantic.error, original.semantic.error);
696 }
697
698 #[test]
699 fn test_to_base24_str_format() {
700 let t = test_theme();
701 let yaml = t.to_base24_str();
702 assert!(yaml.contains("scheme: \"Test Theme\""));
704 assert!(yaml.contains("base00:"));
705 assert!(yaml.contains("base17:"));
706 assert!(!yaml.contains(": \"#"));
708 }
709
710 #[test]
711 fn test_hex_to_rgb_valid() {
712 assert_eq!(hex_to_rgb("#ff5533").unwrap(), (255, 85, 51));
714 assert_eq!(hex_to_rgb("ff5533").unwrap(), (255, 85, 51));
715 assert_eq!(hex_to_rgb("#000000").unwrap(), (0, 0, 0));
717 assert_eq!(hex_to_rgb("#ffffff").unwrap(), (255, 255, 255));
718 }
719
720 #[test]
721 fn test_hex_to_rgb_invalid() {
722 assert!(hex_to_rgb("#fff").is_err());
724 assert!(hex_to_rgb("abc").is_err());
725 assert!(hex_to_rgb("#ff5533aa").is_err());
726 assert!(hex_to_rgb("#gggggg").is_err());
728 assert!(hex_to_rgb("#xyz123").is_err());
729 assert!(hex_to_rgb("").is_err());
731 assert!(hex_to_rgb("#").is_err());
732 }
733}