Some checks failed
ci/someci/tag/woodpecker/5 Pipeline is pending
ci/someci/tag/woodpecker/6 Pipeline is pending
ci/someci/tag/woodpecker/7 Pipeline is pending
ci/someci/tag/woodpecker/1 Pipeline failed
ci/someci/tag/woodpecker/2 Pipeline failed
ci/someci/tag/woodpecker/3 Pipeline failed
ci/someci/tag/woodpecker/4 Pipeline failed
- Introduce multiple built-in themes (`default_dark`, `default_light`, `gruvbox`, `dracula`, `solarized`, `midnight-ocean`, `rose-pine`, `monokai`, `material-dark`, `material-light`). - Implement theming system with customizable color schemes for all UI components in the TUI. - Include documentation for themes in `themes/README.md`. - Add fallback mechanisms for default themes in case of parsing errors. - Support custom themes with overrides via configuration.
646 lines
24 KiB
Rust
646 lines
24 KiB
Rust
//! 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<String, Theme> {
|
|
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<Theme, String> {
|
|
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<Theme> {
|
|
load_all_themes().get(name).cloned()
|
|
}
|
|
|
|
/// Get all built-in themes (embedded in the binary)
|
|
pub fn built_in_themes() -> HashMap<String, Theme> {
|
|
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::<Theme>(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<Theme> {
|
|
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<Color, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let s = String::deserialize(deserializer)?;
|
|
parse_color(&s).map_err(serde::de::Error::custom)
|
|
}
|
|
|
|
fn serialize_color<S>(color: &Color, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
let s = color_to_string(color);
|
|
serializer.serialize_str(&s)
|
|
}
|
|
|
|
fn parse_color(s: &str) -> Result<Color, String> {
|
|
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"));
|
|
}
|
|
}
|