//! Theming system for OWLEN TUI //! //! Provides customizable color schemes for all UI components. use ratatui::style::Color; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; /// A complete theme definition for OWLEN TUI #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Theme { /// Name of the theme pub name: String, /// Default text color #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub text: Color, /// Default background color #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub background: Color, /// Border color for focused panels #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub focused_panel_border: Color, /// Border color for unfocused panels #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub unfocused_panel_border: Color, /// Color for user message role indicator #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub user_message_role: Color, /// Color for assistant message role indicator #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub assistant_message_role: Color, /// Color for thinking panel title #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub thinking_panel_title: Color, /// Background color for command bar #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub command_bar_background: Color, /// Status line background color #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub status_background: Color, /// Color for Normal mode indicator #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub mode_normal: Color, /// Color for Editing mode indicator #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub mode_editing: Color, /// Color for Model Selection mode indicator #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub mode_model_selection: Color, /// Color for Provider Selection mode indicator #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub mode_provider_selection: Color, /// Color for Help mode indicator #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub mode_help: Color, /// Color for Visual mode indicator #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub mode_visual: Color, /// Color for Command mode indicator #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub mode_command: Color, /// Selection/highlight background color #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub selection_bg: Color, /// Selection/highlight foreground color #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub selection_fg: Color, /// Cursor indicator color #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub cursor: Color, /// Placeholder text color #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub placeholder: Color, /// Warning/error message color #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub error: Color, /// Success/info message color #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] pub info: Color, } impl Default for Theme { fn default() -> Self { default_dark() } } /// Get the default themes directory path pub fn default_themes_dir() -> PathBuf { let config_dir = PathBuf::from(shellexpand::tilde(crate::config::DEFAULT_CONFIG_PATH).as_ref()) .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| PathBuf::from("~/.config/owlen")); config_dir.join("themes") } /// Load all available themes (built-in + custom) pub fn load_all_themes() -> HashMap { let mut themes = HashMap::new(); // Load built-in themes for (name, theme) in built_in_themes() { themes.insert(name, theme); } // Load custom themes from disk let themes_dir = default_themes_dir(); if let Ok(entries) = fs::read_dir(&themes_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|s| s.to_str()) == Some("toml") { let name = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string(); match load_theme_from_file(&path) { Ok(theme) => { themes.insert(name.clone(), theme); } Err(e) => { eprintln!("Warning: Failed to load custom theme '{}': {}", name, e); } } } } } themes } /// Load a theme from a TOML file pub fn load_theme_from_file(path: &Path) -> Result { let content = fs::read_to_string(path).map_err(|e| format!("Failed to read theme file: {}", e))?; toml::from_str(&content).map_err(|e| format!("Failed to parse theme file: {}", e)) } /// Get a theme by name (built-in or custom) pub fn get_theme(name: &str) -> Option { load_all_themes().get(name).cloned() } /// Get all built-in themes (embedded in the binary) pub fn built_in_themes() -> HashMap { let mut themes = HashMap::new(); // Load embedded theme files let embedded_themes = [ ( "default_dark", include_str!("../../../themes/default_dark.toml"), ), ( "default_light", include_str!("../../../themes/default_light.toml"), ), ("gruvbox", include_str!("../../../themes/gruvbox.toml")), ("dracula", include_str!("../../../themes/dracula.toml")), ("solarized", include_str!("../../../themes/solarized.toml")), ( "midnight-ocean", include_str!("../../../themes/midnight-ocean.toml"), ), ("rose-pine", include_str!("../../../themes/rose-pine.toml")), ("monokai", include_str!("../../../themes/monokai.toml")), ( "material-dark", include_str!("../../../themes/material-dark.toml"), ), ( "material-light", include_str!("../../../themes/material-light.toml"), ), ]; for (name, content) in embedded_themes { match toml::from_str::(content) { Ok(theme) => { themes.insert(name.to_string(), theme); } Err(e) => { eprintln!("Warning: Failed to parse built-in theme '{}': {}", name, e); // Fallback to hardcoded version if parsing fails if let Some(fallback) = get_fallback_theme(name) { themes.insert(name.to_string(), fallback); } } } } themes } /// Get fallback hardcoded theme (used if embedded TOML fails to parse) fn get_fallback_theme(name: &str) -> Option { match name { "default_dark" => Some(default_dark()), "default_light" => Some(default_light()), "gruvbox" => Some(gruvbox()), "dracula" => Some(dracula()), "solarized" => Some(solarized()), "midnight-ocean" => Some(midnight_ocean()), "rose-pine" => Some(rose_pine()), "monokai" => Some(monokai()), "material-dark" => Some(material_dark()), "material-light" => Some(material_light()), _ => None, } } /// Default dark theme fn default_dark() -> Theme { Theme { name: "default_dark".to_string(), text: Color::White, background: Color::Black, focused_panel_border: Color::LightMagenta, unfocused_panel_border: Color::Rgb(95, 20, 135), user_message_role: Color::LightBlue, assistant_message_role: Color::Yellow, thinking_panel_title: Color::LightMagenta, command_bar_background: Color::Black, status_background: Color::Black, mode_normal: Color::LightBlue, mode_editing: Color::LightGreen, mode_model_selection: Color::LightYellow, mode_provider_selection: Color::LightCyan, mode_help: Color::LightMagenta, mode_visual: Color::Magenta, mode_command: Color::Yellow, selection_bg: Color::LightBlue, selection_fg: Color::Black, cursor: Color::Magenta, placeholder: Color::DarkGray, error: Color::Red, info: Color::LightGreen, } } /// Default light theme fn default_light() -> Theme { Theme { name: "default_light".to_string(), text: Color::Black, background: Color::White, focused_panel_border: Color::Rgb(74, 144, 226), unfocused_panel_border: Color::Rgb(221, 221, 221), user_message_role: Color::Rgb(0, 85, 164), assistant_message_role: Color::Rgb(142, 68, 173), thinking_panel_title: Color::Rgb(142, 68, 173), command_bar_background: Color::White, status_background: Color::White, mode_normal: Color::Rgb(0, 85, 164), mode_editing: Color::Rgb(46, 139, 87), mode_model_selection: Color::Rgb(181, 137, 0), mode_provider_selection: Color::Rgb(0, 139, 139), mode_help: Color::Rgb(142, 68, 173), mode_visual: Color::Rgb(142, 68, 173), mode_command: Color::Rgb(181, 137, 0), selection_bg: Color::Rgb(164, 200, 240), selection_fg: Color::Black, cursor: Color::Rgb(217, 95, 2), placeholder: Color::Gray, error: Color::Rgb(192, 57, 43), info: Color::Green, } } /// Gruvbox theme fn gruvbox() -> Theme { Theme { name: "gruvbox".to_string(), text: Color::Rgb(235, 219, 178), // #ebdbb2 background: Color::Rgb(40, 40, 40), // #282828 focused_panel_border: Color::Rgb(254, 128, 25), // #fe8019 (orange) unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64 user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green) assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue) thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple) command_bar_background: Color::Rgb(60, 56, 54), // #3c3836 status_background: Color::Rgb(60, 56, 54), mode_normal: Color::Rgb(131, 165, 152), // blue mode_editing: Color::Rgb(184, 187, 38), // green mode_model_selection: Color::Rgb(250, 189, 47), // yellow mode_provider_selection: Color::Rgb(142, 192, 124), // aqua mode_help: Color::Rgb(211, 134, 155), // purple mode_visual: Color::Rgb(254, 128, 25), // orange mode_command: Color::Rgb(250, 189, 47), // yellow selection_bg: Color::Rgb(80, 73, 69), selection_fg: Color::Rgb(235, 219, 178), cursor: Color::Rgb(254, 128, 25), placeholder: Color::Rgb(102, 92, 84), error: Color::Rgb(251, 73, 52), // #fb4934 info: Color::Rgb(184, 187, 38), } } /// Dracula theme fn dracula() -> Theme { Theme { name: "dracula".to_string(), text: Color::Rgb(248, 248, 242), // #f8f8f2 background: Color::Rgb(40, 42, 54), // #282a36 focused_panel_border: Color::Rgb(255, 121, 198), // #ff79c6 (pink) unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan) assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink) thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple) command_bar_background: Color::Rgb(68, 71, 90), status_background: Color::Rgb(68, 71, 90), mode_normal: Color::Rgb(139, 233, 253), mode_editing: Color::Rgb(80, 250, 123), // #50fa7b (green) mode_model_selection: Color::Rgb(241, 250, 140), // #f1fa8c (yellow) mode_provider_selection: Color::Rgb(139, 233, 253), mode_help: Color::Rgb(189, 147, 249), mode_visual: Color::Rgb(255, 121, 198), mode_command: Color::Rgb(241, 250, 140), selection_bg: Color::Rgb(68, 71, 90), selection_fg: Color::Rgb(248, 248, 242), cursor: Color::Rgb(255, 121, 198), placeholder: Color::Rgb(98, 114, 164), error: Color::Rgb(255, 85, 85), // #ff5555 info: Color::Rgb(80, 250, 123), } } /// Solarized Dark theme fn solarized() -> Theme { Theme { name: "solarized".to_string(), text: Color::Rgb(131, 148, 150), // #839496 (base0) background: Color::Rgb(0, 43, 54), // #002b36 (base03) focused_panel_border: Color::Rgb(38, 139, 210), // #268bd2 (blue) unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02) user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan) assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange) thinking_panel_title: Color::Rgb(108, 113, 196), // #6c71c4 (violet) command_bar_background: Color::Rgb(7, 54, 66), status_background: Color::Rgb(7, 54, 66), mode_normal: Color::Rgb(38, 139, 210), // blue mode_editing: Color::Rgb(133, 153, 0), // #859900 (green) mode_model_selection: Color::Rgb(181, 137, 0), // #b58900 (yellow) mode_provider_selection: Color::Rgb(42, 161, 152), // cyan mode_help: Color::Rgb(108, 113, 196), // violet mode_visual: Color::Rgb(211, 54, 130), // #d33682 (magenta) mode_command: Color::Rgb(181, 137, 0), // yellow selection_bg: Color::Rgb(7, 54, 66), selection_fg: Color::Rgb(147, 161, 161), cursor: Color::Rgb(211, 54, 130), placeholder: Color::Rgb(88, 110, 117), error: Color::Rgb(220, 50, 47), // #dc322f (red) info: Color::Rgb(133, 153, 0), } } /// Midnight Ocean theme fn midnight_ocean() -> Theme { Theme { name: "midnight-ocean".to_string(), text: Color::Rgb(192, 202, 245), background: Color::Rgb(13, 17, 23), focused_panel_border: Color::Rgb(88, 166, 255), unfocused_panel_border: Color::Rgb(48, 54, 61), user_message_role: Color::Rgb(121, 192, 255), assistant_message_role: Color::Rgb(137, 221, 255), thinking_panel_title: Color::Rgb(158, 206, 106), command_bar_background: Color::Rgb(22, 27, 34), status_background: Color::Rgb(22, 27, 34), mode_normal: Color::Rgb(121, 192, 255), mode_editing: Color::Rgb(158, 206, 106), mode_model_selection: Color::Rgb(255, 212, 59), mode_provider_selection: Color::Rgb(137, 221, 255), mode_help: Color::Rgb(255, 115, 157), mode_visual: Color::Rgb(246, 140, 245), mode_command: Color::Rgb(255, 212, 59), selection_bg: Color::Rgb(56, 139, 253), selection_fg: Color::Rgb(13, 17, 23), cursor: Color::Rgb(246, 140, 245), placeholder: Color::Rgb(110, 118, 129), error: Color::Rgb(248, 81, 73), info: Color::Rgb(158, 206, 106), } } /// Rose Pine theme fn rose_pine() -> Theme { Theme { name: "rose-pine".to_string(), text: Color::Rgb(224, 222, 244), // #e0def4 background: Color::Rgb(25, 23, 36), // #191724 focused_panel_border: Color::Rgb(235, 111, 146), // #eb6f92 (love) unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam) assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light) thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris) command_bar_background: Color::Rgb(38, 35, 58), status_background: Color::Rgb(38, 35, 58), mode_normal: Color::Rgb(156, 207, 216), mode_editing: Color::Rgb(235, 188, 186), // #ebbcba (rose) mode_model_selection: Color::Rgb(246, 193, 119), mode_provider_selection: Color::Rgb(49, 116, 143), mode_help: Color::Rgb(196, 167, 231), mode_visual: Color::Rgb(235, 111, 146), mode_command: Color::Rgb(246, 193, 119), selection_bg: Color::Rgb(64, 61, 82), selection_fg: Color::Rgb(224, 222, 244), cursor: Color::Rgb(235, 111, 146), placeholder: Color::Rgb(110, 106, 134), error: Color::Rgb(235, 111, 146), info: Color::Rgb(156, 207, 216), } } /// Monokai theme fn monokai() -> Theme { Theme { name: "monokai".to_string(), text: Color::Rgb(248, 248, 242), // #f8f8f2 background: Color::Rgb(39, 40, 34), // #272822 focused_panel_border: Color::Rgb(249, 38, 114), // #f92672 (pink) unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan) assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple) thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow) command_bar_background: Color::Rgb(39, 40, 34), status_background: Color::Rgb(39, 40, 34), mode_normal: Color::Rgb(102, 217, 239), mode_editing: Color::Rgb(166, 226, 46), // #a6e22e (green) mode_model_selection: Color::Rgb(230, 219, 116), mode_provider_selection: Color::Rgb(102, 217, 239), mode_help: Color::Rgb(174, 129, 255), mode_visual: Color::Rgb(249, 38, 114), mode_command: Color::Rgb(230, 219, 116), selection_bg: Color::Rgb(117, 113, 94), selection_fg: Color::Rgb(248, 248, 242), cursor: Color::Rgb(249, 38, 114), placeholder: Color::Rgb(117, 113, 94), error: Color::Rgb(249, 38, 114), info: Color::Rgb(166, 226, 46), } } /// Material Dark theme fn material_dark() -> Theme { Theme { name: "material-dark".to_string(), text: Color::Rgb(238, 255, 255), // #eeffff background: Color::Rgb(38, 50, 56), // #263238 focused_panel_border: Color::Rgb(128, 203, 196), // #80cbc4 (cyan) unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue) assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple) thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow) command_bar_background: Color::Rgb(33, 43, 48), status_background: Color::Rgb(33, 43, 48), mode_normal: Color::Rgb(130, 170, 255), mode_editing: Color::Rgb(195, 232, 141), // #c3e88d (green) mode_model_selection: Color::Rgb(255, 203, 107), mode_provider_selection: Color::Rgb(128, 203, 196), mode_help: Color::Rgb(199, 146, 234), mode_visual: Color::Rgb(240, 113, 120), // #f07178 (red) mode_command: Color::Rgb(255, 203, 107), selection_bg: Color::Rgb(84, 110, 122), selection_fg: Color::Rgb(238, 255, 255), cursor: Color::Rgb(255, 204, 0), placeholder: Color::Rgb(84, 110, 122), error: Color::Rgb(240, 113, 120), info: Color::Rgb(195, 232, 141), } } /// Material Light theme fn material_light() -> Theme { Theme { name: "material-light".to_string(), text: Color::Rgb(33, 33, 33), background: Color::Rgb(236, 239, 241), focused_panel_border: Color::Rgb(0, 150, 136), unfocused_panel_border: Color::Rgb(176, 190, 197), user_message_role: Color::Rgb(68, 138, 255), assistant_message_role: Color::Rgb(124, 77, 255), thinking_panel_title: Color::Rgb(245, 124, 0), command_bar_background: Color::Rgb(255, 255, 255), status_background: Color::Rgb(255, 255, 255), mode_normal: Color::Rgb(68, 138, 255), mode_editing: Color::Rgb(56, 142, 60), mode_model_selection: Color::Rgb(245, 124, 0), mode_provider_selection: Color::Rgb(0, 150, 136), mode_help: Color::Rgb(124, 77, 255), mode_visual: Color::Rgb(211, 47, 47), mode_command: Color::Rgb(245, 124, 0), selection_bg: Color::Rgb(176, 190, 197), selection_fg: Color::Rgb(33, 33, 33), cursor: Color::Rgb(194, 24, 91), placeholder: Color::Rgb(144, 164, 174), error: Color::Rgb(211, 47, 47), info: Color::Rgb(56, 142, 60), } } // Helper functions for color serialization/deserialization fn deserialize_color<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; parse_color(&s).map_err(serde::de::Error::custom) } fn serialize_color(color: &Color, serializer: S) -> Result where S: serde::Serializer, { let s = color_to_string(color); serializer.serialize_str(&s) } fn parse_color(s: &str) -> Result { if let Some(hex) = s.strip_prefix('#') { if hex.len() == 6 { let r = u8::from_str_radix(&hex[0..2], 16) .map_err(|_| format!("Invalid hex color: {}", s))?; let g = u8::from_str_radix(&hex[2..4], 16) .map_err(|_| format!("Invalid hex color: {}", s))?; let b = u8::from_str_radix(&hex[4..6], 16) .map_err(|_| format!("Invalid hex color: {}", s))?; return Ok(Color::Rgb(r, g, b)); } } // Try named colors match s.to_lowercase().as_str() { "black" => Ok(Color::Black), "red" => Ok(Color::Red), "green" => Ok(Color::Green), "yellow" => Ok(Color::Yellow), "blue" => Ok(Color::Blue), "magenta" => Ok(Color::Magenta), "cyan" => Ok(Color::Cyan), "gray" | "grey" => Ok(Color::Gray), "darkgray" | "darkgrey" => Ok(Color::DarkGray), "lightred" => Ok(Color::LightRed), "lightgreen" => Ok(Color::LightGreen), "lightyellow" => Ok(Color::LightYellow), "lightblue" => Ok(Color::LightBlue), "lightmagenta" => Ok(Color::LightMagenta), "lightcyan" => Ok(Color::LightCyan), "white" => Ok(Color::White), _ => Err(format!("Unknown color: {}", s)), } } fn color_to_string(color: &Color) -> String { match color { Color::Black => "black".to_string(), Color::Red => "red".to_string(), Color::Green => "green".to_string(), Color::Yellow => "yellow".to_string(), Color::Blue => "blue".to_string(), Color::Magenta => "magenta".to_string(), Color::Cyan => "cyan".to_string(), Color::Gray => "gray".to_string(), Color::DarkGray => "darkgray".to_string(), Color::LightRed => "lightred".to_string(), Color::LightGreen => "lightgreen".to_string(), Color::LightYellow => "lightyellow".to_string(), Color::LightBlue => "lightblue".to_string(), Color::LightMagenta => "lightmagenta".to_string(), Color::LightCyan => "lightcyan".to_string(), Color::White => "white".to_string(), Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b), _ => "#ffffff".to_string(), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_color_parsing() { assert!(matches!(parse_color("#ff0000"), Ok(Color::Rgb(255, 0, 0)))); assert!(matches!(parse_color("red"), Ok(Color::Red))); assert!(matches!(parse_color("lightblue"), Ok(Color::LightBlue))); } #[test] fn test_built_in_themes() { let themes = built_in_themes(); assert!(themes.contains_key("default_dark")); assert!(themes.contains_key("gruvbox")); assert!(themes.contains_key("dracula")); } }