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 #[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 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#[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#[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}