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