Skip to main content

purple_ssh/ui/
theme.rs

1use std::sync::atomic::{AtomicU8, Ordering};
2use std::sync::{OnceLock, RwLock};
3
4use ratatui::style::{Color, Modifier, Style};
5
6/// Color mode: 0 = NO_COLOR, 1 = ANSI 16, 2 = truecolor.
7static COLOR_MODE: AtomicU8 = AtomicU8::new(1);
8
9/// Whether the host terminal renders SGR 58 (styled underline color, "kitty
10/// underline" extension). Defaults to 1 (supported); `init()` flips it to 0
11/// for known holdouts such as macOS Terminal so styles can pick a fallback.
12/// Acts as the central point for terminal-capability quirks, mirroring how
13/// `messages` centralises user-facing strings.
14static COLORED_UNDERLINE: AtomicU8 = AtomicU8::new(1);
15
16/// Active theme.
17static THEME: OnceLock<RwLock<ThemeDef>> = OnceLock::new();
18
19/// A single color slot with per-tier values.
20#[derive(Debug, Clone)]
21pub struct ColorSlot {
22    pub truecolor: Option<Color>,
23    pub ansi16: Option<Color>,
24    pub add_modifier: Option<Modifier>,
25    pub remove_modifier: Option<Modifier>,
26}
27
28impl ColorSlot {
29    pub(crate) const fn new() -> Self {
30        Self {
31            truecolor: None,
32            ansi16: None,
33            add_modifier: None,
34            remove_modifier: None,
35        }
36    }
37
38    pub const fn new_with_modifier(m: Modifier) -> Self {
39        Self {
40            truecolor: None,
41            ansi16: None,
42            add_modifier: Some(m),
43            remove_modifier: None,
44        }
45    }
46
47    /// Resolve this slot to a foreground Style based on color mode.
48    pub fn to_style(&self, mode: u8) -> Style {
49        let mut style = Style::default();
50        match mode {
51            0 => {} // NO_COLOR: no fg/bg colors
52            2 => {
53                if let Some(c) = self.truecolor {
54                    style = style.fg(c);
55                }
56            }
57            _ => {
58                if let Some(c) = self.ansi16 {
59                    style = style.fg(c);
60                }
61            }
62        }
63        if let Some(m) = self.add_modifier {
64            style = style.add_modifier(m);
65        }
66        if let Some(m) = self.remove_modifier {
67            style = style.remove_modifier(m);
68        }
69        style
70    }
71
72    #[allow(dead_code)]
73    pub fn to_style_bg(&self, mode: u8) -> Style {
74        let mut style = Style::default();
75        match mode {
76            0 => {}
77            2 => {
78                if let Some(c) = self.truecolor {
79                    style = style.bg(c);
80                }
81            }
82            _ => {
83                if let Some(c) = self.ansi16 {
84                    style = style.bg(c);
85                }
86            }
87        }
88        if let Some(m) = self.add_modifier {
89            style = style.add_modifier(m);
90        }
91        if let Some(m) = self.remove_modifier {
92            style = style.remove_modifier(m);
93        }
94        style
95    }
96}
97
98/// Complete theme definition with all color slots.
99#[derive(Debug, Clone)]
100pub struct ThemeDef {
101    pub name: String,
102    pub accent: ColorSlot,
103    pub accent_bg: ColorSlot,
104    pub success: ColorSlot,
105    pub success_dim: ColorSlot,
106    pub warning: ColorSlot,
107    pub error: ColorSlot,
108    pub highlight: ColorSlot,
109    pub border: ColorSlot,
110    pub border_active: ColorSlot,
111    pub fg_muted: ColorSlot,
112    pub fg_bold: ColorSlot,
113    pub footer_key: ColorSlot,
114    pub badge: ColorSlot,
115    pub selected_fg: ColorSlot,
116    pub footer_key_fg: ColorSlot,
117    /// Accent for the trailing `.` of the `purple.` logotype in overlay
118    /// headers (Welcome, Help, What's-New). Intentionally different from
119    /// `accent` so the dot reads as a separate accent glyph — mirrors
120    /// the landing-page hero where the dot is cyan over purple text.
121    pub logo_dot: ColorSlot,
122}
123
124impl ThemeDef {
125    pub fn purple() -> Self {
126        Self {
127            name: "Purple".to_string(),
128            accent: ColorSlot {
129                truecolor: Some(Color::Rgb(147, 51, 234)),
130                ansi16: Some(Color::Magenta),
131                add_modifier: None,
132                remove_modifier: None,
133            },
134            accent_bg: ColorSlot {
135                truecolor: Some(Color::Rgb(147, 51, 234)),
136                ansi16: Some(Color::Magenta),
137                add_modifier: Some(Modifier::BOLD),
138                remove_modifier: Some(Modifier::DIM),
139            },
140            success: ColorSlot {
141                truecolor: Some(Color::Rgb(34, 197, 94)),
142                ansi16: Some(Color::Green),
143                add_modifier: Some(Modifier::BOLD),
144                remove_modifier: None,
145            },
146            success_dim: ColorSlot {
147                truecolor: Some(Color::Rgb(34, 197, 94)),
148                ansi16: Some(Color::Green),
149                add_modifier: Some(Modifier::DIM),
150                remove_modifier: None,
151            },
152            warning: ColorSlot {
153                truecolor: Some(Color::Rgb(234, 179, 8)),
154                ansi16: Some(Color::Yellow),
155                add_modifier: Some(Modifier::BOLD),
156                remove_modifier: None,
157            },
158            error: ColorSlot {
159                truecolor: Some(Color::Rgb(239, 68, 68)),
160                ansi16: Some(Color::Red),
161                add_modifier: Some(Modifier::BOLD),
162                remove_modifier: None,
163            },
164            highlight: ColorSlot {
165                truecolor: None,
166                ansi16: None,
167                add_modifier: Some(Modifier::BOLD | Modifier::REVERSED),
168                remove_modifier: None,
169            },
170            border: ColorSlot {
171                truecolor: None,
172                ansi16: None,
173                add_modifier: Some(Modifier::DIM),
174                remove_modifier: None,
175            },
176            border_active: ColorSlot {
177                truecolor: Some(Color::Rgb(147, 51, 234)),
178                ansi16: Some(Color::Magenta),
179                add_modifier: None,
180                remove_modifier: None,
181            },
182            fg_muted: ColorSlot {
183                truecolor: None,
184                ansi16: None,
185                add_modifier: Some(Modifier::DIM),
186                remove_modifier: None,
187            },
188            fg_bold: ColorSlot {
189                truecolor: None,
190                ansi16: None,
191                add_modifier: Some(Modifier::BOLD),
192                remove_modifier: None,
193            },
194            footer_key: ColorSlot {
195                truecolor: Some(Color::Rgb(88, 88, 88)),
196                ansi16: Some(Color::DarkGray),
197                add_modifier: None,
198                remove_modifier: None,
199            },
200            badge: ColorSlot {
201                truecolor: Some(Color::Rgb(147, 51, 234)),
202                ansi16: Some(Color::Magenta),
203                add_modifier: Some(Modifier::BOLD),
204                remove_modifier: Some(Modifier::DIM),
205            },
206            selected_fg: ColorSlot {
207                truecolor: Some(Color::White),
208                ansi16: Some(Color::White),
209                add_modifier: Some(Modifier::BOLD),
210                remove_modifier: None,
211            },
212            footer_key_fg: ColorSlot {
213                truecolor: Some(Color::White),
214                ansi16: Some(Color::White),
215                add_modifier: None,
216                remove_modifier: None,
217            },
218            logo_dot: ColorSlot {
219                truecolor: Some(Color::Rgb(0, 240, 255)),
220                ansi16: Some(Color::Cyan),
221                add_modifier: Some(Modifier::BOLD),
222                remove_modifier: None,
223            },
224        }
225    }
226
227    pub fn purple_purple() -> Self {
228        Self {
229            name: "Purple Purple".to_string(),
230            accent: ColorSlot {
231                truecolor: Some(Color::Rgb(147, 51, 234)),
232                ansi16: Some(Color::Magenta),
233                add_modifier: None,
234                remove_modifier: None,
235            },
236            accent_bg: ColorSlot {
237                truecolor: Some(Color::Rgb(147, 51, 234)),
238                ansi16: Some(Color::Magenta),
239                add_modifier: Some(Modifier::BOLD),
240                remove_modifier: Some(Modifier::DIM),
241            },
242            success: ColorSlot {
243                truecolor: Some(Color::Rgb(34, 197, 94)),
244                ansi16: Some(Color::Green),
245                add_modifier: Some(Modifier::BOLD),
246                remove_modifier: None,
247            },
248            success_dim: ColorSlot {
249                truecolor: Some(Color::Rgb(34, 197, 94)),
250                ansi16: Some(Color::Green),
251                add_modifier: Some(Modifier::DIM),
252                remove_modifier: None,
253            },
254            warning: ColorSlot {
255                truecolor: Some(Color::Rgb(234, 179, 8)),
256                ansi16: Some(Color::Yellow),
257                add_modifier: Some(Modifier::BOLD),
258                remove_modifier: None,
259            },
260            error: ColorSlot {
261                truecolor: Some(Color::Rgb(239, 68, 68)),
262                ansi16: Some(Color::Red),
263                add_modifier: Some(Modifier::BOLD),
264                remove_modifier: None,
265            },
266            highlight: ColorSlot {
267                truecolor: None,
268                ansi16: None,
269                add_modifier: Some(Modifier::BOLD | Modifier::REVERSED),
270                remove_modifier: None,
271            },
272            border: ColorSlot {
273                truecolor: Some(Color::Rgb(147, 51, 234)),
274                ansi16: Some(Color::Magenta),
275                add_modifier: Some(Modifier::DIM),
276                remove_modifier: None,
277            },
278            border_active: ColorSlot {
279                truecolor: Some(Color::Rgb(147, 51, 234)),
280                ansi16: Some(Color::Magenta),
281                add_modifier: None,
282                remove_modifier: None,
283            },
284            fg_muted: ColorSlot {
285                truecolor: None,
286                ansi16: None,
287                add_modifier: Some(Modifier::DIM),
288                remove_modifier: None,
289            },
290            fg_bold: ColorSlot {
291                truecolor: None,
292                ansi16: None,
293                add_modifier: Some(Modifier::BOLD),
294                remove_modifier: None,
295            },
296            footer_key: ColorSlot {
297                truecolor: Some(Color::Rgb(88, 88, 88)),
298                ansi16: Some(Color::DarkGray),
299                add_modifier: None,
300                remove_modifier: None,
301            },
302            badge: ColorSlot {
303                truecolor: Some(Color::Rgb(147, 51, 234)),
304                ansi16: Some(Color::Magenta),
305                add_modifier: Some(Modifier::BOLD),
306                remove_modifier: Some(Modifier::DIM),
307            },
308            selected_fg: ColorSlot {
309                truecolor: Some(Color::White),
310                ansi16: Some(Color::White),
311                add_modifier: Some(Modifier::BOLD),
312                remove_modifier: None,
313            },
314            footer_key_fg: ColorSlot {
315                truecolor: Some(Color::White),
316                ansi16: Some(Color::White),
317                add_modifier: None,
318                remove_modifier: None,
319            },
320            logo_dot: ColorSlot {
321                truecolor: Some(Color::Rgb(0, 240, 255)),
322                ansi16: Some(Color::Cyan),
323                add_modifier: Some(Modifier::BOLD),
324                remove_modifier: None,
325            },
326        }
327    }
328
329    pub fn catppuccin_mocha() -> Self {
330        Self {
331            name: "Catppuccin Mocha".to_string(),
332            accent: ColorSlot {
333                truecolor: Some(Color::Rgb(137, 180, 250)),
334                ansi16: Some(Color::Blue),
335                add_modifier: None,
336                remove_modifier: None,
337            },
338            accent_bg: ColorSlot {
339                truecolor: Some(Color::Rgb(137, 180, 250)),
340                ansi16: Some(Color::Blue),
341                add_modifier: Some(Modifier::BOLD),
342                remove_modifier: Some(Modifier::DIM),
343            },
344            success: ColorSlot {
345                truecolor: Some(Color::Rgb(166, 227, 161)),
346                ansi16: Some(Color::Green),
347                add_modifier: Some(Modifier::BOLD),
348                remove_modifier: None,
349            },
350            success_dim: ColorSlot {
351                truecolor: Some(Color::Rgb(166, 227, 161)),
352                ansi16: Some(Color::Green),
353                add_modifier: Some(Modifier::DIM),
354                remove_modifier: None,
355            },
356            warning: ColorSlot {
357                truecolor: Some(Color::Rgb(249, 226, 175)),
358                ansi16: Some(Color::Yellow),
359                add_modifier: Some(Modifier::BOLD),
360                remove_modifier: None,
361            },
362            error: ColorSlot {
363                truecolor: Some(Color::Rgb(243, 139, 168)),
364                ansi16: Some(Color::Red),
365                add_modifier: Some(Modifier::BOLD),
366                remove_modifier: None,
367            },
368            highlight: ColorSlot {
369                truecolor: None,
370                ansi16: None,
371                add_modifier: Some(Modifier::BOLD | Modifier::REVERSED),
372                remove_modifier: None,
373            },
374            border: ColorSlot {
375                truecolor: Some(Color::Rgb(88, 91, 112)),
376                ansi16: None,
377                add_modifier: Some(Modifier::DIM),
378                remove_modifier: None,
379            },
380            border_active: ColorSlot {
381                truecolor: Some(Color::Rgb(137, 180, 250)),
382                ansi16: Some(Color::Blue),
383                add_modifier: None,
384                remove_modifier: None,
385            },
386            fg_muted: ColorSlot {
387                truecolor: Some(Color::Rgb(108, 112, 134)),
388                ansi16: None,
389                add_modifier: Some(Modifier::DIM),
390                remove_modifier: None,
391            },
392            fg_bold: ColorSlot {
393                truecolor: None,
394                ansi16: None,
395                add_modifier: Some(Modifier::BOLD),
396                remove_modifier: None,
397            },
398            footer_key: ColorSlot {
399                truecolor: Some(Color::Rgb(69, 71, 90)),
400                ansi16: Some(Color::DarkGray),
401                add_modifier: None,
402                remove_modifier: None,
403            },
404            badge: ColorSlot {
405                truecolor: Some(Color::Rgb(137, 180, 250)),
406                ansi16: Some(Color::Blue),
407                add_modifier: Some(Modifier::BOLD),
408                remove_modifier: Some(Modifier::DIM),
409            },
410            selected_fg: ColorSlot {
411                truecolor: Some(Color::Rgb(30, 30, 46)), // Mocha Base
412                ansi16: Some(Color::Black),
413                add_modifier: Some(Modifier::BOLD),
414                remove_modifier: None,
415            },
416            footer_key_fg: ColorSlot {
417                truecolor: Some(Color::White),
418                ansi16: Some(Color::White),
419                add_modifier: None,
420                remove_modifier: None,
421            },
422            logo_dot: ColorSlot {
423                truecolor: Some(Color::Rgb(0, 240, 255)),
424                ansi16: Some(Color::Cyan),
425                add_modifier: Some(Modifier::BOLD),
426                remove_modifier: None,
427            },
428        }
429    }
430
431    pub fn dracula() -> Self {
432        Self {
433            name: "Dracula".to_string(),
434            accent: ColorSlot {
435                truecolor: Some(Color::Rgb(189, 147, 249)),
436                ansi16: Some(Color::Magenta),
437                add_modifier: None,
438                remove_modifier: None,
439            },
440            accent_bg: ColorSlot {
441                truecolor: Some(Color::Rgb(189, 147, 249)),
442                ansi16: Some(Color::Magenta),
443                add_modifier: Some(Modifier::BOLD),
444                remove_modifier: Some(Modifier::DIM),
445            },
446            success: ColorSlot {
447                truecolor: Some(Color::Rgb(80, 250, 123)),
448                ansi16: Some(Color::Green),
449                add_modifier: Some(Modifier::BOLD),
450                remove_modifier: None,
451            },
452            success_dim: ColorSlot {
453                truecolor: Some(Color::Rgb(80, 250, 123)),
454                ansi16: Some(Color::Green),
455                add_modifier: Some(Modifier::DIM),
456                remove_modifier: None,
457            },
458            warning: ColorSlot {
459                truecolor: Some(Color::Rgb(241, 250, 140)),
460                ansi16: Some(Color::Yellow),
461                add_modifier: Some(Modifier::BOLD),
462                remove_modifier: None,
463            },
464            error: ColorSlot {
465                truecolor: Some(Color::Rgb(255, 85, 85)),
466                ansi16: Some(Color::Red),
467                add_modifier: Some(Modifier::BOLD),
468                remove_modifier: None,
469            },
470            highlight: ColorSlot {
471                truecolor: None,
472                ansi16: None,
473                add_modifier: Some(Modifier::BOLD | Modifier::REVERSED),
474                remove_modifier: None,
475            },
476            border: ColorSlot {
477                truecolor: Some(Color::Rgb(68, 71, 90)),
478                ansi16: None,
479                add_modifier: Some(Modifier::DIM),
480                remove_modifier: None,
481            },
482            border_active: ColorSlot {
483                truecolor: Some(Color::Rgb(189, 147, 249)),
484                ansi16: Some(Color::Magenta),
485                add_modifier: None,
486                remove_modifier: None,
487            },
488            fg_muted: ColorSlot {
489                truecolor: Some(Color::Rgb(98, 114, 164)),
490                ansi16: None,
491                add_modifier: Some(Modifier::DIM),
492                remove_modifier: None,
493            },
494            fg_bold: ColorSlot {
495                truecolor: None,
496                ansi16: None,
497                add_modifier: Some(Modifier::BOLD),
498                remove_modifier: None,
499            },
500            footer_key: ColorSlot {
501                truecolor: Some(Color::Rgb(68, 71, 90)),
502                ansi16: Some(Color::DarkGray),
503                add_modifier: None,
504                remove_modifier: None,
505            },
506            badge: ColorSlot {
507                truecolor: Some(Color::Rgb(189, 147, 249)),
508                ansi16: Some(Color::Magenta),
509                add_modifier: Some(Modifier::BOLD),
510                remove_modifier: Some(Modifier::DIM),
511            },
512            selected_fg: ColorSlot {
513                truecolor: Some(Color::Rgb(40, 42, 54)), // Dracula Background
514                ansi16: Some(Color::Black),
515                add_modifier: Some(Modifier::BOLD),
516                remove_modifier: None,
517            },
518            footer_key_fg: ColorSlot {
519                truecolor: Some(Color::White),
520                ansi16: Some(Color::White),
521                add_modifier: None,
522                remove_modifier: None,
523            },
524            logo_dot: ColorSlot {
525                truecolor: Some(Color::Rgb(0, 240, 255)),
526                ansi16: Some(Color::Cyan),
527                add_modifier: Some(Modifier::BOLD),
528                remove_modifier: None,
529            },
530        }
531    }
532
533    pub fn gruvbox_dark() -> Self {
534        Self {
535            name: "Gruvbox Dark".to_string(),
536            accent: ColorSlot {
537                truecolor: Some(Color::Rgb(215, 153, 33)),
538                ansi16: Some(Color::LightYellow),
539                add_modifier: None,
540                remove_modifier: None,
541            },
542            accent_bg: ColorSlot {
543                truecolor: Some(Color::Rgb(215, 153, 33)),
544                ansi16: Some(Color::LightYellow),
545                add_modifier: Some(Modifier::BOLD),
546                remove_modifier: Some(Modifier::DIM),
547            },
548            success: ColorSlot {
549                truecolor: Some(Color::Rgb(152, 151, 26)),
550                ansi16: Some(Color::Green),
551                add_modifier: Some(Modifier::BOLD),
552                remove_modifier: None,
553            },
554            success_dim: ColorSlot {
555                truecolor: Some(Color::Rgb(152, 151, 26)),
556                ansi16: Some(Color::Green),
557                add_modifier: Some(Modifier::DIM),
558                remove_modifier: None,
559            },
560            warning: ColorSlot {
561                truecolor: Some(Color::Rgb(250, 189, 47)),
562                ansi16: Some(Color::Yellow),
563                add_modifier: Some(Modifier::BOLD),
564                remove_modifier: None,
565            },
566            error: ColorSlot {
567                truecolor: Some(Color::Rgb(251, 73, 52)), // Gruvbox bright_red
568                ansi16: Some(Color::Red),
569                add_modifier: Some(Modifier::BOLD),
570                remove_modifier: None,
571            },
572            highlight: ColorSlot {
573                truecolor: None,
574                ansi16: None,
575                add_modifier: Some(Modifier::BOLD | Modifier::REVERSED),
576                remove_modifier: None,
577            },
578            border: ColorSlot {
579                truecolor: Some(Color::Rgb(80, 73, 69)),
580                ansi16: None,
581                add_modifier: Some(Modifier::DIM),
582                remove_modifier: None,
583            },
584            border_active: ColorSlot {
585                truecolor: Some(Color::Rgb(215, 153, 33)),
586                ansi16: Some(Color::LightYellow),
587                add_modifier: None,
588                remove_modifier: None,
589            },
590            fg_muted: ColorSlot {
591                truecolor: Some(Color::Rgb(146, 131, 116)),
592                ansi16: None,
593                add_modifier: Some(Modifier::DIM),
594                remove_modifier: None,
595            },
596            fg_bold: ColorSlot {
597                truecolor: None,
598                ansi16: None,
599                add_modifier: Some(Modifier::BOLD),
600                remove_modifier: None,
601            },
602            footer_key: ColorSlot {
603                truecolor: Some(Color::Rgb(80, 73, 69)),
604                ansi16: Some(Color::DarkGray),
605                add_modifier: None,
606                remove_modifier: None,
607            },
608            badge: ColorSlot {
609                truecolor: Some(Color::Rgb(215, 153, 33)),
610                ansi16: Some(Color::LightYellow),
611                add_modifier: Some(Modifier::BOLD),
612                remove_modifier: Some(Modifier::DIM),
613            },
614            selected_fg: ColorSlot {
615                truecolor: Some(Color::Rgb(40, 40, 40)), // Gruvbox bg0
616                ansi16: Some(Color::Black),
617                add_modifier: Some(Modifier::BOLD),
618                remove_modifier: None,
619            },
620            footer_key_fg: ColorSlot {
621                truecolor: Some(Color::White),
622                ansi16: Some(Color::White),
623                add_modifier: None,
624                remove_modifier: None,
625            },
626            logo_dot: ColorSlot {
627                truecolor: Some(Color::Rgb(0, 240, 255)),
628                ansi16: Some(Color::Cyan),
629                add_modifier: Some(Modifier::BOLD),
630                remove_modifier: None,
631            },
632        }
633    }
634
635    pub fn nord() -> Self {
636        Self {
637            name: "Nord".to_string(),
638            accent: ColorSlot {
639                truecolor: Some(Color::Rgb(136, 192, 208)),
640                ansi16: Some(Color::Cyan),
641                add_modifier: None,
642                remove_modifier: None,
643            },
644            accent_bg: ColorSlot {
645                truecolor: Some(Color::Rgb(136, 192, 208)),
646                ansi16: Some(Color::Cyan),
647                add_modifier: Some(Modifier::BOLD),
648                remove_modifier: Some(Modifier::DIM),
649            },
650            success: ColorSlot {
651                truecolor: Some(Color::Rgb(163, 190, 140)),
652                ansi16: Some(Color::Green),
653                add_modifier: Some(Modifier::BOLD),
654                remove_modifier: None,
655            },
656            success_dim: ColorSlot {
657                truecolor: Some(Color::Rgb(163, 190, 140)),
658                ansi16: Some(Color::Green),
659                add_modifier: Some(Modifier::DIM),
660                remove_modifier: None,
661            },
662            warning: ColorSlot {
663                truecolor: Some(Color::Rgb(235, 203, 139)),
664                ansi16: Some(Color::Yellow),
665                add_modifier: Some(Modifier::BOLD),
666                remove_modifier: None,
667            },
668            error: ColorSlot {
669                truecolor: Some(Color::Rgb(191, 97, 106)),
670                ansi16: Some(Color::Red),
671                add_modifier: Some(Modifier::BOLD),
672                remove_modifier: None,
673            },
674            highlight: ColorSlot {
675                truecolor: None,
676                ansi16: None,
677                add_modifier: Some(Modifier::BOLD | Modifier::REVERSED),
678                remove_modifier: None,
679            },
680            border: ColorSlot {
681                truecolor: Some(Color::Rgb(76, 86, 106)),
682                ansi16: None,
683                add_modifier: Some(Modifier::DIM),
684                remove_modifier: None,
685            },
686            border_active: ColorSlot {
687                truecolor: Some(Color::Rgb(136, 192, 208)),
688                ansi16: Some(Color::Cyan),
689                add_modifier: None,
690                remove_modifier: None,
691            },
692            fg_muted: ColorSlot {
693                truecolor: Some(Color::Rgb(76, 86, 106)),
694                ansi16: None,
695                add_modifier: Some(Modifier::DIM),
696                remove_modifier: None,
697            },
698            fg_bold: ColorSlot {
699                truecolor: None,
700                ansi16: None,
701                add_modifier: Some(Modifier::BOLD),
702                remove_modifier: None,
703            },
704            footer_key: ColorSlot {
705                truecolor: Some(Color::Rgb(76, 86, 106)),
706                ansi16: Some(Color::DarkGray),
707                add_modifier: None,
708                remove_modifier: None,
709            },
710            badge: ColorSlot {
711                truecolor: Some(Color::Rgb(136, 192, 208)),
712                ansi16: Some(Color::Cyan),
713                add_modifier: Some(Modifier::BOLD),
714                remove_modifier: Some(Modifier::DIM),
715            },
716            selected_fg: ColorSlot {
717                truecolor: Some(Color::Rgb(46, 52, 64)), // Nord0 polar night
718                ansi16: Some(Color::Black),
719                add_modifier: Some(Modifier::BOLD),
720                remove_modifier: None,
721            },
722            footer_key_fg: ColorSlot {
723                truecolor: Some(Color::White),
724                ansi16: Some(Color::White),
725                add_modifier: None,
726                remove_modifier: None,
727            },
728            logo_dot: ColorSlot {
729                truecolor: Some(Color::Rgb(0, 240, 255)),
730                ansi16: Some(Color::Cyan),
731                add_modifier: Some(Modifier::BOLD),
732                remove_modifier: None,
733            },
734        }
735    }
736
737    pub fn tokyo_night() -> Self {
738        Self {
739            name: "Tokyo Night".to_string(),
740            accent: ColorSlot {
741                truecolor: Some(Color::Rgb(122, 162, 247)),
742                ansi16: Some(Color::Blue),
743                add_modifier: None,
744                remove_modifier: None,
745            },
746            accent_bg: ColorSlot {
747                truecolor: Some(Color::Rgb(122, 162, 247)),
748                ansi16: Some(Color::Blue),
749                add_modifier: Some(Modifier::BOLD),
750                remove_modifier: Some(Modifier::DIM),
751            },
752            success: ColorSlot {
753                truecolor: Some(Color::Rgb(158, 206, 106)),
754                ansi16: Some(Color::Green),
755                add_modifier: Some(Modifier::BOLD),
756                remove_modifier: None,
757            },
758            success_dim: ColorSlot {
759                truecolor: Some(Color::Rgb(158, 206, 106)),
760                ansi16: Some(Color::Green),
761                add_modifier: Some(Modifier::DIM),
762                remove_modifier: None,
763            },
764            warning: ColorSlot {
765                truecolor: Some(Color::Rgb(224, 175, 104)),
766                ansi16: Some(Color::Yellow),
767                add_modifier: Some(Modifier::BOLD),
768                remove_modifier: None,
769            },
770            error: ColorSlot {
771                truecolor: Some(Color::Rgb(247, 118, 142)),
772                ansi16: Some(Color::Red),
773                add_modifier: Some(Modifier::BOLD),
774                remove_modifier: None,
775            },
776            highlight: ColorSlot {
777                truecolor: None,
778                ansi16: None,
779                add_modifier: Some(Modifier::BOLD | Modifier::REVERSED),
780                remove_modifier: None,
781            },
782            border: ColorSlot {
783                truecolor: Some(Color::Rgb(61, 89, 161)),
784                ansi16: None,
785                add_modifier: Some(Modifier::DIM),
786                remove_modifier: None,
787            },
788            border_active: ColorSlot {
789                truecolor: Some(Color::Rgb(122, 162, 247)),
790                ansi16: Some(Color::Blue),
791                add_modifier: None,
792                remove_modifier: None,
793            },
794            fg_muted: ColorSlot {
795                truecolor: Some(Color::Rgb(86, 95, 137)),
796                ansi16: None,
797                add_modifier: Some(Modifier::DIM),
798                remove_modifier: None,
799            },
800            fg_bold: ColorSlot {
801                truecolor: None,
802                ansi16: None,
803                add_modifier: Some(Modifier::BOLD),
804                remove_modifier: None,
805            },
806            footer_key: ColorSlot {
807                truecolor: Some(Color::Rgb(61, 89, 161)),
808                ansi16: Some(Color::DarkGray),
809                add_modifier: None,
810                remove_modifier: None,
811            },
812            badge: ColorSlot {
813                truecolor: Some(Color::Rgb(122, 162, 247)),
814                ansi16: Some(Color::Blue),
815                add_modifier: Some(Modifier::BOLD),
816                remove_modifier: Some(Modifier::DIM),
817            },
818            selected_fg: ColorSlot {
819                truecolor: Some(Color::Rgb(26, 27, 38)), // Tokyo Night bg
820                ansi16: Some(Color::Black),
821                add_modifier: Some(Modifier::BOLD),
822                remove_modifier: None,
823            },
824            footer_key_fg: ColorSlot {
825                truecolor: Some(Color::White),
826                ansi16: Some(Color::White),
827                add_modifier: None,
828                remove_modifier: None,
829            },
830            logo_dot: ColorSlot {
831                truecolor: Some(Color::Rgb(0, 240, 255)),
832                ansi16: Some(Color::Cyan),
833                add_modifier: Some(Modifier::BOLD),
834                remove_modifier: None,
835            },
836        }
837    }
838
839    pub fn one_dark() -> Self {
840        Self {
841            name: "One Dark".to_string(),
842            accent: ColorSlot {
843                truecolor: Some(Color::Rgb(97, 175, 239)),
844                ansi16: Some(Color::Blue),
845                add_modifier: None,
846                remove_modifier: None,
847            },
848            accent_bg: ColorSlot {
849                truecolor: Some(Color::Rgb(97, 175, 239)),
850                ansi16: Some(Color::Blue),
851                add_modifier: Some(Modifier::BOLD),
852                remove_modifier: Some(Modifier::DIM),
853            },
854            success: ColorSlot {
855                truecolor: Some(Color::Rgb(152, 195, 121)),
856                ansi16: Some(Color::Green),
857                add_modifier: Some(Modifier::BOLD),
858                remove_modifier: None,
859            },
860            success_dim: ColorSlot {
861                truecolor: Some(Color::Rgb(152, 195, 121)),
862                ansi16: Some(Color::Green),
863                add_modifier: Some(Modifier::DIM),
864                remove_modifier: None,
865            },
866            warning: ColorSlot {
867                truecolor: Some(Color::Rgb(229, 192, 123)),
868                ansi16: Some(Color::Yellow),
869                add_modifier: Some(Modifier::BOLD),
870                remove_modifier: None,
871            },
872            error: ColorSlot {
873                truecolor: Some(Color::Rgb(224, 108, 117)),
874                ansi16: Some(Color::Red),
875                add_modifier: Some(Modifier::BOLD),
876                remove_modifier: None,
877            },
878            highlight: ColorSlot {
879                truecolor: None,
880                ansi16: None,
881                add_modifier: Some(Modifier::BOLD | Modifier::REVERSED),
882                remove_modifier: None,
883            },
884            border: ColorSlot {
885                truecolor: Some(Color::Rgb(62, 68, 81)),
886                ansi16: None,
887                add_modifier: Some(Modifier::DIM),
888                remove_modifier: None,
889            },
890            border_active: ColorSlot {
891                truecolor: Some(Color::Rgb(97, 175, 239)),
892                ansi16: Some(Color::Blue),
893                add_modifier: None,
894                remove_modifier: None,
895            },
896            fg_muted: ColorSlot {
897                truecolor: Some(Color::Rgb(92, 99, 112)),
898                ansi16: None,
899                add_modifier: Some(Modifier::DIM),
900                remove_modifier: None,
901            },
902            fg_bold: ColorSlot {
903                truecolor: None,
904                ansi16: None,
905                add_modifier: Some(Modifier::BOLD),
906                remove_modifier: None,
907            },
908            footer_key: ColorSlot {
909                truecolor: Some(Color::Rgb(62, 68, 81)),
910                ansi16: Some(Color::DarkGray),
911                add_modifier: None,
912                remove_modifier: None,
913            },
914            badge: ColorSlot {
915                truecolor: Some(Color::Rgb(97, 175, 239)),
916                ansi16: Some(Color::Blue),
917                add_modifier: Some(Modifier::BOLD),
918                remove_modifier: Some(Modifier::DIM),
919            },
920            selected_fg: ColorSlot {
921                truecolor: Some(Color::Rgb(40, 44, 52)), // One Dark bg
922                ansi16: Some(Color::Black),
923                add_modifier: Some(Modifier::BOLD),
924                remove_modifier: None,
925            },
926            footer_key_fg: ColorSlot {
927                truecolor: Some(Color::White),
928                ansi16: Some(Color::White),
929                add_modifier: None,
930                remove_modifier: None,
931            },
932            logo_dot: ColorSlot {
933                truecolor: Some(Color::Rgb(0, 240, 255)),
934                ansi16: Some(Color::Cyan),
935                add_modifier: Some(Modifier::BOLD),
936                remove_modifier: None,
937            },
938        }
939    }
940
941    pub fn catppuccin_latte() -> Self {
942        Self {
943            name: "Catppuccin Latte".to_string(),
944            accent: ColorSlot {
945                truecolor: Some(Color::Rgb(30, 102, 245)),
946                ansi16: Some(Color::Blue),
947                add_modifier: None,
948                remove_modifier: None,
949            },
950            accent_bg: ColorSlot {
951                truecolor: Some(Color::Rgb(30, 102, 245)),
952                ansi16: Some(Color::Blue),
953                add_modifier: Some(Modifier::BOLD),
954                remove_modifier: Some(Modifier::DIM),
955            },
956            success: ColorSlot {
957                truecolor: Some(Color::Rgb(64, 160, 43)),
958                ansi16: Some(Color::Green),
959                add_modifier: Some(Modifier::BOLD),
960                remove_modifier: None,
961            },
962            success_dim: ColorSlot {
963                truecolor: Some(Color::Rgb(64, 160, 43)),
964                ansi16: Some(Color::Green),
965                add_modifier: Some(Modifier::DIM),
966                remove_modifier: None,
967            },
968            warning: ColorSlot {
969                truecolor: Some(Color::Rgb(223, 142, 29)),
970                ansi16: Some(Color::Yellow),
971                add_modifier: Some(Modifier::BOLD),
972                remove_modifier: None,
973            },
974            error: ColorSlot {
975                truecolor: Some(Color::Rgb(210, 15, 57)),
976                ansi16: Some(Color::Red),
977                add_modifier: Some(Modifier::BOLD),
978                remove_modifier: None,
979            },
980            highlight: ColorSlot {
981                truecolor: None,
982                ansi16: None,
983                add_modifier: Some(Modifier::BOLD | Modifier::REVERSED),
984                remove_modifier: None,
985            },
986            border: ColorSlot {
987                truecolor: Some(Color::Rgb(172, 176, 190)),
988                ansi16: None,
989                add_modifier: None, // No DIM for light themes
990                remove_modifier: None,
991            },
992            border_active: ColorSlot {
993                truecolor: Some(Color::Rgb(30, 102, 245)),
994                ansi16: Some(Color::Blue),
995                add_modifier: None,
996                remove_modifier: None,
997            },
998            fg_muted: ColorSlot {
999                truecolor: Some(Color::Rgb(140, 143, 161)),
1000                ansi16: None,
1001                add_modifier: Some(Modifier::DIM),
1002                remove_modifier: None,
1003            },
1004            fg_bold: ColorSlot {
1005                truecolor: None,
1006                ansi16: None,
1007                add_modifier: Some(Modifier::BOLD),
1008                remove_modifier: None,
1009            },
1010            footer_key: ColorSlot {
1011                truecolor: Some(Color::Rgb(172, 176, 190)),
1012                ansi16: Some(Color::DarkGray),
1013                add_modifier: None,
1014                remove_modifier: None,
1015            },
1016            badge: ColorSlot {
1017                truecolor: Some(Color::Rgb(30, 102, 245)),
1018                ansi16: Some(Color::Blue),
1019                add_modifier: Some(Modifier::BOLD),
1020                remove_modifier: Some(Modifier::DIM),
1021            },
1022            selected_fg: ColorSlot {
1023                truecolor: Some(Color::White),
1024                ansi16: Some(Color::White),
1025                add_modifier: Some(Modifier::BOLD),
1026                remove_modifier: None,
1027            },
1028            footer_key_fg: ColorSlot {
1029                truecolor: Some(Color::Rgb(76, 79, 105)), // Latte Text
1030                ansi16: Some(Color::Black),
1031                add_modifier: None,
1032                remove_modifier: None,
1033            },
1034            logo_dot: ColorSlot {
1035                truecolor: Some(Color::Rgb(0, 240, 255)),
1036                ansi16: Some(Color::Cyan),
1037                add_modifier: Some(Modifier::BOLD),
1038                remove_modifier: None,
1039            },
1040        }
1041    }
1042
1043    pub fn solarized_light() -> Self {
1044        Self {
1045            name: "Solarized Light".to_string(),
1046            accent: ColorSlot {
1047                truecolor: Some(Color::Rgb(38, 139, 210)),
1048                ansi16: Some(Color::Blue),
1049                add_modifier: None,
1050                remove_modifier: None,
1051            },
1052            accent_bg: ColorSlot {
1053                truecolor: Some(Color::Rgb(38, 139, 210)),
1054                ansi16: Some(Color::Blue),
1055                add_modifier: Some(Modifier::BOLD),
1056                remove_modifier: Some(Modifier::DIM),
1057            },
1058            success: ColorSlot {
1059                truecolor: Some(Color::Rgb(133, 153, 0)),
1060                ansi16: Some(Color::Green),
1061                add_modifier: Some(Modifier::BOLD),
1062                remove_modifier: None,
1063            },
1064            success_dim: ColorSlot {
1065                truecolor: Some(Color::Rgb(133, 153, 0)),
1066                ansi16: Some(Color::Green),
1067                add_modifier: Some(Modifier::DIM),
1068                remove_modifier: None,
1069            },
1070            warning: ColorSlot {
1071                truecolor: Some(Color::Rgb(181, 137, 0)),
1072                ansi16: Some(Color::Yellow),
1073                add_modifier: Some(Modifier::BOLD),
1074                remove_modifier: None,
1075            },
1076            error: ColorSlot {
1077                truecolor: Some(Color::Rgb(220, 50, 47)),
1078                ansi16: Some(Color::Red),
1079                add_modifier: Some(Modifier::BOLD),
1080                remove_modifier: None,
1081            },
1082            highlight: ColorSlot {
1083                truecolor: None,
1084                ansi16: None,
1085                add_modifier: Some(Modifier::BOLD | Modifier::REVERSED),
1086                remove_modifier: None,
1087            },
1088            border: ColorSlot {
1089                truecolor: Some(Color::Rgb(147, 161, 161)),
1090                ansi16: None,
1091                add_modifier: None, // No DIM for light themes
1092                remove_modifier: None,
1093            },
1094            border_active: ColorSlot {
1095                truecolor: Some(Color::Rgb(38, 139, 210)),
1096                ansi16: Some(Color::Blue),
1097                add_modifier: None,
1098                remove_modifier: None,
1099            },
1100            fg_muted: ColorSlot {
1101                truecolor: Some(Color::Rgb(147, 161, 161)),
1102                ansi16: None,
1103                add_modifier: None, // No DIM for light themes
1104                remove_modifier: None,
1105            },
1106            fg_bold: ColorSlot {
1107                truecolor: None,
1108                ansi16: None,
1109                add_modifier: Some(Modifier::BOLD),
1110                remove_modifier: None,
1111            },
1112            footer_key: ColorSlot {
1113                truecolor: Some(Color::Rgb(7, 54, 66)), // Solarized base02
1114                ansi16: Some(Color::DarkGray),
1115                add_modifier: None,
1116                remove_modifier: None,
1117            },
1118            badge: ColorSlot {
1119                truecolor: Some(Color::Rgb(38, 139, 210)),
1120                ansi16: Some(Color::Blue),
1121                add_modifier: Some(Modifier::BOLD),
1122                remove_modifier: Some(Modifier::DIM),
1123            },
1124            selected_fg: ColorSlot {
1125                truecolor: Some(Color::White),
1126                ansi16: Some(Color::White),
1127                add_modifier: Some(Modifier::BOLD),
1128                remove_modifier: None,
1129            },
1130            footer_key_fg: ColorSlot {
1131                truecolor: Some(Color::Rgb(238, 232, 213)), // Solarized base2
1132                ansi16: Some(Color::White),
1133                add_modifier: None,
1134                remove_modifier: None,
1135            },
1136            logo_dot: ColorSlot {
1137                truecolor: Some(Color::Rgb(0, 240, 255)),
1138                ansi16: Some(Color::Cyan),
1139                add_modifier: Some(Modifier::BOLD),
1140                remove_modifier: None,
1141            },
1142        }
1143    }
1144
1145    pub fn no_color() -> Self {
1146        Self {
1147            name: "No Color".to_string(),
1148            accent: ColorSlot::new_with_modifier(Modifier::BOLD),
1149            accent_bg: ColorSlot {
1150                truecolor: None,
1151                ansi16: None,
1152                add_modifier: Some(Modifier::BOLD | Modifier::REVERSED),
1153                remove_modifier: Some(Modifier::DIM),
1154            },
1155            success: ColorSlot::new_with_modifier(Modifier::BOLD),
1156            success_dim: ColorSlot::new(),
1157            warning: ColorSlot::new_with_modifier(Modifier::BOLD),
1158            error: ColorSlot::new_with_modifier(Modifier::BOLD),
1159            highlight: ColorSlot {
1160                truecolor: None,
1161                ansi16: None,
1162                add_modifier: Some(Modifier::BOLD | Modifier::REVERSED),
1163                remove_modifier: None,
1164            },
1165            border: ColorSlot::new_with_modifier(Modifier::DIM),
1166            border_active: ColorSlot::new_with_modifier(Modifier::BOLD),
1167            fg_muted: ColorSlot::new_with_modifier(Modifier::DIM),
1168            fg_bold: ColorSlot::new_with_modifier(Modifier::BOLD),
1169            footer_key: ColorSlot::new_with_modifier(Modifier::REVERSED),
1170            badge: ColorSlot {
1171                truecolor: None,
1172                ansi16: None,
1173                add_modifier: Some(Modifier::BOLD | Modifier::REVERSED),
1174                remove_modifier: Some(Modifier::DIM),
1175            },
1176            selected_fg: ColorSlot::new_with_modifier(Modifier::BOLD),
1177            footer_key_fg: ColorSlot::new_with_modifier(Modifier::REVERSED),
1178            logo_dot: ColorSlot::new_with_modifier(Modifier::BOLD),
1179        }
1180    }
1181
1182    pub fn builtins() -> Vec<ThemeDef> {
1183        vec![
1184            Self::purple(),
1185            Self::purple_purple(),
1186            Self::catppuccin_mocha(),
1187            Self::dracula(),
1188            Self::gruvbox_dark(),
1189            Self::nord(),
1190            Self::tokyo_night(),
1191            Self::one_dark(),
1192            Self::catppuccin_latte(),
1193            Self::solarized_light(),
1194            Self::no_color(),
1195        ]
1196    }
1197
1198    pub fn find_builtin(name: &str) -> Option<ThemeDef> {
1199        Self::builtins()
1200            .into_iter()
1201            .find(|t| t.name.eq_ignore_ascii_case(name))
1202    }
1203
1204    pub fn parse_toml(content: &str) -> Option<Self> {
1205        let mut values: std::collections::HashMap<String, String> =
1206            std::collections::HashMap::new();
1207        for line in content.lines() {
1208            let line = line.trim();
1209            if line.is_empty() || line.starts_with('#') {
1210                continue;
1211            }
1212            if let Some((key, val)) = line.split_once('=') {
1213                let key = key.trim().to_string();
1214                let val = val.trim();
1215                let val = if let Some(idx) = val.find(" #") {
1216                    &val[..idx]
1217                } else {
1218                    val
1219                };
1220                let val = val.trim().trim_matches('"').to_string();
1221                values.insert(key, val);
1222            }
1223        }
1224        let name = values.get("name")?.to_string();
1225        let theme_name = name.clone();
1226        let fallback = Self::purple();
1227        let resolve_slot = |key: &str, fb: &ColorSlot| -> ColorSlot {
1228            let truecolor = match values.get(key) {
1229                Some(v) => match parse_hex(v) {
1230                    Some(c) => Some(c),
1231                    None => {
1232                        log::warn!(
1233                            "[config] theme '{}' field '{}' has invalid hex value '{}'; falling back",
1234                            theme_name,
1235                            key,
1236                            v
1237                        );
1238                        fb.truecolor
1239                    }
1240                },
1241                None => fb.truecolor,
1242            };
1243            let ansi_key = format!("{key}_ansi");
1244            let ansi16 = match values.get(&ansi_key) {
1245                Some(v) => match parse_ansi_name(v) {
1246                    Some(c) => Some(c),
1247                    None => {
1248                        log::warn!(
1249                            "[config] theme '{}' field '{}' has invalid colour name '{}'; falling back",
1250                            theme_name,
1251                            ansi_key,
1252                            v
1253                        );
1254                        truecolor.and_then(auto_ansi16).or(fb.ansi16)
1255                    }
1256                },
1257                None => truecolor.and_then(auto_ansi16).or(fb.ansi16),
1258            };
1259            ColorSlot {
1260                truecolor,
1261                ansi16,
1262                add_modifier: fb.add_modifier,
1263                remove_modifier: fb.remove_modifier,
1264            }
1265        };
1266        Some(Self {
1267            name,
1268            accent: resolve_slot("accent", &fallback.accent),
1269            accent_bg: resolve_slot("accent_bg", &fallback.accent_bg),
1270            success: resolve_slot("success", &fallback.success),
1271            success_dim: resolve_slot("success_dim", &fallback.success_dim),
1272            warning: resolve_slot("warning", &fallback.warning),
1273            error: resolve_slot("error", &fallback.error),
1274            highlight: fallback.highlight,
1275            border: resolve_slot("border", &fallback.border),
1276            border_active: resolve_slot("border_active", &fallback.border_active),
1277            fg_muted: resolve_slot("fg_muted", &fallback.fg_muted),
1278            fg_bold: fallback.fg_bold,
1279            footer_key: resolve_slot("footer_key_bg", &fallback.footer_key),
1280            badge: resolve_slot("badge_bg", &fallback.badge),
1281            selected_fg: resolve_slot("selected_fg", &fallback.selected_fg),
1282            footer_key_fg: resolve_slot("footer_key_fg", &fallback.footer_key_fg),
1283            logo_dot: resolve_slot("logo_dot", &fallback.logo_dot),
1284        })
1285    }
1286
1287    pub fn load_custom() -> Vec<ThemeDef> {
1288        let Some(home) = dirs::home_dir() else {
1289            return Vec::new();
1290        };
1291        let dir = home.join(".purple").join("themes");
1292        let Ok(entries) = std::fs::read_dir(&dir) else {
1293            return Vec::new();
1294        };
1295        let mut themes = Vec::new();
1296        for entry in entries.flatten() {
1297            let path = entry.path();
1298            if path.extension().is_some_and(|e| e == "toml") {
1299                if let Ok(content) = std::fs::read_to_string(&path) {
1300                    if let Some(theme) = Self::parse_toml(&content) {
1301                        themes.push(theme);
1302                    } else {
1303                        log::warn!("[config] Invalid theme file: {}", path.display());
1304                    }
1305                }
1306            }
1307        }
1308        themes.sort_by(|a, b| a.name.cmp(&b.name));
1309        themes
1310    }
1311}
1312
1313// ---------------------------------------------------------------------------
1314// TOML parser helpers
1315// ---------------------------------------------------------------------------
1316
1317fn parse_hex(s: &str) -> Option<Color> {
1318    let s = s.trim().strip_prefix('#')?;
1319    if s.len() != 6 {
1320        return None;
1321    }
1322    let r = u8::from_str_radix(&s[0..2], 16).ok()?;
1323    let g = u8::from_str_radix(&s[2..4], 16).ok()?;
1324    let b = u8::from_str_radix(&s[4..6], 16).ok()?;
1325    Some(Color::Rgb(r, g, b))
1326}
1327
1328fn parse_ansi_name(s: &str) -> Option<Color> {
1329    match s.to_ascii_lowercase().as_str() {
1330        "black" => Some(Color::Black),
1331        "red" => Some(Color::Red),
1332        "green" => Some(Color::Green),
1333        "yellow" => Some(Color::Yellow),
1334        "blue" => Some(Color::Blue),
1335        "magenta" => Some(Color::Magenta),
1336        "cyan" => Some(Color::Cyan),
1337        "white" => Some(Color::White),
1338        "darkgray" | "dark_gray" => Some(Color::DarkGray),
1339        "lightred" | "light_red" => Some(Color::LightRed),
1340        "lightgreen" | "light_green" => Some(Color::LightGreen),
1341        "lightyellow" | "light_yellow" => Some(Color::LightYellow),
1342        "lightblue" | "light_blue" => Some(Color::LightBlue),
1343        "lightmagenta" | "light_magenta" => Some(Color::LightMagenta),
1344        "lightcyan" | "light_cyan" => Some(Color::LightCyan),
1345        "gray" => Some(Color::Gray),
1346        _ => None,
1347    }
1348}
1349
1350fn auto_ansi16(color: Color) -> Option<Color> {
1351    let Color::Rgb(r, g, b) = color else {
1352        return Some(color);
1353    };
1354    let max = r.max(g).max(b);
1355    if max < 50 {
1356        return Some(Color::Black);
1357    }
1358    let is_bright = max > 170;
1359    if r > g && r > b {
1360        return Some(if is_bright {
1361            Color::LightRed
1362        } else {
1363            Color::Red
1364        });
1365    }
1366    if g > r && g > b {
1367        return Some(if is_bright {
1368            Color::LightGreen
1369        } else {
1370            Color::Green
1371        });
1372    }
1373    if b > r && b > g {
1374        return Some(if is_bright {
1375            Color::LightBlue
1376        } else {
1377            Color::Blue
1378        });
1379    }
1380    if r > 150 && g > 150 && b < 100 {
1381        return Some(Color::Yellow);
1382    }
1383    if r > 150 && b > 150 && g < 100 {
1384        return Some(Color::Magenta);
1385    }
1386    if g > 150 && b > 150 && r < 100 {
1387        return Some(Color::Cyan);
1388    }
1389    if r > 200 && g > 200 && b > 200 {
1390        return Some(Color::White);
1391    }
1392    if r > 100 && g > 100 && b > 100 {
1393        return Some(Color::Gray);
1394    }
1395    Some(Color::DarkGray)
1396}
1397
1398// ---------------------------------------------------------------------------
1399// Global theme state
1400// ---------------------------------------------------------------------------
1401
1402fn active_theme() -> std::sync::RwLockReadGuard<'static, ThemeDef> {
1403    THEME
1404        .get_or_init(|| RwLock::new(ThemeDef::purple()))
1405        .read()
1406        .unwrap_or_else(|e| e.into_inner())
1407}
1408
1409pub fn set_theme(theme: ThemeDef) {
1410    let lock = THEME.get_or_init(|| RwLock::new(ThemeDef::purple()));
1411    *lock.write().unwrap_or_else(|e| e.into_inner()) = theme;
1412}
1413
1414pub fn current_theme() -> ThemeDef {
1415    active_theme().clone()
1416}
1417
1418pub fn color_mode() -> u8 {
1419    COLOR_MODE.load(Ordering::Acquire)
1420}
1421
1422/// Internal alias for color_mode().
1423fn mode() -> u8 {
1424    COLOR_MODE.load(Ordering::Acquire)
1425}
1426
1427/// True when the terminal is expected to honour SGR 58 (colored underline).
1428/// Falls back to false on known legacy terminals so styles can pick a
1429/// non-styled-underline alternative. Detected once in `init()`.
1430pub fn supports_colored_underline() -> bool {
1431    COLORED_UNDERLINE.load(Ordering::Acquire) != 0
1432}
1433
1434/// Pure decision: given a `TERM_PROGRAM` value, does this terminal honour
1435/// styled underlines (SGR 58/59)? Conservative default is `true`; only known
1436/// holdouts opt out. Kept separate from env reads so it stays unit-testable.
1437fn detects_colored_underline(term_program: Option<&str>) -> bool {
1438    // macOS Terminal (Terminal.app) does not implement styled underlines.
1439    !matches!(term_program, Some("Apple_Terminal"))
1440}
1441
1442/// Read the env once and write the detected cap flags into the atomics.
1443fn detect_terminal_caps() {
1444    let term_program = std::env::var("TERM_PROGRAM").ok();
1445    let supports = detects_colored_underline(term_program.as_deref());
1446    COLORED_UNDERLINE.store(u8::from(supports), Ordering::Release);
1447}
1448
1449/// Initialize theme settings. Call once at startup.
1450pub fn init() {
1451    detect_terminal_caps();
1452    if std::env::var_os("NO_COLOR").is_some() {
1453        COLOR_MODE.store(0, Ordering::Release);
1454        set_theme(ThemeDef::no_color());
1455        return;
1456    }
1457    if std::env::var("COLORTERM")
1458        .map(|v| v == "truecolor" || v == "24bit")
1459        .unwrap_or(false)
1460    {
1461        COLOR_MODE.store(2, Ordering::Release);
1462    }
1463    if let Some(name) = crate::preferences::load_theme() {
1464        if let Some(theme) = ThemeDef::find_builtin(&name) {
1465            set_theme(theme);
1466        } else {
1467            let custom = ThemeDef::load_custom();
1468            if let Some(theme) = custom
1469                .into_iter()
1470                .find(|t| t.name.eq_ignore_ascii_case(&name))
1471            {
1472                set_theme(theme);
1473            }
1474        }
1475    }
1476}
1477
1478#[cfg(test)]
1479pub(crate) fn init_with_mode(m: u8) {
1480    COLOR_MODE.store(m, Ordering::Release);
1481    let _ = THEME.get_or_init(|| RwLock::new(ThemeDef::purple()));
1482}
1483
1484#[cfg(test)]
1485pub(crate) fn set_colored_underline_support(v: bool) {
1486    COLORED_UNDERLINE.store(u8::from(v), Ordering::Release);
1487}
1488
1489// ---------------------------------------------------------------------------
1490// Data-driven public style functions (preserving exact existing signatures)
1491// ---------------------------------------------------------------------------
1492
1493/// Brand badge: purple background with white text. The single splash of color.
1494/// Truecolor: #9333EA purple bg. ANSI 16: Magenta bg. NO_COLOR: REVERSED.
1495/// Removes DIM so border_style doesn't leak through ratatui's Style::patch().
1496pub fn brand_badge() -> Style {
1497    let m = mode();
1498    let t = active_theme();
1499    if m == 0 {
1500        Style::default()
1501            .add_modifier(Modifier::BOLD | Modifier::REVERSED)
1502            .remove_modifier(Modifier::DIM)
1503    } else {
1504        let mut style = t.selected_fg.to_style(m);
1505        style = match m {
1506            2 => {
1507                if let Some(c) = t.badge.truecolor {
1508                    style.bg(c)
1509                } else {
1510                    style
1511                }
1512            }
1513            _ => {
1514                if let Some(c) = t.badge.ansi16 {
1515                    style.bg(c)
1516                } else {
1517                    style
1518                }
1519            }
1520        };
1521        if let Some(add) = t.badge.add_modifier {
1522            style = style.add_modifier(add);
1523        }
1524        if let Some(rm) = t.badge.remove_modifier {
1525            style = style.remove_modifier(rm);
1526        }
1527        style
1528    }
1529}
1530
1531/// Brand accent for dialog/popup titles.
1532/// Removes DIM so border_style doesn't leak through ratatui's Style::patch().
1533pub fn brand() -> Style {
1534    Style::default()
1535        .add_modifier(Modifier::BOLD)
1536        .remove_modifier(Modifier::DIM)
1537}
1538
1539/// Active tab marker for the top navigation bar.
1540///
1541/// Modern terminals (Ghostty, iTerm2 3.4+, WezTerm, Kitty, recent VTE):
1542/// bold white label with a brand-coloured underline via SGR 58. Letters stay
1543/// white so the active tab reads as a label, not as a second brand splash.
1544///
1545/// Legacy terminals without SGR 58 (macOS Terminal): bold white label with
1546/// the basic SGR 4 underline only. The underline inherits the foreground
1547/// (white) because Terminal.app cannot decouple underline colour from fg.
1548/// Brand accent on the underline is sacrificed; the label stays white so
1549/// it does not visually compete with the solid `purple` brand badge.
1550pub fn nav_active() -> Style {
1551    let mut style = bold().add_modifier(Modifier::UNDERLINED);
1552    if !supports_colored_underline() {
1553        return style;
1554    }
1555    let m = mode();
1556    let accent_color = match m {
1557        2 => active_theme().accent.truecolor,
1558        1 => active_theme().accent.ansi16,
1559        _ => None,
1560    };
1561    if let Some(c) = accent_color {
1562        style = style.underline_color(c);
1563    }
1564    style
1565}
1566
1567/// Structural border style for overlay frames and dividers. Reads the
1568/// `border` colour slot, which carries the DIM modifier on most themes so
1569/// overlay frames recede behind their content. Use for borders, dividers
1570/// and any other "dim chrome" that should not steal attention.
1571///
1572/// Distinct from `accent()` and `accent_bold()`: those paint with the brand
1573/// accent colour (purple by default), while this helper paints the dim
1574/// border tone. Earlier releases conflated the two under the `accent` name
1575/// and rendered focus cursors invisibly on the default theme; the rename to
1576/// `border_dim()` plus a new accent-slot-backed `accent()` fixes that.
1577pub fn border_dim() -> Style {
1578    active_theme().border.to_style(mode())
1579}
1580
1581/// Brand-coloured emphasis without BOLD. Reads the `accent` colour slot so
1582/// it renders in the active theme's brand colour (purple on the default
1583/// theme). Use for in-content cursor indicators, focused tag spans and
1584/// any non-bold accent emphasis. Pair with `accent_bold()` when the same
1585/// content should also bold (overlay titles, focused selection, reference
1586/// keys in help text).
1587pub fn accent() -> Style {
1588    active_theme().accent.to_style(mode())
1589}
1590
1591/// Keybinding keys in footer/help. Brand accent slot with BOLD.
1592pub fn accent_bold() -> Style {
1593    let mut style = active_theme().accent.to_style(mode());
1594    style = style.add_modifier(Modifier::BOLD);
1595    style
1596}
1597
1598/// Search match highlight.
1599pub fn highlight_bold() -> Style {
1600    active_theme().highlight.to_style(mode())
1601}
1602
1603/// Footer keycap style: background matches the dim border tone.
1604/// Truecolor: explicit gray bg matching typical DIM rendering.
1605/// ANSI 16: DarkGray bg approximates DIM borders.
1606/// NO_COLOR: REVERSED fallback.
1607pub fn footer_key() -> Style {
1608    let m = mode();
1609    if m == 0 {
1610        return Style::default().add_modifier(Modifier::REVERSED);
1611    }
1612    let t = active_theme();
1613    let mut style = t.footer_key_fg.to_style(m);
1614    style = match m {
1615        2 => {
1616            if let Some(c) = t.footer_key.truecolor {
1617                style.bg(c)
1618            } else {
1619                style
1620            }
1621        }
1622        _ => {
1623            if let Some(c) = t.footer_key.ansi16 {
1624                style.bg(c)
1625            } else {
1626                style
1627            }
1628        }
1629    };
1630    style
1631}
1632
1633/// Muted/secondary text.
1634pub fn muted() -> Style {
1635    active_theme().fg_muted.to_style(mode())
1636}
1637
1638/// Section headers (help overlay, host detail).
1639pub fn section_header() -> Style {
1640    active_theme().fg_bold.to_style(mode())
1641}
1642
1643/// Error message. Red when color is available.
1644pub fn error() -> Style {
1645    active_theme().error.to_style(mode())
1646}
1647
1648/// Success message. Green when color is available.
1649pub fn success() -> Style {
1650    active_theme().success.to_style(mode())
1651}
1652
1653/// Style for online status dot. Three urgency tiers:
1654/// NO_COLOR = normal (no modifier), ANSI 16 = Green + DIM, truecolor = muted green + DIM.
1655pub fn online_dot() -> Style {
1656    active_theme().success_dim.to_style(mode())
1657}
1658
1659/// Style for stable healthy attributes that are neither "live now"
1660/// (`online_dot`) nor a positive action outcome (`success`). Use for
1661/// "passphrase encrypted", "agent loaded", "TTL > threshold" and other
1662/// settled-good states that the user does not need to act on.
1663///
1664/// Currently shares the `success_dim` colour slot with `online_dot()` so
1665/// the visual tone is identical; the distinction lives in intent, not in
1666/// pixels. Centralising the use through this helper lets a future theme
1667/// or NO_COLOR fallback adjust the two tiers independently without a
1668/// codebase-wide sweep.
1669pub fn healthy() -> Style {
1670    active_theme().success_dim.to_style(mode())
1671}
1672
1673/// Style for the active-tunnel indicator on host-list rows. Brand accent
1674/// colour without BOLD. Named so the call sites read as intent ("this row
1675/// has an active tunnel"), not as pixel choice.
1676pub fn tunnel_active() -> Style {
1677    active_theme().accent.to_style(mode())
1678}
1679
1680/// Style for user-defined tag spans rendered in the host list and detail
1681/// panel. Brand accent colour without BOLD. Named so the call sites read
1682/// as intent ("this token is a user tag") rather than as styling choice.
1683pub fn tag_user() -> Style {
1684    active_theme().accent.to_style(mode())
1685}
1686
1687/// Breathing variant of `online_dot()` for per-host indicators on the host
1688/// list. Cycles through three states over ~2.4s (30 ticks at 80ms) to give
1689/// reachable hosts a subtle "alive" pulse without the constant attention
1690/// pull of a blinking glyph or moving shape:
1691///
1692/// - trough: `success_dim` (current static look)
1693/// - mid:    `success` (regular green)
1694/// - peak:   `success` + BOLD
1695///
1696/// Sine-driven so transitions are gradual rather than discrete blinks. At
1697/// `tick = 0` the cycle starts at mid (Regular green), so a freshly-rendered
1698/// frame in tests/visual regressions is reproducible. Down/error/checking
1699/// hosts deliberately stay static — the contrast is the signal.
1700pub fn online_dot_pulsing(tick: u64) -> Style {
1701    use ratatui::style::Modifier;
1702    const PERIOD: u64 = 30;
1703    let phase = (tick % PERIOD) as f32 * std::f32::consts::TAU / PERIOD as f32;
1704    // Map sin(-1..1) to alpha 0..1, then to brightness 0.40..1.00 so even
1705    // the trough remains clearly visible (60-100% alpha range from the
1706    // design brief). Using a smooth sine — never thresholded — eliminates
1707    // the discrete "blink" you get when stepping between BOLD/Regular/DIM
1708    // modifiers in ANSI 16.
1709    let alpha = 0.40 + 0.60 * (phase.sin() * 0.5 + 0.5);
1710    let m = mode();
1711    if m == 2 {
1712        // Truecolor: lerp the success_dim RGB toward white by `alpha`.
1713        // success and success_dim share the same base hue per theme so we
1714        // can read either; we read `success` because it is the canonical
1715        // "fully alive" colour at alpha=1.0.
1716        let base = active_theme().success.truecolor;
1717        if let Some(ratatui::style::Color::Rgb(r, g, b)) = base {
1718            let lerp = |c: u8| -> u8 {
1719                let f = c as f32 / 255.0;
1720                // Lerp between a dimmed version (0.55 of base) and the
1721                // base itself, controlled by alpha. Keeps the hue, only
1722                // varies brightness.
1723                let dim_f = f * 0.55;
1724                ((dim_f + (f - dim_f) * alpha).clamp(0.0, 1.0) * 255.0).round() as u8
1725            };
1726            return ratatui::style::Style::default().fg(ratatui::style::Color::Rgb(
1727                lerp(r),
1728                lerp(g),
1729                lerp(b),
1730            ));
1731        }
1732        // Fallthrough if theme has no truecolor value (NoColor theme).
1733    }
1734    // ANSI 16 / NO_COLOR fallback: discrete 3-state cycle on modifiers.
1735    // Less smooth than truecolor lerp but the only option when we cannot
1736    // address sub-palette brightness. Most users on modern terminals run
1737    // in truecolor mode and get the smooth path above.
1738    if alpha > 0.85 {
1739        active_theme().success.to_style(m)
1740    } else if alpha < 0.55 {
1741        active_theme().success_dim.to_style(m)
1742    } else {
1743        active_theme()
1744            .success
1745            .to_style(m)
1746            .remove_modifier(Modifier::BOLD)
1747    }
1748}
1749
1750/// Warning message. Yellow/amber when color is available.
1751pub fn warning() -> Style {
1752    active_theme().warning.to_style(mode())
1753}
1754
1755/// Toast border for success/confirmation messages.
1756pub fn toast_border_success() -> Style {
1757    let m = mode();
1758    let t = active_theme();
1759    let mut style = t.success.to_style(m);
1760    if m == 0 {
1761        style = Style::default().add_modifier(Modifier::BOLD);
1762    }
1763    style
1764}
1765
1766/// Toast border for error messages (red, bold-on-NO_COLOR).
1767pub fn toast_border_error() -> Style {
1768    let m = mode();
1769    let t = active_theme();
1770    let mut style = t.error.to_style(m);
1771    if m == 0 {
1772        style = Style::default().add_modifier(Modifier::BOLD);
1773    }
1774    style
1775}
1776
1777/// Toast border for warning messages (yellow, bold-on-NO_COLOR).
1778/// Visually distinct from `toast_border_error` so warnings (recoverable)
1779/// and errors (require acknowledgement) can be told apart at a glance.
1780pub fn toast_border_warning() -> Style {
1781    let m = mode();
1782    let t = active_theme();
1783    let mut style = t.warning.to_style(m);
1784    if m == 0 {
1785        style = Style::default().add_modifier(Modifier::BOLD);
1786    }
1787    style
1788}
1789
1790/// Danger action key (delete confirmation). Red when color is available.
1791pub fn danger() -> Style {
1792    active_theme().error.to_style(mode())
1793}
1794
1795/// Default border (unfocused).
1796pub fn border() -> Style {
1797    active_theme().border.to_style(mode())
1798}
1799
1800/// Version number in help overlay. Purple foreground.
1801pub fn version() -> Style {
1802    active_theme().accent.to_style(mode())
1803}
1804
1805/// Search-mode border. Purple to signal active filter state.
1806pub fn border_search() -> Style {
1807    active_theme().border_active.to_style(mode())
1808}
1809
1810/// Accent colour for the trailing `.` of the `purple.` logotype in overlay
1811/// headers. Intentionally distinct from `accent_bold` so the dot echoes
1812/// the cyan accent from the landing-page hero.
1813pub fn logo_dot() -> Style {
1814    active_theme().logo_dot.to_style(mode())
1815}
1816
1817/// Selected item in a list. Purple highlight for brand consistency.
1818pub fn selected_row() -> Style {
1819    let m = mode();
1820    let t = active_theme();
1821    if m == 0 {
1822        return Style::default()
1823            .add_modifier(Modifier::REVERSED)
1824            .remove_modifier(Modifier::DIM);
1825    }
1826    let mut style = t.selected_fg.to_style(m);
1827    style = match m {
1828        2 => {
1829            if let Some(c) = t.accent_bg.truecolor {
1830                style.bg(c)
1831            } else {
1832                style
1833            }
1834        }
1835        _ => {
1836            if let Some(c) = t.accent_bg.ansi16 {
1837                style.bg(c)
1838            } else {
1839                style
1840            }
1841        }
1842    };
1843    if let Some(add) = t.accent_bg.add_modifier {
1844        style = style.add_modifier(add);
1845    }
1846    if let Some(rm) = t.accent_bg.remove_modifier {
1847        style = style.remove_modifier(rm);
1848    }
1849    style
1850}
1851
1852/// Danger border (delete dialog). Red when color is available.
1853pub fn border_danger() -> Style {
1854    active_theme().error.to_style(mode())
1855}
1856
1857/// Bold text (labels, emphasis).
1858pub fn bold() -> Style {
1859    active_theme().fg_bold.to_style(mode())
1860}
1861
1862/// Update available badge. Purple background to stand out in the title bar.
1863pub fn update_badge() -> Style {
1864    brand_badge()
1865}
1866
1867// ---------------------------------------------------------------------------
1868// Tests
1869// ---------------------------------------------------------------------------
1870
1871#[cfg(test)]
1872static TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1873
1874#[cfg(test)]
1875#[path = "theme_tests.rs"]
1876mod tests;