Skip to main content

tui/theme/
mod.rs

1#[cfg(feature = "syntax")]
2use std::sync::Arc;
3
4use crate::rendering::line::Line;
5use crate::style::Style;
6use crossterm::style::Color;
7use thiserror::Error;
8mod defaults;
9
10#[derive(Debug, Clone, Error)]
11pub enum ThemeBuildError {
12    #[error("ThemeBuilder requires {0}")]
13    MissingField(&'static str),
14}
15
16#[cfg(feature = "syntax")]
17mod syntax;
18
19#[doc = include_str!("../docs/theme.md")]
20#[derive(Clone, Debug)]
21pub struct Theme {
22    fg: Color,
23    bg: Color,
24    accent: Color,
25    highlight_bg: Color,
26    highlight_fg: Color,
27
28    text_secondary: Color,
29    code_fg: Color,
30    code_bg: Color,
31
32    heading: Color,
33    link: Color,
34    blockquote: Color,
35    muted: Color,
36
37    success: Color,
38    warning: Color,
39    error: Color,
40    info: Color,
41    secondary: Color,
42
43    sidebar_bg: Color,
44
45    diff_added_fg: Color,
46    diff_removed_fg: Color,
47    diff_added_bg: Color,
48    diff_removed_bg: Color,
49
50    // Cached syntect theme for syntax highlighting (parsed once at construction)
51    #[cfg(feature = "syntax")]
52    #[allow(clippy::struct_field_names)]
53    syntect_theme: Arc<syntect::highlighting::Theme>,
54}
55
56#[derive(Clone, Copy, Debug, Default)]
57pub struct ThemeBuilder {
58    fg: Option<Color>,
59    bg: Option<Color>,
60    accent: Option<Color>,
61    highlight_bg: Option<Color>,
62    highlight_fg: Option<Color>,
63    text_secondary: Option<Color>,
64    code_fg: Option<Color>,
65    code_bg: Option<Color>,
66    heading: Option<Color>,
67    link: Option<Color>,
68    blockquote: Option<Color>,
69    muted: Option<Color>,
70    success: Option<Color>,
71    warning: Option<Color>,
72    error: Option<Color>,
73    info: Option<Color>,
74    secondary: Option<Color>,
75    sidebar_bg: Option<Color>,
76    diff_added_fg: Option<Color>,
77    diff_removed_fg: Option<Color>,
78    diff_added_bg: Option<Color>,
79    diff_removed_bg: Option<Color>,
80}
81
82impl ThemeBuilder {
83    pub fn fg(mut self, color: Color) -> Self {
84        self.fg = Some(color);
85        self
86    }
87
88    pub fn bg(mut self, color: Color) -> Self {
89        self.bg = Some(color);
90        self
91    }
92
93    pub fn accent(mut self, color: Color) -> Self {
94        self.accent = Some(color);
95        self
96    }
97
98    pub fn highlight_bg(mut self, color: Color) -> Self {
99        self.highlight_bg = Some(color);
100        self
101    }
102
103    pub fn highlight_fg(mut self, color: Color) -> Self {
104        self.highlight_fg = Some(color);
105        self
106    }
107
108    pub fn text_secondary(mut self, color: Color) -> Self {
109        self.text_secondary = Some(color);
110        self
111    }
112
113    pub fn code_fg(mut self, color: Color) -> Self {
114        self.code_fg = Some(color);
115        self
116    }
117
118    pub fn code_bg(mut self, color: Color) -> Self {
119        self.code_bg = Some(color);
120        self
121    }
122
123    pub fn heading(mut self, color: Color) -> Self {
124        self.heading = Some(color);
125        self
126    }
127
128    pub fn link(mut self, color: Color) -> Self {
129        self.link = Some(color);
130        self
131    }
132
133    pub fn blockquote(mut self, color: Color) -> Self {
134        self.blockquote = Some(color);
135        self
136    }
137
138    pub fn muted(mut self, color: Color) -> Self {
139        self.muted = Some(color);
140        self
141    }
142
143    pub fn success(mut self, color: Color) -> Self {
144        self.success = Some(color);
145        self
146    }
147
148    pub fn warning(mut self, color: Color) -> Self {
149        self.warning = Some(color);
150        self
151    }
152
153    pub fn error(mut self, color: Color) -> Self {
154        self.error = Some(color);
155        self
156    }
157
158    pub fn info(mut self, color: Color) -> Self {
159        self.info = Some(color);
160        self
161    }
162
163    pub fn secondary(mut self, color: Color) -> Self {
164        self.secondary = Some(color);
165        self
166    }
167
168    pub fn sidebar_bg(mut self, color: Color) -> Self {
169        self.sidebar_bg = Some(color);
170        self
171    }
172
173    pub fn diff_added_fg(mut self, color: Color) -> Self {
174        self.diff_added_fg = Some(color);
175        self
176    }
177
178    pub fn diff_removed_fg(mut self, color: Color) -> Self {
179        self.diff_removed_fg = Some(color);
180        self
181    }
182
183    pub fn diff_added_bg(mut self, color: Color) -> Self {
184        self.diff_added_bg = Some(color);
185        self
186    }
187
188    pub fn diff_removed_bg(mut self, color: Color) -> Self {
189        self.diff_removed_bg = Some(color);
190        self
191    }
192
193    pub fn build(self) -> Result<Theme, ThemeBuildError> {
194        Theme::from_builder(self)
195    }
196}
197
198#[allow(dead_code, clippy::unused_self)]
199impl Theme {
200    pub fn builder() -> ThemeBuilder {
201        ThemeBuilder::default()
202    }
203
204    fn from_builder(b: ThemeBuilder) -> Result<Self, ThemeBuildError> {
205        Ok(Self {
206            fg: b.fg.ok_or(ThemeBuildError::MissingField("fg"))?,
207            bg: b.bg.ok_or(ThemeBuildError::MissingField("bg"))?,
208            accent: b.accent.ok_or(ThemeBuildError::MissingField("accent"))?,
209            highlight_bg: b.highlight_bg.ok_or(ThemeBuildError::MissingField("highlight_bg"))?,
210            highlight_fg: b.highlight_fg.ok_or(ThemeBuildError::MissingField("highlight_fg"))?,
211            text_secondary: b.text_secondary.ok_or(ThemeBuildError::MissingField("text_secondary"))?,
212            code_fg: b.code_fg.ok_or(ThemeBuildError::MissingField("code_fg"))?,
213            code_bg: b.code_bg.ok_or(ThemeBuildError::MissingField("code_bg"))?,
214            heading: b.heading.ok_or(ThemeBuildError::MissingField("heading"))?,
215            link: b.link.ok_or(ThemeBuildError::MissingField("link"))?,
216            blockquote: b.blockquote.ok_or(ThemeBuildError::MissingField("blockquote"))?,
217            muted: b.muted.ok_or(ThemeBuildError::MissingField("muted"))?,
218            success: b.success.ok_or(ThemeBuildError::MissingField("success"))?,
219            warning: b.warning.ok_or(ThemeBuildError::MissingField("warning"))?,
220            error: b.error.ok_or(ThemeBuildError::MissingField("error"))?,
221            info: b.info.ok_or(ThemeBuildError::MissingField("info"))?,
222            secondary: b.secondary.ok_or(ThemeBuildError::MissingField("secondary"))?,
223            sidebar_bg: b.sidebar_bg.ok_or(ThemeBuildError::MissingField("sidebar_bg"))?,
224            diff_added_fg: b.diff_added_fg.ok_or(ThemeBuildError::MissingField("diff_added_fg"))?,
225            diff_removed_fg: b.diff_removed_fg.ok_or(ThemeBuildError::MissingField("diff_removed_fg"))?,
226            diff_added_bg: b.diff_added_bg.ok_or(ThemeBuildError::MissingField("diff_added_bg"))?,
227            diff_removed_bg: b.diff_removed_bg.ok_or(ThemeBuildError::MissingField("diff_removed_bg"))?,
228            #[cfg(feature = "syntax")]
229            syntect_theme: Arc::new(syntax::parse_default_syntect_theme()),
230        })
231    }
232
233    pub fn primary(&self) -> Color {
234        self.fg
235    }
236
237    pub fn text_primary(&self) -> Color {
238        self.fg
239    }
240
241    pub fn background(&self) -> Color {
242        self.bg
243    }
244
245    pub fn code_fg(&self) -> Color {
246        self.code_fg
247    }
248
249    pub fn code_bg(&self) -> Color {
250        self.code_bg
251    }
252
253    pub fn sidebar_bg(&self) -> Color {
254        self.sidebar_bg
255    }
256
257    pub fn accent(&self) -> Color {
258        self.accent
259    }
260
261    pub fn highlight_bg(&self) -> Color {
262        self.highlight_bg
263    }
264
265    pub fn highlight_fg(&self) -> Color {
266        self.highlight_fg
267    }
268
269    pub fn selected_row_style(&self) -> Style {
270        self.selected_row_style_with_fg(self.highlight_fg())
271    }
272
273    pub fn selected_row_style_with_fg(&self, fg: Color) -> Style {
274        Style::fg(fg).bg_color(self.highlight_bg())
275    }
276
277    /// Build a "selected row" line: styled foreground/background plus a row
278    /// fill so the highlight extends through trailing whitespace.
279    pub fn selected_row_line(&self, text: impl Into<String>) -> Line {
280        Line::with_style(text, self.selected_row_style()).with_fill(self.highlight_bg())
281    }
282
283    pub fn secondary(&self) -> Color {
284        self.secondary
285    }
286
287    pub fn text_secondary(&self) -> Color {
288        self.text_secondary
289    }
290
291    pub fn success(&self) -> Color {
292        self.success
293    }
294
295    pub fn warning(&self) -> Color {
296        self.warning
297    }
298
299    pub fn error(&self) -> Color {
300        self.error
301    }
302
303    pub fn info(&self) -> Color {
304        self.info
305    }
306
307    pub fn muted(&self) -> Color {
308        self.muted
309    }
310
311    pub fn heading(&self) -> Color {
312        self.heading
313    }
314
315    pub fn link(&self) -> Color {
316        self.link
317    }
318
319    pub fn blockquote(&self) -> Color {
320        self.blockquote
321    }
322
323    pub fn diff_added_bg(&self) -> Color {
324        self.diff_added_bg
325    }
326
327    pub fn diff_removed_bg(&self) -> Color {
328        self.diff_removed_bg
329    }
330
331    pub fn diff_added_fg(&self) -> Color {
332        self.diff_added_fg
333    }
334
335    pub fn diff_removed_fg(&self) -> Color {
336        self.diff_removed_fg
337    }
338}
339
340#[cfg(feature = "syntax")]
341impl Default for Theme {
342    fn default() -> Self {
343        Self::from(&syntax::parse_default_syntect_theme())
344    }
345}
346
347/// Darken a color to ~30% brightness for use as a subtle background.
348#[allow(clippy::cast_possible_truncation)]
349fn darken_color(color: Color) -> Color {
350    match color {
351        Color::Rgb { r, g, b } => Color::Rgb {
352            r: (u16::from(r) * 30 / 100) as u8,
353            g: (u16::from(g) * 30 / 100) as u8,
354            b: (u16::from(b) * 30 / 100) as u8,
355        },
356        other => other,
357    }
358}
359
360/// Lighten a color to ~10% brightness for use as a subtle background.
361#[allow(clippy::cast_possible_truncation)]
362#[allow(dead_code)]
363fn lighten_color(color: Color) -> Color {
364    match color {
365        Color::Rgb { r, g, b } => Color::Rgb {
366            r: (u16::from(r) * 10 / 100 + 230) as u8,
367            g: (u16::from(g) * 10 / 100 + 230) as u8,
368            b: (u16::from(b) * 10 / 100 + 230) as u8,
369        },
370        other => other,
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn selected_row_style_uses_highlight_fg_and_highlight_bg() {
380        let theme = Theme::default();
381        let style = theme.selected_row_style();
382        assert_eq!(style.fg, Some(theme.highlight_fg()));
383        assert_eq!(style.bg, Some(theme.highlight_bg()));
384    }
385
386    #[test]
387    fn selected_row_style_with_fg_preserves_custom_foreground() {
388        let theme = Theme::default();
389        let style = theme.selected_row_style_with_fg(theme.warning());
390        assert_eq!(style.fg, Some(theme.warning()));
391        assert_eq!(style.bg, Some(theme.highlight_bg()));
392    }
393
394    #[test]
395    fn code_fg_differs_from_text_primary() {
396        let theme = Theme::default();
397        assert_ne!(theme.code_fg(), theme.text_primary(), "code_fg should be visually distinct from body text");
398    }
399
400    #[test]
401    fn darken_color_reduces_brightness() {
402        let bright = Color::Rgb { r: 200, g: 100, b: 50 };
403        let dark = darken_color(bright);
404        assert_eq!(dark, Color::Rgb { r: 60, g: 30, b: 15 });
405    }
406
407    #[test]
408    fn custom_theme_builder() {
409        let theme = Theme::builder()
410            .fg(Color::Black)
411            .bg(Color::White)
412            .accent(Color::Red)
413            .highlight_bg(Color::Green)
414            .highlight_fg(Color::Black)
415            .text_secondary(Color::Yellow)
416            .code_fg(Color::Blue)
417            .code_bg(Color::Magenta)
418            .heading(Color::Cyan)
419            .link(Color::DarkGrey)
420            .blockquote(Color::DarkRed)
421            .muted(Color::DarkGreen)
422            .success(Color::DarkBlue)
423            .warning(Color::DarkCyan)
424            .error(Color::DarkMagenta)
425            .info(Color::Grey)
426            .secondary(Color::Rgb { r: 128, g: 0, b: 128 })
427            .sidebar_bg(Color::Rgb { r: 30, g: 30, b: 30 })
428            .diff_added_fg(Color::Rgb { r: 0, g: 255, b: 0 })
429            .diff_removed_fg(Color::Rgb { r: 255, g: 0, b: 0 })
430            .diff_added_bg(Color::Rgb { r: 0, g: 20, b: 0 })
431            .diff_removed_bg(Color::Rgb { r: 20, g: 0, b: 0 })
432            .build()
433            .unwrap();
434        assert_eq!(theme.primary(), Color::Black);
435        assert_eq!(theme.background(), Color::White);
436        assert_eq!(theme.accent(), Color::Red);
437    }
438
439    #[test]
440    fn build_without_required_field_returns_error() {
441        let result = Theme::builder().fg(Color::Black).build();
442        assert!(result.is_err());
443        let err = result.unwrap_err();
444        assert!(matches!(err, ThemeBuildError::MissingField(_)), "expected MissingField, got: {err}");
445    }
446}