Skip to main content

tui/theme/
mod.rs

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