Skip to main content

piw/theme/
mod.rs

1use anyhow::{Context, Result};
2use ratatui::style::Color;
3use serde::Deserialize;
4use std::path::{Path, PathBuf};
5use toml_edit::{value, DocumentMut};
6
7pub const THEME_NAMES: &[&str] = &[
8    "catppuccin",
9    "catppuccin-latte",
10    "terminal",
11    "tokyo-night",
12    "tokyo-night-day",
13    "dracula",
14    "nord",
15    "gruvbox",
16    "gruvbox-light",
17    "one-dark",
18    "one-light",
19    "solarized",
20    "solarized-light",
21    "kanagawa",
22    "kanagawa-lotus",
23    "rose-pine",
24    "rose-pine-dawn",
25    "vesper",
26];
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Palette {
30    pub name: String,
31    pub app_bg: Color,
32    pub panel_bg: Color,
33    pub canvas_bg: Color,
34    pub node_bg: Color,
35    pub node_focus_bg: Color,
36    pub selection_bg: Color,
37    pub surface_dim: Color,
38    pub border: Color,
39    pub border_focused: Color,
40    pub text: Color,
41    pub subtext: Color,
42    pub muted: Color,
43    pub accent: Color,
44    pub replay_focus: Color,
45    pub running: Color,
46    pub success: Color,
47    pub warning: Color,
48    pub error: Color,
49    pub timed_out: Color,
50    pub cancelled: Color,
51    pub branch: Color,
52    pub user: Color,
53    pub assistant: Color,
54    pub tool: Color,
55    pub timeline_track: Color,
56    pub timeline_fill: Color,
57    pub timeline_thumb: Color,
58}
59
60#[allow(clippy::too_many_arguments)]
61fn palette(
62    name: &str,
63    app_bg: Color,
64    panel_bg: Color,
65    canvas_bg: Color,
66    surface0: Color,
67    surface1: Color,
68    overlay0: Color,
69    overlay1: Color,
70    text: Color,
71    subtext: Color,
72    mauve: Color,
73    green: Color,
74    yellow: Color,
75    red: Color,
76    blue: Color,
77    teal: Color,
78    peach: Color,
79) -> Palette {
80    Palette {
81        name: name.to_string(),
82        app_bg,
83        panel_bg,
84        canvas_bg,
85        node_bg: surface0,
86        node_focus_bg: surface1,
87        selection_bg: surface0,
88        surface_dim: canvas_bg,
89        border: overlay0,
90        border_focused: blue,
91        text,
92        subtext,
93        muted: overlay1,
94        accent: blue,
95        replay_focus: mauve,
96        running: blue,
97        success: green,
98        warning: yellow,
99        error: red,
100        timed_out: peach,
101        cancelled: mauve,
102        branch: teal,
103        user: teal,
104        assistant: green,
105        tool: yellow,
106        timeline_track: surface0,
107        timeline_fill: blue,
108        timeline_thumb: text,
109    }
110}
111
112fn rgb(r: u8, g: u8, b: u8) -> Color {
113    Color::Rgb(r, g, b)
114}
115
116impl Palette {
117    pub fn catppuccin() -> Self {
118        palette(
119            "catppuccin",
120            rgb(17, 17, 27),
121            rgb(24, 24, 37),
122            rgb(30, 30, 46),
123            rgb(49, 50, 68),
124            rgb(69, 71, 90),
125            rgb(108, 112, 134),
126            rgb(127, 132, 156),
127            rgb(205, 214, 244),
128            rgb(166, 173, 200),
129            rgb(203, 166, 247),
130            rgb(166, 227, 161),
131            rgb(249, 226, 175),
132            rgb(243, 139, 168),
133            rgb(137, 180, 250),
134            rgb(148, 226, 213),
135            rgb(250, 179, 135),
136        )
137    }
138
139    pub fn catppuccin_latte() -> Self {
140        palette(
141            "catppuccin-latte",
142            rgb(220, 224, 232),
143            rgb(239, 241, 245),
144            rgb(230, 233, 239),
145            rgb(204, 208, 218),
146            rgb(188, 192, 204),
147            rgb(156, 160, 176),
148            rgb(140, 143, 161),
149            rgb(76, 79, 105),
150            rgb(108, 111, 133),
151            rgb(136, 57, 239),
152            rgb(64, 160, 43),
153            rgb(223, 142, 29),
154            rgb(210, 15, 57),
155            rgb(30, 102, 245),
156            rgb(23, 146, 153),
157            rgb(254, 100, 11),
158        )
159    }
160
161    pub fn terminal() -> Self {
162        palette(
163            "terminal",
164            Color::Reset,
165            Color::Reset,
166            Color::Reset,
167            Color::DarkGray,
168            Color::Gray,
169            Color::DarkGray,
170            Color::Gray,
171            Color::Reset,
172            Color::Gray,
173            Color::Magenta,
174            Color::Green,
175            Color::Yellow,
176            Color::LightRed,
177            Color::Blue,
178            Color::Cyan,
179            Color::LightYellow,
180        )
181    }
182
183    pub fn tokyo_night() -> Self {
184        palette(
185            "tokyo-night",
186            rgb(22, 22, 30),
187            rgb(26, 27, 38),
188            rgb(26, 27, 38),
189            rgb(36, 40, 59),
190            rgb(65, 72, 104),
191            rgb(86, 95, 137),
192            rgb(105, 113, 150),
193            rgb(192, 202, 245),
194            rgb(169, 177, 214),
195            rgb(187, 154, 247),
196            rgb(158, 206, 106),
197            rgb(224, 175, 104),
198            rgb(247, 118, 142),
199            rgb(122, 162, 247),
200            rgb(125, 207, 255),
201            rgb(255, 158, 100),
202        )
203    }
204
205    pub fn tokyo_night_day() -> Self {
206        palette(
207            "tokyo-night-day",
208            rgb(210, 211, 218),
209            rgb(225, 226, 231),
210            rgb(225, 226, 231),
211            rgb(196, 200, 218),
212            rgb(168, 174, 203),
213            rgb(137, 144, 179),
214            rgb(104, 112, 154),
215            rgb(55, 96, 191),
216            rgb(97, 114, 176),
217            rgb(120, 71, 189),
218            rgb(88, 117, 57),
219            rgb(140, 108, 62),
220            rgb(245, 42, 101),
221            rgb(46, 125, 233),
222            rgb(17, 140, 116),
223            rgb(177, 92, 0),
224        )
225    }
226
227    pub fn dracula() -> Self {
228        palette(
229            "dracula",
230            rgb(30, 31, 40),
231            rgb(40, 42, 54),
232            rgb(40, 42, 54),
233            rgb(68, 71, 90),
234            rgb(98, 114, 164),
235            rgb(98, 114, 164),
236            rgb(130, 140, 180),
237            rgb(248, 248, 242),
238            rgb(210, 210, 220),
239            rgb(255, 121, 198),
240            rgb(80, 250, 123),
241            rgb(241, 250, 140),
242            rgb(255, 85, 85),
243            rgb(139, 233, 253),
244            rgb(139, 233, 253),
245            rgb(255, 184, 108),
246        )
247    }
248
249    pub fn nord() -> Self {
250        palette(
251            "nord",
252            rgb(36, 41, 51),
253            rgb(46, 52, 64),
254            rgb(46, 52, 64),
255            rgb(59, 66, 82),
256            rgb(67, 76, 94),
257            rgb(76, 86, 106),
258            rgb(100, 110, 130),
259            rgb(236, 239, 244),
260            rgb(216, 222, 233),
261            rgb(180, 142, 173),
262            rgb(163, 190, 140),
263            rgb(235, 203, 139),
264            rgb(191, 97, 106),
265            rgb(129, 161, 193),
266            rgb(143, 188, 187),
267            rgb(208, 135, 112),
268        )
269    }
270
271    pub fn gruvbox() -> Self {
272        palette(
273            "gruvbox",
274            rgb(29, 32, 33),
275            rgb(40, 40, 40),
276            rgb(40, 40, 40),
277            rgb(60, 56, 54),
278            rgb(80, 73, 69),
279            rgb(146, 131, 116),
280            rgb(168, 153, 132),
281            rgb(235, 219, 178),
282            rgb(213, 196, 161),
283            rgb(211, 134, 155),
284            rgb(184, 187, 38),
285            rgb(250, 189, 47),
286            rgb(251, 73, 52),
287            rgb(131, 165, 152),
288            rgb(142, 192, 124),
289            rgb(254, 128, 25),
290        )
291    }
292
293    pub fn gruvbox_light() -> Self {
294        palette(
295            "gruvbox-light",
296            rgb(242, 229, 188),
297            rgb(251, 241, 199),
298            rgb(251, 241, 199),
299            rgb(235, 219, 178),
300            rgb(213, 196, 161),
301            rgb(146, 131, 116),
302            rgb(124, 111, 100),
303            rgb(60, 56, 54),
304            rgb(80, 73, 69),
305            rgb(143, 63, 113),
306            rgb(121, 116, 14),
307            rgb(181, 118, 20),
308            rgb(157, 0, 6),
309            rgb(7, 102, 120),
310            rgb(66, 123, 88),
311            rgb(175, 58, 3),
312        )
313    }
314
315    pub fn one_dark() -> Self {
316        palette(
317            "one-dark",
318            rgb(33, 37, 43),
319            rgb(40, 44, 52),
320            rgb(40, 44, 52),
321            rgb(44, 49, 58),
322            rgb(62, 68, 81),
323            rgb(92, 99, 112),
324            rgb(115, 122, 135),
325            rgb(171, 178, 191),
326            rgb(150, 156, 168),
327            rgb(198, 120, 221),
328            rgb(152, 195, 121),
329            rgb(229, 192, 123),
330            rgb(224, 108, 117),
331            rgb(97, 175, 239),
332            rgb(86, 182, 194),
333            rgb(209, 154, 102),
334        )
335    }
336
337    pub fn one_light() -> Self {
338        palette(
339            "one-light",
340            rgb(245, 245, 246),
341            rgb(250, 250, 250),
342            rgb(250, 250, 250),
343            rgb(240, 240, 241),
344            rgb(229, 229, 230),
345            rgb(160, 161, 167),
346            rgb(104, 107, 119),
347            rgb(56, 58, 66),
348            rgb(104, 107, 119),
349            rgb(166, 38, 164),
350            rgb(80, 161, 79),
351            rgb(193, 132, 1),
352            rgb(228, 86, 73),
353            rgb(64, 120, 242),
354            rgb(1, 132, 188),
355            rgb(152, 104, 1),
356        )
357    }
358
359    pub fn solarized() -> Self {
360        palette(
361            "solarized",
362            rgb(0, 36, 45),
363            rgb(0, 43, 54),
364            rgb(0, 43, 54),
365            rgb(7, 54, 66),
366            rgb(88, 110, 117),
367            rgb(88, 110, 117),
368            rgb(101, 123, 131),
369            rgb(147, 161, 161),
370            rgb(131, 148, 150),
371            rgb(211, 54, 130),
372            rgb(133, 153, 0),
373            rgb(181, 137, 0),
374            rgb(220, 50, 47),
375            rgb(38, 139, 210),
376            rgb(42, 161, 152),
377            rgb(203, 75, 22),
378        )
379    }
380
381    pub fn solarized_light() -> Self {
382        palette(
383            "solarized-light",
384            rgb(238, 232, 213),
385            rgb(253, 246, 227),
386            rgb(253, 246, 227),
387            rgb(238, 232, 213),
388            rgb(147, 161, 161),
389            rgb(147, 161, 161),
390            rgb(88, 110, 117),
391            rgb(101, 123, 131),
392            rgb(131, 148, 150),
393            rgb(211, 54, 130),
394            rgb(133, 153, 0),
395            rgb(181, 137, 0),
396            rgb(220, 50, 47),
397            rgb(38, 139, 210),
398            rgb(42, 161, 152),
399            rgb(203, 75, 22),
400        )
401    }
402
403    pub fn kanagawa() -> Self {
404        palette(
405            "kanagawa",
406            rgb(22, 22, 29),
407            rgb(31, 31, 40),
408            rgb(31, 31, 40),
409            rgb(42, 42, 55),
410            rgb(54, 54, 70),
411            rgb(114, 113, 105),
412            rgb(135, 134, 125),
413            rgb(220, 215, 186),
414            rgb(200, 195, 170),
415            rgb(149, 127, 184),
416            rgb(118, 148, 106),
417            rgb(192, 163, 110),
418            rgb(195, 64, 67),
419            rgb(126, 156, 216),
420            rgb(127, 180, 202),
421            rgb(255, 160, 102),
422        )
423    }
424
425    pub fn kanagawa_lotus() -> Self {
426        palette(
427            "kanagawa-lotus",
428            rgb(213, 206, 163),
429            rgb(242, 236, 188),
430            rgb(242, 236, 188),
431            rgb(220, 213, 172),
432            rgb(201, 203, 209),
433            rgb(160, 156, 172),
434            rgb(138, 137, 128),
435            rgb(84, 84, 100),
436            rgb(67, 67, 108),
437            rgb(98, 76, 131),
438            rgb(111, 137, 78),
439            rgb(119, 113, 63),
440            rgb(200, 64, 83),
441            rgb(77, 105, 155),
442            rgb(78, 140, 162),
443            rgb(204, 109, 0),
444        )
445    }
446
447    pub fn rose_pine() -> Self {
448        palette(
449            "rose-pine",
450            rgb(18, 16, 27),
451            rgb(25, 23, 36),
452            rgb(25, 23, 36),
453            rgb(31, 29, 46),
454            rgb(38, 35, 58),
455            rgb(110, 106, 134),
456            rgb(144, 140, 170),
457            rgb(224, 222, 244),
458            rgb(200, 197, 220),
459            rgb(196, 167, 231),
460            rgb(49, 116, 143),
461            rgb(246, 193, 119),
462            rgb(235, 111, 146),
463            rgb(49, 116, 143),
464            rgb(156, 207, 216),
465            rgb(234, 154, 151),
466        )
467    }
468
469    pub fn rose_pine_dawn() -> Self {
470        palette(
471            "rose-pine-dawn",
472            rgb(242, 233, 225),
473            rgb(250, 244, 237),
474            rgb(250, 244, 237),
475            rgb(242, 233, 225),
476            rgb(255, 250, 243),
477            rgb(152, 147, 165),
478            rgb(121, 117, 147),
479            rgb(70, 66, 97),
480            rgb(121, 117, 147),
481            rgb(144, 122, 169),
482            rgb(40, 105, 131),
483            rgb(234, 157, 52),
484            rgb(180, 99, 122),
485            rgb(40, 105, 131),
486            rgb(86, 148, 159),
487            rgb(215, 130, 126),
488        )
489    }
490
491    pub fn vesper() -> Self {
492        palette(
493            "vesper",
494            rgb(16, 16, 16),
495            rgb(26, 26, 26),
496            rgb(26, 26, 26),
497            rgb(35, 35, 35),
498            rgb(40, 40, 40),
499            rgb(92, 92, 92),
500            rgb(126, 126, 126),
501            rgb(255, 255, 255),
502            rgb(160, 160, 160),
503            rgb(255, 209, 168),
504            rgb(153, 255, 228),
505            rgb(255, 199, 153),
506            rgb(255, 128, 128),
507            rgb(176, 176, 176),
508            rgb(102, 221, 204),
509            rgb(255, 199, 153),
510        )
511    }
512
513    pub fn from_name(name: &str) -> Option<Self> {
514        match normalize_name(name).as_str() {
515            "catppuccin" | "catppuccin-mocha" => Some(Self::catppuccin()),
516            "catppuccin-latte" | "latte" | "light" => Some(Self::catppuccin_latte()),
517            "terminal" => Some(Self::terminal()),
518            "tokyo-night" | "tokyonight" => Some(Self::tokyo_night()),
519            "tokyo-night-day" | "tokyo-day" | "tokyonight-day" => Some(Self::tokyo_night_day()),
520            "dracula" => Some(Self::dracula()),
521            "nord" => Some(Self::nord()),
522            "gruvbox" | "gruvbox-dark" => Some(Self::gruvbox()),
523            "gruvbox-light" => Some(Self::gruvbox_light()),
524            "one-dark" | "onedark" => Some(Self::one_dark()),
525            "one-light" | "onelight" => Some(Self::one_light()),
526            "solarized" | "solarized-dark" => Some(Self::solarized()),
527            "solarized-light" => Some(Self::solarized_light()),
528            "kanagawa" => Some(Self::kanagawa()),
529            "kanagawa-lotus" | "lotus" => Some(Self::kanagawa_lotus()),
530            "rose-pine" | "rosepine" => Some(Self::rose_pine()),
531            "rose-pine-dawn" | "rosepine-dawn" | "dawn" => Some(Self::rose_pine_dawn()),
532            "vesper" => Some(Self::vesper()),
533            _ => None,
534        }
535    }
536
537    fn apply_overrides(&mut self, custom: &CustomTheme, diagnostics: &mut Vec<String>) {
538        macro_rules! set_color {
539            ($field:ident) => {
540                if let Some(value) = &custom.$field {
541                    match parse_color(value) {
542                        Some(color) => self.$field = color,
543                        None => diagnostics.push(format!(
544                            "invalid theme.custom.{} color {:?}; keeping base value",
545                            stringify!($field),
546                            value
547                        )),
548                    }
549                }
550            };
551        }
552        set_color!(app_bg);
553        set_color!(panel_bg);
554        set_color!(canvas_bg);
555        set_color!(node_bg);
556        set_color!(node_focus_bg);
557        set_color!(selection_bg);
558        set_color!(surface_dim);
559        set_color!(border);
560        set_color!(border_focused);
561        set_color!(text);
562        set_color!(subtext);
563        set_color!(muted);
564        set_color!(accent);
565        set_color!(replay_focus);
566        set_color!(running);
567        set_color!(success);
568        set_color!(warning);
569        set_color!(error);
570        set_color!(timed_out);
571        set_color!(cancelled);
572        set_color!(branch);
573        set_color!(user);
574        set_color!(assistant);
575        set_color!(tool);
576        set_color!(timeline_track);
577        set_color!(timeline_fill);
578        set_color!(timeline_thumb);
579    }
580}
581
582#[derive(Debug, Clone, Default, Deserialize)]
583#[serde(default)]
584pub struct ViewerConfig {
585    pub theme: ThemeConfig,
586    pub ui: UiConfig,
587}
588
589#[derive(Debug, Clone, Default, Deserialize)]
590#[serde(default)]
591pub struct UiConfig {
592    pub sidebar_width: Option<u16>,
593    pub inspector_height: Option<u16>,
594}
595
596#[derive(Debug, Clone, Default, Deserialize)]
597#[serde(default)]
598pub struct ThemeConfig {
599    pub name: Option<String>,
600    pub auto_switch: bool,
601    pub dark_name: Option<String>,
602    pub light_name: Option<String>,
603    pub custom: CustomTheme,
604}
605
606#[derive(Debug, Clone, Default, Deserialize)]
607#[serde(default)]
608pub struct CustomTheme {
609    pub app_bg: Option<String>,
610    pub panel_bg: Option<String>,
611    pub canvas_bg: Option<String>,
612    pub node_bg: Option<String>,
613    pub node_focus_bg: Option<String>,
614    pub selection_bg: Option<String>,
615    pub surface_dim: Option<String>,
616    pub border: Option<String>,
617    pub border_focused: Option<String>,
618    pub text: Option<String>,
619    pub subtext: Option<String>,
620    pub muted: Option<String>,
621    pub accent: Option<String>,
622    pub replay_focus: Option<String>,
623    pub running: Option<String>,
624    pub success: Option<String>,
625    pub warning: Option<String>,
626    pub error: Option<String>,
627    pub timed_out: Option<String>,
628    pub cancelled: Option<String>,
629    pub branch: Option<String>,
630    pub user: Option<String>,
631    pub assistant: Option<String>,
632    pub tool: Option<String>,
633    pub timeline_track: Option<String>,
634    pub timeline_fill: Option<String>,
635    pub timeline_thumb: Option<String>,
636}
637
638#[derive(Debug, Clone)]
639pub struct ResolvedTheme {
640    pub palette: Palette,
641    pub config: ThemeConfig,
642    pub ui: UiConfig,
643    pub diagnostics: Vec<String>,
644    pub config_path: PathBuf,
645}
646
647pub fn config_path() -> PathBuf {
648    if let Some(path) = std::env::var_os("PIW_CONFIG_PATH") {
649        return PathBuf::from(path);
650    }
651    if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME") {
652        return PathBuf::from(dir).join("piw").join("config.toml");
653    }
654    std::env::home_dir()
655        .unwrap_or_else(|| PathBuf::from("."))
656        .join(".config")
657        .join("piw")
658        .join("config.toml")
659}
660
661pub fn palette_with_config(name: &str, config: &ThemeConfig) -> (Palette, Vec<String>) {
662    let mut diagnostics = Vec::new();
663    let mut palette = Palette::from_name(name).unwrap_or_else(|| {
664        diagnostics.push(format!(
665            "unknown theme {:?}; using catppuccin",
666            sanitize_diagnostic(name)
667        ));
668        Palette::catppuccin()
669    });
670    palette.apply_overrides(&config.custom, &mut diagnostics);
671    (palette, diagnostics)
672}
673
674pub fn resolve(cli_theme: Option<&str>) -> ResolvedTheme {
675    let path = config_path();
676    let mut diagnostics = Vec::new();
677    let viewer_config = match std::fs::read_to_string(&path) {
678        Ok(content) => match toml::from_str::<ViewerConfig>(&content) {
679            Ok(config) => config,
680            Err(error) => {
681                diagnostics.push(format!("failed to parse {}: {error}", path.display()));
682                ViewerConfig::default()
683            }
684        },
685        Err(error) if error.kind() == std::io::ErrorKind::NotFound => ViewerConfig::default(),
686        Err(error) => {
687            diagnostics.push(format!("failed to read {}: {error}", path.display()));
688            ViewerConfig::default()
689        }
690    };
691    let config = viewer_config.theme;
692    let explicit = cli_theme
693        .map(str::to_owned)
694        .or_else(|| std::env::var("PIW_THEME").ok());
695    let requested = explicit.unwrap_or_else(|| {
696        if config.auto_switch {
697            match detected_appearance() {
698                Some(HostAppearance::Dark) => config
699                    .dark_name
700                    .clone()
701                    .unwrap_or_else(|| "catppuccin".to_string()),
702                Some(HostAppearance::Light) => config
703                    .light_name
704                    .clone()
705                    .unwrap_or_else(|| "catppuccin-latte".to_string()),
706                None => config
707                    .name
708                    .clone()
709                    .unwrap_or_else(|| "catppuccin".to_string()),
710            }
711        } else {
712            config
713                .name
714                .clone()
715                .unwrap_or_else(|| "catppuccin".to_string())
716        }
717    });
718    let (palette, mut palette_diagnostics) = palette_with_config(&requested, &config);
719    diagnostics.append(&mut palette_diagnostics);
720    ResolvedTheme {
721        palette,
722        config,
723        ui: viewer_config.ui,
724        diagnostics,
725        config_path: path,
726    }
727}
728
729pub fn save_theme(path: &Path, name: &str) -> Result<()> {
730    let content = match std::fs::read_to_string(path) {
731        Ok(content) => content,
732        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
733        Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
734    };
735    let mut document = if content.trim().is_empty() {
736        DocumentMut::new()
737    } else {
738        content
739            .parse::<DocumentMut>()
740            .with_context(|| format!("parsing {}", path.display()))?
741    };
742    document["theme"]["name"] = value(name);
743    document["theme"]["auto_switch"] = value(false);
744    if let Some(parent) = path.parent() {
745        std::fs::create_dir_all(parent)
746            .with_context(|| format!("creating {}", parent.display()))?;
747    }
748    let temp = path.with_extension(format!("tmp-{}", std::process::id()));
749    std::fs::write(&temp, document.to_string())
750        .with_context(|| format!("writing {}", temp.display()))?;
751    std::fs::rename(&temp, path).with_context(|| format!("replacing {}", path.display()))?;
752    Ok(())
753}
754
755pub fn save_layout(path: &Path, sidebar_width: u16, inspector_height: Option<u16>) -> Result<()> {
756    let content = match std::fs::read_to_string(path) {
757        Ok(content) => content,
758        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
759        Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
760    };
761    let mut document = if content.trim().is_empty() {
762        DocumentMut::new()
763    } else {
764        content
765            .parse::<DocumentMut>()
766            .with_context(|| format!("parsing {}", path.display()))?
767    };
768    document["ui"]["sidebar_width"] = value(i64::from(sidebar_width));
769    if let Some(height) = inspector_height {
770        document["ui"]["inspector_height"] = value(i64::from(height));
771    }
772    if let Some(parent) = path.parent() {
773        std::fs::create_dir_all(parent)
774            .with_context(|| format!("creating {}", parent.display()))?;
775    }
776    let temp = path.with_extension(format!("tmp-{}", std::process::id()));
777    std::fs::write(&temp, document.to_string())
778        .with_context(|| format!("writing {}", temp.display()))?;
779    std::fs::rename(&temp, path).with_context(|| format!("replacing {}", path.display()))?;
780    Ok(())
781}
782
783pub fn parse_color(input: &str) -> Option<Color> {
784    let normalized = input.trim().to_ascii_lowercase();
785    match normalized.as_str() {
786        "reset" | "default" | "none" | "transparent" => return Some(Color::Reset),
787        "black" => return Some(Color::Black),
788        "red" => return Some(Color::Red),
789        "green" => return Some(Color::Green),
790        "yellow" => return Some(Color::Yellow),
791        "blue" => return Some(Color::Blue),
792        "magenta" | "purple" => return Some(Color::Magenta),
793        "cyan" => return Some(Color::Cyan),
794        "white" => return Some(Color::White),
795        "gray" | "grey" => return Some(Color::Gray),
796        "darkgray" | "darkgrey" => return Some(Color::DarkGray),
797        "lightred" => return Some(Color::LightRed),
798        "lightgreen" => return Some(Color::LightGreen),
799        "lightyellow" => return Some(Color::LightYellow),
800        "lightblue" => return Some(Color::LightBlue),
801        "lightmagenta" => return Some(Color::LightMagenta),
802        "lightcyan" => return Some(Color::LightCyan),
803        _ => {}
804    }
805    if let Some(hex) = normalized.strip_prefix('#') {
806        if !hex.is_ascii() {
807            return None;
808        }
809        return match hex.len() {
810            3 => {
811                let mut chars = hex.chars();
812                Some(Color::Rgb(
813                    u8::from_str_radix(&chars.next()?.to_string(), 16).ok()? * 17,
814                    u8::from_str_radix(&chars.next()?.to_string(), 16).ok()? * 17,
815                    u8::from_str_radix(&chars.next()?.to_string(), 16).ok()? * 17,
816                ))
817            }
818            6 => Some(Color::Rgb(
819                u8::from_str_radix(&hex[0..2], 16).ok()?,
820                u8::from_str_radix(&hex[2..4], 16).ok()?,
821                u8::from_str_radix(&hex[4..6], 16).ok()?,
822            )),
823            _ => None,
824        };
825    }
826    if let Some(body) = normalized
827        .strip_prefix("rgb(")
828        .and_then(|value| value.strip_suffix(')'))
829    {
830        let values = body
831            .split(',')
832            .map(str::trim)
833            .map(str::parse::<u8>)
834            .collect::<std::result::Result<Vec<_>, _>>()
835            .ok()?;
836        if values.len() == 3 {
837            return Some(Color::Rgb(values[0], values[1], values[2]));
838        }
839    }
840    None
841}
842
843#[derive(Debug, Clone, Copy, PartialEq, Eq)]
844enum HostAppearance {
845    Dark,
846    Light,
847}
848
849fn detected_appearance() -> Option<HostAppearance> {
850    if let Ok(value) = std::env::var("PIW_THEME_APPEARANCE") {
851        return match value.trim().to_ascii_lowercase().as_str() {
852            "dark" => Some(HostAppearance::Dark),
853            "light" => Some(HostAppearance::Light),
854            _ => None,
855        };
856    }
857    let background = std::env::var("COLORFGBG")
858        .ok()?
859        .split(';')
860        .next_back()?
861        .parse::<u8>()
862        .ok()?;
863    Some(if matches!(background, 7 | 15) {
864        HostAppearance::Light
865    } else {
866        HostAppearance::Dark
867    })
868}
869
870fn normalize_name(name: &str) -> String {
871    name.trim().to_ascii_lowercase().replace([' ', '_'], "-")
872}
873
874fn sanitize_diagnostic(value: &str) -> String {
875    value
876        .chars()
877        .filter(|character| !character.is_control())
878        .take(80)
879        .collect()
880}
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885    use std::sync::{Mutex, OnceLock};
886
887    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
888        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
889        LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
890    }
891
892    #[test]
893    fn every_builtin_resolves_and_nodes_contrast_with_canvas() {
894        for name in THEME_NAMES {
895            let palette = Palette::from_name(name).expect(name);
896            assert_ne!(palette.node_bg, palette.canvas_bg, "{name}");
897        }
898    }
899
900    #[test]
901    fn parses_supported_color_forms() {
902        assert_eq!(parse_color("#abc"), Some(Color::Rgb(170, 187, 204)));
903        assert_eq!(parse_color("#89b4fa"), Some(Color::Rgb(137, 180, 250)));
904        assert_eq!(parse_color("rgb(1, 2, 3)"), Some(Color::Rgb(1, 2, 3)));
905        assert_eq!(parse_color("reset"), Some(Color::Reset));
906        assert_eq!(parse_color("not-a-color"), None);
907        assert_eq!(parse_color("#aƩaaa"), None);
908    }
909
910    #[test]
911    fn config_save_preserves_unknown_keys() {
912        let dir = tempfile::tempdir().unwrap();
913        let path = dir.path().join("config.toml");
914        std::fs::write(&path, "answer = 42\n\n[theme]\nname = \"nord\"\n").unwrap();
915        save_theme(&path, "dracula").unwrap();
916        let saved = std::fs::read_to_string(path).unwrap();
917        assert!(saved.contains("answer = 42"));
918        assert!(saved.contains("name = \"dracula\""));
919        assert!(saved.contains("auto_switch = false"));
920    }
921
922    #[test]
923    fn layout_save_preserves_theme_and_unknown_keys() {
924        let dir = tempfile::tempdir().unwrap();
925        let path = dir.path().join("config.toml");
926        std::fs::write(&path, "answer = 42\n\n[theme]\nname = \"nord\"\n").unwrap();
927        save_layout(&path, 40, Some(18)).unwrap();
928        let saved = std::fs::read_to_string(path).unwrap();
929        assert!(saved.contains("answer = 42"));
930        assert!(saved.contains("name = \"nord\""));
931        assert!(saved.contains("sidebar_width = 40"));
932        assert!(saved.contains("inspector_height = 18"));
933        let loaded: ViewerConfig = toml::from_str(&saved).unwrap();
934        assert_eq!(loaded.ui.sidebar_width, Some(40));
935        assert_eq!(loaded.ui.inspector_height, Some(18));
936    }
937
938    #[test]
939    fn cli_theme_overrides_environment_and_config() {
940        let _guard = env_lock();
941        let dir = tempfile::tempdir().unwrap();
942        let path = dir.path().join("config.toml");
943        std::fs::write(&path, "[theme]\nname = \"nord\"\n").unwrap();
944        std::env::set_var("PIW_CONFIG_PATH", &path);
945        std::env::set_var("PIW_THEME", "dracula");
946        let resolved = resolve(Some("catppuccin-latte"));
947        std::env::remove_var("PIW_THEME");
948        std::env::remove_var("PIW_CONFIG_PATH");
949        assert_eq!(resolved.palette.name, "catppuccin-latte");
950    }
951
952    #[test]
953    fn auto_switch_uses_the_configured_light_theme() {
954        let _guard = env_lock();
955        let dir = tempfile::tempdir().unwrap();
956        let path = dir.path().join("config.toml");
957        std::fs::write(
958            &path,
959            "[theme]\nname = \"nord\"\nauto_switch = true\ndark_name = \"nord\"\nlight_name = \"one-light\"\n",
960        )
961        .unwrap();
962        std::env::set_var("PIW_CONFIG_PATH", &path);
963        std::env::set_var("PIW_THEME_APPEARANCE", "light");
964        let resolved = resolve(None);
965        std::env::remove_var("PIW_THEME_APPEARANCE");
966        std::env::remove_var("PIW_CONFIG_PATH");
967        assert_eq!(resolved.palette.name, "one-light");
968    }
969}