1use ratatui::style::{Color, Modifier, Style};
8use serde::{Deserialize, Deserializer, Serialize};
9use std::collections::HashMap;
10use std::fmt;
11use syntect::highlighting::ThemeSet;
12use thiserror::Error;
13use tracing::debug;
14
15mod loader;
16
17pub use loader::ThemeLoader;
18
19static THEME_SET: std::sync::LazyLock<ThemeSet> = std::sync::LazyLock::new(ThemeSet::load_defaults);
21
22#[derive(Debug, Error)]
24pub enum ThemeError {
25 #[error("IO error: {0}")]
26 Io(#[from] std::io::Error),
27
28 #[error("Parse error: {0}")]
29 Parse(#[from] toml::de::Error),
30
31 #[error("Validation error: {0}")]
32 Validation(String),
33
34 #[error("Color not found in palette: {0}")]
35 ColorNotFound(String),
36
37 #[error("Invalid color value: {0}")]
38 InvalidColor(String),
39}
40
41#[derive(Debug, Clone, Deserialize)]
43#[serde(untagged)]
44pub enum ColorValue {
45 Palette(String),
47 Direct(String),
49}
50
51#[derive(Debug, Clone, Deserialize)]
53pub struct ComponentStyle {
54 pub fg: Option<ColorValue>,
55 pub bg: Option<ColorValue>,
56 #[serde(default)]
57 pub bold: bool,
58 #[serde(default)]
59 pub italic: bool,
60 #[serde(default)]
61 pub underlined: bool,
62}
63
64#[derive(Debug, Clone, Deserialize)]
66pub struct RawTheme {
67 pub name: String,
68 pub palette: HashMap<String, RgbColor>,
69 pub components: HashMap<Component, ComponentStyle>,
70 pub syntax: Option<SyntaxConfig>,
71}
72
73#[derive(Debug, Clone, Deserialize)]
75pub struct SyntaxConfig {
76 pub syntect_theme: Option<String>,
78}
79
80pub type Theme = CompiledTheme;
81
82impl Theme {
83 pub fn message_spacing(&self) -> u16 {
85 1 }
87}
88
89#[derive(Debug, Clone, Copy)]
91pub struct RgbColor(pub u8, pub u8, pub u8);
92
93impl<'de> Deserialize<'de> for RgbColor {
94 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
95 where
96 D: Deserializer<'de>,
97 {
98 let s = String::deserialize(deserializer)?;
99
100 if let Some(hex) = s.strip_prefix('#')
102 && hex.len() == 6
103 {
104 let r = u8::from_str_radix(&hex[0..2], 16)
105 .map_err(|_| serde::de::Error::custom(format!("Invalid hex color: {s}")))?;
106 let g = u8::from_str_radix(&hex[2..4], 16)
107 .map_err(|_| serde::de::Error::custom(format!("Invalid hex color: {s}")))?;
108 let b = u8::from_str_radix(&hex[4..6], 16)
109 .map_err(|_| serde::de::Error::custom(format!("Invalid hex color: {s}")))?;
110 return Ok(RgbColor(r, g, b));
111 }
112
113 match s.to_lowercase().as_str() {
115 "black" => Ok(RgbColor(0, 0, 0)),
116 "red" => Ok(RgbColor(255, 0, 0)),
117 "green" => Ok(RgbColor(0, 255, 0)),
118 "yellow" => Ok(RgbColor(255, 255, 0)),
119 "blue" => Ok(RgbColor(0, 0, 255)),
120 "magenta" => Ok(RgbColor(255, 0, 255)),
121 "cyan" => Ok(RgbColor(0, 255, 255)),
122 "white" => Ok(RgbColor(255, 255, 255)),
123 "gray" | "grey" => Ok(RgbColor(128, 128, 128)),
124 "darkgray" | "darkgrey" | "dark_gray" | "dark_grey" => Ok(RgbColor(64, 64, 64)),
125 _ => Err(serde::de::Error::custom(format!("Unknown color: {s}"))),
126 }
127 }
128}
129
130impl From<RgbColor> for Color {
131 fn from(rgb: RgbColor) -> Self {
132 Color::Rgb(rgb.0, rgb.1, rgb.2)
133 }
134}
135
136#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Deserialize, Serialize)]
138#[serde(rename_all = "snake_case")]
139pub enum Component {
140 StatusBar,
142
143 InputPanelBorder,
145 InputPanelBackground,
146 InputPanelBorderActive,
147 InputPanelBorderCommand,
148 InputPanelBorderApproval,
149 InputPanelBorderError,
150 InputPanelBorderEdit,
151 InputPanelLabel,
152 InputPanelLabelActive,
153 InputPanelLabelCommand,
154 InputPanelLabelConfirmExit,
155 InputPanelLabelEdit,
156
157 ChatListBorder,
159 ChatListBackground,
160 UserMessage,
161 UserMessageRole,
162 UserMessageAccent,
163 UserMessageEdit,
164 UserMessageEditAccent,
165 AssistantMessage,
166 AssistantMessageRole,
167 AssistantMessageAccent,
168 SystemMessage,
169 SystemMessageRole,
170 SystemMessageAccent,
171
172 ToolAccent,
174 ToolCall,
175 ToolCallBorder,
176 ToolCallHeader,
177 ToolCallId,
178 ToolOutput,
179 ToolSuccess,
180 ToolError,
181
182 ThoughtBox,
184 ThoughtHeader,
185 ThoughtBorder,
186 ThoughtText,
187
188 CommandPrompt,
190 CommandText,
191 CommandSuccess,
192 CommandError,
193
194 ErrorText,
196 ErrorBold,
197 DimText,
198 SelectionHighlight,
199 PlaceholderText,
200
201 ModelInfo,
203
204 QueuedMessageBorder,
206 QueuedMessageText,
207 QueuedMessageLabel,
208
209 NoticeInfo,
211 NoticeWarn,
212 NoticeError,
213
214 TodoHigh,
216 TodoMedium,
217 TodoLow,
218 TodoPending,
219 TodoInProgress,
220 TodoCompleted,
221
222 CodeAddition,
224 CodeDeletion,
225 CodeFilePath,
226
227 PopupBorder,
229 PopupSelection,
230
231 MarkdownH1,
233 MarkdownH2,
234 MarkdownH3,
235 MarkdownH4,
236 MarkdownH5,
237 MarkdownH6,
238 MarkdownParagraph,
239 MarkdownBold,
240 MarkdownItalic,
241 MarkdownStrikethrough,
242 MarkdownCode,
243 MarkdownCodeBlock,
244 MarkdownLink,
245 MarkdownBlockquote,
246 MarkdownListBullet,
247 MarkdownListNumber,
248 MarkdownTaskChecked,
249 MarkdownTaskUnchecked,
250
251 MarkdownTableBorder,
253 MarkdownTableHeader,
254 MarkdownTableCell,
255
256 SetupTitle,
258 SetupBorder,
259 SetupBorderActive,
260 SetupHeader,
261 SetupText,
262 SetupHighlight,
263 SetupKeyBinding,
264 SetupProviderName,
265 SetupProviderSelected,
266 SetupStatusActive,
267 SetupStatusInactive,
268 SetupStatusInProgress,
269 SetupSuccessIcon,
270 SetupErrorMessage,
271 SetupHint,
272 SetupUrl,
273 SetupInputLabel,
274 SetupInputValue,
275 SetupBigText,
276}
277
278impl fmt::Display for Component {
279 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280 write!(f, "{self:?}")
281 }
282}
283
284#[derive(Debug, Clone)]
286pub struct CompiledTheme {
287 pub name: String,
288 pub styles: HashMap<Component, Style>,
289 pub background_color: Option<Color>,
290 pub syntax_theme: Option<syntect::highlighting::Theme>,
291}
292
293impl RawTheme {
294 pub fn into_theme(self) -> Result<Theme, ThemeError> {
296 let mut styles = HashMap::new();
297
298 let background_color = self.palette.get("background").map(|&rgb| rgb.into());
300
301 let syntax_theme = if let Some(syntax_config) = &self.syntax {
303 debug!("Loading syntect theme from config: {:?}", syntax_config);
304 Some(load_syntect_theme(syntax_config)?)
305 } else {
306 debug!("No syntax config found in theme");
307 None
308 };
309
310 for (component, style_def) in &self.components {
312 let mut style = Style::default();
313
314 if let Some(fg) = &style_def.fg {
316 let color = self.resolve_color(fg.clone())?;
317 style = style.fg(color);
318 }
319
320 if let Some(bg) = &style_def.bg {
322 let color = self.resolve_color(bg.clone())?;
323 style = style.bg(color);
324 }
325
326 if style_def.bold {
328 style = style.add_modifier(Modifier::BOLD);
329 }
330 if style_def.italic {
331 style = style.add_modifier(Modifier::ITALIC);
332 }
333 if style_def.underlined {
334 style = style.add_modifier(Modifier::UNDERLINED);
335 }
336
337 styles.insert(*component, style);
338 }
339
340 Ok(Theme {
341 name: self.name,
342 styles,
343 background_color,
344 syntax_theme,
345 })
346 }
347
348 fn resolve_color(&self, color_value: ColorValue) -> Result<Color, ThemeError> {
350 match color_value {
351 ColorValue::Palette(name) => {
352 self.palette
354 .get(&name)
355 .map(|&rgb| rgb.into())
356 .ok_or(ThemeError::ColorNotFound(name))
357 }
358 ColorValue::Direct(color_str) => {
359 parse_direct_color(&color_str)
361 }
362 }
363 }
364}
365
366fn load_syntect_theme(config: &SyntaxConfig) -> Result<syntect::highlighting::Theme, ThemeError> {
368 if let Some(theme_name) = &config.syntect_theme {
369 THEME_SET.themes.get(theme_name).cloned().ok_or_else(|| {
371 ThemeError::Validation(format!("Syntect theme '{theme_name}' not found"))
372 })
373 } else {
374 THEME_SET
376 .themes
377 .get("base16-ocean.dark")
378 .cloned()
379 .ok_or_else(|| ThemeError::Validation("Default syntect theme not found".to_string()))
380 }
381}
382
383fn parse_direct_color(color_str: &str) -> Result<Color, ThemeError> {
384 if let Some(hex) = color_str.strip_prefix('#')
386 && hex.len() == 6
387 {
388 let r = u8::from_str_radix(&hex[0..2], 16)
389 .map_err(|_| ThemeError::InvalidColor(color_str.to_string()))?;
390 let g = u8::from_str_radix(&hex[2..4], 16)
391 .map_err(|_| ThemeError::InvalidColor(color_str.to_string()))?;
392 let b = u8::from_str_radix(&hex[4..6], 16)
393 .map_err(|_| ThemeError::InvalidColor(color_str.to_string()))?;
394 return Ok(Color::Rgb(r, g, b));
395 }
396
397 match color_str.to_lowercase().as_str() {
399 "black" => Ok(Color::Black),
400 "red" => Ok(Color::Red),
401 "green" => Ok(Color::Green),
402 "yellow" => Ok(Color::Yellow),
403 "blue" => Ok(Color::Blue),
404 "magenta" => Ok(Color::Magenta),
405 "cyan" => Ok(Color::Cyan),
406 "white" => Ok(Color::White),
407 "gray" | "grey" => Ok(Color::Gray),
408 "darkgray" | "darkgrey" | "dark_gray" | "dark_grey" => Ok(Color::DarkGray),
409 "lightred" | "light_red" => Ok(Color::LightRed),
410 "lightgreen" | "light_green" => Ok(Color::LightGreen),
411 "lightyellow" | "light_yellow" => Ok(Color::LightYellow),
412 "lightblue" | "light_blue" => Ok(Color::LightBlue),
413 "lightmagenta" | "light_magenta" => Ok(Color::LightMagenta),
414 "lightcyan" | "light_cyan" => Ok(Color::LightCyan),
415 "reset" => Ok(Color::Reset),
416 _ => Err(ThemeError::InvalidColor(color_str.to_string())),
417 }
418}
419
420impl CompiledTheme {
421 pub fn style(&self, component: Component) -> Style {
423 self.styles.get(&component).copied().unwrap_or_default()
424 }
425
426 pub fn get_background_color(&self) -> Option<Color> {
428 self.background_color
429 }
430
431 pub fn error_text(&self) -> Style {
433 self.style(Component::ErrorText)
434 }
435
436 pub fn dim_text(&self) -> Style {
437 self.style(Component::DimText)
438 }
439
440 pub fn subtle_text(&self) -> Style {
441 self.style(Component::DimText)
442 }
443
444 pub fn text(&self) -> Style {
445 Style::default()
446 }
447}
448
449impl Default for CompiledTheme {
450 fn default() -> Self {
451 create_default_theme()
452 }
453}
454
455fn create_default_theme() -> CompiledTheme {
457 let mut styles = HashMap::new();
458
459 styles.insert(Component::StatusBar, Style::default().fg(Color::LightCyan));
461
462 styles.insert(
464 Component::InputPanelBorder,
465 Style::default().fg(Color::DarkGray),
466 );
467 styles.insert(
468 Component::InputPanelBackground,
469 Style::default().bg(Color::Rgb(30, 35, 40)),
470 );
471 styles.insert(
472 Component::InputPanelBorderActive,
473 Style::default().fg(Color::Yellow),
474 );
475 styles.insert(
476 Component::InputPanelBorderCommand,
477 Style::default().fg(Color::Cyan),
478 );
479 styles.insert(
480 Component::InputPanelBorderApproval,
481 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
482 );
483 styles.insert(
484 Component::InputPanelBorderError,
485 Style::default()
486 .fg(Color::LightRed)
487 .add_modifier(Modifier::BOLD),
488 );
489 styles.insert(
490 Component::InputPanelBorderEdit,
491 Style::default().fg(Color::Yellow),
492 );
493 styles.insert(
494 Component::InputPanelLabelEdit,
495 Style::default().fg(Color::Yellow),
496 );
497
498 styles.insert(
500 Component::ChatListBorder,
501 Style::default().fg(Color::DarkGray),
502 );
503 styles.insert(Component::ChatListBackground, Style::default());
504 styles.insert(
505 Component::UserMessage,
506 Style::default().bg(Color::Rgb(30, 35, 40)),
507 );
508 styles.insert(
509 Component::UserMessageRole,
510 Style::default()
511 .fg(Color::Green)
512 .add_modifier(Modifier::BOLD),
513 );
514 styles.insert(
515 Component::UserMessageAccent,
516 Style::default().fg(Color::Green),
517 );
518 styles.insert(
519 Component::UserMessageEdit,
520 Style::default().bg(Color::Rgb(40, 45, 50)),
521 );
522 styles.insert(
523 Component::UserMessageEditAccent,
524 Style::default().fg(Color::Yellow),
525 );
526 styles.insert(Component::AssistantMessage, Style::default());
527 styles.insert(
528 Component::AssistantMessageRole,
529 Style::default()
530 .fg(Color::Blue)
531 .add_modifier(Modifier::BOLD),
532 );
533 styles.insert(
534 Component::AssistantMessageAccent,
535 Style::default().fg(Color::DarkGray),
536 );
537 styles.insert(Component::SystemMessage, Style::default());
538 styles.insert(
539 Component::SystemMessageRole,
540 Style::default().fg(Color::Yellow),
541 );
542 styles.insert(
543 Component::SystemMessageAccent,
544 Style::default().fg(Color::DarkGray),
545 );
546
547 styles.insert(Component::ToolAccent, Style::default().fg(Color::DarkGray));
549 styles.insert(Component::ToolCall, Style::default().fg(Color::Cyan));
550 styles.insert(Component::ToolCallBorder, Style::default().fg(Color::Cyan));
551 styles.insert(Component::ToolCallHeader, Style::default().fg(Color::Cyan));
552 styles.insert(Component::ToolCallId, Style::default().fg(Color::DarkGray));
553 styles.insert(Component::ToolOutput, Style::default());
554 styles.insert(Component::ToolSuccess, Style::default().fg(Color::Green));
555 styles.insert(Component::ToolError, Style::default().fg(Color::Red));
556
557 styles.insert(Component::ThoughtBox, Style::default().fg(Color::DarkGray));
559 styles.insert(Component::ThoughtHeader, Style::default().fg(Color::Gray));
560 styles.insert(
561 Component::ThoughtBorder,
562 Style::default().fg(Color::DarkGray),
563 );
564 styles.insert(
565 Component::ThoughtText,
566 Style::default()
567 .fg(Color::DarkGray)
568 .add_modifier(Modifier::ITALIC),
569 );
570
571 styles.insert(
573 Component::CommandPrompt,
574 Style::default()
575 .fg(Color::Green)
576 .add_modifier(Modifier::BOLD),
577 );
578 styles.insert(Component::CommandText, Style::default().fg(Color::Cyan));
579 styles.insert(Component::CommandSuccess, Style::default().fg(Color::Green));
580 styles.insert(Component::CommandError, Style::default().fg(Color::Red));
581
582 styles.insert(Component::ErrorText, Style::default().fg(Color::Red));
584 styles.insert(
585 Component::ErrorBold,
586 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
587 );
588 styles.insert(Component::DimText, Style::default().fg(Color::DarkGray));
589 styles.insert(
590 Component::SelectionHighlight,
591 Style::default()
592 .fg(Color::Yellow)
593 .add_modifier(Modifier::BOLD),
594 );
595 styles.insert(
596 Component::PlaceholderText,
597 Style::default()
598 .fg(Color::DarkGray)
599 .add_modifier(Modifier::ITALIC),
600 );
601
602 styles.insert(
604 Component::ModelInfo,
605 Style::default().fg(Color::LightMagenta),
606 );
607
608 styles.insert(Component::NoticeInfo, Style::default().fg(Color::Blue));
610 styles.insert(Component::NoticeWarn, Style::default().fg(Color::Yellow));
611 styles.insert(Component::NoticeError, Style::default().fg(Color::Red));
612
613 styles.insert(Component::TodoHigh, Style::default().fg(Color::Red));
615 styles.insert(Component::TodoMedium, Style::default().fg(Color::Yellow));
616 styles.insert(Component::TodoLow, Style::default().fg(Color::Green));
617 styles.insert(Component::TodoPending, Style::default().fg(Color::Blue));
618 styles.insert(
619 Component::TodoInProgress,
620 Style::default().fg(Color::Yellow),
621 );
622 styles.insert(Component::TodoCompleted, Style::default().fg(Color::Green));
623
624 styles.insert(Component::CodeAddition, Style::default().fg(Color::Green));
626 styles.insert(Component::CodeDeletion, Style::default().fg(Color::Red));
627 styles.insert(Component::CodeFilePath, Style::default().fg(Color::Yellow));
628
629 styles.insert(Component::PopupBorder, Style::default().fg(Color::White));
631 styles.insert(
632 Component::PopupSelection,
633 Style::default().fg(Color::Yellow).bg(Color::DarkGray),
634 );
635
636 styles.insert(Component::MarkdownH1, Style::default().fg(Color::Cyan));
638 styles.insert(Component::MarkdownH2, Style::default().fg(Color::Cyan));
639 styles.insert(Component::MarkdownH3, Style::default().fg(Color::Cyan));
640 styles.insert(Component::MarkdownH4, Style::default().fg(Color::LightCyan));
641 styles.insert(Component::MarkdownH5, Style::default().fg(Color::LightCyan));
642 styles.insert(Component::MarkdownH6, Style::default().fg(Color::Gray));
643 styles.insert(Component::MarkdownParagraph, Style::default());
644 styles.insert(Component::MarkdownBold, Style::default());
645 styles.insert(Component::MarkdownItalic, Style::default());
646 styles.insert(Component::MarkdownStrikethrough, Style::default());
647 styles.insert(
648 Component::MarkdownCode,
649 Style::default().fg(Color::White).bg(Color::Black),
650 );
651 styles.insert(
652 Component::MarkdownCodeBlock,
653 Style::default().bg(Color::Black),
654 );
655 styles.insert(Component::MarkdownLink, Style::default().fg(Color::Blue));
656 styles.insert(
657 Component::MarkdownBlockquote,
658 Style::default().fg(Color::Green),
659 );
660 styles.insert(
661 Component::MarkdownListBullet,
662 Style::default().fg(Color::Gray),
663 );
664 styles.insert(
665 Component::MarkdownListNumber,
666 Style::default().fg(Color::LightBlue),
667 );
668
669 styles.insert(
671 Component::MarkdownTableBorder,
672 Style::default().fg(Color::DarkGray),
673 );
674 styles.insert(
675 Component::MarkdownTableHeader,
676 Style::default()
677 .fg(Color::Cyan)
678 .add_modifier(Modifier::BOLD),
679 );
680 styles.insert(Component::MarkdownTableCell, Style::default());
681
682 styles.insert(
684 Component::MarkdownTaskChecked,
685 Style::default().fg(Color::Green),
686 );
687 styles.insert(
688 Component::MarkdownTaskUnchecked,
689 Style::default().fg(Color::Gray),
690 );
691
692 styles.insert(
694 Component::SetupTitle,
695 Style::default()
696 .fg(Color::Cyan)
697 .add_modifier(Modifier::BOLD),
698 );
699 styles.insert(Component::SetupBorder, Style::default().fg(Color::DarkGray));
700 styles.insert(
701 Component::SetupBorderActive,
702 Style::default().fg(Color::Yellow),
703 );
704 styles.insert(
705 Component::SetupHeader,
706 Style::default()
707 .fg(Color::Cyan)
708 .add_modifier(Modifier::BOLD),
709 );
710 styles.insert(Component::SetupText, Style::default());
711 styles.insert(
712 Component::SetupHighlight,
713 Style::default()
714 .bg(Color::DarkGray)
715 .add_modifier(Modifier::BOLD),
716 );
717 styles.insert(
718 Component::SetupKeyBinding,
719 Style::default()
720 .fg(Color::Green)
721 .add_modifier(Modifier::BOLD),
722 );
723 styles.insert(Component::SetupProviderName, Style::default());
724 styles.insert(
725 Component::SetupProviderSelected,
726 Style::default()
727 .bg(Color::DarkGray)
728 .add_modifier(Modifier::BOLD),
729 );
730 styles.insert(
731 Component::SetupStatusActive,
732 Style::default().fg(Color::Green),
733 );
734 styles.insert(
735 Component::SetupStatusInactive,
736 Style::default().fg(Color::Red),
737 );
738 styles.insert(
739 Component::SetupStatusInProgress,
740 Style::default().fg(Color::Yellow),
741 );
742 styles.insert(
743 Component::SetupSuccessIcon,
744 Style::default()
745 .fg(Color::Green)
746 .add_modifier(Modifier::BOLD),
747 );
748 styles.insert(
749 Component::SetupErrorMessage,
750 Style::default().fg(Color::Red),
751 );
752 styles.insert(Component::SetupHint, Style::default().fg(Color::DarkGray));
753 styles.insert(
754 Component::SetupUrl,
755 Style::default()
756 .fg(Color::Blue)
757 .add_modifier(Modifier::UNDERLINED),
758 );
759 styles.insert(Component::SetupInputLabel, Style::default());
760 styles.insert(
761 Component::SetupInputValue,
762 Style::default().fg(Color::Yellow),
763 );
764 styles.insert(
765 Component::SetupBigText,
766 Style::default()
767 .fg(Color::Cyan)
768 .add_modifier(Modifier::BOLD),
769 );
770
771 CompiledTheme {
772 name: "Default".to_string(),
773 styles,
774 background_color: None, syntax_theme: None,
776 }
777}