diff --git a/src/theme.rs b/src/theme.rs index 5c3c8e6..a51764b 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -5,9 +5,21 @@ //! small for the walking skeleton; it grows as more views are //! added. Contrast is chosen against the target background so //! that foreground text meets WCAG-AA (NFR-5) on both variants. +//! +//! Per-token-class colours (the `tok_*` fields) drive ambient +//! typing assistance (ADR-0022 §3). Each token class has a +//! distinct colour so the user can tell keywords from +//! identifiers from string literals at a glance, with punct and +//! identifier intentionally close to `fg` to keep the surface +//! quiet for the dominant content. The `tok_error` colour +//! reuses the existing error palette so lex-error tokens and +//! parse-error overlays read consistently with `[error]` +//! lines elsewhere. use ratatui::style::Color; +use crate::dsl::lexer::TokenKind; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Background { Light, @@ -26,6 +38,14 @@ pub struct Theme { pub mode_advanced: Color, pub system: Color, pub error: Color, + // ---- Per-token-class colours (ADR-0022 §3) ------------------- + pub tok_keyword: Color, + pub tok_identifier: Color, + pub tok_number: Color, + pub tok_string: Color, + pub tok_punct: Color, + pub tok_flag: Color, + pub tok_error: Color, } impl Theme { @@ -42,6 +62,20 @@ impl Theme { mode_advanced: Color::Rgb(0xFF, 0x9E, 0x6B), system: Color::Rgb(0x9F, 0xD8, 0x91), error: Color::Rgb(0xFF, 0x6B, 0x6B), + // Token classes — distinct enough to tell apart at a + // glance, quiet enough that 80-char lines don't read + // like a Christmas tree. Identifier and punct sit + // close to `fg`/`muted` so the dominant content + // remains restful; literals and flags get warm + // accent tones; keyword takes a cool accent tone + // distinct from the mode-banner blue. + tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple + tok_identifier: Color::Rgb(0xE6, 0xE6, 0xE6), // == fg + tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange + tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green + tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted + tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber + tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error } } @@ -58,6 +92,33 @@ impl Theme { mode_advanced: Color::Rgb(0xB0, 0x4A, 0x12), system: Color::Rgb(0x2E, 0x7C, 0x3C), error: Color::Rgb(0xC0, 0x39, 0x2B), + // Light-theme token palette: same intent as dark — + // identifier/punct close to fg/muted; warm tones for + // literals + flags; cool accent for keyword. + tok_keyword: Color::Rgb(0x6F, 0x42, 0xC1), // royal purple + tok_identifier: Color::Rgb(0x1A, 0x1F, 0x2C), // == fg + tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange + tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green + tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted + tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard + tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error + } + } + + /// Map a `TokenKind` to its display colour for ambient + /// highlighting (ADR-0022 §3). Lex-error tokens always render + /// in `tok_error`, regardless of the parse-time error overlay + /// applied separately by the renderer. + #[must_use] + pub const fn token_color(&self, kind: &TokenKind) -> Color { + match kind { + TokenKind::Keyword(_) => self.tok_keyword, + TokenKind::Identifier(_) => self.tok_identifier, + TokenKind::Number(_) => self.tok_number, + TokenKind::StringLiteral(_) => self.tok_string, + TokenKind::Punct(_) => self.tok_punct, + TokenKind::Flag(_) => self.tok_flag, + TokenKind::Error(_) => self.tok_error, } } } @@ -67,3 +128,92 @@ impl Default for Theme { Self::dark() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::dsl::keyword::{Keyword, Punct}; + use crate::dsl::lexer::LexError; + + #[test] + fn dark_theme_token_colours_differ_from_background() { + let t = Theme::dark(); + for (name, c) in [ + ("tok_keyword", t.tok_keyword), + ("tok_number", t.tok_number), + ("tok_string", t.tok_string), + ("tok_flag", t.tok_flag), + ("tok_error", t.tok_error), + ] { + assert_ne!( + c, t.bg, + "{name} must contrast against bg in dark theme", + ); + } + } + + #[test] + fn light_theme_token_colours_differ_from_background() { + let t = Theme::light(); + for (name, c) in [ + ("tok_keyword", t.tok_keyword), + ("tok_number", t.tok_number), + ("tok_string", t.tok_string), + ("tok_flag", t.tok_flag), + ("tok_error", t.tok_error), + ] { + assert_ne!( + c, t.bg, + "{name} must contrast against bg in light theme", + ); + } + } + + #[test] + fn token_color_maps_each_kind_to_the_expected_field() { + let t = Theme::dark(); + assert_eq!( + t.token_color(&TokenKind::Keyword(Keyword::Create)), + t.tok_keyword, + ); + assert_eq!( + t.token_color(&TokenKind::Identifier("Customers".to_string())), + t.tok_identifier, + ); + assert_eq!( + t.token_color(&TokenKind::Number("42".to_string())), + t.tok_number, + ); + assert_eq!( + t.token_color(&TokenKind::StringLiteral("hi".to_string())), + t.tok_string, + ); + assert_eq!( + t.token_color(&TokenKind::Punct(Punct::Colon)), + t.tok_punct, + ); + assert_eq!( + t.token_color(&TokenKind::Flag("all-rows".to_string())), + t.tok_flag, + ); + assert_eq!( + t.token_color(&TokenKind::Error(LexError::UnknownChar('$'))), + t.tok_error, + ); + } + + #[test] + fn lex_error_tokens_render_in_tok_error_regardless_of_kind() { + let t = Theme::dark(); + for err in [ + LexError::UnknownChar('$'), + LexError::UnterminatedString, + LexError::BadFlag, + ] { + assert_eq!( + t.token_color(&TokenKind::Error(err)), + t.tok_error, + ); + } + } +}