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 fg: Color,
34 bg: Color,
35 accent: Color,
36 highlight_bg: Color,
37 highlight_fg: Color,
38
39 text_secondary: Color,
41 code_fg: Color,
42 code_bg: Color,
43
44 heading: Color,
46 link: Color,
47 blockquote: Color,
48 muted: Color,
49
50 success: Color,
52 warning: Color,
53 error: Color,
54 info: Color,
55 secondary: Color,
56
57 sidebar_bg: Color,
59
60 diff_added_fg: Color,
62 diff_removed_fg: Color,
63 diff_added_bg: Color,
64 diff_removed_bg: Color,
65
66 #[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 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#[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#[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}