Skip to main content

tui/theme/
mod.rs

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