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,
24 bg: Color,
25 accent: Color,
26 highlight_bg: Color,
27 highlight_fg: Color,
28
29 text_secondary: Color,
31 code_fg: Color,
32 code_bg: Color,
33
34 heading: Color,
36 link: Color,
37 blockquote: Color,
38 muted: Color,
39
40 success: Color,
42 warning: Color,
43 error: Color,
44 info: Color,
45 secondary: Color,
46
47 sidebar_bg: Color,
49
50 diff_added_fg: Color,
52 diff_removed_fg: Color,
53 diff_added_bg: Color,
54 diff_removed_bg: Color,
55
56 #[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 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#[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#[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}