use ratatui::style::{Color, Modifier, Style};
use crate::app::model::shared::theme_id::ThemeId;
use crate::app::policy::write::write_guardrails::RiskLevel;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusTone {
Success,
Error,
Warning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ThemePalette {
pub semantic: SemanticTokens,
pub component: ComponentTokens,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SemanticTokens {
pub surface: SurfaceTokens,
pub text: TextTokens,
pub status: StatusTokens,
pub cursor: CursorTokens,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SurfaceTokens {
pub focus_border: Color,
pub unfocus_border: Color,
pub highlight_border: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextTokens {
pub primary: Color,
pub secondary: Color,
pub muted: Color,
pub dim: Color,
pub accent: Color,
pub placeholder: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StatusTokens {
pub success: Color,
pub error: Color,
pub warning: Color,
pub pending: Color,
pub medium_risk: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CursorTokens {
pub fg: Color,
pub bg: Color,
pub text_fg: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ComponentTokens {
pub modal: ModalTokens,
pub navigation: NavigationTokens,
pub editor: EditorTokens,
pub table: TableTokens,
pub feedback: FeedbackTokens,
pub syntax: SyntaxTokens,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ModalTokens {
pub title: Color,
pub hint: Color,
pub border: Color,
pub border_highlight: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NavigationTokens {
pub key_chip_bg: Color,
pub key_chip_fg: Color,
pub section_header: Color,
pub scrollbar_active: Color,
pub scrollbar_inactive: Color,
pub tab_active: Color,
pub tab_inactive: Color,
pub active_indicator: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EditorTokens {
pub current_line_bg: Color,
pub completion_selected_bg: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TableTokens {
pub result_row_active_bg: Color,
pub result_cell_active_bg: Color,
pub cell_edit_fg: Color,
pub staged_delete_bg: Color,
pub staged_delete_fg: Color,
pub striped_row_bg: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FeedbackTokens {
pub yank_flash_bg: Color,
pub yank_flash_fg: Color,
pub note_text: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SyntaxTokens {
pub sql_keyword: Color,
pub sql_string: Color,
pub sql_number: Color,
pub sql_comment: Color,
pub sql_operator: Color,
pub sql_text: Color,
}
impl ThemePalette {
pub fn risk_color(&self, level: RiskLevel) -> Color {
match level {
RiskLevel::Low => self.semantic.status.warning,
RiskLevel::Medium => self.semantic.status.medium_risk,
RiskLevel::High => self.semantic.status.error,
}
}
pub fn modal_title_style(&self) -> Style {
Style::default()
.fg(self.component.modal.title)
.add_modifier(Modifier::BOLD)
}
pub fn modal_hint_style(&self) -> Style {
Style::default().fg(self.component.modal.hint)
}
pub fn panel_border_style(&self, focused: bool, highlight: bool) -> Style {
let color = if focused {
self.semantic.surface.focus_border
} else if highlight {
self.semantic.surface.highlight_border
} else {
self.semantic.surface.unfocus_border
};
Style::default().fg(color)
}
pub fn picker_selected_style(&self) -> Style {
Style::default()
.bg(self.component.editor.completion_selected_bg)
.fg(self.semantic.text.primary)
.add_modifier(Modifier::BOLD)
}
pub fn modal_input_border_style(&self, focused: bool, has_error: bool) -> Style {
let color = if has_error {
self.semantic.status.error
} else if focused {
self.component.modal.border_highlight
} else {
self.component.modal.border
};
Style::default().fg(color)
}
pub fn modal_border_style(&self) -> Style {
Style::default().fg(self.component.modal.border)
}
pub fn status_style(&self, tone: StatusTone) -> Style {
let color = match tone {
StatusTone::Success => self.semantic.status.success,
StatusTone::Error => self.semantic.status.error,
StatusTone::Warning => self.semantic.status.warning,
};
Style::default().fg(color)
}
pub fn block_cursor_style(&self) -> Style {
Style::default()
.bg(self.semantic.cursor.bg)
.fg(self.semantic.cursor.text_fg)
}
pub fn insert_cursor_style(&self) -> Style {
Style::default().fg(self.semantic.cursor.fg)
}
}
pub const DEFAULT_THEME: ThemePalette = ThemePalette {
semantic: SemanticTokens {
surface: SurfaceTokens {
focus_border: Color::Rgb(0x97, 0xc9, 0xc3),
unfocus_border: Color::Rgb(0x45, 0x47, 0x55),
highlight_border: Color::Rgb(0xb0, 0xdd, 0xd8),
},
text: TextTokens {
primary: Color::Rgb(0xe9, 0xdb, 0xdb),
secondary: Color::Rgb(0xc0, 0xb8, 0xb8),
muted: Color::Rgb(0x5c, 0x63, 0x70),
dim: Color::Rgb(0x6a, 0x6e, 0x7a),
accent: Color::Rgb(0xd4, 0xa4, 0x85),
placeholder: Color::Rgb(0x5b, 0x5f, 0x6e),
},
status: StatusTokens {
success: Color::Rgb(0x97, 0xc9, 0xc3),
error: Color::Rgb(0xc4, 0x74, 0x6e),
warning: Color::Rgb(0xe0, 0xaf, 0x68),
pending: Color::Rgb(0xd4, 0xa0, 0x60),
medium_risk: Color::Rgb(0xd4, 0x70, 0x50),
},
cursor: CursorTokens {
fg: Color::White,
bg: Color::White,
text_fg: Color::Black,
},
},
component: ComponentTokens {
modal: ModalTokens {
title: Color::Rgb(0xe9, 0xdb, 0xdb),
hint: Color::Rgb(0xc0, 0xb8, 0xb0),
border: Color::Rgb(0x70, 0x68, 0x60),
border_highlight: Color::Rgb(0xc0, 0xb8, 0xb8),
},
navigation: NavigationTokens {
key_chip_bg: Color::Rgb(0x3a, 0x3a, 0x4a),
key_chip_fg: Color::Rgb(0xd4, 0xa4, 0x85),
section_header: Color::Rgb(0x6a, 0xb8, 0x9a),
scrollbar_active: Color::Rgb(0xc0, 0xb8, 0xb0),
scrollbar_inactive: Color::Rgb(0x50, 0x52, 0x5e),
tab_active: Color::Rgb(0xd0, 0xc0, 0xa0),
tab_inactive: Color::Rgb(0x5b, 0x5f, 0x6e),
active_indicator: Color::Rgb(0xff, 0xff, 0xff),
},
editor: EditorTokens {
current_line_bg: Color::Rgb(0x22, 0x26, 0x33),
completion_selected_bg: Color::Rgb(0x45, 0x47, 0x5a),
},
table: TableTokens {
result_row_active_bg: Color::Rgb(0x2e, 0x2e, 0x44),
result_cell_active_bg: Color::Rgb(0x3a, 0x3a, 0x5a),
cell_edit_fg: Color::Rgb(0xa8, 0xb8, 0xb5),
staged_delete_bg: Color::Rgb(0x3d, 0x22, 0x22),
staged_delete_fg: Color::Rgb(0xee, 0x77, 0x77),
striped_row_bg: Color::Rgb(0x1e, 0x1e, 0x23),
},
feedback: FeedbackTokens {
yank_flash_bg: Color::Rgb(0xF4, 0x9E, 0x4C),
yank_flash_fg: Color::Rgb(0x11, 0x14, 0x19),
note_text: Color::Rgb(0x66, 0x66, 0x77),
},
syntax: SyntaxTokens {
sql_keyword: Color::Rgb(0x80, 0x90, 0xa8),
sql_string: Color::Rgb(0xcd, 0xc8, 0xdb),
sql_number: Color::Rgb(0xd4, 0xa4, 0x85),
sql_comment: Color::Rgb(0x62, 0x72, 0xa4),
sql_operator: Color::Rgb(0x8a, 0x91, 0xa5),
sql_text: Color::Rgb(0xe9, 0xdb, 0xdb),
},
},
};
#[cfg(any(test, feature = "test-support"))]
pub const TEST_CONTRAST_THEME: ThemePalette = ThemePalette {
semantic: SemanticTokens {
surface: SurfaceTokens {
focus_border: Color::Rgb(0x2f, 0xc4, 0xb2),
unfocus_border: Color::Rgb(0x5d, 0x62, 0x74),
highlight_border: Color::Rgb(0xff, 0xc8, 0x57),
},
text: TextTokens {
primary: Color::Rgb(0xf6, 0xf0, 0xe8),
secondary: Color::Rgb(0xc9, 0xd6, 0xdf),
muted: Color::Rgb(0x92, 0xb3, 0xc2),
dim: Color::Rgb(0x6a, 0x85, 0x95),
accent: Color::Rgb(0xff, 0xc8, 0x57),
placeholder: Color::Rgb(0x92, 0xb3, 0xc2),
},
status: StatusTokens {
success: Color::Rgb(0x7b, 0xe0, 0x73),
error: Color::Rgb(0xff, 0x7a, 0x59),
warning: Color::Rgb(0xff, 0xc8, 0x57),
pending: Color::Rgb(0xff, 0x9f, 0x1c),
medium_risk: Color::Rgb(0xff, 0x9f, 0x1c),
},
cursor: CursorTokens {
fg: Color::Rgb(0xff, 0xf4, 0xe0),
bg: Color::Rgb(0xff, 0xf4, 0xe0),
text_fg: Color::Rgb(0x0d, 0x11, 0x18),
},
},
component: ComponentTokens {
modal: ModalTokens {
title: Color::Rgb(0xf6, 0xf0, 0xe8),
hint: Color::Rgb(0x7b, 0xe0, 0x73),
border: Color::Rgb(0xd8, 0x2a, 0x1f),
border_highlight: Color::Rgb(0xff, 0xe0, 0x66),
},
navigation: NavigationTokens {
key_chip_bg: Color::Rgb(0x1a, 0x45, 0x5e),
key_chip_fg: Color::Rgb(0xff, 0xe0, 0x66),
section_header: Color::Rgb(0x2f, 0xc4, 0xb2),
scrollbar_active: Color::Rgb(0x2f, 0xc4, 0xb2),
scrollbar_inactive: Color::Rgb(0x5d, 0x62, 0x74),
tab_active: Color::Rgb(0x2f, 0xc4, 0xb2),
tab_inactive: Color::Rgb(0x92, 0xb3, 0xc2),
active_indicator: Color::Rgb(0x2f, 0xc4, 0xb2),
},
editor: EditorTokens {
current_line_bg: Color::Rgb(0x1d, 0x2d, 0x3f),
completion_selected_bg: Color::Rgb(0x2d, 0x5d, 0x46),
},
table: TableTokens {
result_row_active_bg: Color::Rgb(0x2b, 0x32, 0x54),
result_cell_active_bg: Color::Rgb(0x3a, 0x44, 0x6e),
cell_edit_fg: Color::Rgb(0xff, 0xe0, 0x66),
staged_delete_bg: Color::Rgb(0x4a, 0x1f, 0x1f),
staged_delete_fg: Color::Rgb(0xff, 0x7a, 0x59),
striped_row_bg: Color::Rgb(0x1d, 0x21, 0x2b),
},
feedback: FeedbackTokens {
yank_flash_bg: Color::Rgb(0xff, 0xc8, 0x57),
yank_flash_fg: Color::Rgb(0x14, 0x17, 0x21),
note_text: Color::Rgb(0x92, 0xb3, 0xc2),
},
syntax: SyntaxTokens {
sql_keyword: Color::Rgb(0x7d, 0xc4, 0xff),
sql_string: Color::Rgb(0x9b, 0xf0, 0x8f),
sql_number: Color::Rgb(0xff, 0xb8, 0x6b),
sql_comment: Color::Rgb(0x7c, 0x8a, 0xa5),
sql_operator: Color::Rgb(0x5e, 0xe0, 0xd5),
sql_text: Color::Rgb(0xf6, 0xf0, 0xe8),
},
},
};
pub fn palette_for(theme_id: ThemeId) -> &'static ThemePalette {
match theme_id {
ThemeId::Default => &DEFAULT_THEME,
#[cfg(any(test, feature = "test-support"))]
ThemeId::TestContrast => &TEST_CONTRAST_THEME,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn palette_for_default_returns_default_theme() {
assert_eq!(palette_for(ThemeId::Default), &DEFAULT_THEME);
}
#[test]
fn palette_for_test_contrast_returns_test_theme() {
assert_eq!(palette_for(ThemeId::TestContrast), &TEST_CONTRAST_THEME);
}
#[test]
fn panel_border_style_prefers_focus_over_highlight() {
let style = DEFAULT_THEME.panel_border_style(true, true);
assert_eq!(style.fg, Some(DEFAULT_THEME.semantic.surface.focus_border));
}
#[test]
fn picker_selected_style_uses_selected_colors() {
let style = DEFAULT_THEME.picker_selected_style();
assert_eq!(
style.bg,
Some(DEFAULT_THEME.component.editor.completion_selected_bg)
);
assert_eq!(style.fg, Some(DEFAULT_THEME.semantic.text.primary));
assert!(style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn modal_input_border_style_prefers_error_over_focus() {
let style = DEFAULT_THEME.modal_input_border_style(true, true);
assert_eq!(style.fg, Some(DEFAULT_THEME.semantic.status.error));
}
#[test]
fn status_style_uses_requested_tone() {
let style = DEFAULT_THEME.status_style(StatusTone::Warning);
assert_eq!(style.fg, Some(DEFAULT_THEME.semantic.status.warning));
}
#[test]
fn modal_hint_style_uses_hint_token_without_bold() {
let style = DEFAULT_THEME.modal_hint_style();
assert_eq!(style.fg, Some(DEFAULT_THEME.component.modal.hint));
assert!(!style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn modal_border_style_uses_component_modal_border() {
let style = DEFAULT_THEME.modal_border_style();
assert_eq!(style.fg, Some(DEFAULT_THEME.component.modal.border));
}
#[test]
fn block_cursor_style_uses_semantic_cursor_colors() {
let style = DEFAULT_THEME.block_cursor_style();
assert_eq!(style.bg, Some(DEFAULT_THEME.semantic.cursor.bg));
assert_eq!(style.fg, Some(DEFAULT_THEME.semantic.cursor.text_fg));
}
}