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 tool_pending_bg: Color,
69 pub tool_executing_bg: Color,
71 pub tool_success_bg: Color,
73 pub tool_error_bg: Color,
75}
76
77impl Default for ColorScheme {
78 fn default() -> Self {
79 Self::dark()
80 }
81}
82
83impl ColorScheme {
84 pub fn dark() -> Self {
86 Self {
87 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), 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), }
107 }
108
109 pub fn light() -> Self {
111 Self {
112 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),
120 accent: Color::Rgb(136, 57, 239), border: Color::Indexed(7),
122 user_border: Color::Rgb(30, 102, 240), user_bg: Color::Rgb(225, 236, 255), cursor_fg: Color::Rgb(239, 241, 245),
125 cursor_bg: Color::Rgb(76, 79, 105),
126 selection_bg: Color::Rgb(204, 208, 218),
127 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), }
132 }
133
134 pub fn to_style(&self) -> Style {
136 Style::default()
137 .fg(self.foreground.to_ratatui())
138 .bg(self.background.to_ratatui())
139 }
140
141 pub fn to_styles(&self) -> ThemeStyles {
143 ThemeStyles {
144 normal: Style::default().fg(self.foreground.to_ratatui()),
145 primary: Style::default().fg(self.primary.to_ratatui()),
146 secondary: Style::default().fg(self.secondary.to_ratatui()),
147 error: Style::default().fg(self.error.to_ratatui()),
148 warning: Style::default().fg(self.warning.to_ratatui()),
149 success: Style::default().fg(self.success.to_ratatui()),
150 muted: Style::default().fg(self.muted.to_ratatui()),
151 accent: Style::default().fg(self.accent.to_ratatui()),
152 border: Style::default().fg(self.border.to_ratatui()),
153 cursor_fg: Style::default().fg(self.cursor_fg.to_ratatui()),
154 cursor_bg: Style::default().fg(self.cursor_bg.to_ratatui()),
155 selection_bg: Style::default().bg(self.selection_bg.to_ratatui()),
156 user_border: Style::default().fg(self.user_border.to_ratatui()),
157 user_bg: Style::default().bg(self.user_bg.to_ratatui()),
158 tool_pending_bg: Style::default().bg(self.tool_pending_bg.to_ratatui()),
159 tool_executing_bg: Style::default().bg(self.tool_executing_bg.to_ratatui()),
160 tool_success_bg: Style::default().bg(self.tool_success_bg.to_ratatui()),
161 tool_error_bg: Style::default().bg(self.tool_error_bg.to_ratatui()),
162 }
163 }
164}
165
166#[derive(Debug, Clone, Copy, Default)]
168pub struct ThemeStyles {
169 pub normal: Style,
171 pub primary: Style,
173 pub secondary: Style,
175 pub error: Style,
177 pub warning: Style,
179 pub success: Style,
181 pub muted: Style,
183 pub accent: Style,
185 pub border: Style,
187 pub cursor_fg: Style,
189 pub cursor_bg: Style,
191 pub selection_bg: Style,
193 pub user_border: Style,
195 pub user_bg: Style,
197 pub tool_pending_bg: Style,
199 pub tool_executing_bg: Style,
201 pub tool_success_bg: Style,
203 pub tool_error_bg: Style,
205}
206
207#[derive(Clone, Debug, Copy)]
215pub struct Spacing {
216 pub padding: u16,
218 pub margin: u16,
220 pub border_width: u16,
222 pub line_spacing: u16,
224}
225
226impl Default for Spacing {
227 fn default() -> Self {
228 Self {
229 padding: 1,
230 margin: 0,
231 border_width: 1,
232 line_spacing: 0,
233 }
234 }
235}
236
237impl Theme {
242 pub fn dark() -> Self {
244 Self {
245 name: "dark".into(),
246 colors: ColorScheme::dark(),
247 spacing: Spacing::default(),
248 }
249 }
250
251 pub fn light() -> Self {
253 Self {
254 name: "light".into(),
255 colors: ColorScheme::light(),
256 spacing: Spacing::default(),
257 }
258 }
259
260 pub fn to_style(&self) -> Style {
262 self.colors.to_style()
263 }
264
265 pub fn to_styles(&self) -> ThemeStyles {
267 self.colors.to_styles()
268 }
269}
270
271impl Default for Theme {
272 fn default() -> Self {
273 Self::dark()
274 }
275}
276
277#[derive(Clone, Debug, Deserialize, Default)]
283pub struct ThemeFile {
284 #[serde(default)]
286 pub name: String,
287 #[serde(default)]
289 pub colors: ThemeFileColors,
290}
291
292#[derive(Clone, Debug, Deserialize, Default)]
294pub struct ThemeFileColors {
295 pub foreground: Option<String>,
297 pub background: Option<String>,
299 pub primary: Option<String>,
301 pub secondary: Option<String>,
303 pub error: Option<String>,
305 pub warning: Option<String>,
307 pub success: Option<String>,
309 pub muted: Option<String>,
311 pub accent: Option<String>,
313 pub border: Option<String>,
315 pub user_border: Option<String>,
317 pub user_bg: Option<String>,
319 pub cursor_fg: Option<String>,
321 pub cursor_bg: Option<String>,
323 pub selection_bg: Option<String>,
325 pub tool_pending_bg: Option<String>,
327 pub tool_executing_bg: Option<String>,
329 pub tool_success_bg: Option<String>,
331 pub tool_error_bg: Option<String>,
333}
334
335impl ThemeFile {
336 pub fn from_toml(path: &Path) -> anyhow::Result<Self> {
338 let content = std::fs::read_to_string(path)?;
339 let theme: ThemeFile = toml::from_str(&content)?;
340 Ok(theme)
341 }
342
343 pub fn from_json(path: &Path) -> anyhow::Result<Self> {
345 let content = std::fs::read_to_string(path)?;
346 let theme: ThemeFile = serde_json::from_str(&content)?;
347 Ok(theme)
348 }
349
350 pub fn load(path: &Path) -> anyhow::Result<Self> {
352 match path.extension().and_then(|e| e.to_str()) {
353 Some("toml") => Self::from_toml(path),
354 Some("json") => Self::from_json(path),
355 _ => anyhow::bail!(
356 "Unsupported theme file format: {:?}. Use .toml or .json",
357 path.extension()
358 ),
359 }
360 }
361
362 pub fn into_theme(self) -> Theme {
364 let defaults = ColorScheme::dark();
365
366 fn resolve(value: Option<String>, fallback: Color, field_name: &str) -> Color {
368 match value.as_deref().and_then(parse_color) {
369 Some(c) => c,
370 None => {
371 if let Some(ref v) = value {
372 tracing::warn!(
373 "Invalid theme color for '{}': '{}' - using default",
374 field_name,
375 v
376 );
377 }
378 fallback
379 }
380 }
381 }
382
383 let colors = ColorScheme {
384 foreground: resolve(self.colors.foreground, defaults.foreground, "foreground"),
385 background: resolve(self.colors.background, defaults.background, "background"),
386 primary: resolve(self.colors.primary, defaults.primary, "primary"),
387 secondary: resolve(self.colors.secondary, defaults.secondary, "secondary"),
388 error: resolve(self.colors.error, defaults.error, "error"),
389 warning: resolve(self.colors.warning, defaults.warning, "warning"),
390 success: resolve(self.colors.success, defaults.success, "success"),
391 muted: resolve(self.colors.muted, defaults.muted, "muted"),
392 accent: resolve(self.colors.accent, defaults.accent, "accent"),
393 border: resolve(self.colors.border, defaults.border, "border"),
394 user_border: resolve(self.colors.user_border, defaults.user_border, "user_border"),
395 user_bg: resolve(self.colors.user_bg, defaults.user_bg, "user_bg"),
396 cursor_fg: resolve(self.colors.cursor_fg, defaults.cursor_fg, "cursor_fg"),
397 cursor_bg: resolve(self.colors.cursor_bg, defaults.cursor_bg, "cursor_bg"),
398 selection_bg: resolve(
399 self.colors.selection_bg,
400 defaults.selection_bg,
401 "selection_bg",
402 ),
403 tool_pending_bg: resolve(
404 self.colors.tool_pending_bg,
405 defaults.tool_pending_bg,
406 "tool_pending_bg",
407 ),
408 tool_executing_bg: resolve(
409 self.colors.tool_executing_bg,
410 defaults.tool_executing_bg,
411 "tool_executing_bg",
412 ),
413 tool_success_bg: resolve(
414 self.colors.tool_success_bg,
415 defaults.tool_success_bg,
416 "tool_success_bg",
417 ),
418 tool_error_bg: resolve(
419 self.colors.tool_error_bg,
420 defaults.tool_error_bg,
421 "tool_error_bg",
422 ),
423 };
424 Theme {
425 name: if self.name.is_empty() {
426 "custom".into()
427 } else {
428 self.name
429 },
430 colors,
431 spacing: Spacing::default(),
432 }
433 }
434}
435
436fn parse_color(s: &str) -> Option<Color> {
444 let s = s.trim();
445 if let Some(hex) = s.strip_prefix('#') {
447 return parse_hex(hex);
448 }
449 if let Some(idx_str) = s.strip_prefix('i') {
451 if let Ok(n) = idx_str.parse::<u8>() {
452 return Some(Color::Indexed(n));
453 }
454 }
455 match s.to_lowercase().as_str() {
457 "black" => Some(Color::Black),
458 "red" => Some(Color::Red),
459 "green" => Some(Color::Green),
460 "yellow" => Some(Color::Yellow),
461 "blue" => Some(Color::Blue),
462 "magenta" => Some(Color::Magenta),
463 "cyan" => Some(Color::Cyan),
464 "white" => Some(Color::White),
465 "bright-black" | "brightblack" | "gray" | "grey" => Some(Color::Indexed(8)),
466 "bright-red" | "brightred" => Some(Color::Indexed(9)),
467 "bright-green" | "brightgreen" => Some(Color::Indexed(10)),
468 "bright-yellow" | "brightyellow" => Some(Color::Indexed(11)),
469 "bright-blue" | "brightblue" => Some(Color::Indexed(12)),
470 "bright-magenta" | "brightmagenta" => Some(Color::Indexed(13)),
471 "bright-cyan" | "brightcyan" => Some(Color::Indexed(14)),
472 "bright-white" | "brightwhite" => Some(Color::Indexed(15)),
473 "default" => Some(Color::Default),
474 _ => None,
475 }
476}
477
478fn parse_hex(hex: &str) -> Option<Color> {
479 match hex.len() {
480 6 => {
481 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
482 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
483 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
484 Some(Color::Rgb(r, g, b))
485 }
486 3 => {
487 let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
488 let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
489 let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
490 Some(Color::Rgb(r, g, b))
491 }
492 _ => None,
493 }
494}
495
496pub struct ThemeManager {
502 theme: Arc<parking_lot::RwLock<Theme>>,
504 watch_path: Option<PathBuf>,
506 last_modified: Option<std::time::SystemTime>,
508 poll_interval: std::time::Duration,
510 last_poll: Instant,
512}
513
514impl ThemeManager {
515 pub fn new(theme: Theme) -> Self {
517 Self {
518 theme: Arc::new(parking_lot::RwLock::new(theme)),
519 watch_path: None,
520 last_modified: None,
521 poll_interval: std::time::Duration::from_secs(1),
522 last_poll: Instant::now(),
523 }
524 }
525
526 pub fn dark() -> Self {
528 Self::new(Theme::dark())
529 }
530
531 pub fn light() -> Self {
533 Self::new(Theme::light())
534 }
535
536 pub fn watch_file(&mut self, path: impl Into<PathBuf>) -> anyhow::Result<()> {
542 let path = path.into();
543 let file = ThemeFile::load(&path)?;
545 let theme = file.into_theme();
546 *self.theme.write() = theme;
547 self.last_modified = std::fs::metadata(&path)
548 .ok()
549 .and_then(|m| m.modified().ok());
550 self.watch_path = Some(path);
551 Ok(())
552 }
553
554 pub fn theme(&self) -> Theme {
556 self.theme.read().clone()
557 }
558
559 pub fn theme_handle(&self) -> Arc<parking_lot::RwLock<Theme>> {
561 Arc::clone(&self.theme)
562 }
563
564 pub fn set_theme(&self, theme: Theme) {
566 *self.theme.write() = theme;
567 }
568
569 pub fn set_theme_by_name(&self, name: &str) -> bool {
573 let theme = match name {
574 "dark" => Theme::dark(),
575 "light" => Theme::light(),
576 _ => return false,
577 };
578 self.set_theme(theme);
579 true
580 }
581
582 pub fn check_reload(&mut self) -> bool {
587 let path = match &self.watch_path {
588 Some(p) => p.clone(),
589 None => return false,
590 };
591
592 if self.last_poll.elapsed() < self.poll_interval {
594 return false;
595 }
596 self.last_poll = Instant::now();
597
598 let current_mtime = match std::fs::metadata(&path)
599 .ok()
600 .and_then(|m| m.modified().ok())
601 {
602 Some(t) => t,
603 None => return false,
604 };
605
606 let changed = match self.last_modified {
607 Some(prev) => current_mtime > prev,
608 None => true,
609 };
610
611 if changed {
612 match ThemeFile::load(&path) {
613 Ok(file) => {
614 let theme = file.into_theme();
615 *self.theme.write() = theme;
616 self.last_modified = Some(current_mtime);
617 tracing::info!("Theme reloaded from {:?}", path);
618 true
619 }
620 Err(e) => {
621 tracing::warn!("Failed to reload theme from {:?}: {}", path, e);
622 false
623 }
624 }
625 } else {
626 false
627 }
628 }
629
630 pub fn set_poll_interval(&mut self, interval: std::time::Duration) {
632 self.poll_interval = interval;
633 }
634}
635
636impl fmt::Display for Theme {
637 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
638 write!(f, "Theme({})", self.name)
639 }
640}
641
642#[cfg(test)]
647mod tests {
648 use super::*;
649
650 #[test]
651 fn default_theme_is_dark() {
652 let theme = Theme::default();
653 assert_eq!(theme.name, "dark");
654 }
655
656 #[test]
657 fn dark_theme_has_light_foreground() {
658 let theme = Theme::dark();
659 match theme.colors.foreground {
661 Color::Rgb(r, _, _) => assert!(r > 200, "dark theme foreground should be light"),
662 _ => panic!("expected Rgb foreground"),
663 }
664 }
665
666 #[test]
667 fn light_theme_has_dark_foreground() {
668 let theme = Theme::light();
669 match theme.colors.foreground {
670 Color::Rgb(r, _, _) => assert!(r < 150, "light theme foreground should be dark"),
671 _ => panic!("expected Rgb foreground"),
672 }
673 }
674
675 #[test]
676 fn parse_hex_colors() {
677 assert_eq!(parse_color("#ff8800"), Some(Color::Rgb(255, 136, 0)));
678 assert_eq!(parse_color("#f80"), Some(Color::Rgb(255, 136, 0)));
679 }
680
681 #[test]
682 fn parse_named_colors() {
683 assert_eq!(parse_color("red"), Some(Color::Red));
684 assert_eq!(parse_color("bright-black"), Some(Color::Indexed(8)));
685 assert_eq!(parse_color("default"), Some(Color::Default));
686 }
687
688 #[test]
689 fn parse_indexed_color() {
690 assert_eq!(parse_color("i42"), Some(Color::Indexed(42)));
691 }
692
693 #[test]
694 fn theme_manager_set_by_name() {
695 let mgr = ThemeManager::dark();
696 assert!(mgr.set_theme_by_name("light"));
697 assert_eq!(mgr.theme().name, "light");
698 assert!(!mgr.set_theme_by_name("nonexistent"));
699 assert_eq!(mgr.theme().name, "light");
700 }
701
702 #[test]
703 fn theme_file_from_json() {
704 let json = r##"{"name":"test","colors":{"foreground":"#ffffff","background":"#000000"}}"##;
705 let file: ThemeFile = serde_json::from_str(json).unwrap();
706 let theme = file.into_theme();
707 assert_eq!(theme.name, "test");
708 assert_eq!(theme.colors.foreground, Color::Rgb(255, 255, 255));
709 assert_eq!(theme.colors.background, Color::Rgb(0, 0, 0));
710 }
711
712 #[test]
713 fn theme_file_roundtrip() {
714 let dir = std::env::temp_dir().join("oxi-tui-theme-test");
715 std::fs::create_dir_all(&dir).unwrap();
716
717 let json_path = dir.join("test_theme.json");
718 std::fs::write(
719 &json_path,
720 r##"{"name":"mytheme","colors":{"primary":"#ff0000"}}"##,
721 )
722 .unwrap();
723 let file = ThemeFile::load(&json_path).unwrap();
724 let theme = file.into_theme();
725 assert_eq!(theme.name, "mytheme");
726 assert_eq!(theme.colors.primary, Color::Rgb(255, 0, 0));
727 assert!(matches!(theme.colors.foreground, Color::Rgb(_, _, _)));
729
730 std::fs::remove_dir_all(&dir).ok();
731 }
732}