jolt_theme/
iterm2.rs

1use std::fmt;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4
5const ITERM2_REPO_URL: &str =
6    "https://raw.githubusercontent.com/mbadolato/iTerm2-Color-Schemes/master/schemes";
7const ITERM2_API_URL: &str =
8    "https://api.github.com/repos/mbadolato/iTerm2-Color-Schemes/contents/schemes";
9
10pub const ITERM2_GALLERY_URL: &str = "https://iterm2colorschemes.com/";
11
12static VARIANT_PAIRS: &[(&str, &str)] = &[
13    ("3024 Night", "3024 Day"),
14    ("Aizen Dark", "Aizen Light"),
15    ("Atom One Dark", "Atom One Light"),
16    ("Belafonte Night", "Belafonte Day"),
17    ("Bluloco Dark", "Bluloco Light"),
18    ("Builtin Dark", "Builtin Light"),
19    ("Builtin Tango Dark", "Builtin Tango Light"),
20    ("Farmhouse Dark", "Farmhouse Light"),
21    ("Flexoki Dark", "Flexoki Light"),
22    ("GitHub Dark", "GitHub"),
23    ("GitHub Dark Colorblind", "GitHub Light Colorblind"),
24    ("GitHub Dark Default", "GitHub Light Default"),
25    ("GitHub Dark High Contrast", "GitHub Light High Contrast"),
26    ("GitLab Dark", "GitLab Light"),
27    ("Gruvbox Dark", "Gruvbox Light"),
28    ("Gruvbox Dark Hard", "Gruvbox Light Hard"),
29    ("Gruvbox Material Dark", "Gruvbox Material Light"),
30    ("Iceberg Dark", "Iceberg Light"),
31    ("Melange Dark", "Melange Light"),
32    ("Neobones Dark", "Neobones Light"),
33    ("Nvim Dark", "Nvim Light"),
34    ("One Double Dark", "One Double Light"),
35    ("One Half Dark", "One Half Light"),
36    ("Pencil Dark", "Pencil Light"),
37    ("Raycast Dark", "Raycast Light"),
38    ("Selenized Dark", "Selenized Light"),
39    ("Seoulbones Dark", "Seoulbones Light"),
40    ("Tinacious Design Dark", "Tinacious Design Light"),
41    ("Violet Dark", "Violet Light"),
42    ("Xcode Dark", "Xcode Light"),
43    ("Xcode Dark hc", "Xcode Light hc"),
44    ("Zenbones Dark", "Zenbones Light"),
45    ("Zenwritten Dark", "Zenwritten Light"),
46    ("iTerm2 Dark Background", "iTerm2 Light Background"),
47    ("iTerm2 Solarized Dark", "iTerm2 Solarized Light"),
48    ("iTerm2 Tango Dark", "iTerm2 Tango Light"),
49    ("Adwaita Dark", "Adwaita"),
50    ("Night Owl", "Light Owl"),
51    ("Nord", "Nord Light"),
52    ("Onenord", "Onenord Light"),
53    ("Pro", "Pro Light"),
54    ("Terminal Basic Dark", "Terminal Basic"),
55    ("No Clown Fiesta", "No Clown Fiesta Light"),
56    ("Rose Pine Moon", "Rose Pine Dawn"),
57    ("Rose Pine", "Rose Pine Dawn"),
58    ("TokyoNight Night", "TokyoNight Day"),
59    ("TokyoNight Moon", "TokyoNight Day"),
60    ("TokyoNight Storm", "TokyoNight Day"),
61    ("TokyoNight", "TokyoNight Day"),
62    ("Ayu", "Ayu Light"),
63    ("Ayu Mirage", "Ayu Light"),
64    ("Everforest Dark Hard", "Everforest Light Med"),
65    ("Tomorrow Night", "Tomorrow"),
66    ("Tomorrow Night Blue", "Tomorrow"),
67    ("Tomorrow Night Bright", "Tomorrow"),
68    ("Tomorrow Night Burns", "Tomorrow"),
69    ("Tomorrow Night Eighties", "Tomorrow"),
70    ("Catppuccin Frappe", "Catppuccin Latte"),
71    ("Catppuccin Macchiato", "Catppuccin Latte"),
72    ("Catppuccin Mocha", "Catppuccin Latte"),
73];
74
75pub fn lookup_variant_pair(name: &str) -> Option<(&'static str, &'static str)> {
76    let lower = name.to_lowercase();
77    for &(dark, light) in VARIANT_PAIRS {
78        if dark.to_lowercase() == lower || light.to_lowercase() == lower {
79            return Some((dark, light));
80        }
81    }
82    None
83}
84
85#[derive(Debug)]
86pub enum Iterm2Error {
87    NetworkError(String),
88    ParseError(String),
89    NotFound(String),
90    IoError(String),
91}
92
93impl fmt::Display for Iterm2Error {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        match self {
96            Self::NetworkError(msg) => write!(f, "Network error: {}", msg),
97            Self::ParseError(msg) => write!(f, "Parse error: {}", msg),
98            Self::NotFound(msg) => write!(f, "Not found: {}", msg),
99            Self::IoError(msg) => write!(f, "IO error: {}", msg),
100        }
101    }
102}
103
104impl std::error::Error for Iterm2Error {}
105
106const MIN_CONTRAST_RATIO: f64 = 4.6;
107
108#[derive(Debug, Clone)]
109pub struct Iterm2Color {
110    pub r: f64,
111    pub g: f64,
112    pub b: f64,
113}
114
115impl Iterm2Color {
116    pub fn to_hex(&self) -> String {
117        let r = (self.r * 255.0).round() as u8;
118        let g = (self.g * 255.0).round() as u8;
119        let b = (self.b * 255.0).round() as u8;
120        format!("#{:02x}{:02x}{:02x}", r, g, b)
121    }
122
123    fn blend(&self, other: &Iterm2Color, ratio: f64) -> Iterm2Color {
124        Iterm2Color {
125            r: self.r * (1.0 - ratio) + other.r * ratio,
126            g: self.g * (1.0 - ratio) + other.g * ratio,
127            b: self.b * (1.0 - ratio) + other.b * ratio,
128        }
129    }
130
131    fn linearize(val: f64) -> f64 {
132        if val <= 0.03928 {
133            val / 12.92
134        } else {
135            ((val + 0.055) / 1.055).powf(2.4)
136        }
137    }
138
139    fn luminance(&self) -> f64 {
140        0.2126 * Self::linearize(self.r)
141            + 0.7152 * Self::linearize(self.g)
142            + 0.0722 * Self::linearize(self.b)
143    }
144
145    fn contrast_ratio(&self, other: &Iterm2Color) -> f64 {
146        let l1 = self.luminance();
147        let l2 = other.luminance();
148        let lighter = l1.max(l2);
149        let darker = l1.min(l2);
150        (lighter + 0.05) / (darker + 0.05)
151    }
152
153    fn lighten(&self, amount: f64) -> Iterm2Color {
154        Iterm2Color {
155            r: self.r + (1.0 - self.r) * amount,
156            g: self.g + (1.0 - self.g) * amount,
157            b: self.b + (1.0 - self.b) * amount,
158        }
159    }
160
161    fn darken(&self, amount: f64) -> Iterm2Color {
162        Iterm2Color {
163            r: self.r * (1.0 - amount),
164            g: self.g * (1.0 - amount),
165            b: self.b * (1.0 - amount),
166        }
167    }
168
169    fn ensure_contrast(&self, bg: &Iterm2Color, min_ratio: f64) -> Iterm2Color {
170        if self.contrast_ratio(bg) >= min_ratio {
171            return self.clone();
172        }
173
174        let lighten_result = self.adjust_for_contrast(bg, min_ratio, true);
175        let darken_result = self.adjust_for_contrast(bg, min_ratio, false);
176
177        let lighten_passes = lighten_result.contrast_ratio(bg) >= min_ratio;
178        let darken_passes = darken_result.contrast_ratio(bg) >= min_ratio;
179
180        match (lighten_passes, darken_passes) {
181            (true, false) => lighten_result,
182            (false, true) => darken_result,
183            (true, true) => {
184                let lighten_dist = self.color_distance(&lighten_result);
185                let darken_dist = self.color_distance(&darken_result);
186                if lighten_dist <= darken_dist {
187                    lighten_result
188                } else {
189                    darken_result
190                }
191            }
192            (false, false) => {
193                if lighten_result.contrast_ratio(bg) > darken_result.contrast_ratio(bg) {
194                    lighten_result
195                } else {
196                    darken_result
197                }
198            }
199        }
200    }
201
202    fn adjust_for_contrast(&self, bg: &Iterm2Color, min_ratio: f64, lighten: bool) -> Iterm2Color {
203        let mut low = 0.0;
204        let mut high = 1.0;
205        let mut best = self.clone();
206
207        for _ in 0..20 {
208            let mid = (low + high) / 2.0;
209            let adjusted = if lighten {
210                self.lighten(mid)
211            } else {
212                self.darken(mid)
213            };
214
215            if adjusted.contrast_ratio(bg) >= min_ratio {
216                best = adjusted;
217                high = mid;
218            } else {
219                low = mid;
220            }
221        }
222
223        best
224    }
225
226    fn color_distance(&self, other: &Iterm2Color) -> f64 {
227        let dr = self.r - other.r;
228        let dg = self.g - other.g;
229        let db = self.b - other.b;
230        (dr * dr + dg * dg + db * db).sqrt()
231    }
232}
233
234#[derive(Debug, Clone, Copy, PartialEq)]
235pub enum SchemeVariant {
236    Dark,
237    Light,
238    Unknown,
239}
240
241#[derive(Debug)]
242pub struct Iterm2Scheme {
243    pub background: Iterm2Color,
244    pub foreground: Iterm2Color,
245    pub selection_bg: Iterm2Color,
246    pub selection_fg: Iterm2Color,
247    pub ansi: [Iterm2Color; 16],
248}
249
250fn detect_variant(name: &str) -> SchemeVariant {
251    if let Some((dark, light)) = lookup_variant_pair(name) {
252        let lower = name.to_lowercase();
253        if dark.to_lowercase() == lower {
254            return SchemeVariant::Dark;
255        } else if light.to_lowercase() == lower {
256            return SchemeVariant::Light;
257        }
258    }
259
260    let lower = name.to_lowercase();
261    if lower.contains("light") || lower.contains("day") || lower.contains("dawn") {
262        SchemeVariant::Light
263    } else if lower.contains("dark") || lower.contains("night") || lower.contains("moon") {
264        SchemeVariant::Dark
265    } else {
266        SchemeVariant::Unknown
267    }
268}
269
270fn find_counterpart_name(name: &str) -> Option<String> {
271    if let Some((dark, light)) = lookup_variant_pair(name) {
272        let lower = name.to_lowercase();
273        if dark.to_lowercase() == lower {
274            return Some(light.to_string());
275        } else {
276            return Some(dark.to_string());
277        }
278    }
279
280    let variant = detect_variant(name);
281    match variant {
282        SchemeVariant::Dark => {
283            let lower = name.to_lowercase();
284            if let Some(pos) = lower.find("dark") {
285                let mut result = name.to_string();
286                let replacement = if &name[pos..pos + 4] == "Dark" {
287                    "Light"
288                } else {
289                    "light"
290                };
291                result.replace_range(pos..pos + 4, replacement);
292                Some(result)
293            } else {
294                None
295            }
296        }
297        SchemeVariant::Light => {
298            let lower = name.to_lowercase();
299            if let Some(pos) = lower.find("light") {
300                let mut result = name.to_string();
301                let replacement = if &name[pos..pos + 5] == "Light" {
302                    "Dark"
303                } else {
304                    "dark"
305                };
306                result.replace_range(pos..pos + 5, replacement);
307                Some(result)
308            } else {
309                None
310            }
311        }
312        SchemeVariant::Unknown => None,
313    }
314}
315
316fn find_variant_names(name: &str) -> Vec<String> {
317    let variant = detect_variant(name);
318
319    if variant == SchemeVariant::Unknown {
320        vec![
321            format!("{} Light", name),
322            format!("{} Dark", name),
323            format!("{}-light", name),
324            format!("{}-dark", name),
325        ]
326    } else {
327        vec![]
328    }
329}
330
331fn parse_color_dict(dict: &plist::Dictionary) -> Option<Iterm2Color> {
332    let r = dict.get("Red Component")?.as_real()?;
333    let g = dict.get("Green Component")?.as_real()?;
334    let b = dict.get("Blue Component")?.as_real()?;
335    Some(Iterm2Color { r, g, b })
336}
337
338fn extract_color(root: &plist::Dictionary, key: &str) -> Option<Iterm2Color> {
339    let dict = root.get(key)?.as_dictionary()?;
340    parse_color_dict(dict)
341}
342
343pub fn parse_scheme(plist_content: &[u8]) -> Result<Iterm2Scheme, Iterm2Error> {
344    let value: plist::Value =
345        plist::from_bytes(plist_content).map_err(|e| Iterm2Error::ParseError(e.to_string()))?;
346
347    let root = value
348        .as_dictionary()
349        .ok_or_else(|| Iterm2Error::ParseError("Expected dictionary at root".to_string()))?;
350
351    let background = extract_color(root, "Background Color")
352        .ok_or_else(|| Iterm2Error::ParseError("Missing Background Color".to_string()))?;
353
354    let foreground = extract_color(root, "Foreground Color")
355        .ok_or_else(|| Iterm2Error::ParseError("Missing Foreground Color".to_string()))?;
356
357    let selection_bg = extract_color(root, "Selection Color")
358        .unwrap_or_else(|| background.blend(&foreground, 0.3));
359
360    let selection_fg =
361        extract_color(root, "Selected Text Color").unwrap_or_else(|| foreground.clone());
362
363    let mut ansi = Vec::with_capacity(16);
364    for i in 0..16 {
365        let key = format!("Ansi {} Color", i);
366        let color = extract_color(root, &key).unwrap_or(if i < 8 {
367            Iterm2Color {
368                r: 0.5,
369                g: 0.5,
370                b: 0.5,
371            }
372        } else {
373            Iterm2Color {
374                r: 0.7,
375                g: 0.7,
376                b: 0.7,
377            }
378        });
379        ansi.push(color);
380    }
381
382    Ok(Iterm2Scheme {
383        background,
384        foreground,
385        selection_bg,
386        selection_fg,
387        ansi: ansi.try_into().unwrap(),
388    })
389}
390
391impl Iterm2Scheme {
392    fn to_colors_toml(&self) -> String {
393        let bg = &self.background;
394        let fg = self.foreground.ensure_contrast(bg, MIN_CONTRAST_RATIO);
395        let dialog_bg = bg.blend(&self.ansi[8], 0.15);
396        let border_color = bg.blend(&self.ansi[8], 0.4);
397
398        let accent = self.ansi[4]
399            .ensure_contrast(bg, MIN_CONTRAST_RATIO)
400            .ensure_contrast(&dialog_bg, MIN_CONTRAST_RATIO);
401        let accent_secondary = self.ansi[5]
402            .ensure_contrast(bg, MIN_CONTRAST_RATIO)
403            .ensure_contrast(&dialog_bg, MIN_CONTRAST_RATIO);
404        let highlight = self.ansi[3].ensure_contrast(bg, MIN_CONTRAST_RATIO);
405        let success = self.ansi[2].ensure_contrast(bg, MIN_CONTRAST_RATIO);
406        let danger = self.ansi[1].ensure_contrast(bg, MIN_CONTRAST_RATIO);
407
408        let muted = self.derive_muted_color(bg);
409        let warning = self.derive_warning_color(bg);
410
411        let graph_line = accent.clone();
412
413        let selection_fg = self
414            .selection_fg
415            .ensure_contrast(&self.selection_bg, MIN_CONTRAST_RATIO);
416
417        format!(
418            r##"bg = "{}"
419dialog_bg = "{}"
420fg = "{}"
421accent = "{}"
422accent_secondary = "{}"
423highlight = "{}"
424muted = "{}"
425success = "{}"
426warning = "{}"
427danger = "{}"
428border = "{}"
429selection_bg = "{}"
430selection_fg = "{}"
431graph_line = "{}""##,
432            bg.to_hex(),
433            dialog_bg.to_hex(),
434            fg.to_hex(),
435            accent.to_hex(),
436            accent_secondary.to_hex(),
437            highlight.to_hex(),
438            muted.to_hex(),
439            success.to_hex(),
440            warning.to_hex(),
441            danger.to_hex(),
442            border_color.to_hex(),
443            self.selection_bg.to_hex(),
444            selection_fg.to_hex(),
445            graph_line.to_hex(),
446        )
447    }
448
449    fn derive_muted_color(&self, bg: &Iterm2Color) -> Iterm2Color {
450        let candidates = [&self.ansi[8], &self.foreground.blend(bg, 0.5)];
451
452        for candidate in candidates {
453            let adjusted = candidate.ensure_contrast(bg, MIN_CONTRAST_RATIO);
454            if adjusted.contrast_ratio(bg) >= MIN_CONTRAST_RATIO {
455                return adjusted;
456            }
457        }
458
459        self.foreground.blend(bg, 0.4)
460    }
461
462    fn derive_warning_color(&self, bg: &Iterm2Color) -> Iterm2Color {
463        let candidates = [&self.ansi[11], &self.ansi[3], &self.ansi[9]];
464
465        for candidate in candidates {
466            let adjusted = candidate.ensure_contrast(bg, MIN_CONTRAST_RATIO);
467            if adjusted.contrast_ratio(bg) >= MIN_CONTRAST_RATIO {
468                return adjusted;
469            }
470        }
471
472        self.ansi[3].ensure_contrast(bg, MIN_CONTRAST_RATIO)
473    }
474}
475
476pub struct ImportResult {
477    pub path: PathBuf,
478    pub dark_source: Option<String>,
479    pub light_source: Option<String>,
480}
481
482fn try_fetch_scheme(name: &str) -> Option<Iterm2Scheme> {
483    let url = format!("{}/{}.itermcolors", ITERM2_REPO_URL, name);
484
485    let response = ureq::get(&url).call().ok()?;
486
487    let mut bytes = Vec::new();
488    response.into_reader().read_to_end(&mut bytes).ok()?;
489
490    parse_scheme(&bytes).ok()
491}
492
493pub fn fetch_scheme(name: &str) -> Result<Iterm2Scheme, Iterm2Error> {
494    let url = format!("{}/{}.itermcolors", ITERM2_REPO_URL, name);
495
496    let response = ureq::get(&url).call().map_err(|e| match e {
497        ureq::Error::Status(404, _) => Iterm2Error::NotFound(format!(
498            "Scheme '{}' not found. Browse available themes at: {}",
499            name, ITERM2_GALLERY_URL
500        )),
501        _ => Iterm2Error::NetworkError(e.to_string()),
502    })?;
503
504    let mut bytes = Vec::new();
505    response
506        .into_reader()
507        .read_to_end(&mut bytes)
508        .map_err(|e| Iterm2Error::NetworkError(e.to_string()))?;
509
510    parse_scheme(&bytes)
511}
512
513#[derive(Debug, serde::Deserialize)]
514struct GitHubFile {
515    name: String,
516    #[serde(rename = "type")]
517    file_type: String,
518}
519
520pub fn list_available_schemes() -> Result<Vec<String>, Iterm2Error> {
521    let response = ureq::get(ITERM2_API_URL)
522        .set("User-Agent", "jolt-theme-importer")
523        .call()
524        .map_err(|e| Iterm2Error::NetworkError(e.to_string()))?;
525
526    let body = response
527        .into_string()
528        .map_err(|e| Iterm2Error::NetworkError(e.to_string()))?;
529
530    let files: Vec<GitHubFile> =
531        serde_json::from_str(&body).map_err(|e| Iterm2Error::ParseError(e.to_string()))?;
532
533    let schemes: Vec<String> = files
534        .into_iter()
535        .filter(|f| f.file_type == "file" && f.name.ends_with(".itermcolors"))
536        .map(|f| f.name.trim_end_matches(".itermcolors").to_string())
537        .collect();
538
539    Ok(schemes)
540}
541
542fn derive_base_name(name: &str) -> String {
543    let lower = name.to_lowercase();
544
545    for suffix in [" light", " dark", "-light", "-dark"] {
546        if lower.ends_with(suffix) {
547            return name[..name.len() - suffix.len()].to_string();
548        }
549    }
550
551    name.to_string()
552}
553
554pub fn import_scheme(
555    name: &str,
556    custom_name: Option<&str>,
557    themes_dir: &Path,
558) -> Result<ImportResult, Iterm2Error> {
559    let primary = fetch_scheme(name)?;
560    let primary_variant = detect_variant(name);
561
562    let mut dark_scheme: Option<Iterm2Scheme> = None;
563    let mut light_scheme: Option<Iterm2Scheme> = None;
564    let mut dark_source: Option<String> = None;
565    let mut light_source: Option<String> = None;
566
567    match primary_variant {
568        SchemeVariant::Dark => {
569            dark_scheme = Some(primary);
570            dark_source = Some(name.to_string());
571
572            if let Some(counterpart) = find_counterpart_name(name) {
573                if let Some(light) = try_fetch_scheme(&counterpart) {
574                    light_scheme = Some(light);
575                    light_source = Some(counterpart);
576                }
577            }
578        }
579        SchemeVariant::Light => {
580            light_scheme = Some(primary);
581            light_source = Some(name.to_string());
582
583            if let Some(counterpart) = find_counterpart_name(name) {
584                if let Some(dark) = try_fetch_scheme(&counterpart) {
585                    dark_scheme = Some(dark);
586                    dark_source = Some(counterpart);
587                }
588            }
589        }
590        SchemeVariant::Unknown => {
591            dark_scheme = Some(primary);
592            dark_source = Some(name.to_string());
593
594            for variant_name in find_variant_names(name) {
595                if let Some(scheme) = try_fetch_scheme(&variant_name) {
596                    let variant = detect_variant(&variant_name);
597                    if variant == SchemeVariant::Light && light_scheme.is_none() {
598                        light_scheme = Some(scheme);
599                        light_source = Some(variant_name);
600                        break;
601                    }
602                }
603            }
604        }
605    }
606
607    let base_name = custom_name
608        .map(|s| s.to_string())
609        .unwrap_or_else(|| derive_base_name(name));
610
611    let file_name = base_name.to_lowercase().replace(' ', "-");
612
613    let mut toml_content = format!("name = \"{}\"\n", base_name);
614
615    if let Some(ref dark) = dark_scheme {
616        toml_content.push_str("\n[dark]\n");
617        toml_content.push_str(&dark.to_colors_toml());
618        toml_content.push('\n');
619    }
620
621    if let Some(ref light) = light_scheme {
622        toml_content.push_str("\n[light]\n");
623        toml_content.push_str(&light.to_colors_toml());
624        toml_content.push('\n');
625    }
626
627    std::fs::create_dir_all(themes_dir).map_err(|e| Iterm2Error::IoError(e.to_string()))?;
628
629    let theme_path = themes_dir.join(format!("{}.toml", file_name));
630
631    std::fs::write(&theme_path, toml_content).map_err(|e| Iterm2Error::IoError(e.to_string()))?;
632
633    Ok(ImportResult {
634        path: theme_path,
635        dark_source,
636        light_source,
637    })
638}
639
640pub fn search_schemes(query: &str) -> Result<Vec<String>, Iterm2Error> {
641    let all_schemes = list_available_schemes()?;
642    let query_lower = query.to_lowercase();
643
644    let matches: Vec<String> = all_schemes
645        .into_iter()
646        .filter(|s| s.to_lowercase().contains(&query_lower))
647        .collect();
648
649    Ok(matches)
650}
651
652pub fn find_variant_suggestions(
653    name: &str,
654    target_variant: SchemeVariant,
655) -> Result<Vec<String>, Iterm2Error> {
656    let base_name = derive_base_name(name);
657    let all_schemes = list_available_schemes()?;
658    let base_lower = base_name.to_lowercase();
659
660    let mut suggestions: Vec<String> = all_schemes
661        .into_iter()
662        .filter(|s| {
663            let s_lower = s.to_lowercase();
664            if s_lower == name.to_lowercase() {
665                return false;
666            }
667
668            let matches_base = s_lower.contains(&base_lower)
669                || base_lower.contains(&derive_base_name(s).to_lowercase());
670
671            if !matches_base {
672                return false;
673            }
674
675            let variant = detect_variant(s);
676            variant == target_variant || variant == SchemeVariant::Unknown
677        })
678        .collect();
679
680    suggestions.sort();
681    Ok(suggestions)
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    #[test]
689    fn test_color_to_hex() {
690        let color = Iterm2Color {
691            r: 1.0,
692            g: 0.5,
693            b: 0.0,
694        };
695        assert_eq!(color.to_hex(), "#ff8000");
696    }
697
698    #[test]
699    fn test_color_blend() {
700        let black = Iterm2Color {
701            r: 0.0,
702            g: 0.0,
703            b: 0.0,
704        };
705        let white = Iterm2Color {
706            r: 1.0,
707            g: 1.0,
708            b: 1.0,
709        };
710        let gray = black.blend(&white, 0.5);
711        assert!((gray.r - 0.5).abs() < 0.01);
712        assert!((gray.g - 0.5).abs() < 0.01);
713        assert!((gray.b - 0.5).abs() < 0.01);
714    }
715
716    #[test]
717    fn test_detect_variant() {
718        assert_eq!(detect_variant("Gruvbox Dark"), SchemeVariant::Dark);
719        assert_eq!(detect_variant("Gruvbox Light"), SchemeVariant::Light);
720        assert_eq!(detect_variant("Dracula"), SchemeVariant::Unknown);
721        assert_eq!(detect_variant("One Dark"), SchemeVariant::Dark);
722        assert_eq!(detect_variant("Solarized Light"), SchemeVariant::Light);
723    }
724
725    #[test]
726    fn test_find_counterpart_name() {
727        assert_eq!(
728            find_counterpart_name("Gruvbox Dark"),
729            Some("Gruvbox Light".to_string())
730        );
731        assert_eq!(
732            find_counterpart_name("Gruvbox Light"),
733            Some("Gruvbox Dark".to_string())
734        );
735        assert_eq!(
736            find_counterpart_name("Gruvbox Dark Hard"),
737            Some("Gruvbox Light Hard".to_string())
738        );
739        assert_eq!(find_counterpart_name("Dracula"), None);
740    }
741
742    #[test]
743    fn test_derive_base_name() {
744        assert_eq!(derive_base_name("Gruvbox Dark"), "Gruvbox");
745        assert_eq!(derive_base_name("Gruvbox Light"), "Gruvbox");
746        assert_eq!(derive_base_name("Gruvbox Dark Hard"), "Gruvbox Dark Hard");
747        assert_eq!(derive_base_name("One Dark"), "One");
748        assert_eq!(derive_base_name("Dracula"), "Dracula");
749    }
750
751    #[test]
752    fn test_lookup_variant_pair() {
753        let mocha = lookup_variant_pair("Catppuccin Mocha");
754        assert!(mocha.is_some());
755        assert_eq!(mocha.unwrap().1, "Catppuccin Latte");
756
757        let latte = lookup_variant_pair("Catppuccin Latte");
758        assert!(latte.is_some());
759        assert_eq!(latte.unwrap().1, "Catppuccin Latte");
760
761        assert_eq!(
762            lookup_variant_pair("Tomorrow Night"),
763            Some(("Tomorrow Night", "Tomorrow"))
764        );
765        assert_eq!(lookup_variant_pair("Nord"), Some(("Nord", "Nord Light")));
766        assert_eq!(lookup_variant_pair("Dracula"), None);
767    }
768
769    #[test]
770    fn test_find_counterpart_via_lookup() {
771        assert_eq!(
772            find_counterpart_name("Catppuccin Mocha"),
773            Some("Catppuccin Latte".to_string())
774        );
775        let latte_counterpart = find_counterpart_name("Catppuccin Latte");
776        assert!(latte_counterpart.is_some());
777        assert!(latte_counterpart.unwrap().starts_with("Catppuccin"));
778
779        assert_eq!(
780            find_counterpart_name("Tomorrow Night"),
781            Some("Tomorrow".to_string())
782        );
783        assert_eq!(
784            find_counterpart_name("Nord"),
785            Some("Nord Light".to_string())
786        );
787        assert_eq!(
788            find_counterpart_name("Nord Light"),
789            Some("Nord".to_string())
790        );
791    }
792
793    #[test]
794    fn test_detect_variant_via_lookup() {
795        assert_eq!(detect_variant("Catppuccin Mocha"), SchemeVariant::Dark);
796        assert_eq!(detect_variant("Catppuccin Latte"), SchemeVariant::Light);
797        assert_eq!(detect_variant("Nord"), SchemeVariant::Dark);
798        assert_eq!(detect_variant("Tomorrow"), SchemeVariant::Light);
799    }
800}