1#[cfg(feature = "fs")]
2use anyhow::{Context, Result};
3use ratatui::style::Color;
4use std::collections::HashMap;
5#[cfg(feature = "fs")]
6use std::path::PathBuf;
7
8#[cfg(feature = "fs")]
9use tca_types::BuiltinTheme;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Meta {
14 pub name: String,
16 pub author: Option<String>,
18 pub version: Option<String>,
20 pub description: Option<String>,
22 pub dark: Option<bool>,
24}
25
26impl Default for Meta {
27 fn default() -> Self {
28 Self {
29 name: "Unnamed Theme".to_string(),
30 author: None,
31 version: None,
32 description: None,
33 dark: None,
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Ansi {
45 pub black: Color,
47 pub red: Color,
49 pub green: Color,
51 pub yellow: Color,
53 pub blue: Color,
55 pub magenta: Color,
57 pub cyan: Color,
59 pub white: Color,
61 pub bright_black: Color,
63 pub bright_red: Color,
65 pub bright_green: Color,
67 pub bright_yellow: Color,
69 pub bright_blue: Color,
71 pub bright_magenta: Color,
73 pub bright_cyan: Color,
75 pub bright_white: Color,
77}
78
79impl Ansi {
80 pub fn get(&self, key: &str) -> Option<Color> {
84 match key {
85 "black" => Some(self.black),
86 "red" => Some(self.red),
87 "green" => Some(self.green),
88 "yellow" => Some(self.yellow),
89 "blue" => Some(self.blue),
90 "magenta" => Some(self.magenta),
91 "cyan" => Some(self.cyan),
92 "white" => Some(self.white),
93 "bright_black" => Some(self.bright_black),
94 "bright_red" => Some(self.bright_red),
95 "bright_green" => Some(self.bright_green),
96 "bright_yellow" => Some(self.bright_yellow),
97 "bright_blue" => Some(self.bright_blue),
98 "bright_magenta" => Some(self.bright_magenta),
99 "bright_cyan" => Some(self.bright_cyan),
100 "bright_white" => Some(self.bright_white),
101 _ => None,
102 }
103 }
104}
105
106impl Default for Ansi {
107 fn default() -> Self {
108 Self {
109 black: Color::Black,
110 red: Color::Red,
111 green: Color::Green,
112 yellow: Color::Yellow,
113 blue: Color::Blue,
114 magenta: Color::Magenta,
115 cyan: Color::Cyan,
116 white: Color::Gray,
117 bright_black: Color::DarkGray,
118 bright_red: Color::LightRed,
119 bright_green: Color::LightGreen,
120 bright_yellow: Color::LightYellow,
121 bright_blue: Color::LightBlue,
122 bright_magenta: Color::LightMagenta,
123 bright_cyan: Color::LightCyan,
124 bright_white: Color::White,
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Default)]
131pub struct ColorRamp {
132 pub colors: Vec<Color>,
134}
135
136impl ColorRamp {
137 pub fn get(&self, idx: usize) -> Option<Color> {
139 self.colors.get(idx).copied()
140 }
141
142 pub fn len(&self) -> usize {
144 self.colors.len()
145 }
146
147 pub fn is_empty(&self) -> bool {
149 self.colors.is_empty()
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Default)]
158pub struct Palette(pub(crate) HashMap<String, ColorRamp>);
159
160impl Palette {
161 pub fn get_ramp(&self, name: &str) -> Option<&ColorRamp> {
163 self.0.get(name)
164 }
165
166 pub fn ramp_names(&self) -> Vec<&str> {
168 let mut names: Vec<&str> = self.0.keys().map(String::as_str).collect();
169 names.sort();
170 names
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Default)]
178pub struct Base16(pub(crate) HashMap<String, Color>);
179
180impl Base16 {
181 pub fn get(&self, key: &str) -> Option<Color> {
183 self.0.get(key).copied()
184 }
185
186 pub fn entries(&self) -> impl Iterator<Item = (&str, Color)> {
188 let mut pairs: Vec<(&str, Color)> = self.0.iter().map(|(k, &v)| (k.as_str(), v)).collect();
189 pairs.sort_by_key(|(k, _)| *k);
190 pairs.into_iter()
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct Semantic {
199 pub error: Color,
201 pub warning: Color,
203 pub info: Color,
205 pub success: Color,
207 pub highlight: Color,
209 pub link: Color,
211}
212
213impl Default for Semantic {
214 fn default() -> Self {
215 Self {
216 error: Color::Red,
217 warning: Color::Yellow,
218 info: Color::Blue,
219 success: Color::Green,
220 highlight: Color::Cyan,
221 link: Color::Blue,
222 }
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct Ui {
234 pub bg_primary: Color,
236 pub bg_secondary: Color,
238 pub fg_primary: Color,
240 pub fg_secondary: Color,
242 pub fg_muted: Color,
244 pub border_primary: Color,
246 pub border_muted: Color,
248 pub cursor_primary: Color,
250 pub cursor_muted: Color,
252 pub selection_bg: Color,
254 pub selection_fg: Color,
256}
257
258impl Default for Ui {
259 fn default() -> Self {
260 Self {
261 bg_primary: Color::Black,
262 bg_secondary: Color::Black,
263 fg_primary: Color::White,
264 fg_secondary: Color::Gray,
265 fg_muted: Color::DarkGray,
266 border_primary: Color::White,
267 border_muted: Color::DarkGray,
268 cursor_primary: Color::White,
269 cursor_muted: Color::Gray,
270 selection_bg: Color::DarkGray,
271 selection_fg: Color::White,
272 }
273 }
274}
275
276#[derive(Debug, Clone)]
281pub struct TcaTheme {
282 pub meta: Meta,
284 pub ansi: Ansi,
286 pub palette: Palette,
288 pub base16: Base16,
290 pub semantic: Semantic,
292 pub ui: Ui,
294}
295
296impl TcaTheme {
297 #[cfg(feature = "fs")]
312 pub fn new(name: Option<&str>) -> Self {
313 TcaTheme::try_from(tca_types::Theme::from_name(name)).unwrap_or_else(|_| {
314 use terminal_colorsaurus::{theme_mode, QueryOptions, ThemeMode};
315 let builtin = match theme_mode(QueryOptions::default()).ok() {
316 Some(ThemeMode::Light) => BuiltinTheme::default_light(),
317 _ => BuiltinTheme::default(),
318 };
319 TcaTheme::try_from(builtin.theme()).expect("hardcoded default must be valid")
320 })
321 }
322 pub fn name_slug(&self) -> String {
327 heck::AsKebabCase(&self.meta.name).to_string()
328 }
329
330 pub fn to_filename(&self) -> String {
335 let mut theme_name = self.name_slug();
336 if !theme_name.ends_with(".toml") {
337 theme_name.push_str(".toml");
338 }
339 theme_name
340 }
341
342 #[cfg(feature = "fs")]
348 pub fn to_pathbuf(&self) -> Result<PathBuf> {
349 use tca_types::user_themes_path;
350
351 let mut path = user_themes_path()?;
352 path.push(self.to_filename());
353 Ok(path)
354 }
355}
356
357#[cfg(feature = "fs")]
358impl Default for TcaTheme {
359 fn default() -> Self {
360 TcaTheme::new(None)
361 }
362}
363
364#[cfg(feature = "fs")]
365impl TryFrom<&str> for TcaTheme {
367 type Error = anyhow::Error;
368 fn try_from(value: &str) -> Result<TcaTheme, Self::Error> {
369 let raw: tca_types::Theme = toml::from_str(value)?;
370 TcaTheme::try_from(raw)
371 }
372}
373
374#[cfg(feature = "fs")]
380impl TryFrom<tca_types::Theme> for TcaTheme {
381 type Error = anyhow::Error;
382 fn try_from(raw: tca_types::Theme) -> Result<TcaTheme, Self::Error> {
383 let ansi = parse_ansi(&raw.ansi)?;
385
386 let palette = parse_palette(raw.palette.as_ref(), &raw.ansi);
388 let base16 = parse_base16(raw.base16.as_ref(), &raw.ansi, &palette);
389
390 let resolve = |r: &str| resolve_ref(r, &ansi, &palette, &base16);
391
392 let defaults = Semantic::default();
393 let semantic = Semantic {
394 error: resolve(&raw.semantic.error).unwrap_or(defaults.error),
395 warning: resolve(&raw.semantic.warning).unwrap_or(defaults.warning),
396 info: resolve(&raw.semantic.info).unwrap_or(defaults.info),
397 success: resolve(&raw.semantic.success).unwrap_or(defaults.success),
398 highlight: resolve(&raw.semantic.highlight).unwrap_or(defaults.highlight),
399 link: resolve(&raw.semantic.link).unwrap_or(defaults.link),
400 };
401
402 let defaults = Ui::default();
403 let ui = Ui {
404 bg_primary: resolve(&raw.ui.bg.primary).unwrap_or(defaults.bg_primary),
405 bg_secondary: resolve(&raw.ui.bg.secondary).unwrap_or(defaults.bg_secondary),
406 fg_primary: resolve(&raw.ui.fg.primary).unwrap_or(defaults.fg_primary),
407 fg_secondary: resolve(&raw.ui.fg.secondary).unwrap_or(defaults.fg_secondary),
408 fg_muted: resolve(&raw.ui.fg.muted).unwrap_or(defaults.fg_muted),
409 border_primary: resolve(&raw.ui.border.primary).unwrap_or(defaults.border_primary),
410 border_muted: resolve(&raw.ui.border.muted).unwrap_or(defaults.border_muted),
411 cursor_primary: resolve(&raw.ui.cursor.primary).unwrap_or(defaults.cursor_primary),
412 cursor_muted: resolve(&raw.ui.cursor.muted).unwrap_or(defaults.cursor_muted),
413 selection_bg: resolve(&raw.ui.selection.bg).unwrap_or(defaults.selection_bg),
414 selection_fg: resolve(&raw.ui.selection.fg).unwrap_or(defaults.selection_fg),
415 };
416
417 let meta = Meta {
418 name: raw.meta.name,
419 author: raw.meta.author,
420 version: raw.meta.version,
421 description: raw.meta.description,
422 dark: raw.meta.dark,
423 };
424
425 Ok(TcaTheme {
426 meta,
427 ansi,
428 palette,
429 base16,
430 semantic,
431 ui,
432 })
433 }
434}
435
436impl PartialEq for TcaTheme {
437 fn eq(&self, other: &Self) -> bool {
438 self.name_slug() == other.name_slug()
439 }
440}
441impl Eq for TcaTheme {}
442
443impl PartialOrd for TcaTheme {
444 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
445 Some(self.cmp(other))
446 }
447}
448
449impl Ord for TcaTheme {
450 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
451 self.name_slug().cmp(&other.name_slug())
452 }
453}
454
455#[derive(Debug, Clone, Default)]
477pub struct TcaThemeBuilder {
478 meta: Meta,
479 ansi: Ansi,
480 palette: Palette,
481 base16: Base16,
482 semantic: Semantic,
483 ui: Ui,
484}
485
486impl TcaThemeBuilder {
487 pub fn new() -> Self {
489 Self::default()
490 }
491
492 pub fn meta(mut self, meta: Meta) -> Self {
494 self.meta = meta;
495 self
496 }
497
498 pub fn ansi(mut self, ansi: Ansi) -> Self {
500 self.ansi = ansi;
501 self
502 }
503
504 pub fn palette(mut self, palette: Palette) -> Self {
506 self.palette = palette;
507 self
508 }
509
510 pub fn base16(mut self, base16: Base16) -> Self {
512 self.base16 = base16;
513 self
514 }
515
516 pub fn semantic(mut self, semantic: Semantic) -> Self {
518 self.semantic = semantic;
519 self
520 }
521
522 pub fn ui(mut self, ui: Ui) -> Self {
524 self.ui = ui;
525 self
526 }
527
528 pub fn build(self) -> TcaTheme {
530 TcaTheme {
531 meta: self.meta,
532 ansi: self.ansi,
533 palette: self.palette,
534 base16: self.base16,
535 semantic: self.semantic,
536 ui: self.ui,
537 }
538 }
539}
540
541#[cfg(feature = "fs")]
543fn hex_to_color(hex: &str) -> Option<Color> {
544 let (r, g, b) = tca_types::hex_to_rgb(hex).ok()?;
545 Some(Color::Rgb(r, g, b))
546}
547
548#[cfg(feature = "fs")]
553fn resolve_ref(r: &str, ansi: &Ansi, palette: &Palette, base16: &Base16) -> Option<Color> {
554 if r.starts_with('#') {
555 return hex_to_color(r);
556 }
557
558 let parts: Vec<&str> = r.splitn(3, '.').collect();
559 match parts.as_slice() {
560 ["ansi", key] => ansi.get(key),
561 ["palette", ramp, idx_str] => {
562 let idx: usize = idx_str.parse().ok()?;
563 palette.get_ramp(ramp)?.get(idx)
564 }
565 ["base16", key] => base16.get(key),
566 _ => None,
567 }
568}
569
570#[cfg(feature = "fs")]
573fn parse_ansi(raw: &tca_types::Ansi) -> Result<Ansi> {
574 let p = |hex: &str| -> Result<Color> {
575 hex_to_color(hex).with_context(|| format!("Invalid hex color in [ansi]: {:?}", hex))
576 };
577 Ok(Ansi {
578 black: p(&raw.black)?,
579 red: p(&raw.red)?,
580 green: p(&raw.green)?,
581 yellow: p(&raw.yellow)?,
582 blue: p(&raw.blue)?,
583 magenta: p(&raw.magenta)?,
584 cyan: p(&raw.cyan)?,
585 white: p(&raw.white)?,
586 bright_black: p(&raw.bright_black)?,
587 bright_red: p(&raw.bright_red)?,
588 bright_green: p(&raw.bright_green)?,
589 bright_yellow: p(&raw.bright_yellow)?,
590 bright_blue: p(&raw.bright_blue)?,
591 bright_magenta: p(&raw.bright_magenta)?,
592 bright_cyan: p(&raw.bright_cyan)?,
593 bright_white: p(&raw.bright_white)?,
594 })
595}
596
597#[cfg(feature = "fs")]
601fn parse_palette(raw: Option<&tca_types::Palette>, raw_ansi: &tca_types::Ansi) -> Palette {
602 let Some(raw_palette) = raw else {
603 return Palette::default();
604 };
605
606 let ramps = raw_palette
607 .entries()
608 .map(|(name, values)| {
609 let colors = values
610 .iter()
611 .filter_map(|v| {
612 if v.starts_with('#') {
613 hex_to_color(v)
614 } else if let Some(key) = v.strip_prefix("ansi.") {
615 hex_to_color(raw_ansi.get(key)?)
616 } else {
617 None
618 }
619 })
620 .collect();
621 (name.to_string(), ColorRamp { colors })
622 })
623 .collect();
624
625 Palette(ramps)
626}
627
628#[cfg(feature = "fs")]
632fn parse_base16(
633 raw: Option<&tca_types::Base16>,
634 raw_ansi: &tca_types::Ansi,
635 palette: &Palette,
636) -> Base16 {
637 let Some(raw_b16) = raw else {
638 return Base16::default();
639 };
640
641 let map = raw_b16
642 .entries()
643 .filter_map(|(key, value)| {
644 let color = if value.starts_with('#') {
645 hex_to_color(value)?
646 } else if let Some(k) = value.strip_prefix("ansi.") {
647 hex_to_color(raw_ansi.get(k)?)?
648 } else {
649 let parts: Vec<&str> = value.splitn(3, '.').collect();
650 match parts.as_slice() {
651 ["palette", ramp, idx_str] => {
652 let idx: usize = idx_str.parse().ok()?;
653 palette.get_ramp(ramp)?.get(idx)?
654 }
655 _ => return None,
656 }
657 };
658 Some((key.to_string(), color))
659 })
660 .collect();
661
662 Base16(map)
663}
664
665#[cfg(feature = "fs")]
683pub struct TcaThemeCursor(tca_types::ThemeCursor<TcaTheme>);
684
685#[cfg(feature = "fs")]
686impl TcaThemeCursor {
687 pub fn new(themes: impl IntoIterator<Item = TcaTheme>) -> Self {
689 Self(tca_types::ThemeCursor::new(themes))
690 }
691
692 pub fn with_builtins() -> Self {
695 Self::new(
696 tca_types::BuiltinTheme::iter().filter_map(|b| TcaTheme::try_from(b.theme()).ok()),
697 )
698 }
699
700 pub fn with_user_themes() -> Self {
702 Self::new(
703 tca_types::all_user_themes()
704 .into_iter()
705 .filter_map(|t| TcaTheme::try_from(t).ok()),
706 )
707 }
708
709 pub fn with_all_themes() -> Self {
712 Self::new(
713 tca_types::all_themes()
714 .into_iter()
715 .filter_map(|t| TcaTheme::try_from(t).ok()),
716 )
717 }
718
719 pub fn peek(&self) -> Option<&TcaTheme> {
721 self.0.peek()
722 }
723
724 #[allow(clippy::should_implement_trait)]
726 pub fn next(&mut self) -> Option<&TcaTheme> {
727 self.0.next()
728 }
729
730 pub fn prev(&mut self) -> Option<&TcaTheme> {
732 self.0.prev()
733 }
734
735 pub fn themes(&self) -> &[TcaTheme] {
737 self.0.themes()
738 }
739
740 pub fn len(&self) -> usize {
742 self.0.len()
743 }
744
745 pub fn is_empty(&self) -> bool {
747 self.0.is_empty()
748 }
749
750 pub fn set_current(&mut self, name: &str) -> Option<&TcaTheme> {
755 let slug = heck::AsKebabCase(name).to_string();
756 let idx = self.0.themes().iter().position(|t| t.name_slug() == slug)?;
757 self.0.set_index(idx)
758 }
759}