sabiql 1.10.0

A fast, driver-less TUI for browsing and editing PostgreSQL databases
Documentation
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 modal_border: Color,
    pub modal_border_highlight: Color,
    pub modal_title: Color,
    pub modal_hint: Color,
    pub key_chip_bg: Color,
    pub key_chip_fg: Color,
    pub editor_current_line_bg: Color,
    pub completion_selected_bg: Color,
    pub input_value: Color,
    pub note_text: Color,
    pub focus_border: Color,
    pub unfocus_border: Color,
    pub highlight_border: Color,
    pub text_primary: Color,
    pub text_secondary: Color,
    pub text_muted: Color,
    pub text_dim: Color,
    pub text_accent: Color,
    pub status_success: Color,
    pub status_error: Color,
    pub status_warning: Color,
    pub status_medium_risk: Color,
    pub cursor_fg: Color,
    pub cursor_bg: Color,
    pub cursor_text_fg: Color,
    pub section_header: Color,
    pub scrollbar_active: Color,
    pub scrollbar_inactive: Color,
    pub result_row_active_bg: Color,
    pub result_cell_active_bg: Color,
    pub cell_edit_fg: Color,
    pub cell_draft_pending_fg: Color,
    pub staged_delete_bg: Color,
    pub staged_delete_fg: Color,
    pub yank_flash_bg: Color,
    pub yank_flash_fg: Color,
    pub sql_keyword: Color,
    pub sql_string: Color,
    pub sql_number: Color,
    pub sql_comment: Color,
    pub sql_operator: Color,
    pub sql_text: Color,
    pub json_key: Color,
    pub json_string: Color,
    pub json_number: Color,
    pub json_bool: Color,
    pub json_null: Color,
    pub json_bracket: Color,
    pub striped_row_bg: Color,
    pub tab_active: Color,
    pub tab_inactive: Color,
    pub active_indicator: Color,
    pub inactive_indicator: Color,
    pub placeholder_text: Color,
}

impl ThemePalette {
    pub fn risk_color(&self, level: RiskLevel) -> Color {
        match level {
            RiskLevel::Low => self.status_warning,
            RiskLevel::Medium => self.status_medium_risk,
            RiskLevel::High => self.status_error,
        }
    }

    pub fn modal_title_style(&self) -> Style {
        Style::default()
            .fg(self.modal_title)
            .add_modifier(Modifier::BOLD)
    }

    pub fn modal_hint_style(&self) -> Style {
        Style::default().fg(self.modal_hint)
    }

    pub fn panel_border_style(&self, focused: bool, highlight: bool) -> Style {
        let color = if focused {
            self.focus_border
        } else if highlight {
            self.highlight_border
        } else {
            self.unfocus_border
        };
        Style::default().fg(color)
    }

    pub fn picker_selected_style(&self) -> Style {
        Style::default()
            .bg(self.completion_selected_bg)
            .fg(self.text_primary)
            .add_modifier(Modifier::BOLD)
    }

    pub fn input_border_style(&self, focused: bool, has_error: bool) -> Style {
        let color = if has_error {
            self.status_error
        } else if focused {
            self.modal_border_highlight
        } else {
            self.modal_border
        };
        Style::default().fg(color)
    }

    pub fn status_style(&self, tone: StatusTone) -> Style {
        let color = match tone {
            StatusTone::Success => self.status_success,
            StatusTone::Error => self.status_error,
            StatusTone::Warning => self.status_warning,
        };
        Style::default().fg(color)
    }

    pub fn cursor_style(&self) -> Style {
        Style::default().bg(self.cursor_bg).fg(self.cursor_text_fg)
    }
}

