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