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