1use crate::cell::Color;
8use ratatui::style::Style;
9use serde::Deserialize;
10use std::fmt;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::time::Instant;
14
15#[derive(Clone, Debug)]
21pub struct Theme {
22 pub name: String,
24 pub colors: ColorScheme,
26 pub spacing: Spacing,
28}
29
30#[derive(Clone, Debug)]
36pub struct ColorScheme {
37 pub foreground: Color,
39 pub background: Color,
41 pub primary: Color,
43 pub secondary: Color,
45 pub error: Color,
47 pub warning: Color,
49 pub success: Color,
51 pub muted: Color,
53 pub accent: Color,
55 pub border: Color,
57 pub user_border: Color,
59 pub user_bg: Color,
61 pub cursor_fg: Color,
63 pub cursor_bg: Color,
65 pub selection_bg: Color,
67 pub code_fg: Color,
69 pub code_bg: Color,
71 pub tool_pending_bg: Color,
73 pub tool_executing_bg: Color,
75 pub tool_success_bg: Color,
77 pub tool_error_bg: Color,
79}
80
81impl Default for ColorScheme {
82 fn default() -> Self {
83 Self::dark()
84 }
85}
86
87impl ColorScheme {
88 pub fn dark() -> Self {
90 Self {
91 foreground: Color::Rgb(205, 214, 244), background: Color::Rgb(0, 0, 0), primary: Color::Rgb(122, 162, 247), secondary: Color::Rgb(158, 206, 106), error: Color::Rgb(247, 118, 142), warning: Color::Rgb(224, 175, 104), success: Color::Rgb(158, 206, 106), muted: Color::Rgb(80, 80, 100), accent: Color::Rgb(187, 154, 247), border: Color::Rgb(30, 30, 30), user_border: Color::Rgb(122, 162, 247), user_bg: Color::Rgb(18, 22, 38), cursor_fg: Color::Rgb(0, 0, 0), cursor_bg: Color::Rgb(205, 214, 244), selection_bg: Color::Rgb(40, 40, 60), code_fg: Color::Rgb(255, 200, 100), code_bg: Color::Rgb(35, 30, 20), tool_pending_bg: Color::Rgb(18, 20, 28), tool_executing_bg: Color::Rgb(28, 24, 14), tool_success_bg: Color::Rgb(16, 26, 14), tool_error_bg: Color::Rgb(32, 16, 18), }
113 }
114
115 pub fn light() -> Self {
117 Self {
118 foreground: Color::Rgb(76, 79, 105), background: Color::Rgb(239, 241, 245), primary: Color::Rgb(30, 102, 240), secondary: Color::Rgb(64, 160, 43), error: Color::Rgb(210, 15, 57), warning: Color::Rgb(223, 142, 29), success: Color::Rgb(64, 160, 43), muted: Color::Indexed(8),
126 accent: Color::Rgb(136, 57, 239), border: Color::Indexed(7),
128 user_border: Color::Rgb(30, 102, 240), user_bg: Color::Rgb(225, 236, 255), cursor_fg: Color::Rgb(239, 241, 245),
131 cursor_bg: Color::Rgb(76, 79, 105),
132 selection_bg: Color::Rgb(204, 208, 218),
133 code_fg: Color::Rgb(180, 60, 60), code_bg: Color::Rgb(240, 240, 245), tool_pending_bg: Color::Rgb(235, 238, 245), tool_executing_bg: Color::Rgb(255, 248, 230), tool_success_bg: Color::Rgb(230, 248, 230), tool_error_bg: Color::Rgb(255, 230, 235), }
140 }
141
142 pub fn to_style(&self) -> Style {
144 Style::default()
145 .fg(self.foreground.to_ratatui())
146 .bg(self.background.to_ratatui())
147 }
148
149 pub fn to_styles(&self) -> ThemeStyles {
151 ThemeStyles {
152 normal: Style::default().fg(self.foreground.to_ratatui()),
153 primary: Style::default().fg(self.primary.to_ratatui()),
154 secondary: Style::default().fg(self.secondary.to_ratatui()),
155 error: Style::default().fg(self.error.to_ratatui()),
156 warning: Style::default().fg(self.warning.to_ratatui()),
157 success: Style::default().fg(self.success.to_ratatui()),
158 muted: Style::default().fg(self.muted.to_ratatui()),
159 accent: Style::default().fg(self.accent.to_ratatui()),
160 border: Style::default().fg(self.border.to_ratatui()),
161 cursor_fg: Style::default().fg(self.cursor_fg.to_ratatui()),
162 cursor_bg: Style::default().fg(self.cursor_bg.to_ratatui()),
163 selection_bg: Style::default().bg(self.selection_bg.to_ratatui()),
164 user_border: Style::default().fg(self.user_border.to_ratatui()),
165 user_bg: Style::default().bg(self.user_bg.to_ratatui()),
166 tool_pending_bg: Style::default().bg(self.tool_pending_bg.to_ratatui()),
167 tool_executing_bg: Style::default().bg(self.tool_executing_bg.to_ratatui()),
168 tool_success_bg: Style::default().bg(self.tool_success_bg.to_ratatui()),
169 tool_error_bg: Style::default().bg(self.tool_error_bg.to_ratatui()),
170 }
171 }
172}
173
174#[derive(Debug, Clone, Copy, Default)]
176pub struct ThemeStyles {
177 pub normal: Style,
179 pub primary: Style,
181 pub secondary: Style,
183 pub error: Style,
185 pub warning: Style,
187 pub success: Style,
189 pub muted: Style,
191 pub accent: Style,
193 pub border: Style,
195 pub cursor_fg: Style,
197 pub cursor_bg: Style,
199 pub selection_bg: Style,
201 pub user_border: Style,
203 pub user_bg: Style,
205 pub tool_pending_bg: Style,
207 pub tool_executing_bg: Style,
209 pub tool_success_bg: Style,
211 pub tool_error_bg: Style,
213}
214
215#[derive(Clone, Debug, Copy)]
223pub struct Spacing {
224 pub padding: u16,
226 pub margin: u16,
228 pub border_width: u16,
230 pub line_spacing: u16,
232}
233
234impl Default for Spacing {
235 fn default() -> Self {
236 Self {
237 padding: 1,
238 margin: 0,
239 border_width: 1,
240 line_spacing: 0,
241 }
242 }
243}
244
245impl Theme {
250 pub fn dark() -> Self {
252 Self {
253 name: "dark".into(),
254 colors: ColorScheme::dark(),
255 spacing: Spacing::default(),
256 }
257 }
258
259 pub fn light() -> Self {
261 Self {
262 name: "light".into(),
263 colors: ColorScheme::light(),
264 spacing: Spacing::default(),
265 }
266 }
267
268 pub fn to_style(&self) -> Style {
270 self.colors.to_style()
271 }
272
273 pub fn to_styles(&self) -> ThemeStyles {
275 self.colors.to_styles()
276 }
277}
278
279impl Default for Theme {
280 fn default() -> Self {
281 Self::dark()
282 }
283}
284
285#[derive(Clone, Debug, Deserialize, Default)]
291pub struct ThemeFile {
292 #[serde(default)]
294 pub name: String,
295 #[serde(default)]
297 pub colors: ThemeFileColors,
298}
299
300#[derive(Clone, Debug, Deserialize, Default)]
302pub struct ThemeFileColors {
303 pub foreground: Option<String>,
305 pub background: Option<String>,
307 pub primary: Option<String>,
309 pub secondary: Option<String>,
311 pub error: Option<String>,
313 pub warning: Option<String>,
315 pub success: Option<String>,
317 pub muted: Option<String>,
319 pub accent: Option<String>,
321 pub border: Option<String>,
323 pub user_border: Option<String>,
325 pub user_bg: Option<String>,
327 pub cursor_fg: Option<String>,
329 pub cursor_bg: Option<String>,
331 pub selection_bg: Option<String>,
333 pub code_fg: Option<String>,
335 pub code_bg: Option<String>,
337 pub tool_pending_bg: Option<String>,
339 pub tool_executing_bg: Option<String>,
341 pub tool_success_bg: Option<String>,
343 pub tool_error_bg: Option<String>,
345}
346
347impl ThemeFile {
348 pub fn from_toml(path: &Path) -> anyhow::Result<Self> {
350 let content = std::fs::read_to_string(path)?;
351 let theme: ThemeFile = toml::from_str(&content)?;
352 Ok(theme)
353 }
354
355 pub fn from_json(path: &Path) -> anyhow::Result<Self> {
357 let content = std::fs::read_to_string(path)?;
358 let theme: ThemeFile = serde_json::from_str(&content)?;
359 Ok(theme)
360 }
361
362 pub fn load(path: &Path) -> anyhow::Result<Self> {
364 match path.extension().and_then(|e| e.to_str()) {
365 Some("toml") => Self::from_toml(path),
366 Some("json") => Self::from_json(path),
367 _ => anyhow::bail!(
368 "Unsupported theme file format: {:?}. Use .toml or .json",
369 path.extension()
370 ),
371 }
372 }
373
374 pub fn into_theme(self) -> Theme {
376 let defaults = ColorScheme::dark();
377
378 fn resolve(value: Option<String>, fallback: Color, field_name: &str) -> Color {
380 match value.as_deref().and_then(parse_color) {
381 Some(c) => c,
382 None => {
383 if let Some(ref v) = value {
384 tracing::warn!(
385 "Invalid theme color for '{}': '{}' - using default",
386 field_name,
387 v
388 );
389 }
390 fallback
391 }
392 }
393 }
394
395 let colors = ColorScheme {
396 foreground: resolve(self.colors.foreground, defaults.foreground, "foreground"),
397 background: resolve(self.colors.background, defaults.background, "background"),
398 primary: resolve(self.colors.primary, defaults.primary, "primary"),
399 secondary: resolve(self.colors.secondary, defaults.secondary, "secondary"),
400 error: resolve(self.colors.error, defaults.error, "error"),
401 warning: resolve(self.colors.warning, defaults.warning, "warning"),
402 success: resolve(self.colors.success, defaults.success, "success"),
403 muted: resolve(self.colors.muted, defaults.muted, "muted"),
404 accent: resolve(self.colors.accent, defaults.accent, "accent"),
405 border: resolve(self.colors.border, defaults.border, "border"),
406 user_border: resolve(self.colors.user_border, defaults.user_border, "user_border"),
407 user_bg: resolve(self.colors.user_bg, defaults.user_bg, "user_bg"),
408 cursor_fg: resolve(self.colors.cursor_fg, defaults.cursor_fg, "cursor_fg"),
409 cursor_bg: resolve(self.colors.cursor_bg, defaults.cursor_bg, "cursor_bg"),
410 selection_bg: resolve(
411 self.colors.selection_bg,
412 defaults.selection_bg,
413 "selection_bg",
414 ),
415 code_fg: resolve(self.colors.code_fg, defaults.code_fg, "code_fg"),
416 code_bg: resolve(self.colors.code_bg, defaults.code_bg, "code_bg"),
417 tool_pending_bg: resolve(
418 self.colors.tool_pending_bg,
419 defaults.tool_pending_bg,
420 "tool_pending_bg",
421 ),
422 tool_executing_bg: resolve(
423 self.colors.tool_executing_bg,
424 defaults.tool_executing_bg,
425 "tool_executing_bg",
426 ),
427 tool_success_bg: resolve(
428 self.colors.tool_success_bg,
429 defaults.tool_success_bg,
430 "tool_success_bg",
431 ),
432 tool_error_bg: resolve(
433 self.colors.tool_error_bg,
434 defaults.tool_error_bg,
435 "tool_error_bg",
436 ),
437 };
438 Theme {
439 name: if self.name.is_empty() {
440 "custom".into()
441 } else {
442 self.name
443 },
444 colors,
445 spacing: Spacing::default(),
446 }
447 }
448}
449
450fn parse_color(s: &str) -> Option<Color> {
458 let s = s.trim();
459 if let Some(hex) = s.strip_prefix('#') {
461 return parse_hex(hex);
462 }
463 if let Some(idx_str) = s.strip_prefix('i') {
465 if let Ok(n) = idx_str.parse::<u8>() {
466 return Some(Color::Indexed(n));
467 }
468 }
469 match s.to_lowercase().as_str() {
471 "black" => Some(Color::Black),
472 "red" => Some(Color::Red),
473 "green" => Some(Color::Green),
474 "yellow" => Some(Color::Yellow),
475 "blue" => Some(Color::Blue),
476 "magenta" => Some(Color::Magenta),
477 "cyan" => Some(Color::Cyan),
478 "white" => Some(Color::White),
479 "bright-black" | "brightblack" | "gray" | "grey" => Some(Color::Indexed(8)),
480 "bright-red" | "brightred" => Some(Color::Indexed(9)),
481 "bright-green" | "brightgreen" => Some(Color::Indexed(10)),
482 "bright-yellow" | "brightyellow" => Some(Color::Indexed(11)),
483 "bright-blue" | "brightblue" => Some(Color::Indexed(12)),
484 "bright-magenta" | "brightmagenta" => Some(Color::Indexed(13)),
485 "bright-cyan" | "brightcyan" => Some(Color::Indexed(14)),
486 "bright-white" | "brightwhite" => Some(Color::Indexed(15)),
487 "default" => Some(Color::Default),
488 _ => None,
489 }
490}
491
492fn parse_hex(hex: &str) -> Option<Color> {
493 match hex.len() {
494 6 => {
495 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
496 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
497 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
498 Some(Color::Rgb(r, g, b))
499 }
500 3 => {
501 let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
502 let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
503 let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
504 Some(Color::Rgb(r, g, b))
505 }
506 _ => None,
507 }
508}
509
510pub struct ThemeManager {
516 theme: Arc<parking_lot::RwLock<Theme>>,
518 watch_path: Option<PathBuf>,
520 last_modified: Option<std::time::SystemTime>,
522 poll_interval: std::time::Duration,
524 last_poll: Instant,
526}
527
528impl ThemeManager {
529 pub fn new(theme: Theme) -> Self {
531 Self {
532 theme: Arc::new(parking_lot::RwLock::new(theme)),
533 watch_path: None,
534 last_modified: None,
535 poll_interval: std::time::Duration::from_secs(1),
536 last_poll: Instant::now(),
537 }
538 }
539
540 pub fn dark() -> Self {
542 Self::new(Theme::dark())
543 }
544
545 pub fn light() -> Self {
547 Self::new(Theme::light())
548 }
549
550 pub fn watch_file(&mut self, path: impl Into<PathBuf>) -> anyhow::Result<()> {
556 let path = path.into();
557 let file = ThemeFile::load(&path)?;
559 let theme = file.into_theme();
560 *self.theme.write() = theme;
561 self.last_modified = std::fs::metadata(&path)
562 .ok()
563 .and_then(|m| m.modified().ok());
564 self.watch_path = Some(path);
565 Ok(())
566 }
567
568 pub fn theme(&self) -> Theme {
570 self.theme.read().clone()
571 }
572
573 pub fn theme_handle(&self) -> Arc<parking_lot::RwLock<Theme>> {
575 Arc::clone(&self.theme)
576 }
577
578 pub fn set_theme(&self, theme: Theme) {
580 *self.theme.write() = theme;
581 }
582
583 pub fn set_theme_by_name(&self, name: &str) -> bool {
587 let theme = match name {
588 "dark" => Theme::dark(),
589 "light" => Theme::light(),
590 _ => return false,
591 };
592 self.set_theme(theme);
593 true
594 }
595
596 pub fn check_reload(&mut self) -> bool {
601 let path = match &self.watch_path {
602 Some(p) => p.clone(),
603 None => return false,
604 };
605
606 if self.last_poll.elapsed() < self.poll_interval {
608 return false;
609 }
610 self.last_poll = Instant::now();
611
612 let current_mtime = match std::fs::metadata(&path)
613 .ok()
614 .and_then(|m| m.modified().ok())
615 {
616 Some(t) => t,
617 None => return false,
618 };
619
620 let changed = match self.last_modified {
621 Some(prev) => current_mtime > prev,
622 None => true,
623 };
624
625 if changed {
626 match ThemeFile::load(&path) {
627 Ok(file) => {
628 let theme = file.into_theme();
629 *self.theme.write() = theme;
630 self.last_modified = Some(current_mtime);
631 tracing::info!("Theme reloaded from {:?}", path);
632 true
633 }
634 Err(e) => {
635 tracing::warn!("Failed to reload theme from {:?}: {}", path, e);
636 false
637 }
638 }
639 } else {
640 false
641 }
642 }
643
644 pub fn set_poll_interval(&mut self, interval: std::time::Duration) {
646 self.poll_interval = interval;
647 }
648}
649
650impl fmt::Display for Theme {
651 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
652 write!(f, "Theme({})", self.name)
653 }
654}
655
656#[cfg(test)]
661mod tests {
662 use super::*;
663
664 #[test]
665 fn default_theme_is_dark() {
666 let theme = Theme::default();
667 assert_eq!(theme.name, "dark");
668 }
669
670 #[test]
671 fn dark_theme_has_light_foreground() {
672 let theme = Theme::dark();
673 match theme.colors.foreground {
675 Color::Rgb(r, _, _) => assert!(r > 200, "dark theme foreground should be light"),
676 _ => panic!("expected Rgb foreground"),
677 }
678 }
679
680 #[test]
681 fn light_theme_has_dark_foreground() {
682 let theme = Theme::light();
683 match theme.colors.foreground {
684 Color::Rgb(r, _, _) => assert!(r < 150, "light theme foreground should be dark"),
685 _ => panic!("expected Rgb foreground"),
686 }
687 }
688
689 #[test]
690 fn parse_hex_colors() {
691 assert_eq!(parse_color("#ff8800"), Some(Color::Rgb(255, 136, 0)));
692 assert_eq!(parse_color("#f80"), Some(Color::Rgb(255, 136, 0)));
693 }
694
695 #[test]
696 fn parse_named_colors() {
697 assert_eq!(parse_color("red"), Some(Color::Red));
698 assert_eq!(parse_color("bright-black"), Some(Color::Indexed(8)));
699 assert_eq!(parse_color("default"), Some(Color::Default));
700 }
701
702 #[test]
703 fn parse_indexed_color() {
704 assert_eq!(parse_color("i42"), Some(Color::Indexed(42)));
705 }
706
707 #[test]
708 fn theme_manager_set_by_name() {
709 let mgr = ThemeManager::dark();
710 assert!(mgr.set_theme_by_name("light"));
711 assert_eq!(mgr.theme().name, "light");
712 assert!(!mgr.set_theme_by_name("nonexistent"));
713 assert_eq!(mgr.theme().name, "light");
714 }
715
716 #[test]
717 fn theme_file_from_json() {
718 let json = r##"{"name":"test","colors":{"foreground":"#ffffff","background":"#000000"}}"##;
719 let file: ThemeFile = serde_json::from_str(json).unwrap();
720 let theme = file.into_theme();
721 assert_eq!(theme.name, "test");
722 assert_eq!(theme.colors.foreground, Color::Rgb(255, 255, 255));
723 assert_eq!(theme.colors.background, Color::Rgb(0, 0, 0));
724 }
725
726 #[test]
727 fn theme_file_roundtrip() {
728 let dir = std::env::temp_dir().join("oxi-tui-theme-test");
729 std::fs::create_dir_all(&dir).unwrap();
730
731 let json_path = dir.join("test_theme.json");
732 std::fs::write(
733 &json_path,
734 r##"{"name":"mytheme","colors":{"primary":"#ff0000"}}"##,
735 )
736 .unwrap();
737 let file = ThemeFile::load(&json_path).unwrap();
738 let theme = file.into_theme();
739 assert_eq!(theme.name, "mytheme");
740 assert_eq!(theme.colors.primary, Color::Rgb(255, 0, 0));
741 assert!(matches!(theme.colors.foreground, Color::Rgb(_, _, _)));
743
744 std::fs::remove_dir_all(&dir).ok();
745 }
746}