1use std::{collections::HashMap, fs::read_to_string, str::FromStr};
2
3use etcetera::{choose_base_strategy, BaseStrategy};
4use ratatui::{style::Color, widgets, widgets::Borders};
5use serde::Deserialize;
6
7#[derive(Clone, Copy, Debug, PartialEq)]
12pub struct Theme {
13 pub text: Color,
15 pub background: Color,
17 pub muted: Color,
19 pub accent: Color,
21 pub border: Color,
24 pub border_active: Color,
25 pub border_type: Option<BorderKind>,
26 pub border_edges: Edges,
27 pub heading_1: Color,
28 pub heading_2: Color,
29 pub heading_3: Color,
30 pub heading_4: Color,
31 pub heading_5: Color,
32 pub heading_6: Color,
33 pub code_bg: Color,
35 pub blockquote: Color,
37 pub list_marker: Color,
39 pub task: Color,
41 pub mode_insert: Color,
43 pub mode_normal: Color,
45 pub mode_read: Color,
47 pub success: Color,
48 pub info: Color,
49 pub warning: Color,
50 pub error: Color,
51 pub explorer: Pane,
53 pub note_editor: Pane,
54 pub outline: Pane,
55 pub status_bar: StatusBar,
56}
57
58#[derive(Clone, Copy, Debug, PartialEq)]
62pub struct Pane {
63 pub background: Color,
64 pub border: Color,
65 pub border_active: Color,
66 pub border_type: Option<BorderKind>,
67 pub border_edges: Edges,
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Default)]
73#[serde(rename_all = "kebab-case")]
74pub enum Edges {
75 #[default]
76 All,
77 None,
78 Top,
79 Bottom,
80 Left,
81 Right,
82 Vertical,
84 Horizontal,
86}
87
88impl Edges {
89 pub fn to_borders(self) -> Borders {
90 match self {
91 Edges::All => Borders::ALL,
92 Edges::None => Borders::NONE,
93 Edges::Top => Borders::TOP,
94 Edges::Bottom => Borders::BOTTOM,
95 Edges::Left => Borders::LEFT,
96 Edges::Right => Borders::RIGHT,
97 Edges::Vertical => Borders::LEFT | Borders::RIGHT,
98 Edges::Horizontal => Borders::TOP | Borders::BOTTOM,
99 }
100 }
101}
102
103impl Pane {
104 pub fn border(&self, active: bool) -> Color {
106 if active {
107 self.border_active
108 } else {
109 self.border
110 }
111 }
112
113 pub fn border_line(&self, fallback: widgets::BorderType) -> Option<widgets::BorderType> {
117 match self.border_type {
118 Some(kind) => kind.line(),
119 None => Some(fallback),
120 }
121 }
122
123 pub fn collapsed_borders(&self, strip: Borders) -> Borders {
127 match self.border_edges {
128 Edges::All => strip,
129 edges => edges.to_borders(),
130 }
131 }
132}
133
134#[derive(Clone, Copy, Debug, PartialEq)]
136pub struct StatusBar {
137 pub background: Color,
138 pub foreground: Color,
139}
140
141#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
143#[serde(rename_all = "kebab-case")]
144pub enum BorderKind {
145 None,
146 Plain,
147 Rounded,
148 Thick,
149 Double,
150}
151
152impl BorderKind {
153 pub fn line(self) -> Option<widgets::BorderType> {
155 match self {
156 BorderKind::None => None,
157 BorderKind::Plain => Some(widgets::BorderType::Plain),
158 BorderKind::Rounded => Some(widgets::BorderType::Rounded),
159 BorderKind::Thick => Some(widgets::BorderType::Thick),
160 BorderKind::Double => Some(widgets::BorderType::Double),
161 }
162 }
163}
164
165impl Default for Theme {
166 fn default() -> Self {
167 let pane = Pane {
168 background: Color::Reset,
169 border: Color::Reset,
170 border_active: Color::Reset,
171 border_type: None,
172 border_edges: Edges::All,
173 };
174 Self {
175 text: Color::Reset,
176 background: Color::Reset,
177 muted: Color::DarkGray,
178 accent: Color::Magenta,
179 border: Color::Reset,
180 border_active: Color::Reset,
181 border_type: None,
182 border_edges: Edges::All,
183 heading_1: Color::Reset,
184 heading_2: Color::Yellow,
185 heading_3: Color::Cyan,
186 heading_4: Color::Magenta,
187 heading_5: Color::Reset,
188 heading_6: Color::Reset,
189 code_bg: Color::Black,
190 blockquote: Color::Magenta,
191 list_marker: Color::DarkGray,
192 task: Color::Magenta,
193 mode_insert: Color::Green,
194 mode_normal: Color::Gray,
195 mode_read: Color::Gray,
196 success: Color::Green,
197 info: Color::Blue,
198 warning: Color::Yellow,
199 error: Color::Red,
200 explorer: pane,
201 note_editor: pane,
202 outline: pane,
203 status_bar: StatusBar {
204 background: Color::Reset,
205 foreground: Color::Reset,
206 },
207 }
208 }
209}
210
211impl Theme {
212 pub fn heading(&self, level: usize) -> Color {
214 match level {
215 1 => self.heading_1,
216 2 => self.heading_2,
217 3 => self.heading_3,
218 4 => self.heading_4,
219 5 => self.heading_5,
220 6 => self.heading_6,
221 _ => self.text,
222 }
223 }
224}
225
226#[derive(Clone, Debug, Default, Deserialize)]
232#[serde(rename_all = "kebab-case")]
233struct TomlTheme {
234 #[serde(default)]
235 palette: HashMap<String, String>,
236 text: Option<String>,
237 background: Option<String>,
238 muted: Option<String>,
239 accent: Option<String>,
240 border: Option<String>,
241 border_active: Option<String>,
242 border_type: Option<BorderKind>,
243 border_edges: Option<Edges>,
244 heading_1: Option<String>,
245 heading_2: Option<String>,
246 heading_3: Option<String>,
247 heading_4: Option<String>,
248 heading_5: Option<String>,
249 heading_6: Option<String>,
250 code_bg: Option<String>,
251 blockquote: Option<String>,
252 list_marker: Option<String>,
253 task: Option<String>,
254 mode_insert: Option<String>,
255 mode_normal: Option<String>,
256 mode_read: Option<String>,
257 success: Option<String>,
258 info: Option<String>,
259 warning: Option<String>,
260 error: Option<String>,
261 #[serde(default)]
262 explorer: TomlPane,
263 #[serde(default)]
264 note_editor: TomlPane,
265 #[serde(default)]
266 outline: TomlPane,
267 #[serde(default)]
268 status_bar: TomlStatusBar,
269}
270
271#[derive(Clone, Debug, Default, Deserialize)]
272#[serde(rename_all = "kebab-case")]
273struct TomlPane {
274 background: Option<String>,
275 border: Option<String>,
276 border_active: Option<String>,
277 border_type: Option<BorderKind>,
278 border_edges: Option<Edges>,
279}
280
281#[derive(Clone, Debug, Default, Deserialize)]
282#[serde(rename_all = "kebab-case")]
283struct TomlStatusBar {
284 background: Option<String>,
285 foreground: Option<String>,
286}
287
288fn resolve(palette: &HashMap<String, String>, role: Option<String>, fallback: Color) -> Color {
291 role.map(|color| {
292 let literal = palette.get(&color).unwrap_or(&color);
293 Color::from_str(literal).unwrap_or(fallback)
294 })
295 .unwrap_or(fallback)
296}
297
298fn resolve_pane(palette: &HashMap<String, String>, toml: TomlPane, default: Pane) -> Pane {
299 Pane {
300 background: resolve(palette, toml.background, default.background),
301 border: resolve(palette, toml.border, default.border),
302 border_active: resolve(palette, toml.border_active, default.border_active),
303 border_type: toml.border_type.or(default.border_type),
304 border_edges: toml.border_edges.unwrap_or(default.border_edges),
305 }
306}
307
308impl From<TomlTheme> for Theme {
309 fn from(value: TomlTheme) -> Self {
310 let default = Theme::default();
311 let palette = &value.palette;
312 let color = |role, fallback| resolve(palette, role, fallback);
313
314 let background = color(value.background, default.background);
317 let pane_default = Pane {
318 background,
319 border: color(value.border, default.border),
320 border_active: color(value.border_active, default.border_active),
321 border_type: value.border_type.or(default.border_type),
322 border_edges: value.border_edges.unwrap_or(default.border_edges),
323 };
324
325 Self {
326 text: color(value.text, default.text),
327 background,
328 muted: color(value.muted, default.muted),
329 accent: color(value.accent, default.accent),
330 border: pane_default.border,
331 border_active: pane_default.border_active,
332 border_type: pane_default.border_type,
333 border_edges: pane_default.border_edges,
334 heading_1: color(value.heading_1, default.heading_1),
335 heading_2: color(value.heading_2, default.heading_2),
336 heading_3: color(value.heading_3, default.heading_3),
337 heading_4: color(value.heading_4, default.heading_4),
338 heading_5: color(value.heading_5, default.heading_5),
339 heading_6: color(value.heading_6, default.heading_6),
340 code_bg: color(value.code_bg, default.code_bg),
341 blockquote: color(value.blockquote, default.blockquote),
342 list_marker: color(value.list_marker, default.list_marker),
343 task: color(value.task, default.task),
344 mode_insert: color(value.mode_insert, default.mode_insert),
345 mode_normal: color(value.mode_normal, default.mode_normal),
346 mode_read: color(value.mode_read, default.mode_read),
347 success: color(value.success, default.success),
348 info: color(value.info, default.info),
349 warning: color(value.warning, default.warning),
350 error: color(value.error, default.error),
351 explorer: resolve_pane(palette, value.explorer, pane_default),
352 note_editor: resolve_pane(palette, value.note_editor, pane_default),
353 outline: resolve_pane(palette, value.outline, pane_default),
354 status_bar: StatusBar {
355 background: resolve(palette, value.status_bar.background, background),
356 foreground: resolve(
357 palette,
358 value.status_bar.foreground,
359 default.status_bar.foreground,
360 ),
361 },
362 }
363 }
364}
365
366fn parse_theme(toml: &str) -> Theme {
367 toml::from_str::<TomlTheme>(toml)
368 .map(Theme::from)
369 .unwrap_or_default()
370}
371
372const BUILTIN_THEMES: &[(&str, &str)] = &[
374 (
375 "default",
376 include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/default.toml")),
377 ),
378 (
379 "causeway-dark",
380 include_str!(concat!(
381 env!("CARGO_MANIFEST_DIR"),
382 "/themes/causeway-dark.toml"
383 )),
384 ),
385 (
386 "causeway-light",
387 include_str!(concat!(
388 env!("CARGO_MANIFEST_DIR"),
389 "/themes/causeway-light.toml"
390 )),
391 ),
392 (
393 "gruvbox-dark",
394 include_str!(concat!(
395 env!("CARGO_MANIFEST_DIR"),
396 "/themes/gruvbox-dark.toml"
397 )),
398 ),
399 (
400 "gruvbox-light",
401 include_str!(concat!(
402 env!("CARGO_MANIFEST_DIR"),
403 "/themes/gruvbox-light.toml"
404 )),
405 ),
406 (
407 "nord",
408 include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/nord.toml")),
409 ),
410 (
411 "dracula",
412 include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/dracula.toml")),
413 ),
414 (
415 "catppuccin-latte",
416 include_str!(concat!(
417 env!("CARGO_MANIFEST_DIR"),
418 "/themes/catppuccin-latte.toml"
419 )),
420 ),
421 (
422 "catppuccin-frappe",
423 include_str!(concat!(
424 env!("CARGO_MANIFEST_DIR"),
425 "/themes/catppuccin-frappe.toml"
426 )),
427 ),
428 (
429 "catppuccin-macchiato",
430 include_str!(concat!(
431 env!("CARGO_MANIFEST_DIR"),
432 "/themes/catppuccin-macchiato.toml"
433 )),
434 ),
435 (
436 "catppuccin-mocha",
437 include_str!(concat!(
438 env!("CARGO_MANIFEST_DIR"),
439 "/themes/catppuccin-mocha.toml"
440 )),
441 ),
442 (
443 "everforest-dark",
444 include_str!(concat!(
445 env!("CARGO_MANIFEST_DIR"),
446 "/themes/everforest-dark.toml"
447 )),
448 ),
449 (
450 "everforest-light",
451 include_str!(concat!(
452 env!("CARGO_MANIFEST_DIR"),
453 "/themes/everforest-light.toml"
454 )),
455 ),
456 (
457 "minimal",
458 include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/minimal.toml")),
459 ),
460];
461
462fn user_themes_dir() -> Option<std::path::PathBuf> {
464 choose_base_strategy()
465 .ok()
466 .map(|strategy| strategy.config_dir().join("basalt/themes"))
467}
468
469fn user_themes() -> Vec<(String, Theme)> {
470 let Some(dir) = user_themes_dir() else {
471 return vec![];
472 };
473 let Ok(entries) = std::fs::read_dir(dir) else {
474 return vec![];
475 };
476
477 entries
478 .flatten()
479 .map(|entry| entry.path())
480 .filter(|path| path.extension().is_some_and(|ext| ext == "toml"))
481 .filter_map(|path| {
482 let name = path.file_stem()?.to_string_lossy().into_owned();
483 let theme = parse_theme(&read_to_string(&path).ok()?);
484 Some((name, theme))
485 })
486 .collect()
487}
488
489pub fn load_themes() -> Vec<(String, Theme)> {
492 let mut themes: Vec<(String, Theme)> = BUILTIN_THEMES
493 .iter()
494 .map(|(name, toml)| (name.to_string(), parse_theme(toml)))
495 .collect();
496
497 for (name, theme) in user_themes() {
498 match themes.iter_mut().find(|(existing, _)| *existing == name) {
499 Some((_, existing)) => *existing = theme,
500 None => themes.push((name, theme)),
501 }
502 }
503
504 themes
505}
506
507pub fn theme_by_name(name: &str) -> Theme {
509 load_themes()
510 .into_iter()
511 .find(|(theme_name, _)| theme_name == name)
512 .map(|(_, theme)| theme)
513 .unwrap_or_default()
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 #[test]
521 fn builtin_default_matches_struct_default() {
522 let (_, default) = load_themes()
523 .into_iter()
524 .find(|(name, _)| name == "default")
525 .unwrap();
526 assert_eq!(default, Theme::default());
527 }
528
529 #[test]
530 fn resolves_palette_and_literals() {
531 let toml = r##"
532 accent = "red"
533 muted = "#102030"
534 error = "green"
535
536 [palette]
537 red = "#ff0000"
538 "##;
539 let theme = parse_theme(toml);
540 assert_eq!(theme.accent, Color::Rgb(255, 0, 0));
541 assert_eq!(theme.muted, Color::Rgb(16, 32, 48));
542 assert_eq!(theme.error, Color::Green);
543 }
544
545 #[test]
546 fn unset_roles_fall_back_to_default() {
547 let theme = parse_theme("accent = \"#abcdef\"");
548 assert_eq!(theme.accent, Color::Rgb(0xab, 0xcd, 0xef));
549 assert_eq!(theme.muted, Theme::default().muted);
550 assert_eq!(theme.heading_2, Theme::default().heading_2);
551 }
552
553 #[test]
554 fn resolves_pane_sections() {
555 let theme = parse_theme(
556 r##"
557 background = "#000000"
558
559 [explorer]
560 background = "surface"
561 border = "#111111"
562 border-active = "#00ff00"
563 border-type = "none"
564
565 [palette]
566 surface = "#101010"
567 "##,
568 );
569 assert_eq!(theme.explorer.background, Color::Rgb(16, 16, 16));
570 assert_eq!(theme.explorer.border, Color::Rgb(0x11, 0x11, 0x11));
571 assert_eq!(theme.explorer.border(true), Color::Rgb(0, 255, 0));
572 assert_eq!(theme.explorer.border_type, Some(BorderKind::None));
573 assert_eq!(theme.note_editor.background, Color::Rgb(0, 0, 0));
575 assert_eq!(theme.note_editor.border_type, None);
576 }
577
578 #[test]
579 fn resolves_border_edges() {
580 let theme = parse_theme(
581 r##"
582 [explorer]
583 border-edges = "right"
584
585 [outline]
586 border-edges = "left"
587 "##,
588 );
589 assert_eq!(theme.explorer.border_edges, Edges::Right);
590 assert_eq!(theme.outline.border_edges, Edges::Left);
591 assert_eq!(theme.explorer.border_edges.to_borders(), Borders::RIGHT);
592 assert_eq!(theme.note_editor.border_edges, Edges::All);
594 }
595
596 #[test]
597 fn status_bar_section() {
598 let theme = parse_theme(
599 r##"
600 [status-bar]
601 background = "#222222"
602 foreground = "#eeeeee"
603 "##,
604 );
605 assert_eq!(theme.status_bar.background, Color::Rgb(0x22, 0x22, 0x22));
606 assert_eq!(theme.status_bar.foreground, Color::Rgb(0xee, 0xee, 0xee));
607 }
608
609 #[test]
610 fn all_builtins_parse() {
611 let themes = load_themes();
612 for name in [
613 "causeway-dark",
614 "causeway-light",
615 "gruvbox-dark",
616 "gruvbox-light",
617 "nord",
618 "dracula",
619 "catppuccin-latte",
620 "catppuccin-frappe",
621 "catppuccin-macchiato",
622 "catppuccin-mocha",
623 "everforest-dark",
624 "everforest-light",
625 "minimal",
626 ] {
627 assert!(
628 themes.iter().any(|(theme, _)| theme == name),
629 "missing {name}"
630 );
631 }
632 }
633}