pub const DEFAULT_THEME: ThemePalette = ThemePalette {
    modal_border: Color::Rgb(0x70, 0x68, 0x60),
    modal_border_highlight: Color::Rgb(0xc0, 0xb8, 0xb8),
    modal_title: Color::Rgb(0xe9, 0xdb, 0xdb),
    modal_hint: Color::Rgb(0xc0, 0xb8, 0xb0),
    key_chip_bg: Color::Rgb(0x3a, 0x3a, 0x4a),
    key_chip_fg: Color::Rgb(0xd4, 0xa4, 0x85),
    editor_current_line_bg: Color::Rgb(0x22, 0x26, 0x33),
    completion_selected_bg: Color::Rgb(0x45, 0x47, 0x5a),
    input_value: Color::Rgb(0xaa, 0xaa, 0xaa),
    note_text: Color::Rgb(0x66, 0x66, 0x77),
    focus_border: Color::Rgb(0x97, 0xc9, 0xc3),
    unfocus_border: Color::Rgb(0x45, 0x47, 0x55),
    highlight_border: Color::Rgb(0xb0, 0xdd, 0xd8),
    text_primary: Color::Rgb(0xe9, 0xdb, 0xdb),
    text_secondary: Color::Rgb(0xc0, 0xb8, 0xb8),
    text_muted: Color::Rgb(0x5c, 0x63, 0x70),
    text_dim: Color::Rgb(0x6a, 0x6e, 0x7a),
    text_accent: Color::Rgb(0xd4, 0xa4, 0x85),
    status_success: Color::Rgb(0x97, 0xc9, 0xc3),
    status_error: Color::Rgb(0xc4, 0x74, 0x6e),
    status_warning: Color::Rgb(0xe0, 0xaf, 0x68),
    status_medium_risk: Color::Rgb(0xd4, 0x70, 0x50),
    cursor_fg: Color::White,
    cursor_bg: Color::White,
    cursor_text_fg: Color::Black,
    section_header: Color::Rgb(0x6a, 0xb8, 0x9a),
    scrollbar_active: Color::Rgb(0xc0, 0xb8, 0xb0),
    scrollbar_inactive: Color::Rgb(0x50, 0x52, 0x5e),
    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),
    cell_draft_pending_fg: Color::Rgb(0xd4, 0xa0, 0x60),
    staged_delete_bg: Color::Rgb(0x3d, 0x22, 0x22),
    staged_delete_fg: Color::Rgb(0xee, 0x77, 0x77),
    yank_flash_bg: Color::Rgb(0xF4, 0x9E, 0x4C),
    yank_flash_fg: Color::Rgb(0x11, 0x14, 0x19),
    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),
    json_key: Color::Rgb(0x7a, 0x9f, 0xc8),
    json_string: Color::Rgb(0x8a, 0xb8, 0x8a),
    json_number: Color::Rgb(0xb0, 0x9a, 0x88),
    json_bool: Color::Rgb(0xb0, 0x9a, 0x88),
    json_null: Color::Rgb(0x5b, 0x5f, 0x6e),
    json_bracket: Color::Rgb(0xc0, 0xb8, 0xb8),
    striped_row_bg: Color::Rgb(0x1e, 0x1e, 0x23),
    tab_active: Color::Rgb(0xd0, 0xc0, 0xa0),
    tab_inactive: Color::Rgb(0x5b, 0x5f, 0x6e),
    active_indicator: Color::Rgb(0xff, 0xff, 0xff),
    inactive_indicator: Color::Rgb(0x5b, 0x5f, 0x6e),
    placeholder_text: Color::Rgb(0x5b, 0x5f, 0x6e),
};

