1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6pub const BUILTIN_THEME_NAMES: [&str; 6] = [
7 "tokyonight-dark",
8 "tokyonight-moon",
9 "tokyonight-light",
10 "tokyonight-day",
11 "solarized-dark",
12 "solarized-light",
13];
14
15#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
16pub enum BuiltinTheme {
17 TokyoNightDark,
18 TokyoNightMoon,
19 TokyoNightLight,
20 TokyoNightDay,
21 SolarizedDark,
22 SolarizedLight,
23}
24
25impl BuiltinTheme {
26 #[must_use]
28 pub const fn name(self) -> &'static str {
29 match self {
30 Self::TokyoNightDark => "tokyonight-dark",
31 Self::TokyoNightMoon => "tokyonight-moon",
32 Self::TokyoNightLight => "tokyonight-light",
33 Self::TokyoNightDay => "tokyonight-day",
34 Self::SolarizedDark => "solarized-dark",
35 Self::SolarizedLight => "solarized-light",
36 }
37 }
38
39 #[must_use]
44 pub fn from_name(name: &str) -> Option<Self> {
45 match name.trim().to_ascii_lowercase().as_str() {
46 "tokyonight-dark" | "tokyo-night" => Some(Self::TokyoNightDark),
47 "tokyonight-moon" => Some(Self::TokyoNightMoon),
48 "tokyonight-light" | "tokyo-day" => Some(Self::TokyoNightLight),
49 "tokyonight-day" => Some(Self::TokyoNightDay),
50 "solarized-dark" => Some(Self::SolarizedDark),
51 "solarized-light" => Some(Self::SolarizedLight),
52 _ => None,
53 }
54 }
55
56 const fn source(self) -> &'static str {
58 match self {
59 Self::TokyoNightDark => include_str!("../themes/tokyonight-dark.json"),
60 Self::TokyoNightMoon => include_str!("../themes/tokyonight-moon.json"),
61 Self::TokyoNightLight => include_str!("../themes/tokyonight-light.json"),
62 Self::TokyoNightDay => include_str!("../themes/tokyonight-day.json"),
63 Self::SolarizedDark => include_str!("../themes/solarized-dark.json"),
64 Self::SolarizedLight => include_str!("../themes/solarized-light.json"),
65 }
66 }
67}
68
69#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
70pub struct Rgb {
71 pub r: u8,
72 pub g: u8,
73 pub b: u8,
74}
75
76impl Rgb {
77 #[must_use]
79 pub const fn new(r: u8, g: u8, b: u8) -> Self {
80 Self { r, g, b }
81 }
82}
83
84#[derive(Debug, Clone, Copy, Eq, PartialEq, Default, Serialize, Deserialize)]
85pub struct Style {
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub fg: Option<Rgb>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub bg: Option<Rgb>,
90 #[serde(default)]
91 pub bold: bool,
92 #[serde(default)]
93 pub italic: bool,
94 #[serde(default)]
95 pub underline: bool,
96}
97
98#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
99pub enum UiRole {
100 DefaultFg,
101 DefaultBg,
102 Statusline,
103 StatuslineInactive,
104 TabActive,
105 TabInactive,
106 Selection,
107 Cursorline,
108}
109
110impl UiRole {
111 #[must_use]
113 pub const fn key(self) -> &'static str {
114 match self {
115 Self::DefaultFg => "default_fg",
116 Self::DefaultBg => "default_bg",
117 Self::Statusline => "statusline",
118 Self::StatuslineInactive => "statusline_inactive",
119 Self::TabActive => "tab_active",
120 Self::TabInactive => "tab_inactive",
121 Self::Selection => "selection",
122 Self::Cursorline => "cursorline",
123 }
124 }
125
126 #[must_use]
128 pub fn from_name(name: &str) -> Option<Self> {
129 match normalize_capture_name(name).as_str() {
130 "default_fg" | "defaultfg" | "terminal_fg" | "terminalfg" => Some(Self::DefaultFg),
131 "default_bg" | "defaultbg" | "terminal_bg" | "terminalbg" => Some(Self::DefaultBg),
132 "statusline" | "status_line" => Some(Self::Statusline),
133 "statusline_inactive" | "status_line_inactive" | "statuslineinactive" => {
134 Some(Self::StatuslineInactive)
135 }
136 "tab_active" | "tabactive" | "tab" => Some(Self::TabActive),
137 "tab_inactive" | "tabinactive" => Some(Self::TabInactive),
138 "selection" => Some(Self::Selection),
139 "cursorline" | "cursor_line" => Some(Self::Cursorline),
140 _ => None,
141 }
142 }
143}
144
145#[derive(Debug, Clone, Default, Eq, PartialEq)]
146pub struct Theme {
147 styles: BTreeMap<String, Style>,
148 ui: BTreeMap<String, Style>,
149}
150
151impl Theme {
152 #[must_use]
154 pub fn new() -> Self {
155 Self::default()
156 }
157
158 #[must_use]
160 pub fn from_styles(styles: BTreeMap<String, Style>) -> Self {
161 Self::from_parts(styles, BTreeMap::new())
162 }
163
164 #[must_use]
166 pub fn from_parts(styles: BTreeMap<String, Style>, ui: BTreeMap<String, Style>) -> Self {
167 let mut theme = Self::new();
168 for (name, style) in styles {
169 let _ = theme.insert(name, style);
170 }
171 for (name, style) in ui {
172 let _ = theme.insert_ui(name, style);
173 }
174 theme
175 }
176
177 pub fn insert(&mut self, capture_name: impl AsRef<str>, style: Style) -> Option<Style> {
182 self.styles
183 .insert(normalize_capture_name(capture_name.as_ref()), style)
184 }
185
186 #[must_use]
188 pub fn styles(&self) -> &BTreeMap<String, Style> {
189 &self.styles
190 }
191
192 pub fn insert_ui(&mut self, role_name: impl AsRef<str>, style: Style) -> Option<Style> {
197 self.ui
198 .insert(normalize_capture_name(role_name.as_ref()), style)
199 }
200
201 #[must_use]
203 pub fn ui_styles(&self) -> &BTreeMap<String, Style> {
204 &self.ui
205 }
206
207 #[must_use]
209 pub fn get_exact(&self, capture_name: &str) -> Option<&Style> {
210 self.styles.get(&normalize_capture_name(capture_name))
211 }
212
213 #[must_use]
215 pub fn get_ui_exact(&self, role_name: &str) -> Option<&Style> {
216 self.ui.get(&normalize_capture_name(role_name))
217 }
218
219 #[must_use]
224 pub fn resolve(&self, capture_name: &str) -> Option<&Style> {
225 let mut key = normalize_capture_name(capture_name);
226
227 loop {
228 if let Some(style) = self.styles.get(&key) {
229 return Some(style);
230 }
231
232 let Some(index) = key.rfind('.') else {
233 break;
234 };
235 key.truncate(index);
236 }
237
238 self.styles.get("normal")
239 }
240
241 #[must_use]
246 pub fn resolve_ui(&self, role_name: &str) -> Option<Style> {
247 let normalized = normalize_capture_name(role_name);
248 if let Some(style) = self.ui.get(&normalized).copied() {
249 return Some(style);
250 }
251 if let Some(style) = self.styles.get(&normalized).copied() {
252 return Some(style);
253 }
254
255 if let Some(role) = UiRole::from_name(&normalized) {
256 return self.resolve_ui_role(role);
257 }
258
259 None
260 }
261
262 #[must_use]
264 pub fn resolve_ui_role(&self, role: UiRole) -> Option<Style> {
265 let key = role.key();
266 if let Some(style) = self.ui.get(key).copied() {
267 return Some(style);
268 }
269 if let Some(style) = self.styles.get(key).copied() {
270 return Some(style);
271 }
272
273 match role {
274 UiRole::DefaultFg => self.styles.get("normal").and_then(|normal| {
275 normal.fg.map(|fg| Style {
276 fg: Some(fg),
277 ..Style::default()
278 })
279 }),
280 UiRole::DefaultBg => self.styles.get("normal").and_then(|normal| {
281 normal.bg.map(|bg| Style {
282 bg: Some(bg),
283 ..Style::default()
284 })
285 }),
286 UiRole::Statusline => self.styles.get("statusline").copied(),
287 UiRole::StatuslineInactive => self
288 .ui
289 .get("statusline_inactive")
290 .copied()
291 .or_else(|| self.styles.get("statusline_inactive").copied())
292 .or_else(|| self.styles.get("ignore").copied())
293 .or_else(|| self.styles.get("statusline").copied()),
294 UiRole::TabActive => self
295 .ui
296 .get("tab_active")
297 .copied()
298 .or_else(|| self.styles.get("tab_active").copied())
299 .or_else(|| self.styles.get("statusline").copied()),
300 UiRole::TabInactive => self
301 .ui
302 .get("tab_inactive")
303 .copied()
304 .or_else(|| self.styles.get("tab_inactive").copied())
305 .or_else(|| self.styles.get("ignore").copied())
306 .or_else(|| self.styles.get("statusline").copied()),
307 UiRole::Selection => self
308 .ui
309 .get("selection")
310 .copied()
311 .or_else(|| self.styles.get("selection").copied()),
312 UiRole::Cursorline => self
313 .ui
314 .get("cursorline")
315 .copied()
316 .or_else(|| self.styles.get("cursorline").copied())
317 .or_else(|| self.styles.get("selection").copied()),
318 }
319 }
320
321 #[must_use]
326 pub fn default_terminal_colors(&self) -> (Option<Rgb>, Option<Rgb>) {
327 let fg = self
328 .resolve_ui_role(UiRole::DefaultFg)
329 .and_then(|style| style.fg)
330 .or_else(|| self.styles.get("normal").and_then(|style| style.fg));
331 let bg = self
332 .resolve_ui_role(UiRole::DefaultBg)
333 .and_then(|style| style.bg)
334 .or_else(|| self.styles.get("normal").and_then(|style| style.bg));
335 (fg, bg)
336 }
337
338 pub fn from_json_str(input: &str) -> Result<Self, ThemeError> {
346 let parsed = serde_json::from_str::<ThemeDocument>(input)?;
347 Ok(parsed.into_theme())
348 }
349
350 pub fn from_toml_str(input: &str) -> Result<Self, ThemeError> {
356 let parsed = toml::from_str::<ThemeDocument>(input)?;
357 Ok(parsed.into_theme())
358 }
359
360 pub fn from_builtin(theme: BuiltinTheme) -> Result<Self, ThemeError> {
366 Self::from_json_str(theme.source())
367 }
368
369 pub fn from_builtin_name(name: &str) -> Result<Self, ThemeError> {
375 let theme = BuiltinTheme::from_name(name)
376 .ok_or_else(|| ThemeError::UnknownBuiltinTheme(name.trim().to_string()))?;
377 Self::from_builtin(theme)
378 }
379}
380
381#[must_use]
383pub const fn available_themes() -> &'static [&'static str] {
384 &BUILTIN_THEME_NAMES
385}
386
387pub fn load_theme(name: &str) -> Result<Theme, ThemeError> {
393 Theme::from_builtin_name(name)
394}
395
396#[derive(Debug, Error)]
397pub enum ThemeError {
398 #[error("failed to parse theme JSON: {0}")]
399 Json(#[from] serde_json::Error),
400 #[error("failed to parse theme TOML: {0}")]
401 Toml(#[from] toml::de::Error),
402 #[error(
403 "unknown built-in theme '{0}', available: tokyonight-dark, tokyonight-moon, tokyonight-light, tokyonight-day, solarized-dark, solarized-light"
404 )]
405 UnknownBuiltinTheme(String),
406}
407
408#[derive(Debug, Deserialize)]
409#[serde(deny_unknown_fields)]
410struct WrappedThemeDocument {
411 #[serde(default)]
412 styles: BTreeMap<String, Style>,
413 #[serde(default)]
414 ui: BTreeMap<String, Style>,
415}
416
417#[derive(Debug, Deserialize)]
418#[serde(untagged)]
419enum ThemeDocument {
420 Wrapped(WrappedThemeDocument),
421 Flat(BTreeMap<String, Style>),
422}
423
424impl ThemeDocument {
425 fn into_theme(self) -> Theme {
427 match self {
428 ThemeDocument::Wrapped(doc) => Theme::from_parts(doc.styles, doc.ui),
429 ThemeDocument::Flat(styles) => Theme::from_styles(styles),
430 }
431 }
432}
433
434#[must_use]
438pub fn normalize_capture_name(capture_name: &str) -> String {
439 let trimmed = capture_name.trim();
440 let without_prefix = trimmed.strip_prefix('@').unwrap_or(trimmed);
441 without_prefix.to_ascii_lowercase()
442}
443
444#[cfg(test)]
445mod tests {
446 use super::{
447 available_themes, load_theme, normalize_capture_name, BuiltinTheme, Rgb, Style, Theme,
448 ThemeError, UiRole,
449 };
450
451 #[test]
452 fn normalizes_capture_names() {
454 assert_eq!(normalize_capture_name("@Comment.Doc"), "comment.doc");
455 assert_eq!(normalize_capture_name(" keyword "), "keyword");
456 }
457
458 #[test]
459 fn resolves_dot_fallback_then_normal() {
461 let mut theme = Theme::new();
462 let _ = theme.insert(
463 "comment",
464 Style {
465 fg: Some(Rgb::new(1, 2, 3)),
466 ..Style::default()
467 },
468 );
469 let _ = theme.insert(
470 "normal",
471 Style {
472 fg: Some(Rgb::new(9, 9, 9)),
473 ..Style::default()
474 },
475 );
476
477 let comment = theme
478 .resolve("@comment.documentation")
479 .expect("missing comment");
480 assert_eq!(comment.fg, Some(Rgb::new(1, 2, 3)));
481
482 let unknown = theme.resolve("@does.not.exist").expect("missing normal");
483 assert_eq!(unknown.fg, Some(Rgb::new(9, 9, 9)));
484 }
485
486 #[test]
487 fn parses_json_theme_document() {
489 let input = r#"
490{
491 "styles": {
492 "@keyword": { "fg": { "r": 255, "g": 0, "b": 0 }, "bold": true },
493 "normal": { "fg": { "r": 200, "g": 200, "b": 200 } }
494 }
495}
496"#;
497
498 let theme = Theme::from_json_str(input).expect("failed to parse json");
499 let style = theme.resolve("keyword").expect("keyword style missing");
500 assert_eq!(style.fg, Some(Rgb::new(255, 0, 0)));
501 assert!(style.bold);
502 }
503
504 #[test]
505 fn parses_toml_flat_theme_document() {
507 let input = r#"
508[normal]
509fg = { r = 40, g = 41, b = 42 }
510
511["@string"]
512fg = { r = 120, g = 121, b = 122 }
513italic = true
514"#;
515
516 let theme = Theme::from_toml_str(input).expect("failed to parse toml");
517 let style = theme.resolve("string").expect("string style missing");
518 assert_eq!(style.fg, Some(Rgb::new(120, 121, 122)));
519 assert!(style.italic);
520 }
521
522 #[test]
523 fn loads_all_built_in_themes() {
525 for name in available_themes() {
526 let theme = load_theme(name).expect("failed to load built-in theme");
527 assert!(
528 theme.get_exact("normal").is_some(),
529 "missing normal style in {name}"
530 );
531 }
532 }
533
534 #[test]
535 fn loads_built_in_theme_by_enum() {
537 let theme = Theme::from_builtin(BuiltinTheme::TokyoNightDark)
538 .expect("failed to load tokyonight-dark");
539 assert!(theme.resolve("keyword").is_some());
540 }
541
542 #[test]
543 fn rejects_unknown_built_in_theme_name() {
545 let err = load_theme("unknown-theme").expect_err("expected unknown-theme to fail");
546 assert!(matches!(err, ThemeError::UnknownBuiltinTheme(_)));
547 }
548
549 #[test]
550 fn supports_theme_aliases() {
552 assert!(load_theme("tokyo-night").is_ok());
553 assert!(load_theme("tokyo-day").is_ok());
554 assert!(load_theme("tokyonight-moon").is_ok());
555 assert!(load_theme("tokyonight-day").is_ok());
556 }
557
558 #[test]
559 fn loads_distinct_tokyonight_variants() {
561 let moon = load_theme("tokyonight-moon").expect("failed to load moon");
562 let dark = load_theme("tokyonight-dark").expect("failed to load dark");
563 let day = load_theme("tokyonight-day").expect("failed to load day");
564 let light = load_theme("tokyonight-light").expect("failed to load light");
565
566 assert_ne!(moon, dark, "moon should differ from dark");
567 assert_ne!(day, light, "day should differ from light");
568 }
569
570 #[test]
571 fn builtins_include_xml_capture_styles() {
573 for name in available_themes() {
574 let theme = load_theme(name).expect("failed to load built-in theme");
575 assert!(
576 theme.get_exact("tag").is_some(),
577 "missing XML tag style in {name}"
578 );
579 assert!(
580 theme.get_exact("property").is_some(),
581 "missing XML property style in {name}"
582 );
583 }
584 }
585
586 #[test]
587 fn parses_ui_roles_from_wrapped_document() {
589 let input = r#"
590{
591 "styles": {
592 "normal": { "fg": { "r": 10, "g": 11, "b": 12 }, "bg": { "r": 13, "g": 14, "b": 15 } }
593 },
594 "ui": {
595 "default_fg": { "fg": { "r": 1, "g": 2, "b": 3 } },
596 "tab_active": { "fg": { "r": 20, "g": 21, "b": 22 }, "bg": { "r": 30, "g": 31, "b": 32 } }
597 }
598}
599"#;
600
601 let theme = Theme::from_json_str(input).expect("failed to parse json");
602 let default_fg = theme
603 .resolve_ui_role(UiRole::DefaultFg)
604 .expect("missing default fg");
605 assert_eq!(default_fg.fg, Some(Rgb::new(1, 2, 3)));
606
607 let tab = theme
608 .resolve_ui("tab_active")
609 .expect("missing tab_active role");
610 assert_eq!(tab.bg, Some(Rgb::new(30, 31, 32)));
611 }
612
613 #[test]
614 fn default_terminal_colors_fallback_to_normal() {
616 let theme = Theme::from_json_str(
617 r#"{
618 "styles": {
619 "normal": { "fg": { "r": 100, "g": 101, "b": 102 }, "bg": { "r": 110, "g": 111, "b": 112 } }
620 }
621}"#,
622 )
623 .expect("failed to parse json");
624
625 let (fg, bg) = theme.default_terminal_colors();
626 assert_eq!(fg, Some(Rgb::new(100, 101, 102)));
627 assert_eq!(bg, Some(Rgb::new(110, 111, 112)));
628 }
629
630 #[test]
631 fn ui_role_falls_back_to_legacy_style_keys() {
633 let mut theme = Theme::new();
634 let _ = theme.insert(
635 "statusline",
636 Style {
637 fg: Some(Rgb::new(1, 1, 1)),
638 bg: Some(Rgb::new(2, 2, 2)),
639 ..Style::default()
640 },
641 );
642 let _ = theme.insert(
643 "ignore",
644 Style {
645 fg: Some(Rgb::new(3, 3, 3)),
646 bg: Some(Rgb::new(4, 4, 4)),
647 ..Style::default()
648 },
649 );
650
651 let active = theme
652 .resolve_ui_role(UiRole::TabActive)
653 .expect("missing active tab");
654 assert_eq!(active.bg, Some(Rgb::new(2, 2, 2)));
655
656 let inactive = theme
657 .resolve_ui_role(UiRole::TabInactive)
658 .expect("missing inactive tab");
659 assert_eq!(inactive.bg, Some(Rgb::new(4, 4, 4)));
660 }
661}