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}