1use std::borrow::Cow;
21use std::cell::RefCell;
22use std::sync::{Arc, LazyLock};
23
24pub use pi_tui::components::{DefaultTextStyle, MarkdownOptions, MarkdownTheme};
25use pi_tui::components::{SelectListTheme, SettingsListTheme};
26use pi_tui::text::{truncate_to_width, visible_width};
27
28use crate::core::config;
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum ColorMode {
33 Truecolor,
35 Palette256,
37}
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
43pub enum ThemeColor {
44 Accent,
46 Border,
48 BorderAccent,
50 BorderMuted,
52 Success,
54 Error,
56 Warning,
58 Muted,
60 Dim,
62 Text,
64 ThinkingText,
66 UserMessageText,
68 CustomMessageText,
70 CustomMessageLabel,
72 ToolTitle,
74 ToolOutput,
76 MdHeading,
78 MdLink,
80 MdLinkUrl,
82 MdCode,
84 MdCodeBlock,
86 MdCodeBlockBorder,
88 MdQuote,
90 MdQuoteBorder,
92 MdHr,
94 MdListBullet,
96 ToolDiffAdded,
98 ToolDiffRemoved,
100 ToolDiffContext,
102 SyntaxComment,
104 SyntaxKeyword,
106 SyntaxFunction,
108 SyntaxVariable,
110 SyntaxString,
112 SyntaxNumber,
114 SyntaxType,
116 SyntaxOperator,
118 SyntaxPunctuation,
120 ThinkingOff,
122 ThinkingMinimal,
124 ThinkingLow,
126 ThinkingMedium,
128 ThinkingHigh,
130 ThinkingXhigh,
132 ThinkingMax,
134 BashMode,
136}
137
138#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
140pub enum ThemeBg {
141 SelectedBg,
143 UserMessageBg,
145 CustomMessageBg,
147 ToolPendingBg,
149 ToolSuccessBg,
151 ToolErrorBg,
153}
154
155pub const ALL_FG: [ThemeColor; 46] = [
157 ThemeColor::Accent,
158 ThemeColor::Border,
159 ThemeColor::BorderAccent,
160 ThemeColor::BorderMuted,
161 ThemeColor::Success,
162 ThemeColor::Error,
163 ThemeColor::Warning,
164 ThemeColor::Muted,
165 ThemeColor::Dim,
166 ThemeColor::Text,
167 ThemeColor::ThinkingText,
168 ThemeColor::UserMessageText,
169 ThemeColor::CustomMessageText,
170 ThemeColor::CustomMessageLabel,
171 ThemeColor::ToolTitle,
172 ThemeColor::ToolOutput,
173 ThemeColor::MdHeading,
174 ThemeColor::MdLink,
175 ThemeColor::MdLinkUrl,
176 ThemeColor::MdCode,
177 ThemeColor::MdCodeBlock,
178 ThemeColor::MdCodeBlockBorder,
179 ThemeColor::MdQuote,
180 ThemeColor::MdQuoteBorder,
181 ThemeColor::MdHr,
182 ThemeColor::MdListBullet,
183 ThemeColor::ToolDiffAdded,
184 ThemeColor::ToolDiffRemoved,
185 ThemeColor::ToolDiffContext,
186 ThemeColor::SyntaxComment,
187 ThemeColor::SyntaxKeyword,
188 ThemeColor::SyntaxFunction,
189 ThemeColor::SyntaxVariable,
190 ThemeColor::SyntaxString,
191 ThemeColor::SyntaxNumber,
192 ThemeColor::SyntaxType,
193 ThemeColor::SyntaxOperator,
194 ThemeColor::SyntaxPunctuation,
195 ThemeColor::ThinkingOff,
196 ThemeColor::ThinkingMinimal,
197 ThemeColor::ThinkingLow,
198 ThemeColor::ThinkingMedium,
199 ThemeColor::ThinkingHigh,
200 ThemeColor::ThinkingXhigh,
201 ThemeColor::ThinkingMax,
202 ThemeColor::BashMode,
203];
204
205pub const ALL_BG: [ThemeBg; 6] = [
207 ThemeBg::SelectedBg,
208 ThemeBg::UserMessageBg,
209 ThemeBg::CustomMessageBg,
210 ThemeBg::ToolPendingBg,
211 ThemeBg::ToolSuccessBg,
212 ThemeBg::ToolErrorBg,
213];
214
215#[derive(Clone, Copy, Debug, Eq, PartialEq)]
219pub struct Rgb(pub u8, pub u8, pub u8);
220
221impl Rgb {
222 #[must_use]
224 pub const fn none() -> Self {
225 Self(0, 0, 0)
226 }
227}
228
229#[derive(Clone, Copy, Debug, Eq, PartialEq)]
230enum ResolvedColor {
231 Default,
232 Indexed(u8),
233 Rgb(Rgb),
234}
235
236impl ResolvedColor {
237 const fn rgb(self) -> Rgb {
238 match self {
239 Self::Default => Rgb::none(),
240 Self::Indexed(index) => Rgb(index, index, index),
241 Self::Rgb(rgb) => rgb,
242 }
243 }
244}
245
246#[derive(Clone, Debug)]
251pub struct ResolvedTheme {
252 fg: [ResolvedColor; ALL_FG.len()],
253 bg: [ResolvedColor; ALL_BG.len()],
254 mode: ColorMode,
255 pub name: Cow<'static, str>,
257}
258
259impl ResolvedTheme {
260 fn fg_index(color: ThemeColor) -> usize {
261 ALL_FG.iter().position(|c| *c == color).unwrap_or(0)
262 }
263
264 fn bg_index(bg: ThemeBg) -> usize {
265 ALL_BG.iter().position(|b| *b == bg).unwrap_or(0)
266 }
267
268 #[must_use]
270 pub fn from_slots(
271 fg: impl IntoIterator<Item = (ThemeColor, Rgb)>,
272 bg: impl IntoIterator<Item = (ThemeBg, Rgb)>,
273 mode: ColorMode,
274 name: impl Into<Cow<'static, str>>,
275 ) -> Self {
276 Self::from_resolved_slots(
277 fg.into_iter().map(|(slot, rgb)| {
278 let color = if rgb == Rgb::none() {
279 ResolvedColor::Default
280 } else {
281 ResolvedColor::Rgb(rgb)
282 };
283 (slot, color)
284 }),
285 bg.into_iter().map(|(slot, rgb)| {
286 let color = if rgb == Rgb::none() {
287 ResolvedColor::Default
288 } else {
289 ResolvedColor::Rgb(rgb)
290 };
291 (slot, color)
292 }),
293 mode,
294 name,
295 )
296 }
297
298 fn from_resolved_slots(
299 fg: impl IntoIterator<Item = (ThemeColor, ResolvedColor)>,
300 bg: impl IntoIterator<Item = (ThemeBg, ResolvedColor)>,
301 mode: ColorMode,
302 name: impl Into<Cow<'static, str>>,
303 ) -> Self {
304 let mut fg_arr = [ResolvedColor::Default; ALL_FG.len()];
305 for (color, resolved) in fg {
306 fg_arr[Self::fg_index(color)] = resolved;
307 }
308 let mut bg_arr = [ResolvedColor::Default; ALL_BG.len()];
309 for (bg, resolved) in bg {
310 bg_arr[Self::bg_index(bg)] = resolved;
311 }
312 Self {
313 fg: fg_arr,
314 bg: bg_arr,
315 mode,
316 name: name.into(),
317 }
318 }
319
320 fn from_rgb_arrays(
321 fg: [Rgb; ALL_FG.len()],
322 bg: [Rgb; ALL_BG.len()],
323 mode: ColorMode,
324 name: impl Into<Cow<'static, str>>,
325 ) -> Self {
326 Self {
327 fg: fg.map(ResolvedColor::Rgb),
328 bg: bg.map(ResolvedColor::Rgb),
329 mode,
330 name: name.into(),
331 }
332 }
333
334 #[must_use]
336 pub const fn mode(&self) -> ColorMode {
337 self.mode
338 }
339
340 #[must_use]
342 pub fn fg_rgb(&self, color: ThemeColor) -> Rgb {
343 self.fg[Self::fg_index(color)].rgb()
344 }
345
346 #[must_use]
348 pub fn bg_rgb(&self, bg: ThemeBg) -> Rgb {
349 self.bg[Self::bg_index(bg)].rgb()
350 }
351
352 #[must_use]
354 pub fn is_fg_empty(&self, color: ThemeColor) -> bool {
355 self.fg[Self::fg_index(color)] == ResolvedColor::Default
356 }
357
358 #[must_use]
360 pub fn is_bg_empty(&self, bg: ThemeBg) -> bool {
361 self.bg[Self::bg_index(bg)] == ResolvedColor::Default
362 }
363
364 #[must_use]
368 pub fn fg(&self, color: ThemeColor, text: &str) -> String {
369 let mut out = String::with_capacity(text.len() + 24);
370 self.push_fg(&mut out, self.fg[Self::fg_index(color)]);
371 out.push_str(text);
372 out.push_str("\x1b[39m");
373 out
374 }
375
376 #[must_use]
378 pub fn bg(&self, bg: ThemeBg, text: &str) -> String {
379 let mut out = String::with_capacity(text.len() + 24);
380 self.push_bg(&mut out, self.bg[Self::bg_index(bg)]);
381 out.push_str(text);
382 out.push_str("\x1b[49m");
383 out
384 }
385
386 fn push_fg(&self, out: &mut String, color: ResolvedColor) {
387 match color {
388 ResolvedColor::Default => out.push_str("\x1b[39m"),
389 ResolvedColor::Indexed(index) => {
390 out.push_str("\x1b[38;5;");
391 out.push_str(index.to_string().as_str());
392 out.push('m');
393 }
394 ResolvedColor::Rgb(rgb) => match self.mode {
395 ColorMode::Truecolor => {
396 out.push_str("\x1b[38;2;");
397 push_rgb(out, rgb);
398 out.push('m');
399 }
400 ColorMode::Palette256 => {
401 out.push_str("\x1b[38;5;");
402 out.push_str(rgb_to_256(rgb).to_string().as_str());
403 out.push('m');
404 }
405 },
406 }
407 }
408
409 fn push_bg(&self, out: &mut String, color: ResolvedColor) {
410 match color {
411 ResolvedColor::Default => out.push_str("\x1b[49m"),
412 ResolvedColor::Indexed(index) => {
413 out.push_str("\x1b[48;5;");
414 out.push_str(index.to_string().as_str());
415 out.push('m');
416 }
417 ResolvedColor::Rgb(rgb) => match self.mode {
418 ColorMode::Truecolor => {
419 out.push_str("\x1b[48;2;");
420 push_rgb(out, rgb);
421 out.push('m');
422 }
423 ColorMode::Palette256 => {
424 out.push_str("\x1b[48;5;");
425 out.push_str(rgb_to_256(rgb).to_string().as_str());
426 out.push('m');
427 }
428 },
429 }
430 }
431
432 #[must_use]
437 pub fn bg_ansi(&self, bg: ThemeBg) -> String {
438 let mut out = String::new();
439 self.push_bg(&mut out, self.bg[Self::bg_index(bg)]);
440 out
441 }
442
443 #[must_use]
445 pub fn fg_ansi(&self, color: ThemeColor) -> String {
446 let mut out = String::new();
447 self.push_fg(&mut out, self.fg[Self::fg_index(color)]);
448 out
449 }
450}
451
452fn push_rgb(out: &mut String, Rgb(r, g, b): Rgb) {
453 out.push_str(r.to_string().as_str());
454 out.push(';');
455 out.push_str(g.to_string().as_str());
456 out.push(';');
457 out.push_str(b.to_string().as_str());
458}
459
460thread_local! {
461 static CURRENT: RefCell<Option<Arc<ResolvedTheme>>> = const { RefCell::new(None) };
463}
464
465pub fn with_theme<R>(theme: Arc<ResolvedTheme>, f: impl FnOnce() -> R) -> R {
470 let prior = CURRENT.with(|c| c.borrow().clone());
471 CURRENT.with(|c| *c.borrow_mut() = Some(theme));
472 let r = f();
473 CURRENT.with(|c| *c.borrow_mut() = prior);
474 r
475}
476
477pub fn set_current(theme: Arc<ResolvedTheme>) {
482 CURRENT.with(|c| *c.borrow_mut() = Some(theme));
483}
484
485#[must_use]
489pub fn current() -> Arc<ResolvedTheme> {
490 CURRENT.with(|c| c.borrow().clone()).unwrap_or_else(dark)
491}
492
493#[must_use]
503pub fn make_fg(color: ThemeColor) -> fn(&str) -> String {
504 match color {
505 ThemeColor::Accent => |s| current().fg(ThemeColor::Accent, s),
506 ThemeColor::Border => |s| current().fg(ThemeColor::Border, s),
507 ThemeColor::BorderAccent => |s| current().fg(ThemeColor::BorderAccent, s),
508 ThemeColor::BorderMuted => |s| current().fg(ThemeColor::BorderMuted, s),
509 ThemeColor::Success => |s| current().fg(ThemeColor::Success, s),
510 ThemeColor::Error => |s| current().fg(ThemeColor::Error, s),
511 ThemeColor::Warning => |s| current().fg(ThemeColor::Warning, s),
512 ThemeColor::Muted => |s| current().fg(ThemeColor::Muted, s),
513 ThemeColor::Dim => |s| current().fg(ThemeColor::Dim, s),
514 ThemeColor::Text => |s| current().fg(ThemeColor::Text, s),
515 ThemeColor::ThinkingText => |s| current().fg(ThemeColor::ThinkingText, s),
516 ThemeColor::UserMessageText => |s| current().fg(ThemeColor::UserMessageText, s),
517 ThemeColor::CustomMessageText => |s| current().fg(ThemeColor::CustomMessageText, s),
518 ThemeColor::CustomMessageLabel => |s| current().fg(ThemeColor::CustomMessageLabel, s),
519 ThemeColor::ToolTitle => |s| current().fg(ThemeColor::ToolTitle, s),
520 ThemeColor::ToolOutput => |s| current().fg(ThemeColor::ToolOutput, s),
521 ThemeColor::MdHeading => |s| current().fg(ThemeColor::MdHeading, s),
522 ThemeColor::MdLink => |s| current().fg(ThemeColor::MdLink, s),
523 ThemeColor::MdLinkUrl => |s| current().fg(ThemeColor::MdLinkUrl, s),
524 ThemeColor::MdCode => |s| current().fg(ThemeColor::MdCode, s),
525 ThemeColor::MdCodeBlock => |s| current().fg(ThemeColor::MdCodeBlock, s),
526 ThemeColor::MdCodeBlockBorder => |s| current().fg(ThemeColor::MdCodeBlockBorder, s),
527 ThemeColor::MdQuote => |s| current().fg(ThemeColor::MdQuote, s),
528 ThemeColor::MdQuoteBorder => |s| current().fg(ThemeColor::MdQuoteBorder, s),
529 ThemeColor::MdHr => |s| current().fg(ThemeColor::MdHr, s),
530 ThemeColor::MdListBullet => |s| current().fg(ThemeColor::MdListBullet, s),
531 ThemeColor::ToolDiffAdded => |s| current().fg(ThemeColor::ToolDiffAdded, s),
532 ThemeColor::ToolDiffRemoved => |s| current().fg(ThemeColor::ToolDiffRemoved, s),
533 ThemeColor::ToolDiffContext => |s| current().fg(ThemeColor::ToolDiffContext, s),
534 ThemeColor::SyntaxComment => |s| current().fg(ThemeColor::SyntaxComment, s),
535 ThemeColor::SyntaxKeyword => |s| current().fg(ThemeColor::SyntaxKeyword, s),
536 ThemeColor::SyntaxFunction => |s| current().fg(ThemeColor::SyntaxFunction, s),
537 ThemeColor::SyntaxVariable => |s| current().fg(ThemeColor::SyntaxVariable, s),
538 ThemeColor::SyntaxString => |s| current().fg(ThemeColor::SyntaxString, s),
539 ThemeColor::SyntaxNumber => |s| current().fg(ThemeColor::SyntaxNumber, s),
540 ThemeColor::SyntaxType => |s| current().fg(ThemeColor::SyntaxType, s),
541 ThemeColor::SyntaxOperator => |s| current().fg(ThemeColor::SyntaxOperator, s),
542 ThemeColor::SyntaxPunctuation => |s| current().fg(ThemeColor::SyntaxPunctuation, s),
543 ThemeColor::ThinkingOff => |s| current().fg(ThemeColor::ThinkingOff, s),
544 ThemeColor::ThinkingMinimal => |s| current().fg(ThemeColor::ThinkingMinimal, s),
545 ThemeColor::ThinkingLow => |s| current().fg(ThemeColor::ThinkingLow, s),
546 ThemeColor::ThinkingMedium => |s| current().fg(ThemeColor::ThinkingMedium, s),
547 ThemeColor::ThinkingHigh => |s| current().fg(ThemeColor::ThinkingHigh, s),
548 ThemeColor::ThinkingXhigh => |s| current().fg(ThemeColor::ThinkingXhigh, s),
549 ThemeColor::ThinkingMax => |s| current().fg(ThemeColor::ThinkingMax, s),
550 ThemeColor::BashMode => |s| current().fg(ThemeColor::BashMode, s),
551 }
552}
553
554#[must_use]
556pub fn markdown_theme() -> MarkdownTheme {
557 MarkdownTheme {
558 heading: make_fg(ThemeColor::MdHeading),
559 link: make_fg(ThemeColor::MdLink),
560 link_url: make_fg(ThemeColor::MdLinkUrl),
561 code: make_fg(ThemeColor::MdCode),
562 code_block: make_fg(ThemeColor::MdCodeBlock),
563 code_block_border: make_fg(ThemeColor::MdCodeBlockBorder),
564 quote: make_fg(ThemeColor::MdQuote),
565 quote_border: make_fg(ThemeColor::MdQuoteBorder),
566 hr: make_fg(ThemeColor::MdHr),
567 list_bullet: make_fg(ThemeColor::MdListBullet),
568 bold,
569 italic,
570 underline,
571 strikethrough,
572 highlight_code: None,
573 code_block_indent: " ".to_owned(),
574 }
575}
576
577#[must_use]
579pub fn user_markdown_options() -> MarkdownOptions {
580 MarkdownOptions {
581 preserve_ordered_list_markers: true,
582 preserve_backslash_escapes: true,
583 hyperlinks: false,
584 }
585}
586
587#[must_use]
589pub fn select_list_theme() -> SelectListTheme {
590 SelectListTheme {
591 selected_prefix: make_fg(ThemeColor::Accent),
592 selected_text: make_fg(ThemeColor::Accent),
593 description: make_fg(ThemeColor::Muted),
594 scroll_info: make_fg(ThemeColor::Muted),
595 no_match: make_fg(ThemeColor::Muted),
596 }
597}
598
599#[must_use]
603pub fn settings_list_theme() -> SettingsListTheme {
604 SettingsListTheme {
605 label: label_selected,
606 value: value_selected,
607 description: make_fg(ThemeColor::Dim),
608 cursor: current().fg(ThemeColor::Accent, "→ "),
609 hint: make_fg(ThemeColor::Dim),
610 }
611}
612
613fn label_selected(s: &str, selected: bool) -> String {
614 if selected {
615 current().fg(ThemeColor::Accent, s)
616 } else {
617 s.to_owned()
618 }
619}
620
621fn value_selected(s: &str, selected: bool) -> String {
622 if selected {
623 current().fg(ThemeColor::Accent, s)
624 } else {
625 current().fg(ThemeColor::Muted, s)
626 }
627}
628
629#[must_use]
631pub fn bold(s: &str) -> String {
632 format!("\x1b[1m{s}\x1b[22m")
633}
634
635#[must_use]
637pub fn italic(s: &str) -> String {
638 format!("\x1b[3m{s}\x1b[23m")
639}
640
641#[must_use]
643pub fn underline(s: &str) -> String {
644 format!("\x1b[4m{s}\x1b[24m")
645}
646
647#[must_use]
649pub fn strikethrough(s: &str) -> String {
650 format!("\x1b[9m{s}\x1b[29m")
651}
652
653#[must_use]
655pub fn inverse(s: &str) -> String {
656 format!("\x1b[7m{s}\x1b[27m")
657}
658
659#[must_use]
661pub fn default_text_style() -> DefaultTextStyle {
662 DefaultTextStyle::default()
663}
664
665#[must_use]
667pub fn truncate_line(text: &str, width: usize, ellipsis: &str) -> String {
668 if width == 0 {
669 return String::new();
670 }
671 if visible_width(text) <= width {
672 return text.to_owned();
673 }
674 truncate_to_width(text, width, ellipsis, false)
675}
676
677const CUBE_VALUES: [u8; 6] = [0, 95, 135, 175, 215, 255];
682
683fn closest_cube(value: u8) -> usize {
684 let mut best = 0usize;
685 let mut best_dist = u32::MAX;
686 for (i, c) in CUBE_VALUES.iter().enumerate() {
687 let d = (i32::from(value) - i32::from(*c)).unsigned_abs();
688 if d < best_dist {
689 best_dist = d;
690 best = i;
691 }
692 }
693 best
694}
695
696fn color_distance(r1: u8, g1: u8, b1: u8, r2: u8, g2: u8, b2: u8) -> u32 {
697 let dr = i32::from(r1) - i32::from(r2);
698 let dg = i32::from(g1) - i32::from(g2);
699 let db = i32::from(b1) - i32::from(b2);
700 let weighted = (dr * dr * 299 + dg * dg * 587 + db * db * 114) / 1000;
701 weighted.try_into().unwrap_or(u32::MAX)
702}
703
704#[must_use]
706pub fn rgb_to_256(rgb: Rgb) -> u8 {
707 let Rgb(r, g, b) = rgb;
708 let r_idx = closest_cube(r);
709 let g_idx = closest_cube(g);
710 let b_idx = closest_cube(b);
711 let cube_r = CUBE_VALUES[r_idx];
712 let cube_g = CUBE_VALUES[g_idx];
713 let cube_b = CUBE_VALUES[b_idx];
714 let cube_index = 16 + 36 * r_idx + 6 * g_idx + b_idx;
715 let cube_dist = color_distance(r, g, b, cube_r, cube_g, cube_b);
716
717 let gray = u8::try_from((u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114) / 1000)
718 .unwrap_or(255);
719 let gray_idx = closest_gray(gray);
720 let gray_value = GRAY_VALUES[gray_idx];
721 let gray_index = 232 + gray_idx;
722 let gray_dist = color_distance(r, g, b, gray_value, gray_value, gray_value);
723
724 let max_c = r.max(g).max(b);
725 let min_c = r.min(g).min(b);
726 let spread = u32::from(max_c) - u32::from(min_c);
727
728 if spread < 10 && gray_dist < cube_dist {
729 u8::try_from(gray_index).unwrap_or(255)
730 } else {
731 u8::try_from(cube_index).unwrap_or(255)
732 }
733}
734
735const GRAY_VALUES: [u8; 24] = gray_values();
736const fn gray_values() -> [u8; 24] {
737 let mut out = [0u8; 24];
738 let mut i = 0u8;
739 while i < 24 {
740 out[i as usize] = 8 + i * 10;
742 i += 1;
743 }
744 out
745}
746
747fn closest_gray(gray: u8) -> usize {
748 let mut best = 0usize;
749 let mut best_dist = u32::MAX;
750 for (i, v) in GRAY_VALUES.iter().enumerate() {
751 let d = (i32::from(gray) - i32::from(*v)).unsigned_abs();
752 if d < best_dist {
753 best_dist = d;
754 best = i;
755 }
756 }
757 best
758}
759
760const fn rgb(r: u8, g: u8, b: u8) -> Rgb {
765 Rgb(r, g, b)
766}
767
768#[must_use]
770pub fn dark() -> Arc<ResolvedTheme> {
771 DARK_CLONE.clone()
772}
773
774#[must_use]
776pub fn light() -> Arc<ResolvedTheme> {
777 LIGHT_INTERN.clone()
778}
779
780static DARK_CLONE: LazyLock<Arc<ResolvedTheme>> = LazyLock::new(|| {
781 Arc::new(ResolvedTheme::from_rgb_arrays(
782 dark_fg(),
783 dark_bg(),
784 ColorMode::Truecolor,
785 Cow::Borrowed("dark"),
786 ))
787});
788
789static LIGHT_INTERN: LazyLock<Arc<ResolvedTheme>> = LazyLock::new(|| {
790 Arc::new(ResolvedTheme::from_rgb_arrays(
791 light_fg(),
792 light_bg(),
793 ColorMode::Truecolor,
794 Cow::Borrowed("light"),
795 ))
796});
797
798#[derive(Debug, thiserror::Error)]
804pub enum ThemeError {
805 #[error("missing required color slot: {0}")]
807 MissingColor(String),
808 #[error("invalid color value for `{slot}`: {value}")]
810 InvalidColor {
811 slot: String,
813 value: String,
815 },
816 #[error("unknown theme variable reference: {0}")]
818 UnknownVar(String),
819 #[error("circular theme variable reference: {0}")]
821 CircularVar(String),
822 #[error("theme `{field}` must be a string or integer")]
824 InvalidFieldType {
825 field: &'static str,
827 },
828 #[error("theme json must be an object")]
830 NotAnObject,
831 #[error("theme json missing required field: name")]
833 MissingName,
834 #[error("invalid theme name \"{0}\": names cannot contain '/'")]
836 InvalidName(String),
837 #[error("theme not found: {0}")]
839 NotFound(String),
840 #[error("io error: {0}")]
842 Io(#[from] std::io::Error),
843 #[error("json error: {0}")]
845 Json(#[from] serde_json::Error),
846}
847
848#[derive(Clone, Debug)]
850pub struct ThemeJson {
851 name: String,
852 vars: Vec<(String, ColorValue)>,
853 colors: Vec<(String, ColorValue)>,
854}
855
856#[derive(Clone, Debug)]
857enum ColorValue {
858 Hex(String),
859 Var(String),
860 Empty,
861 Indexed(u8),
862}
863
864impl ThemeJson {
865 pub fn parse(json: &str) -> Result<Self, ThemeError> {
871 let value: serde_json::Value = serde_json::from_str(json)?;
872 Self::from_value(&value)
873 }
874
875 pub fn from_value(value: &serde_json::Value) -> Result<Self, ThemeError> {
881 let obj = value.as_object().ok_or(ThemeError::NotAnObject)?;
882 let name = obj
883 .get("name")
884 .and_then(serde_json::Value::as_str)
885 .ok_or(ThemeError::MissingName)?
886 .to_owned();
887 if name.contains('/') {
888 return Err(ThemeError::InvalidName(name));
889 }
890 let vars = parse_color_map(obj.get("vars"), "vars")?;
891 let colors_obj = obj
892 .get("colors")
893 .and_then(|v| v.as_object())
894 .ok_or_else(|| ThemeError::MissingColor("colors".to_owned()))?;
895 let mut colors = Vec::new();
896 for slot in REQUIRED_COLORS {
897 let val = colors_obj
898 .get(*slot)
899 .ok_or_else(|| ThemeError::MissingColor((*slot).to_owned()))?;
900 let cv = parse_color_value(val).ok_or_else(|| ThemeError::InvalidColor {
901 slot: (*slot).to_owned(),
902 value: val.to_string(),
903 })?;
904 colors.push(((*slot).to_owned(), cv));
905 }
906 if !colors.iter().any(|(k, _)| k == "thinkingMax")
908 && let Some((_, x)) = colors.iter().find(|(k, _)| k == "thinkingXhigh")
909 {
910 colors.push(("thinkingMax".to_owned(), x.clone()));
911 }
912 Ok(Self { name, vars, colors })
913 }
914
915 #[must_use]
917 pub fn name(&self) -> &str {
918 &self.name
919 }
920
921 pub fn resolve(&self, mode: ColorMode) -> Result<Arc<ResolvedTheme>, ThemeError> {
927 Ok(Arc::new(self.resolve_owned(mode)?))
928 }
929
930 pub fn resolve_owned(&self, mode: ColorMode) -> Result<ResolvedTheme, ThemeError> {
936 let mut fg = [(ThemeColor::Accent, ResolvedColor::Default); ALL_FG.len()];
937 for (i, (slot_enum, slot_name)) in ALL_FG_SLOTS.iter().enumerate() {
938 let value = self
939 .colors
940 .iter()
941 .find(|(k, _)| k == *slot_name)
942 .map(|(_, v)| v.clone())
943 .ok_or_else(|| ThemeError::MissingColor((*slot_name).to_owned()))?;
944 let resolved = resolve_value(&value, &self.vars, slot_name)?;
945 fg[i] = (*slot_enum, resolved);
946 }
947 let mut bg = [(ThemeBg::SelectedBg, ResolvedColor::Default); ALL_BG.len()];
948 for (i, (slot_enum, slot_name)) in ALL_BG_SLOTS.iter().enumerate() {
949 let value = self
950 .colors
951 .iter()
952 .find(|(k, _)| k == *slot_name)
953 .map(|(_, v)| v.clone())
954 .ok_or_else(|| ThemeError::MissingColor((*slot_name).to_owned()))?;
955 let resolved = resolve_value(&value, &self.vars, slot_name)?;
956 bg[i] = (*slot_enum, resolved);
957 }
958 Ok(ResolvedTheme::from_resolved_slots(
959 fg,
960 bg,
961 mode,
962 self.name.clone(),
963 ))
964 }
965}
966
967fn parse_color_map(
968 value: Option<&serde_json::Value>,
969 field: &'static str,
970) -> Result<Vec<(String, ColorValue)>, ThemeError> {
971 let Some(obj) = value.and_then(|v| v.as_object()) else {
972 return Ok(Vec::new());
973 };
974 let mut out = Vec::with_capacity(obj.len());
975 for (k, v) in obj {
976 let cv = parse_color_value(v).ok_or(ThemeError::InvalidFieldType { field })?;
977 out.push((k.clone(), cv));
978 }
979 Ok(out)
980}
981
982fn parse_color_value(v: &serde_json::Value) -> Option<ColorValue> {
983 if let Some(s) = v.as_str() {
984 if s.is_empty() {
985 return Some(ColorValue::Empty);
986 }
987 if let Some(rest) = s.strip_prefix('#') {
988 if rest.len() == 6 && rest.chars().all(|c| c.is_ascii_hexdigit()) {
989 return Some(ColorValue::Hex(s.to_owned()));
990 }
991 return None;
992 }
993 return Some(ColorValue::Var(s.to_owned()));
994 }
995 if let Some(n) = v.as_i64().filter(|n| (0..=255).contains(n)) {
996 return Some(ColorValue::Indexed(u8::try_from(n).unwrap_or(0)));
997 }
998 None
999}
1000
1001fn resolve_value(
1002 value: &ColorValue,
1003 vars: &[(String, ColorValue)],
1004 slot: &str,
1005) -> Result<ResolvedColor, ThemeError> {
1006 let mut visited: Vec<String> = Vec::new();
1007 let mut current = value.clone();
1008 loop {
1009 match current {
1010 ColorValue::Empty => return Ok(ResolvedColor::Default),
1011 ColorValue::Hex(s) => {
1012 return hex_to_rgb(&s).map(ResolvedColor::Rgb).ok_or_else(|| {
1013 ThemeError::InvalidColor {
1014 slot: slot.to_owned(),
1015 value: s.clone(),
1016 }
1017 });
1018 }
1019 ColorValue::Indexed(index) => return Ok(ResolvedColor::Indexed(index)),
1020 ColorValue::Var(name) => {
1021 if visited.iter().any(|v| v == &name) {
1022 return Err(ThemeError::CircularVar(name));
1023 }
1024 visited.push(name.clone());
1025 let Some((_, next)) = vars.iter().find(|(k, _)| k == &name) else {
1026 return Err(ThemeError::UnknownVar(name));
1027 };
1028 current = next.clone();
1029 }
1030 }
1031 }
1032}
1033
1034fn hex_to_rgb(hex: &str) -> Option<Rgb> {
1035 let rest = hex.strip_prefix('#')?;
1036 if rest.len() != 6 {
1037 return None;
1038 }
1039 let r = u8::from_str_radix(&rest[0..2], 16).ok()?;
1040 let g = u8::from_str_radix(&rest[2..4], 16).ok()?;
1041 let b = u8::from_str_radix(&rest[4..6], 16).ok()?;
1042 Some(Rgb(r, g, b))
1043}
1044
1045pub fn load_by_name(name: &str, mode: ColorMode) -> Result<Arc<ResolvedTheme>, ThemeError> {
1054 if name == "dark" {
1055 return Ok(dark());
1056 }
1057 if name == "light" {
1058 return Ok(light());
1059 }
1060 let builtin_path = config::get_themes_dir().join(format!("{name}.json"));
1061 let custom_path = config::get_custom_themes_dir().join(format!("{name}.json"));
1062 let path = if builtin_path.exists() {
1063 builtin_path
1064 } else if custom_path.exists() {
1065 custom_path
1066 } else {
1067 return Err(ThemeError::NotFound(name.to_owned()));
1068 };
1069 let text = std::fs::read_to_string(&path)?;
1070 ThemeJson::parse(&text)?.resolve(mode)
1071}
1072
1073#[must_use]
1078pub fn load_or_dark(name: &str, mode: ColorMode) -> Arc<ResolvedTheme> {
1079 load_by_name(name, mode).unwrap_or_else(|_| dark())
1080}
1081
1082const REQUIRED_COLORS: &[&str] = &[
1085 "accent",
1086 "border",
1087 "borderAccent",
1088 "borderMuted",
1089 "success",
1090 "error",
1091 "warning",
1092 "muted",
1093 "dim",
1094 "text",
1095 "thinkingText",
1096 "selectedBg",
1097 "userMessageBg",
1098 "userMessageText",
1099 "customMessageBg",
1100 "customMessageText",
1101 "customMessageLabel",
1102 "toolPendingBg",
1103 "toolSuccessBg",
1104 "toolErrorBg",
1105 "toolTitle",
1106 "toolOutput",
1107 "mdHeading",
1108 "mdLink",
1109 "mdLinkUrl",
1110 "mdCode",
1111 "mdCodeBlock",
1112 "mdCodeBlockBorder",
1113 "mdQuote",
1114 "mdQuoteBorder",
1115 "mdHr",
1116 "mdListBullet",
1117 "toolDiffAdded",
1118 "toolDiffRemoved",
1119 "toolDiffContext",
1120 "syntaxComment",
1121 "syntaxKeyword",
1122 "syntaxFunction",
1123 "syntaxVariable",
1124 "syntaxString",
1125 "syntaxNumber",
1126 "syntaxType",
1127 "syntaxOperator",
1128 "syntaxPunctuation",
1129 "thinkingOff",
1130 "thinkingMinimal",
1131 "thinkingLow",
1132 "thinkingMedium",
1133 "thinkingHigh",
1134 "thinkingXhigh",
1135 "bashMode",
1136];
1137
1138const ALL_FG_SLOTS: &[(ThemeColor, &str)] = &[
1139 (ThemeColor::Accent, "accent"),
1140 (ThemeColor::Border, "border"),
1141 (ThemeColor::BorderAccent, "borderAccent"),
1142 (ThemeColor::BorderMuted, "borderMuted"),
1143 (ThemeColor::Success, "success"),
1144 (ThemeColor::Error, "error"),
1145 (ThemeColor::Warning, "warning"),
1146 (ThemeColor::Muted, "muted"),
1147 (ThemeColor::Dim, "dim"),
1148 (ThemeColor::Text, "text"),
1149 (ThemeColor::ThinkingText, "thinkingText"),
1150 (ThemeColor::UserMessageText, "userMessageText"),
1151 (ThemeColor::CustomMessageText, "customMessageText"),
1152 (ThemeColor::CustomMessageLabel, "customMessageLabel"),
1153 (ThemeColor::ToolTitle, "toolTitle"),
1154 (ThemeColor::ToolOutput, "toolOutput"),
1155 (ThemeColor::MdHeading, "mdHeading"),
1156 (ThemeColor::MdLink, "mdLink"),
1157 (ThemeColor::MdLinkUrl, "mdLinkUrl"),
1158 (ThemeColor::MdCode, "mdCode"),
1159 (ThemeColor::MdCodeBlock, "mdCodeBlock"),
1160 (ThemeColor::MdCodeBlockBorder, "mdCodeBlockBorder"),
1161 (ThemeColor::MdQuote, "mdQuote"),
1162 (ThemeColor::MdQuoteBorder, "mdQuoteBorder"),
1163 (ThemeColor::MdHr, "mdHr"),
1164 (ThemeColor::MdListBullet, "mdListBullet"),
1165 (ThemeColor::ToolDiffAdded, "toolDiffAdded"),
1166 (ThemeColor::ToolDiffRemoved, "toolDiffRemoved"),
1167 (ThemeColor::ToolDiffContext, "toolDiffContext"),
1168 (ThemeColor::SyntaxComment, "syntaxComment"),
1169 (ThemeColor::SyntaxKeyword, "syntaxKeyword"),
1170 (ThemeColor::SyntaxFunction, "syntaxFunction"),
1171 (ThemeColor::SyntaxVariable, "syntaxVariable"),
1172 (ThemeColor::SyntaxString, "syntaxString"),
1173 (ThemeColor::SyntaxNumber, "syntaxNumber"),
1174 (ThemeColor::SyntaxType, "syntaxType"),
1175 (ThemeColor::SyntaxOperator, "syntaxOperator"),
1176 (ThemeColor::SyntaxPunctuation, "syntaxPunctuation"),
1177 (ThemeColor::ThinkingOff, "thinkingOff"),
1178 (ThemeColor::ThinkingMinimal, "thinkingMinimal"),
1179 (ThemeColor::ThinkingLow, "thinkingLow"),
1180 (ThemeColor::ThinkingMedium, "thinkingMedium"),
1181 (ThemeColor::ThinkingHigh, "thinkingHigh"),
1182 (ThemeColor::ThinkingXhigh, "thinkingXhigh"),
1183 (ThemeColor::ThinkingMax, "thinkingMax"),
1184 (ThemeColor::BashMode, "bashMode"),
1185];
1186
1187const ALL_BG_SLOTS: &[(ThemeBg, &str)] = &[
1188 (ThemeBg::SelectedBg, "selectedBg"),
1189 (ThemeBg::UserMessageBg, "userMessageBg"),
1190 (ThemeBg::CustomMessageBg, "customMessageBg"),
1191 (ThemeBg::ToolPendingBg, "toolPendingBg"),
1192 (ThemeBg::ToolSuccessBg, "toolSuccessBg"),
1193 (ThemeBg::ToolErrorBg, "toolErrorBg"),
1194];
1195
1196const fn dark_fg() -> [Rgb; 46] {
1199 [
1200 rgb(138, 190, 183), rgb(95, 135, 255), rgb(0, 215, 255), rgb(80, 80, 80), rgb(181, 189, 104), rgb(204, 102, 102), rgb(255, 255, 0), rgb(128, 128, 128), rgb(102, 102, 102), rgb(212, 212, 212), rgb(128, 128, 128), rgb(212, 212, 212), rgb(212, 212, 212), rgb(149, 117, 205), rgb(212, 212, 212), rgb(128, 128, 128), rgb(240, 198, 116), rgb(129, 162, 190), rgb(102, 102, 102), rgb(138, 190, 183), rgb(181, 189, 104), rgb(128, 128, 128), rgb(128, 128, 128), rgb(128, 128, 128), rgb(128, 128, 128), rgb(138, 190, 183), rgb(181, 189, 104), rgb(204, 102, 102), rgb(128, 128, 128), rgb(106, 153, 85), rgb(86, 156, 214), rgb(220, 220, 170), rgb(156, 220, 254), rgb(206, 145, 120), rgb(181, 206, 168), rgb(78, 201, 176), rgb(212, 212, 212), rgb(212, 212, 212), rgb(80, 80, 80), rgb(110, 110, 110), rgb(95, 135, 175), rgb(129, 162, 190), rgb(178, 148, 187), rgb(209, 131, 232), rgb(255, 95, 255), rgb(181, 189, 104), ]
1247}
1248
1249const fn dark_bg() -> [Rgb; 6] {
1250 [
1251 rgb(58, 58, 74), rgb(52, 53, 65), rgb(45, 40, 56), rgb(40, 40, 50), rgb(40, 50, 40), rgb(60, 40, 40), ]
1258}
1259
1260const fn light_fg() -> [Rgb; 46] {
1261 [
1262 rgb(90, 128, 128), rgb(84, 125, 167), rgb(90, 128, 128), rgb(176, 176, 176), rgb(88, 132, 88), rgb(170, 85, 85), rgb(154, 115, 38), rgb(108, 108, 108), rgb(118, 118, 118), rgb(31, 35, 40), rgb(108, 108, 108), rgb(31, 35, 40), rgb(31, 35, 40), rgb(126, 87, 194), rgb(31, 35, 40), rgb(108, 108, 108), rgb(154, 115, 38), rgb(84, 125, 167), rgb(118, 118, 118), rgb(90, 128, 128), rgb(88, 132, 88), rgb(108, 108, 108), rgb(108, 108, 108), rgb(108, 108, 108), rgb(108, 108, 108), rgb(88, 132, 88), rgb(88, 132, 88), rgb(170, 85, 85), rgb(108, 108, 108), rgb(0, 128, 0), rgb(0, 0, 255), rgb(121, 94, 38), rgb(0, 16, 128), rgb(163, 21, 21), rgb(9, 134, 88), rgb(38, 127, 153), rgb(0, 0, 0), rgb(0, 0, 0), rgb(176, 176, 176), rgb(118, 118, 118), rgb(84, 125, 167), rgb(90, 128, 128), rgb(135, 95, 135), rgb(139, 0, 139), rgb(175, 0, 95), rgb(88, 132, 88), ]
1309}
1310
1311const fn light_bg() -> [Rgb; 6] {
1312 [
1313 rgb(208, 208, 224), rgb(232, 232, 232), rgb(237, 231, 246), rgb(232, 232, 240), rgb(232, 240, 232), rgb(240, 232, 232), ]
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324 use super::*;
1325
1326 type TestResult = Result<(), String>;
1327
1328 fn parsed_theme(overrides: &[(&str, serde_json::Value)]) -> Result<ThemeJson, String> {
1329 let mut colors = serde_json::Map::new();
1330 for slot in REQUIRED_COLORS {
1331 colors.insert((*slot).to_owned(), serde_json::json!("#010203"));
1332 }
1333 for (slot, value) in overrides {
1334 colors.insert((*slot).to_owned(), value.clone());
1335 }
1336 ThemeJson::from_value(&serde_json::json!({
1337 "name": "test",
1338 "colors": colors,
1339 }))
1340 .map_err(|error| format!("test theme should parse: {error}"))
1341 }
1342
1343 #[test]
1344 fn dark_accent_resolves() {
1345 let th = dark();
1346 assert_eq!(th.fg_rgb(ThemeColor::Accent), Rgb(138, 190, 183));
1347 }
1348
1349 #[test]
1350 fn builtin_literal_black_remains_a_color() {
1351 let theme = light();
1352 assert!(!theme.is_fg_empty(ThemeColor::SyntaxOperator));
1353 assert_eq!(
1354 theme.fg_ansi(ThemeColor::SyntaxOperator),
1355 "\x1b[38;2;0;0;0m"
1356 );
1357 }
1358
1359 #[test]
1360 fn json_black_is_distinct_from_default() -> TestResult {
1361 let theme = parsed_theme(&[
1362 ("accent", serde_json::json!("#000000")),
1363 ("muted", serde_json::json!("")),
1364 ("selectedBg", serde_json::json!("#000000")),
1365 ("toolErrorBg", serde_json::json!("")),
1366 ])?
1367 .resolve_owned(ColorMode::Truecolor)
1368 .map_err(|error| format!("test theme should resolve: {error}"))?;
1369
1370 assert_eq!(theme.fg_rgb(ThemeColor::Accent), Rgb(0, 0, 0));
1371 assert!(!theme.is_fg_empty(ThemeColor::Accent));
1372 assert_eq!(theme.fg_ansi(ThemeColor::Accent), "\x1b[38;2;0;0;0m");
1373 assert!(theme.is_fg_empty(ThemeColor::Muted));
1374 assert_eq!(theme.fg_ansi(ThemeColor::Muted), "\x1b[39m");
1375
1376 assert_eq!(theme.bg_rgb(ThemeBg::SelectedBg), Rgb(0, 0, 0));
1377 assert!(!theme.is_bg_empty(ThemeBg::SelectedBg));
1378 assert_eq!(theme.bg_ansi(ThemeBg::SelectedBg), "\x1b[48;2;0;0;0m");
1379 assert!(theme.is_bg_empty(ThemeBg::ToolErrorBg));
1380 assert_eq!(theme.bg_ansi(ThemeBg::ToolErrorBg), "\x1b[49m");
1381 Ok(())
1382 }
1383
1384 #[test]
1385 fn json_indexed_colors_emit_exact_sequences_in_every_mode() -> TestResult {
1386 let parsed = parsed_theme(&[
1387 ("accent", serde_json::json!(17)),
1388 ("selectedBg", serde_json::json!(231)),
1389 ])?;
1390
1391 for mode in [ColorMode::Truecolor, ColorMode::Palette256] {
1392 let theme = parsed
1393 .resolve_owned(mode)
1394 .map_err(|error| format!("test theme should resolve: {error}"))?;
1395 assert_eq!(theme.fg_ansi(ThemeColor::Accent), "\x1b[38;5;17m");
1396 assert_eq!(theme.bg_ansi(ThemeBg::SelectedBg), "\x1b[48;5;231m");
1397 assert_eq!(theme.fg_rgb(ThemeColor::Accent), Rgb(17, 17, 17));
1398 assert_eq!(theme.bg_rgb(ThemeBg::SelectedBg), Rgb(231, 231, 231));
1399 }
1400 Ok(())
1401 }
1402}