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#[derive(Clone, Debug)]
33pub struct Theme {
34 fg: Color,
36 bg: Color,
37 accent: Color,
38 highlight_bg: Color,
39 highlight_fg: Color,
40
41 text_secondary: Color,
43 code_fg: Color,
44 code_bg: Color,
45
46 heading: Color,
48 link: Color,
49 blockquote: Color,
50 muted: Color,
51
52 success: Color,
54 warning: Color,
55 error: Color,
56 info: Color,
57 secondary: Color,
58
59 sidebar_bg: Color,
61
62 diff_added_fg: Color,
64 diff_removed_fg: Color,
65 diff_added_bg: Color,
66 diff_removed_bg: Color,
67
68 #[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#[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#[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}