color schemes

This commit is contained in:
Dhanji Prasanna
2025-10-08 11:14:56 +11:00
parent ed769bd58a
commit e11a287acc
4 changed files with 261 additions and 60 deletions

View File

@@ -12,7 +12,7 @@ tokio = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] } tracing-subscriber = { workspace = true, features = ["env-filter"] }
serde = { workspace = true } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
rustyline = "17.0.1" rustyline = "17.0.1"
dirs = "5.0" dirs = "5.0"

View File

@@ -11,9 +11,11 @@ use tracing::{error, info};
mod retro_tui; mod retro_tui;
mod tui; mod tui;
mod ui_writer_impl; mod ui_writer_impl;
mod theme;
use retro_tui::RetroTui; use retro_tui::RetroTui;
use tui::SimpleOutput; use tui::SimpleOutput;
use ui_writer_impl::{ConsoleUiWriter, RetroTuiWriter}; use ui_writer_impl::{ConsoleUiWriter, RetroTuiWriter};
use theme::ColorTheme;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "g3")] #[command(name = "g3")]
@@ -54,6 +56,10 @@ pub struct Cli {
/// Use retro terminal UI (inspired by 80s sci-fi) /// Use retro terminal UI (inspired by 80s sci-fi)
#[arg(long)] #[arg(long)]
pub retro: bool, pub retro: bool,
/// Color theme for retro mode (default, dracula, or path to theme file)
#[arg(long, value_name = "THEME")]
pub theme: Option<String>,
} }
pub async fn run() -> Result<()> { pub async fn run() -> Result<()> {
@@ -171,7 +177,7 @@ pub async fn run() -> Result<()> {
if cli.retro { if cli.retro {
// Use retro terminal UI // Use retro terminal UI
run_interactive_retro(config, cli.show_prompt, cli.show_code).await?; run_interactive_retro(config, cli.show_prompt, cli.show_code, cli.theme).await?;
} else { } else {
// Use standard terminal UI // Use standard terminal UI
let output = SimpleOutput::new(); let output = SimpleOutput::new();
@@ -183,15 +189,24 @@ pub async fn run() -> Result<()> {
Ok(()) Ok(())
} }
async fn run_interactive_retro(config: Config, show_prompt: bool, show_code: bool) -> Result<()> { async fn run_interactive_retro(config: Config, show_prompt: bool, show_code: bool, theme_name: Option<String>) -> Result<()> {
use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use std::time::Duration; use std::time::Duration;
// Set environment variable to suppress println in other crates // Set environment variable to suppress println in other crates
std::env::set_var("G3_RETRO_MODE", "1"); std::env::set_var("G3_RETRO_MODE", "1");
// Load the color theme
let theme = match ColorTheme::load(theme_name.as_deref()) {
Ok(t) => t,
Err(e) => {
eprintln!("Failed to load theme: {}. Using default.", e);
ColorTheme::default()
}
};
// Initialize the retro terminal UI // Initialize the retro terminal UI
let tui = RetroTui::start().await?; let tui = RetroTui::start(theme).await?;
// Create agent with RetroTuiWriter // Create agent with RetroTuiWriter
let ui_writer = RetroTuiWriter::new(tui.clone()); let ui_writer = RetroTuiWriter::new(tui.clone());
@@ -266,10 +281,10 @@ async fn run_interactive_retro(config: Config, show_prompt: bool, show_code: boo
KeyCode::Right => { KeyCode::Right => {
tui.cursor_right(); tui.cursor_right();
} }
KeyCode::Home => { KeyCode::Home if !key.modifiers.contains(KeyModifiers::CONTROL) => {
tui.cursor_home(); tui.cursor_home();
} }
KeyCode::End => { KeyCode::End if !key.modifiers.contains(KeyModifiers::CONTROL) => {
tui.cursor_end(); tui.cursor_end();
} }
KeyCode::Delete => { KeyCode::Delete => {

View File

@@ -18,16 +18,9 @@ use std::time::{Duration, Instant};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use std::collections::VecDeque; use std::collections::VecDeque;
// Retro sci-fi color scheme inspired by Alien terminals use crate::theme::ColorTheme;
const TERMINAL_GREEN: Color = Color::Rgb(136, 244, 152); // Mid green
const TERMINAL_AMBER: Color = Color::Rgb(242, 204, 148); // Softer amber for warnings // Color theme will be loaded dynamically
const TERMINAL_DIM_GREEN: Color = Color::Rgb(154, 174, 135); // softer vintage green for borders
const TERMINAL_BG: Color = Color::Rgb(0, 10, 0); // Very dark green background
const TERMINAL_CYAN: Color = Color::Rgb(0, 255, 255); // Cyan for highlights
const TERMINAL_RED: Color = Color::Rgb(239, 119, 109); // Red for errors or negative diffs
const TERMINAL_PALE_BLUE: Color = Color::Rgb(173, 234, 251); // Pale blue for READY status
const TERMINAL_DARK_AMBER: Color = Color::Rgb(204, 119, 34); // Dark amber for PROCESSING status
const TERMINAL_WHITE: Color = Color::Rgb(218, 218, 219); // Dimmer white for punchy text
// Scrolling configuration // Scrolling configuration
const SCROLL_PAST_END_BUFFER: usize = 10; // Extra lines to allow scrolling past the end const SCROLL_PAST_END_BUFFER: usize = 10; // Extra lines to allow scrolling past the end
@@ -64,6 +57,8 @@ pub enum TuiMessage {
/// Shared state for the retro terminal /// Shared state for the retro terminal
struct TerminalState { struct TerminalState {
/// Color theme
theme: ColorTheme,
/// Current input buffer /// Current input buffer
input_buffer: String, input_buffer: String,
/// Cursor position in input buffer (for editing) /// Cursor position in input buffer (for editing)
@@ -119,8 +114,9 @@ struct TerminalState {
} }
impl TerminalState { impl TerminalState {
fn new() -> Self { fn new(theme: ColorTheme) -> Self {
Self { Self {
theme,
input_buffer: String::new(), input_buffer: String::new(),
cursor_position: 0, cursor_position: 0,
output_history: vec![ output_history: vec![
@@ -332,7 +328,7 @@ pub struct RetroTui {
impl RetroTui { impl RetroTui {
/// Create and start the retro terminal UI /// Create and start the retro terminal UI
pub async fn start() -> Result<Self> { pub async fn start(theme: ColorTheme) -> Result<Self> {
// Setup terminal // Setup terminal
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
@@ -343,7 +339,7 @@ impl RetroTui {
// Create message channel // Create message channel
let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>(); let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
let state = Arc::new(Mutex::new(TerminalState::new())); let state = Arc::new(Mutex::new(TerminalState::new(theme)));
let terminal = Arc::new(Mutex::new(terminal)); let terminal = Arc::new(Mutex::new(terminal));
// Clone for the background task // Clone for the background task
@@ -575,16 +571,16 @@ impl RetroTui {
} }
// Draw header/input area // Draw header/input area
Self::draw_input_area(f, chunks[0], &state.input_buffer, state.cursor_position, state.cursor_blink, state.is_processing); Self::draw_input_area(f, chunks[0], &state.input_buffer, state.cursor_position, state.cursor_blink, state.is_processing, &state.theme);
// Draw main output area // Draw main output area
Self::draw_output_area(f, chunks[1], &state.output_history, state.scroll_offset); Self::draw_output_area(f, chunks[1], &state.output_history, state.scroll_offset, &state.theme);
// Draw activity area only if it's visible (during animation or when shown) // Draw activity area only if it's visible (during animation or when shown)
if activity_height > 0 { if activity_height > 0 {
// Apply fade effect by adjusting opacity through color intensity // Apply fade effect by adjusting opacity through color intensity
let opacity = state.activity_animation; let opacity = state.activity_animation;
Self::draw_activity_area(f, chunks[2], state, opacity); Self::draw_activity_area(f, chunks[2], state, opacity, &state.theme);
} }
// Draw status bar - use the last chunk which is either index 2 or 3 // Draw status bar - use the last chunk which is either index 2 or 3
@@ -600,6 +596,7 @@ impl RetroTui {
state.context_info, state.context_info,
&state.provider_info, &state.provider_info,
state.status_blink, state.status_blink,
&state.theme,
); );
})?; })?;
@@ -607,7 +604,7 @@ impl RetroTui {
} }
/// Draw the input area with prompt /// Draw the input area with prompt
fn draw_input_area(f: &mut Frame, area: Rect, input_buffer: &str, cursor_position: usize, cursor_blink: bool, is_processing: bool) { fn draw_input_area(f: &mut Frame, area: Rect, input_buffer: &str, cursor_position: usize, cursor_blink: bool, is_processing: bool, theme: &ColorTheme) {
let prompt = "g3> "; let prompt = "g3> ";
let prompt_len = prompt.len(); let prompt_len = prompt.len();
@@ -664,14 +661,14 @@ impl RetroTui {
} }
let input = Paragraph::new(display_text) let input = Paragraph::new(display_text)
.style(Style::default().fg(TERMINAL_GREEN)) .style(Style::default().fg(theme.terminal_green.to_color()))
.block( .block(
Block::default() Block::default()
.title(" COMMAND INPUT ") .title(" COMMAND INPUT ")
.title_alignment(Alignment::Center) .title_alignment(Alignment::Center)
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(TERMINAL_DIM_GREEN)) .border_style(Style::default().fg(theme.terminal_dim_green.to_color()))
.style(Style::default().bg(TERMINAL_BG)), .style(Style::default().bg(theme.terminal_bg.to_color())),
); );
f.render_widget(input, area); f.render_widget(input, area);
@@ -683,6 +680,7 @@ impl RetroTui {
area: Rect, area: Rect,
output_history: &[String], output_history: &[String],
scroll_offset: usize, scroll_offset: usize,
theme: &ColorTheme,
) { ) {
// Calculate visible lines (no borders now, but padding takes 2 lines) // Calculate visible lines (no borders now, but padding takes 2 lines)
let visible_height = area.height.saturating_sub(2) as usize; // Account for padding let visible_height = area.height.saturating_sub(2) as usize; // Account for padding
@@ -719,7 +717,7 @@ impl RetroTui {
return Line::from(Span::styled( return Line::from(Span::styled(
format!(" {}", cleaned), format!(" {}", cleaned),
Style::default() Style::default()
.bg(TERMINAL_AMBER) .bg(theme.terminal_amber.to_color())
.fg(Color::Black) .fg(Color::Black)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)); ));
@@ -730,7 +728,7 @@ impl RetroTui {
return Line::from(Span::styled( return Line::from(Span::styled(
format!(" {}", cleaned), format!(" {}", cleaned),
Style::default() Style::default()
.bg(TERMINAL_GREEN) .bg(theme.terminal_green.to_color())
.fg(Color::Black) .fg(Color::Black)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)); ));
@@ -741,7 +739,7 @@ impl RetroTui {
return Line::from(Span::styled( return Line::from(Span::styled(
format!(" {}", cleaned), format!(" {}", cleaned),
Style::default() Style::default()
.bg(TERMINAL_RED) .bg(theme.terminal_red.to_color())
.fg(Color::Black) .fg(Color::Black)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)); ));
@@ -755,31 +753,31 @@ impl RetroTui {
{ {
return Line::from(Span::styled( return Line::from(Span::styled(
format!(" {}", line), format!(" {}", line),
Style::default().fg(TERMINAL_DIM_GREEN), Style::default().fg(theme.terminal_dim_green.to_color()),
)); ));
} }
// Apply different colors based on content // Apply different colors based on content
let style = if line.starts_with("ERROR:") { let style = if line.starts_with("ERROR:") {
Style::default() Style::default()
.fg(TERMINAL_RED) .fg(theme.terminal_red.to_color())
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
} else if line.starts_with('>') { } else if line.starts_with('>') {
Style::default().fg(TERMINAL_CYAN) Style::default().fg(theme.terminal_cyan.to_color())
} else if line.starts_with("SYSTEM:") } else if line.starts_with("SYSTEM:")
|| line.starts_with("WEYLAND") || line.starts_with("WEYLAND")
|| line.starts_with("MU/TH/UR") || line.starts_with("MU/TH/UR")
{ {
Style::default() Style::default()
.fg(TERMINAL_AMBER) .fg(theme.terminal_amber.to_color())
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
} else if line.starts_with("SYSTEM INITIALIZED") } else if line.starts_with("SYSTEM INITIALIZED")
|| line.starts_with("AWAITING COMMAND") || line.starts_with("AWAITING COMMAND")
{ {
Style::default() Style::default()
.fg(TERMINAL_DIM_GREEN) .fg(theme.terminal_dim_green.to_color())
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
} else { } else {
Style::default().fg(TERMINAL_GREEN) Style::default().fg(theme.terminal_green.to_color())
}; };
Line::from(Span::styled(format!(" {}", line), style)) Line::from(Span::styled(format!(" {}", line), style))
@@ -793,7 +791,7 @@ impl RetroTui {
.borders(Borders::NONE) .borders(Borders::NONE)
// Add padding to maintain the same spacing as borders would provide // Add padding to maintain the same spacing as borders would provide
.padding(ratatui::widgets::Padding::new(1, 1, 1, 1)) .padding(ratatui::widgets::Padding::new(1, 1, 1, 1))
.style(Style::default().bg(TERMINAL_BG)), .style(Style::default().bg(theme.terminal_bg.to_color())),
) )
.wrap(Wrap { trim: false }); .wrap(Wrap { trim: false });
@@ -806,7 +804,7 @@ impl RetroTui {
.end_symbol(Some("")) .end_symbol(Some(""))
.track_symbol(Some("")) .track_symbol(Some(""))
.thumb_symbol("") .thumb_symbol("")
.style(Style::default().fg(TERMINAL_DIM_GREEN)); .style(Style::default().fg(theme.terminal_dim_green.to_color()));
let mut scrollbar_state = ScrollbarState::new(total_lines) let mut scrollbar_state = ScrollbarState::new(total_lines)
.position(scroll) .position(scroll)
@@ -829,6 +827,7 @@ impl RetroTui {
area: Rect, area: Rect,
state: &TerminalState, state: &TerminalState,
opacity: f32, opacity: f32,
theme: &ColorTheme,
) { ) {
// Note: scroll_offset is managed by the state and auto-scrolls to show latest content when new data arrives // Note: scroll_offset is managed by the state and auto-scrolls to show latest content when new data arrives
@@ -870,7 +869,7 @@ impl RetroTui {
let visible_lines: Vec<Line> = if state.tool_activity.is_empty() { let visible_lines: Vec<Line> = if state.tool_activity.is_empty() {
vec![Line::from(Span::styled( vec![Line::from(Span::styled(
" No tool activity yet", " No tool activity yet",
Style::default().fg(fade_color(TERMINAL_DIM_GREEN)).add_modifier(Modifier::ITALIC), Style::default().fg(fade_color(theme.terminal_dim_green.to_color())).add_modifier(Modifier::ITALIC),
))] ))]
} else { } else {
state.tool_activity state.tool_activity
@@ -880,11 +879,11 @@ impl RetroTui {
.map(|line| { .map(|line| {
// Style the header lines differently // Style the header lines differently
let style = if line.starts_with('[') && line.contains(']') { let style = if line.starts_with('[') && line.contains(']') {
Style::default().fg(fade_color(TERMINAL_CYAN)).add_modifier(Modifier::BOLD) Style::default().fg(fade_color(theme.terminal_cyan.to_color())).add_modifier(Modifier::BOLD)
} else if line.is_empty() { } else if line.is_empty() {
Style::default() Style::default()
} else { } else {
Style::default().fg(fade_color(TERMINAL_GREEN)) Style::default().fg(fade_color(theme.terminal_green.to_color()))
}; };
Line::from(Span::styled(format!(" {}", line), style)) Line::from(Span::styled(format!(" {}", line), style))
}) })
@@ -897,15 +896,15 @@ impl RetroTui {
.title(" TOOL DETAIL ") .title(" TOOL DETAIL ")
.title_alignment(Alignment::Center) .title_alignment(Alignment::Center)
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(fade_color(TERMINAL_DIM_GREEN))) .border_style(Style::default().fg(fade_color(theme.terminal_dim_green.to_color())))
.style(Style::default().bg(TERMINAL_BG)), .style(Style::default().bg(theme.terminal_bg.to_color())),
) )
.wrap(Wrap { trim: false }); .wrap(Wrap { trim: false });
f.render_widget(tool_output, chunks[0]); f.render_widget(tool_output, chunks[0]);
// Draw right half - Activity graphs with wave animations // Draw right half - Activity graphs with wave animations
Self::draw_activity_graphs(f, chunks[1], &state.token_wave_history, &state.sse_wave_history, opacity); Self::draw_activity_graphs(f, chunks[1], &state.token_wave_history, &state.sse_wave_history, opacity, theme);
} }
/// Draw activity graphs with wave animations for tokens and SSEs /// Draw activity graphs with wave animations for tokens and SSEs
@@ -915,6 +914,7 @@ impl RetroTui {
token_wave: &VecDeque<f64>, token_wave: &VecDeque<f64>,
sse_wave: &VecDeque<f64>, sse_wave: &VecDeque<f64>,
opacity: f32, opacity: f32,
theme: &ColorTheme,
) { ) {
// Apply fade effect by adjusting colors based on opacity // Apply fade effect by adjusting colors based on opacity
let fade_color = |color: Color| -> Color { let fade_color = |color: Color| -> Color {
@@ -934,8 +934,8 @@ impl RetroTui {
.title(" ACTIVITY ") .title(" ACTIVITY ")
.title_alignment(Alignment::Center) .title_alignment(Alignment::Center)
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(fade_color(TERMINAL_DIM_GREEN))) .border_style(Style::default().fg(fade_color(theme.terminal_dim_green.to_color())))
.style(Style::default().bg(TERMINAL_BG)); .style(Style::default().bg(theme.terminal_bg.to_color()));
// Calculate inner area for chart // Calculate inner area for chart
let inner = block.inner(area); let inner = block.inner(area);
@@ -963,8 +963,8 @@ impl RetroTui {
graph_chunks[0], graph_chunks[0],
token_wave, token_wave,
"TOKENS", "TOKENS",
fade_color(TERMINAL_CYAN), fade_color(theme.terminal_cyan.to_color()),
fade_color(TERMINAL_DIM_GREEN), fade_color(theme.terminal_dim_green.to_color()),
opacity, opacity,
); );
@@ -974,8 +974,8 @@ impl RetroTui {
graph_chunks[1], graph_chunks[1],
sse_wave, sse_wave,
"SSE", "SSE",
fade_color(TERMINAL_GREEN), fade_color(theme.terminal_green.to_color()),
fade_color(TERMINAL_DIM_GREEN), fade_color(theme.terminal_dim_green.to_color()),
opacity, opacity,
); );
} }
@@ -987,7 +987,7 @@ impl RetroTui {
wave_data: &VecDeque<f64>, wave_data: &VecDeque<f64>,
label: &str, label: &str,
wave_color: Color, wave_color: Color,
axis_color: Color, _axis_color: Color,
_opacity: f32, _opacity: f32,
) { ) {
let width = area.width as usize; let width = area.width as usize;
@@ -1038,6 +1038,7 @@ impl RetroTui {
context_info: (u32, u32, f32), context_info: (u32, u32, f32),
provider_info: &(String, String), provider_info: &(String, String),
status_blink: bool, status_blink: bool,
theme: &ColorTheme,
) { ) {
let (used, total, percentage) = context_info; let (used, total, percentage) = context_info;
@@ -1052,15 +1053,15 @@ impl RetroTui {
let (status_color, status_text) = if status_line == "PROCESSING" { let (status_color, status_text) = if status_line == "PROCESSING" {
// Blink the PROCESSING status // Blink the PROCESSING status
if status_blink { if status_blink {
(TERMINAL_DARK_AMBER, status_line) (theme.terminal_dark_amber.to_color(), status_line)
} else { } else {
(TERMINAL_BG, " ") // Hide text by matching background (theme.terminal_bg.to_color(), " ") // Hide text by matching background
} }
} else if status_line == "READY" { } else if status_line == "READY" {
(TERMINAL_PALE_BLUE, status_line) (theme.terminal_pale_blue.to_color(), status_line)
} else { } else {
// Default to amber for other statuses // Default to amber for other statuses
(TERMINAL_AMBER, status_line) (theme.terminal_amber.to_color(), status_line)
}; };
// Build the status line with different colored spans // Build the status line with different colored spans
@@ -1068,7 +1069,7 @@ impl RetroTui {
Span::styled( Span::styled(
" STATUS: ", " STATUS: ",
Style::default() Style::default()
.fg(TERMINAL_AMBER) .fg(theme.terminal_amber.to_color())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
@@ -1080,25 +1081,25 @@ impl RetroTui {
Span::styled( Span::styled(
" | CONTEXT: ", " | CONTEXT: ",
Style::default() Style::default()
.fg(TERMINAL_AMBER) .fg(theme.terminal_amber.to_color())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
format!("{} {:.1}% ({}/{})", meter, percentage, used, total), format!("{} {:.1}% ({}/{})", meter, percentage, used, total),
Style::default() Style::default()
.fg(TERMINAL_AMBER) .fg(theme.terminal_amber.to_color())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
" | ", " | ",
Style::default() Style::default()
.fg(TERMINAL_AMBER) .fg(theme.terminal_amber.to_color())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
format!("{} ", model), format!("{} ", model),
Style::default() Style::default()
.fg(TERMINAL_AMBER) .fg(theme.terminal_amber.to_color())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
]; ];
@@ -1106,7 +1107,7 @@ impl RetroTui {
let status_line = Line::from(status_spans); let status_line = Line::from(status_spans);
let status = Paragraph::new(status_line) let status = Paragraph::new(status_line)
.style(Style::default().bg(TERMINAL_BG)) .style(Style::default().bg(theme.terminal_bg.to_color()))
.alignment(Alignment::Left); .alignment(Alignment::Left);
f.render_widget(status, area); f.render_widget(status, area);

185
crates/g3-cli/src/theme.rs Normal file
View File

@@ -0,0 +1,185 @@
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use anyhow::Result;
/// Color theme configuration for the retro TUI
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColorTheme {
/// Name of the theme
pub name: String,
/// Main terminal text color (for general output)
pub terminal_green: ColorValue,
/// Warning/system messages color
pub terminal_amber: ColorValue,
/// Border and dim text color
pub terminal_dim_green: ColorValue,
/// Background color
pub terminal_bg: ColorValue,
/// Highlight/emphasis color
pub terminal_cyan: ColorValue,
/// Error/negative diff color
pub terminal_red: ColorValue,
/// READY status color
pub terminal_pale_blue: ColorValue,
/// PROCESSING status color
pub terminal_dark_amber: ColorValue,
/// Bright/punchy text color
pub terminal_white: ColorValue,
}
/// Represents a color value that can be serialized/deserialized
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ColorValue {
/// RGB color with r, g, b components
Rgb { r: u8, g: u8, b: u8 },
/// Named color
Named(String),
}
impl ColorValue {
/// Convert to ratatui Color
pub fn to_color(&self) -> Color {
match self {
ColorValue::Rgb { r, g, b } => Color::Rgb(*r, *g, *b),
ColorValue::Named(name) => match name.to_lowercase().as_str() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Gray,
"darkgray" | "darkgrey" => Color::DarkGray,
"lightred" => Color::LightRed,
"lightgreen" => Color::LightGreen,
"lightyellow" => Color::LightYellow,
"lightblue" => Color::LightBlue,
"lightmagenta" => Color::LightMagenta,
"lightcyan" => Color::LightCyan,
"white" => Color::White,
_ => Color::White, // Default fallback
},
}
}
}
impl ColorTheme {
/// Load a theme from a JSON file
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(path)?;
let theme: ColorTheme = serde_json::from_str(&content)?;
Ok(theme)
}
/// Save a theme to a JSON file
pub fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let content = serde_json::to_string_pretty(self)?;
fs::write(path, content)?;
Ok(())
}
/// Get the default retro sci-fi theme (inspired by Alien terminals)
pub fn default() -> Self {
ColorTheme {
name: "Retro Sci-Fi".to_string(),
terminal_green: ColorValue::Rgb { r: 136, g: 244, b: 152 },
terminal_amber: ColorValue::Rgb { r: 242, g: 204, b: 148 },
terminal_dim_green: ColorValue::Rgb { r: 154, g: 174, b: 135 },
terminal_bg: ColorValue::Rgb { r: 0, g: 10, b: 0 },
terminal_cyan: ColorValue::Rgb { r: 0, g: 255, b: 255 },
terminal_red: ColorValue::Rgb { r: 239, g: 119, b: 109 },
terminal_pale_blue: ColorValue::Rgb { r: 173, g: 234, b: 251 },
terminal_dark_amber: ColorValue::Rgb { r: 204, g: 119, b: 34 },
terminal_white: ColorValue::Rgb { r: 218, g: 218, b: 219 },
}
}
/// Get the Dracula theme
pub fn dracula() -> Self {
ColorTheme {
name: "Dracula".to_string(),
terminal_green: ColorValue::Rgb { r: 80, g: 250, b: 123 }, // Dracula green
terminal_amber: ColorValue::Rgb { r: 255, g: 184, b: 108 }, // Dracula orange
terminal_dim_green: ColorValue::Rgb { r: 98, g: 114, b: 164 }, // Dracula comment
terminal_bg: ColorValue::Rgb { r: 40, g: 42, b: 54 }, // Dracula background
terminal_cyan: ColorValue::Rgb { r: 139, g: 233, b: 253 }, // Dracula cyan
terminal_red: ColorValue::Rgb { r: 255, g: 85, b: 85 }, // Dracula red
terminal_pale_blue: ColorValue::Rgb { r: 189, g: 147, b: 249 }, // Dracula purple
terminal_dark_amber: ColorValue::Rgb { r: 255, g: 121, b: 198 }, // Dracula pink
terminal_white: ColorValue::Rgb { r: 248, g: 248, b: 242 }, // Dracula foreground
}
}
/// Get a theme by name or from file
pub fn load(theme_name: Option<&str>) -> Result<Self> {
match theme_name {
None => Ok(Self::default()),
Some("default") | Some("retro") => Ok(Self::default()),
Some("dracula") => Ok(Self::dracula()),
Some(path) => {
// Try to load from file
if Path::new(path).exists() {
Self::from_file(path)
} else {
// Try to find in standard locations
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?;
let theme_file = home.join(".config").join("g3").join("themes").join(format!("{}.json", path));
if theme_file.exists() {
Self::from_file(theme_file)
} else {
Err(anyhow::anyhow!("Theme '{}' not found", path))
}
}
}
}
}
}
/// Create example theme files in the user's config directory
pub fn create_example_themes() -> Result<()> {
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?;
let themes_dir = home.join(".config").join("g3").join("themes");
// Create directory if it doesn't exist
fs::create_dir_all(&themes_dir)?;
// Save default theme
let default_theme = ColorTheme::default();
default_theme.to_file(themes_dir.join("retro.json"))?;
// Save Dracula theme
let dracula_theme = ColorTheme::dracula();
dracula_theme.to_file(themes_dir.join("dracula.json"))?;
// Create a custom example theme (Matrix-inspired)
let matrix_theme = ColorTheme {
name: "Matrix".to_string(),
terminal_green: ColorValue::Rgb { r: 0, g: 255, b: 0 },
terminal_amber: ColorValue::Rgb { r: 0, g: 200, b: 0 },
terminal_dim_green: ColorValue::Rgb { r: 0, g: 100, b: 0 },
terminal_bg: ColorValue::Rgb { r: 0, g: 0, b: 0 },
terminal_cyan: ColorValue::Rgb { r: 0, g: 255, b: 128 },
terminal_red: ColorValue::Rgb { r: 255, g: 0, b: 0 },
terminal_pale_blue: ColorValue::Rgb { r: 0, g: 255, b: 200 },
terminal_dark_amber: ColorValue::Rgb { r: 0, g: 150, b: 0 },
terminal_white: ColorValue::Rgb { r: 200, g: 255, b: 200 },
};
matrix_theme.to_file(themes_dir.join("matrix.json"))?;
println!("Example theme files created in: {}", themes_dir.display());
Ok(())
}