#[cfg(any(test, feature = "test-support"))]
pub const TEST_CONTRAST_THEME: ThemePalette = ThemePalette {
    modal_border: Color::Rgb(0xd8, 0x2a, 0x1f),
    modal_border_highlight: Color::Rgb(0xff, 0xe0, 0x66),
    modal_title: Color::Rgb(0xf6, 0xf0, 0xe8),
    modal_hint: Color::Rgb(0x7b, 0xe0, 0x73),
    key_chip_bg: Color::Rgb(0x1a, 0x45, 0x5e),
    key_chip_fg: Color::Rgb(0xff, 0xe0, 0x66),
    editor_current_line_bg: Color::Rgb(0x1d, 0x2d, 0x3f),
    completion_selected_bg: Color::Rgb(0x2d, 0x5d, 0x46),
    input_value: Color::Rgb(0xf6, 0xf0, 0xe8),
    note_text: Color::Rgb(0x92, 0xb3, 0xc2),
    focus_border: Color::Rgb(0x2f, 0xc4, 0xb2),
    unfocus_border: Color::Rgb(0x5d, 0x62, 0x74),
    highlight_border: Color::Rgb(0xff, 0xc8, 0x57),
    text_primary: Color::Rgb(0xf6, 0xf0, 0xe8),
    text_secondary: Color::Rgb(0xc9, 0xd6, 0xdf),
    text_muted: Color::Rgb(0x92, 0xb3, 0xc2),
    text_dim: Color::Rgb(0x6a, 0x85, 0x95),
    text_accent: Color::Rgb(0xff, 0xc8, 0x57),
    status_success: Color::Rgb(0x7b, 0xe0, 0x73),
    status_error: Color::Rgb(0xff, 0x7a, 0x59),
    status_warning: Color::Rgb(0xff, 0xc8, 0x57),
    status_medium_risk: Color::Rgb(0xff, 0x9f, 0x1c),
    cursor_fg: Color::Rgb(0xff, 0xf4, 0xe0),
    cursor_bg: Color::Rgb(0xff, 0xf4, 0xe0),
    cursor_text_fg: Color::Rgb(0x0d, 0x11, 0x18),
    section_header: Color::Rgb(0x2f, 0xc4, 0xb2),
    scrollbar_active: Color::Rgb(0x2f, 0xc4, 0xb2),
    scrollbar_inactive: Color::Rgb(0x5d, 0x62, 0x74),
    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),
    cell_draft_pending_fg: Color::Rgb(0xff, 0x9f, 0x1c),
    staged_delete_bg: Color::Rgb(0x4a, 0x1f, 0x1f),
    staged_delete_fg: Color::Rgb(0xff, 0x7a, 0x59),
    yank_flash_bg: Color::Rgb(0xff, 0xc8, 0x57),
    yank_flash_fg: Color::Rgb(0x14, 0x17, 0x21),
    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),
    json_key: Color::Rgb(0x2f, 0xc4, 0xb2),
    json_string: Color::Rgb(0x9b, 0xf0, 0x8f),
    json_number: Color::Rgb(0xff, 0xb8, 0x6b),
    json_bool: Color::Rgb(0xff, 0xb8, 0x6b),
    json_null: Color::Rgb(0x92, 0xb3, 0xc2),
    json_bracket: Color::Rgb(0xc9, 0xd6, 0xdf),
    striped_row_bg: Color::Rgb(0x1d, 0x21, 0x2b),
    tab_active: Color::Rgb(0x2f, 0xc4, 0xb2),
    tab_inactive: Color::Rgb(0x92, 0xb3, 0xc2),
    active_indicator: Color::Rgb(0x2f, 0xc4, 0xb2),
    inactive_indicator: Color::Rgb(0x92, 0xb3, 0xc2),
    placeholder_text: Color::Rgb(0x92, 0xb3, 0xc2),
};

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.focus_border));
    }

    #[test]
    fn picker_selected_style_uses_selected_colors() {
        let style = DEFAULT_THEME.picker_selected_style();

        assert_eq!(style.bg, Some(DEFAULT_THEME.completion_selected_bg));
        assert_eq!(style.fg, Some(DEFAULT_THEME.text_primary));
        assert!(style.add_modifier.contains(Modifier::BOLD));
    }

    #[test]
    fn input_border_style_prefers_error_over_focus() {
        let style = DEFAULT_THEME.input_border_style(true, true);

        assert_eq!(style.fg, Some(DEFAULT_THEME.status_error));
    }

    #[test]
    fn status_style_uses_requested_tone() {
        let style = DEFAULT_THEME.status_style(StatusTone::Warning);

        assert_eq!(style.fg, Some(DEFAULT_THEME.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.modal_hint));
        assert!(!style.add_modifier.contains(Modifier::BOLD));
    }

    #[test]
    fn cursor_style_inverts_cursor_and_selection_colors() {
        let style = DEFAULT_THEME.cursor_style();

        assert_eq!(style.bg, Some(DEFAULT_THEME.cursor_bg));
        assert_eq!(style.fg, Some(DEFAULT_THEME.cursor_text_fg));
    }
}