Skip to main content

zeph_tui/theme/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! TUI visual theme system.
5//!
6//! A [`Theme`] is a flat collection of [`Style`] and
7//! [`Color`] values consumed by every widget render function.
8//!
9//! The palette-driven workflow:
10//! 1. Load a [`SemanticPalette`] — from a built-in preset, user file, or default.
11//! 2. Detect or override terminal colour capability via [`resolve_color_mode`].
12//! 3. Call [`Theme::from_palette_with_mode`] **once** at startup.
13//! 4. Store the result in [`crate::App`] and thread `&theme` into every render call.
14//!
15//! [`Theme::default()`] is byte-identical to the pre-2.0 hardcoded styles and is kept
16//! for backward compatibility and tests. Use [`Theme::from_palette`] for new code.
17
18pub mod color_mode;
19pub mod palette;
20pub mod presets;
21
22pub use color_mode::{
23    EffectiveColorMode, apply_mode, detect_unicode_capable, map_color, resolve_color_mode,
24};
25pub use palette::{ExtendedRoles, Rgb, SemanticPalette};
26pub use presets::{Preset, ThemeLoadError, resolve_palette};
27
28use ratatui::style::{Color, Modifier, Style};
29
30use crate::theme::color_mode::EffectiveColorMode as Ecm;
31use crate::theme::palette::SemanticPalette as Palette;
32
33/// Ratatui [`Style`] mappings for tree-sitter syntax-highlight capture groups.
34///
35/// Each field corresponds to a tree-sitter capture name (e.g. `"keyword"`,
36/// `"string"`, `"comment"`). The [`crate::highlight::SyntaxHighlighter`] uses
37/// this struct to map highlight events to terminal styles.
38///
39/// The [`Default`] implementation provides a dark One Dark-inspired palette.
40///
41/// # Examples
42///
43/// ```rust
44/// use zeph_tui::theme::SyntaxTheme;
45///
46/// let theme = SyntaxTheme::default();
47/// // Keywords are rendered bold.
48/// use ratatui::style::Modifier;
49/// assert!(theme.keyword.add_modifier.contains(Modifier::BOLD));
50/// ```
51pub struct SyntaxTheme {
52    /// Style for language keywords (e.g. `fn`, `let`, `if`).
53    pub keyword: Style,
54    /// Style for string literals.
55    pub string: Style,
56    /// Style for comments.
57    pub comment: Style,
58    /// Style for function names.
59    pub function: Style,
60    /// Style for type names and constructors.
61    pub r#type: Style,
62    /// Style for numeric literals.
63    pub number: Style,
64    /// Style for operators.
65    pub operator: Style,
66    /// Style for variable names and parameters.
67    pub variable: Style,
68    /// Style for attributes and annotations.
69    pub attribute: Style,
70    /// Style for punctuation tokens.
71    pub punctuation: Style,
72    /// Style for constants and built-in values.
73    pub constant: Style,
74    /// Fallback style for unstyled source text.
75    pub default: Style,
76}
77
78/// Visual theme for the TUI dashboard widgets.
79///
80/// Contains [`Style`] values for every distinct UI element — message roles,
81/// input fields, borders, diff gutters, hyperlinks, and status elements.
82/// The [`Default`] implementation provides a dark blue colour scheme.
83///
84/// Build a configured theme with [`Theme::from_palette_with_mode`] at startup and store it
85/// in the TUI `App`; widget render functions receive `&Theme` as a parameter.
86///
87/// # Examples
88///
89/// ```rust
90/// use zeph_tui::theme::{Theme, SemanticPalette, resolve_color_mode};
91/// use zeph_config::ColorMode;
92///
93/// let palette = SemanticPalette::zephyr();
94/// // Use Truecolor so the test is deterministic regardless of the CI environment.
95/// let mode = resolve_color_mode(ColorMode::Truecolor);
96/// let theme = Theme::from_palette_with_mode(&palette, mode);
97/// assert_ne!(theme.user_message, theme.assistant_message);
98/// ```
99pub struct Theme {
100    /// Style applied to user-role chat messages.
101    pub user_message: Style,
102    /// Style applied to assistant-role chat messages.
103    pub assistant_message: Style,
104    /// Style applied to system-role chat messages.
105    pub system_message: Style,
106    /// Style for the text body of the input field.
107    pub input_text: Style,
108    /// Style for the blinking cursor in the input field.
109    pub input_cursor: Style,
110    /// Style for the status bar at the bottom of the screen.
111    pub status_bar: Style,
112    /// Style for the top header bar (provider / model info).
113    pub header: Style,
114    /// Style for panel border lines.
115    pub panel_border: Style,
116    /// Style for panel title labels.
117    pub panel_title: Style,
118    /// Style for highlighted / selected items.
119    pub highlight: Style,
120    /// Style for error messages and indicators.
121    pub error: Style,
122    /// Style for thinking / reasoning messages.
123    pub thinking_message: Style,
124    /// Style for inline code spans within chat messages.
125    pub code_inline: Style,
126    /// Style for multi-line code blocks.
127    pub code_block: Style,
128    /// Style for the streaming cursor shown while the model is generating.
129    pub streaming_cursor: Style,
130    /// Style for tool command lines (shell commands, etc.).
131    pub tool_command: Style,
132    /// Accent style for assistant messages (orange / warm tone).
133    pub assistant_accent: Style,
134    /// Accent style for tool output messages (olive / warm tone).
135    pub tool_accent: Style,
136    /// Background colour for added lines in diffs.
137    pub diff_added_bg: Color,
138    /// Background colour for removed lines in diffs.
139    pub diff_removed_bg: Color,
140    /// Background colour for word-level added regions in diffs.
141    pub diff_word_added_bg: Color,
142    /// Background colour for word-level removed regions in diffs.
143    pub diff_word_removed_bg: Color,
144    /// Style for the `+` gutter marker in diffs.
145    pub diff_gutter_add: Style,
146    /// Style for the `-` gutter marker in diffs.
147    pub diff_gutter_remove: Style,
148    /// Style for diff file/hunk headers.
149    pub diff_header: Style,
150    /// Style for hyperlinks.
151    pub link: Style,
152    /// Style for table border lines.
153    pub table_border: Style,
154    /// Background tint applied to user message lines.
155    pub user_message_bg: Color,
156    /// Style for turn-separator lines between role changes.
157    pub turn_separator: Style,
158    /// Style for the bullet of a successfully completed tool call.
159    pub tool_success: Style,
160    /// Style for the bullet of a failed tool call.
161    pub tool_failure: Style,
162    /// Syntax-highlight styles for code blocks and diffs.
163    ///
164    /// Initialised once at startup; passed as `&theme.syntax_theme` to `render_diff_lines`
165    /// and the syntax highlighter so that `SyntaxTheme::default()` is never called per frame.
166    pub syntax_theme: SyntaxTheme,
167}
168
169impl Theme {
170    /// Derive all widget styles from a [`SemanticPalette`] at truecolor fidelity.
171    ///
172    /// Equivalent to `from_palette_with_mode(p, EffectiveColorMode::Truecolor)`.
173    ///
174    /// # Examples
175    ///
176    /// ```rust
177    /// use zeph_tui::theme::{Theme, SemanticPalette};
178    ///
179    /// let theme = Theme::from_palette(&SemanticPalette::zephyr());
180    /// assert_ne!(theme.user_message, theme.assistant_message);
181    /// ```
182    #[must_use]
183    pub fn from_palette(p: &SemanticPalette) -> Self {
184        Self::from_palette_with_mode(p, Ecm::Truecolor)
185    }
186
187    /// Derive all widget styles from a [`SemanticPalette`], downgrading colours for the
188    /// given terminal capability.
189    ///
190    /// This is the single derivation path — all `Rgb` values produced here pass through
191    /// [`apply_mode`] (for `Style` fields) and [`map_color`] (for bare `Color` fields).
192    /// The downgrade happens exactly once at startup, never per frame.
193    ///
194    /// # Examples
195    ///
196    /// ```rust
197    /// use zeph_tui::theme::{Theme, SemanticPalette, EffectiveColorMode};
198    ///
199    /// let t = Theme::from_palette_with_mode(&SemanticPalette::zephyr(), EffectiveColorMode::Ansi256);
200    /// // In Ansi256 mode, colours are indexed — no Rgb variants in fg/bg.
201    /// if let Some(fg) = t.user_message.fg {
202    ///     assert!(!matches!(fg, ratatui::style::Color::Rgb(..)));
203    /// }
204    /// ```
205    #[must_use]
206    pub fn from_palette_with_mode(p: &Palette, mode: Ecm) -> Self {
207        let am = |style: Style| apply_mode(style, mode);
208        let mc = |color: Color| map_color(color, mode);
209
210        let fg = |c: Rgb| Color::from(c);
211
212        Self {
213            user_message: am(Style::default().fg(fg(p.accent))),
214            assistant_message: am(Style::default().fg(fg(p.text))),
215            system_message: am(Style::default().fg(fg(p.muted))),
216            input_text: am(Style::default().fg(fg(p.accent))),
217            input_cursor: am(Style::default()
218                .fg(fg(p.warning))
219                .add_modifier(Modifier::BOLD)),
220            status_bar: am(Style::default().fg(fg(p.text)).bg(fg(p.surface))),
221            header: am(Style::default()
222                .fg(fg(p.text))
223                .bg(fg(p.extended.header_bg))
224                .add_modifier(Modifier::BOLD)),
225            panel_border: am(Style::default().fg(fg(p.border))),
226            panel_title: am(Style::default().fg(fg(p.text)).add_modifier(Modifier::BOLD)),
227            highlight: am(Style::default().fg(fg(p.extended.highlight))),
228            error: am(Style::default().fg(fg(p.error))),
229            thinking_message: am(Style::default().fg(fg(p.muted))),
230            code_inline: am(Style::default()
231                .fg(fg(p.info))
232                .bg(fg(p.surface))
233                .add_modifier(Modifier::BOLD)),
234            code_block: am(Style::default().fg(fg(p.text)).bg(fg(p.surface))),
235            streaming_cursor: am(Style::default().fg(fg(p.muted))),
236            tool_command: am(Style::default()
237                .fg(fg(p.warning))
238                .add_modifier(Modifier::BOLD)),
239            assistant_accent: am(Style::default().fg(fg(p.extended.accent_alt))),
240            tool_accent: am(Style::default().fg(fg(p.extended.accent_alt))),
241            // S3: bare Color fields go through map_color, not apply_mode.
242            diff_added_bg: mc(Color::Rgb(0, 40, 0)),
243            diff_removed_bg: mc(Color::Rgb(40, 0, 0)),
244            diff_word_added_bg: mc(Color::Rgb(0, 80, 0)),
245            diff_word_removed_bg: mc(Color::Rgb(80, 0, 0)),
246            diff_gutter_add: am(Style::default().fg(fg(p.success))),
247            diff_gutter_remove: am(Style::default().fg(fg(p.error))),
248            diff_header: am(Style::default().fg(fg(p.muted))),
249            link: am(Style::default()
250                .fg(fg(p.accent))
251                .add_modifier(Modifier::UNDERLINED)),
252            table_border: am(Style::default().fg(fg(p.muted))),
253            user_message_bg: mc(fg(p.surface)),
254            turn_separator: am(Style::default().fg(fg(p.muted)).add_modifier(Modifier::DIM)),
255            tool_success: am(Style::default().fg(fg(p.success))),
256            tool_failure: am(Style::default().fg(fg(p.error))),
257            syntax_theme: SyntaxTheme::default(),
258        }
259    }
260}
261
262impl Default for Theme {
263    /// Returns the legacy hardcoded dark-blue colour scheme.
264    ///
265    /// This implementation is byte-identical to the pre-2.0 default and is kept for
266    /// backward compatibility. For a configurable theme, use [`Theme::from_palette_with_mode`].
267    fn default() -> Self {
268        Self {
269            user_message: Style::default().fg(Color::Cyan),
270            assistant_message: Style::default().fg(Color::Rgb(200, 200, 210)),
271            system_message: Style::default().fg(Color::DarkGray),
272            input_text: Style::default().fg(Color::Cyan),
273            input_cursor: Style::default()
274                .fg(Color::Yellow)
275                .add_modifier(Modifier::BOLD),
276            status_bar: Style::default().fg(Color::White).bg(Color::DarkGray),
277            header: Style::default()
278                .fg(Color::Rgb(200, 220, 255))
279                .bg(Color::Rgb(20, 40, 80))
280                .add_modifier(Modifier::BOLD),
281            panel_border: Style::default().fg(Color::Gray),
282            panel_title: Style::default()
283                .fg(Color::White)
284                .add_modifier(Modifier::BOLD),
285            highlight: Style::default().fg(Color::Rgb(215, 150, 60)),
286            error: Style::default().fg(Color::Red),
287            thinking_message: Style::default().fg(Color::DarkGray),
288            code_inline: Style::default()
289                .fg(Color::Rgb(100, 180, 255))
290                .bg(Color::Rgb(15, 30, 55))
291                .add_modifier(Modifier::BOLD),
292            code_block: Style::default()
293                .fg(Color::Rgb(190, 175, 145))
294                .bg(Color::Rgb(20, 25, 35)),
295            streaming_cursor: Style::default().fg(Color::DarkGray),
296            tool_command: Style::default()
297                .fg(Color::Yellow)
298                .add_modifier(Modifier::BOLD),
299            assistant_accent: Style::default().fg(Color::Rgb(185, 85, 25)),
300            tool_accent: Style::default().fg(Color::Rgb(140, 120, 50)),
301            diff_added_bg: Color::Rgb(0, 40, 0),
302            diff_removed_bg: Color::Rgb(40, 0, 0),
303            diff_word_added_bg: Color::Rgb(0, 80, 0),
304            diff_word_removed_bg: Color::Rgb(80, 0, 0),
305            diff_gutter_add: Style::default().fg(Color::Green),
306            diff_gutter_remove: Style::default().fg(Color::Red),
307            diff_header: Style::default().fg(Color::DarkGray),
308            link: Style::default()
309                .fg(Color::Cyan)
310                .add_modifier(Modifier::UNDERLINED),
311            table_border: Style::default().fg(Color::DarkGray),
312            user_message_bg: Color::Rgb(20, 25, 35),
313            turn_separator: Style::default()
314                .fg(Color::DarkGray)
315                .add_modifier(Modifier::DIM),
316            tool_success: Style::default().fg(Color::Green),
317            tool_failure: Style::default().fg(Color::Red),
318            syntax_theme: SyntaxTheme::default(),
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn default_theme_has_distinct_message_styles() {
329        let theme = Theme::default();
330        assert_ne!(theme.user_message, theme.assistant_message);
331        assert_ne!(theme.assistant_message, theme.system_message);
332    }
333
334    #[test]
335    fn default_theme_status_bar_has_background() {
336        let theme = Theme::default();
337        assert_eq!(theme.status_bar.bg, Some(Color::DarkGray));
338    }
339
340    /// S4: set a sentinel palette and assert a non-default theme changes rendered output.
341    #[test]
342    fn from_palette_changes_user_message_fg() {
343        let mut p = SemanticPalette::zephyr();
344        // Use a distinctive colour that is not Color::Cyan (the default).
345        p.accent = Rgb(0xAB, 0xCD, 0xEF);
346        let theme = Theme::from_palette(&p);
347        assert_ne!(
348            theme.user_message.fg,
349            Some(Color::Cyan),
350            "palette-derived theme must differ from Theme::default() user_message"
351        );
352        assert_eq!(theme.user_message.fg, Some(Color::Rgb(0xAB, 0xCD, 0xEF)));
353    }
354
355    #[test]
356    fn from_palette_never_strips_fg() {
357        let theme = Theme::from_palette_with_mode(&SemanticPalette::zephyr(), Ecm::Never);
358        // All Style fg/bg must be None in Never mode.
359        assert_eq!(theme.user_message.fg, None);
360        assert_eq!(theme.status_bar.bg, None);
361        // Modifiers must be preserved.
362        assert!(theme.header.add_modifier.contains(Modifier::BOLD));
363    }
364
365    #[test]
366    fn from_palette_ansi256_no_rgb_fg() {
367        let theme = Theme::from_palette_with_mode(&SemanticPalette::zephyr(), Ecm::Ansi256);
368        // In Ansi256 mode the fg must NOT be Rgb — it must be Indexed.
369        if let Some(fg) = theme.user_message.fg {
370            assert!(
371                !matches!(fg, Color::Rgb(..)),
372                "Ansi256 mode must downgrade Rgb: {fg:?}"
373            );
374        }
375    }
376
377    /// S6: WCAG contrast smoke test — text/bg pair must be ≥ 4.5:1.
378    #[test]
379    fn zephyr_text_bg_contrast_aa() {
380        let p = SemanticPalette::zephyr();
381        let text_l = relative_luminance(p.text.0, p.text.1, p.text.2);
382        let bg_l = relative_luminance(p.bg.0, p.bg.1, p.bg.2);
383        let ratio = contrast_ratio(text_l, bg_l);
384        assert!(
385            ratio >= 4.5,
386            "zephyr text/bg WCAG contrast {ratio:.2}:1 is below AA threshold 4.5:1"
387        );
388    }
389
390    #[test]
391    fn high_contrast_text_bg_contrast_aaa() {
392        let p = presets::Preset::HighContrast.palette();
393        let text_l = relative_luminance(p.text.0, p.text.1, p.text.2);
394        let bg_l = relative_luminance(p.bg.0, p.bg.1, p.bg.2);
395        let ratio = contrast_ratio(text_l, bg_l);
396        assert!(
397            ratio >= 7.0,
398            "high-contrast text/bg WCAG contrast {ratio:.2}:1 is below AAA threshold 7.0:1"
399        );
400    }
401
402    fn srgb_linearize(u: u8) -> f64 {
403        let c = f64::from(u) / 255.0;
404        if c <= 0.04045 {
405            c / 12.92
406        } else {
407            ((c + 0.055) / 1.055).powf(2.4)
408        }
409    }
410
411    fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
412        0.2126 * srgb_linearize(r) + 0.7152 * srgb_linearize(g) + 0.0722 * srgb_linearize(b)
413    }
414
415    fn contrast_ratio(l1: f64, l2: f64) -> f64 {
416        let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
417        (lighter + 0.05) / (darker + 0.05)
418    }
419}