1use std::collections::{HashMap, HashSet};
8use std::env;
9use std::fmt;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use ratatui::style::{Color, Modifier, Style};
14
15#[derive(Debug)]
21pub enum ThemeError {
22 Io {
24 path: PathBuf,
25 source: std::io::Error,
26 },
27 ParseToml {
29 path: PathBuf,
30 source: toml::de::Error,
31 },
32 MissingTheme {
34 name: String,
35 },
36 InheritanceCycle {
38 name: String,
39 },
40 InvalidThemeRoot,
42 InvalidInherits {
44 value: toml::Value,
45 },
46 InvalidPaletteEntry {
48 name: String,
49 value: toml::Value,
50 },
51 InvalidStyle {
53 scope: String,
54 reason: String,
55 },
56 UnknownKey {
58 scope: String,
59 key: String,
60 },
61}
62
63impl fmt::Display for ThemeError {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 match self {
66 Self::Io { path, source } => {
67 write!(f, "failed to read {}: {}", path.display(), source)
68 }
69 Self::ParseToml { path, source } => {
70 write!(f, "failed to parse {}: {}", path.display(), source)
71 }
72 Self::MissingTheme { name } => {
73 write!(f, "theme {name:?} not found in any search directory")
74 }
75 Self::InheritanceCycle { name } => {
76 write!(f, "inheritance cycle detected for theme {name:?}")
77 }
78 Self::InvalidThemeRoot => {
79 write!(f, "theme root must be a TOML table")
80 }
81 Self::InvalidInherits { value } => {
82 write!(f, "inherits must be a string, got {:?}", value.type_str())
83 }
84 Self::InvalidPaletteEntry { name, value } => {
85 write!(
86 f,
87 "invalid palette entry {name:?}: expected a string, got {:?}",
88 value.type_str()
89 )
90 }
91 Self::InvalidStyle { scope, reason } => {
92 write!(f, "invalid style for scope {scope:?}: {reason}")
93 }
94 Self::UnknownKey { scope, key } => {
95 write!(f, "unknown key {key:?} in style table for scope {scope:?}")
96 }
97 }
98 }
99}
100
101impl std::error::Error for ThemeError {
102 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
103 match self {
104 Self::Io { source, .. } => Some(source),
105 Self::ParseToml { source, .. } => Some(source),
106 _ => None,
107 }
108 }
109}
110
111fn builtin_palette() -> HashMap<String, Color> {
118 let mut m = HashMap::new();
119 m.insert("default".into(), Color::Reset);
120 m.insert("black".into(), Color::Black);
121 m.insert("red".into(), Color::Red);
122 m.insert("green".into(), Color::Green);
123 m.insert("yellow".into(), Color::Yellow);
124 m.insert("blue".into(), Color::Blue);
125 m.insert("magenta".into(), Color::Magenta);
126 m.insert("cyan".into(), Color::Cyan);
127 m.insert("gray".into(), Color::Gray);
128 m.insert("light-red".into(), Color::LightRed);
129 m.insert("light-green".into(), Color::LightGreen);
130 m.insert("light-yellow".into(), Color::LightYellow);
131 m.insert("light-blue".into(), Color::LightBlue);
132 m.insert("light-magenta".into(), Color::LightMagenta);
133 m.insert("light-cyan".into(), Color::LightCyan);
134 m.insert("light-gray".into(), Color::DarkGray);
135 m.insert("white".into(), Color::White);
136 m
137}
138
139fn parse_palette_color(raw: &str) -> Result<Color, ThemeError> {
150 if let Some(hex) = raw.strip_prefix('#') {
151 if hex.len() == 6 {
152 let r =
153 u8::from_str_radix(&hex[0..2], 16).map_err(|_| ThemeError::InvalidPaletteEntry {
154 name: raw.into(),
155 value: toml::Value::String(raw.into()),
156 })?;
157 let g =
158 u8::from_str_radix(&hex[2..4], 16).map_err(|_| ThemeError::InvalidPaletteEntry {
159 name: raw.into(),
160 value: toml::Value::String(raw.into()),
161 })?;
162 let b =
163 u8::from_str_radix(&hex[4..6], 16).map_err(|_| ThemeError::InvalidPaletteEntry {
164 name: raw.into(),
165 value: toml::Value::String(raw.into()),
166 })?;
167 return Ok(Color::Rgb(r, g, b));
168 }
169 return Err(ThemeError::InvalidPaletteEntry {
170 name: raw.into(),
171 value: toml::Value::String(raw.into()),
172 });
173 }
174
175 if let Ok(idx) = raw.parse::<u8>() {
177 return Ok(Color::Indexed(idx));
178 }
179
180 Err(ThemeError::InvalidPaletteEntry {
181 name: raw.into(),
182 value: toml::Value::String(raw.into()),
183 })
184}
185
186fn resolve_color(name: &str, palette: &HashMap<String, Color>) -> Result<Color, ThemeError> {
189 if let Some(c) = palette.get(name) {
190 return Ok(*c);
191 }
192 parse_palette_color(name)
193}
194
195fn parse_modifier(raw: &str) -> Option<Modifier> {
201 match raw {
202 "bold" => Some(Modifier::BOLD),
203 "dim" => Some(Modifier::DIM),
204 "italic" => Some(Modifier::ITALIC),
205 "underlined" => Some(Modifier::UNDERLINED),
206 "slow_blink" | "slow-blink" => Some(Modifier::SLOW_BLINK),
207 "rapid_blink" | "rapid-blink" => Some(Modifier::RAPID_BLINK),
208 "reversed" => Some(Modifier::REVERSED),
209 "hidden" => Some(Modifier::HIDDEN),
210 "crossed_out" | "crossed-out" => Some(Modifier::CROSSED_OUT),
211 _ => None,
212 }
213}
214
215fn parse_style(
225 scope: &str,
226 value: &toml::Value,
227 palette: &HashMap<String, Color>,
228) -> Result<Style, ThemeError> {
229 match value {
230 toml::Value::String(s) => {
231 let color =
232 resolve_color(s, palette).map_err(|_| ThemeError::InvalidStyle {
233 scope: scope.into(),
234 reason: format!("unknown color {s:?}"),
235 })?;
236 Ok(Style::default().fg(color))
237 }
238 toml::Value::Table(table) => parse_style_table(scope, table, palette),
239 _ => Err(ThemeError::InvalidStyle {
240 scope: scope.into(),
241 reason: format!("expected string or table, got {:?}", value.type_str()),
242 }),
243 }
244}
245
246fn parse_style_table(
247 scope: &str,
248 table: &toml::map::Map<String, toml::Value>,
249 palette: &HashMap<String, Color>,
250) -> Result<Style, ThemeError> {
251 let mut style = Style::default();
252
253 let known: HashSet<&str> = ["fg", "bg", "modifiers", "underline"]
255 .iter()
256 .copied()
257 .collect();
258
259 for key in table.keys() {
260 if !known.contains(key.as_str()) {
261 return Err(ThemeError::UnknownKey {
262 scope: scope.into(),
263 key: key.clone(),
264 });
265 }
266 }
267
268 if let Some(fg) = table.get("fg").and_then(|v| v.as_str()) {
270 let color =
271 resolve_color(fg, palette).map_err(|_| ThemeError::InvalidStyle {
272 scope: scope.into(),
273 reason: format!("unknown fg color {fg:?}"),
274 })?;
275 style = style.fg(color);
276 }
277
278 if let Some(bg) = table.get("bg").and_then(|v| v.as_str()) {
280 let color =
281 resolve_color(bg, palette).map_err(|_| ThemeError::InvalidStyle {
282 scope: scope.into(),
283 reason: format!("unknown bg color {bg:?}"),
284 })?;
285 style = style.bg(color);
286 }
287
288 if let Some(mods) = table.get("modifiers").and_then(|v| v.as_array()) {
290 for m in mods {
291 if let Some(name) = m.as_str() {
292 if let Some(modifier) = parse_modifier(name) {
293 style = style.add_modifier(modifier);
294 } else {
295 return Err(ThemeError::InvalidStyle {
296 scope: scope.into(),
297 reason: format!("unknown modifier {name:?}"),
298 });
299 }
300 } else {
301 return Err(ThemeError::InvalidStyle {
302 scope: scope.into(),
303 reason: "modifiers must be strings".into(),
304 });
305 }
306 }
307 }
308
309 if let Some(ul) = table.get("underline").and_then(|v| v.as_table()) {
311 let ul_known: HashSet<&str> = ["color", "style"].iter().copied().collect();
312 for key in ul.keys() {
313 if !ul_known.contains(key.as_str()) {
314 return Err(ThemeError::UnknownKey {
315 scope: scope.into(),
316 key: format!("underline.{key}"),
317 });
318 }
319 }
320
321 if let Some(color_name) = ul.get("color").and_then(|v| v.as_str()) {
322 let color = resolve_color(color_name, palette).map_err(|_| {
323 ThemeError::InvalidStyle {
324 scope: scope.into(),
325 reason: format!("unknown underline color {color_name:?}"),
326 }
327 })?;
328 style = style.underline_color(color);
329 }
330 style = style.add_modifier(Modifier::UNDERLINED);
332 }
333
334 Ok(style)
335}
336
337fn merge_theme_values(mut parent: toml::Value, child: toml::Value) -> toml::Value {
349 let Some(parent_table) = parent.as_table_mut() else {
350 return child;
351 };
352 let Some(child_table) = child.as_table() else {
353 return parent;
354 };
355
356 let parent_palette = parent_table
358 .get("palette")
359 .and_then(|v| v.as_table())
360 .cloned()
361 .unwrap_or_default();
362 let child_palette = child_table
363 .get("palette")
364 .and_then(|v| v.as_table())
365 .cloned()
366 .unwrap_or_default();
367
368 let mut merged_palette = parent_palette.clone();
369 for (k, v) in &child_palette {
370 merged_palette.insert(k.clone(), v.clone());
371 }
372
373 for (key, val) in child_table {
375 if key == "palette" {
376 parent_table.insert("palette".into(), toml::Value::Table(merged_palette.clone()));
377 } else {
378 parent_table.insert(key.clone(), val.clone());
379 }
380 }
381
382 parent
383}
384
385#[derive(Debug, Clone)]
393pub struct Theme {
394 name: String,
395 styles: HashMap<String, Style>,
396}
397
398impl Theme {
399 pub fn empty() -> Self {
402 Self {
403 name: String::new(),
404 styles: HashMap::new(),
405 }
406 }
407
408 pub fn name(&self) -> &str {
410 &self.name
411 }
412
413 pub fn get(&self, scope: &str) -> Style {
416 self.try_get(scope).unwrap_or_default()
417 }
418
419 pub fn try_get(&self, scope: &str) -> Option<Style> {
422 let mut current = scope;
423 loop {
424 if let Some(style) = self.try_get_exact(current) {
425 return Some(style);
426 }
427 let Some((parent, _)) = current.rsplit_once('.') else {
428 return None;
429 };
430 current = parent;
431 }
432 }
433
434 pub fn try_get_exact(&self, scope: &str) -> Option<Style> {
436 self.styles.get(scope).copied()
437 }
438
439 pub fn styles(&self) -> &HashMap<String, Style> {
441 &self.styles
442 }
443
444 pub fn role(&self, role: ThemeRole) -> Style {
446 self.style_from_scopes(role.scopes())
447 }
448
449 pub fn style_from_scopes(&self, scopes: &[&str]) -> Style {
454 let mut style = Style::default();
455 for scope in scopes {
456 if let Some(found) = self.try_get(scope) {
457 style = patch_missing_style(style, found);
458 if style.fg.is_some() && style.bg.is_some() {
459 break;
460 }
461 }
462 }
463 style
464 }
465}
466
467fn patch_missing_style(mut style: Style, fallback: Style) -> Style {
468 if style.fg.is_none() {
469 style.fg = fallback.fg;
470 }
471 if style.bg.is_none() {
472 style.bg = fallback.bg;
473 }
474 if style.add_modifier.is_empty() && style.sub_modifier.is_empty() {
475 style.add_modifier = fallback.add_modifier;
476 style.sub_modifier = fallback.sub_modifier;
477 }
478 style
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
490pub enum ThemeRole {
491 Background,
492 Text,
493 TextFocus,
494 TextInactive,
495 Muted,
496 LineNumberSelected,
497 Selection,
498 Menu,
499 MenuSelected,
500 Window,
501 Popup,
502 Help,
503 Statusline,
504 StatuslineInactive,
505 StatuslineNormal,
506 StatuslineInsert,
507 StatuslineSelect,
508 Cursor,
509 CursorNormal,
510 CursorInsert,
511 CursorSelect,
512 Cursorline,
513 Warning,
514 Error,
515 Info,
516 Hint,
517 Success,
518}
519
520impl ThemeRole {
521 pub fn scopes(self) -> &'static [&'static str] {
523 match self {
524 Self::Background => &["ui.background"],
525 Self::Text => &["ui.text"],
526 Self::TextFocus => &["ui.text.focus", "ui.text"],
527 Self::TextInactive => &["ui.text.inactive", "ui.virtual", "comment"],
528 Self::Muted => &["ui.linenr", "ui.virtual", "comment"],
529 Self::LineNumberSelected => &["ui.linenr.selected", "ui.linenr", "ui.virtual", "comment"],
530 Self::Selection => &["ui.selection.primary", "ui.selection"],
531 Self::Menu => &["ui.menu", "ui.popup"],
532 Self::MenuSelected => &["ui.menu.selected", "ui.selection"],
533 Self::Window => &["ui.window"],
534 Self::Popup => &["ui.popup"],
535 Self::Help => &["ui.help", "ui.popup"],
536 Self::Statusline => &["ui.statusline"],
537 Self::StatuslineInactive => &["ui.statusline.inactive", "ui.statusline"],
538 Self::StatuslineNormal => &["ui.statusline.normal", "ui.statusline"],
539 Self::StatuslineInsert => &["ui.statusline.insert", "ui.statusline"],
540 Self::StatuslineSelect => &["ui.statusline.select", "ui.statusline"],
541 Self::Cursor => &["ui.cursor"],
542 Self::CursorNormal => &["ui.cursor.primary.normal", "ui.cursor.normal", "ui.cursor"],
543 Self::CursorInsert => &["ui.cursor.primary.insert", "ui.cursor.insert", "ui.cursor"],
544 Self::CursorSelect => &["ui.cursor.primary.select", "ui.cursor.select", "ui.cursor"],
545 Self::Cursorline => &["ui.cursorline.primary", "ui.cursorline"],
546 Self::Warning => &["warning", "diagnostic.warning"],
547 Self::Error => &["error", "diagnostic.error"],
548 Self::Info => &["info", "diagnostic.info"],
549 Self::Hint => &["hint", "diagnostic.hint"],
550 Self::Success => &["diagnostic.hint", "info"],
551 }
552 }
553}
554
555#[derive(Debug, Clone)]
557pub struct ThemeStyles {
558 pub background: Style,
559 pub text: Style,
560 pub text_focus: Style,
561 pub text_inactive: Style,
562 pub muted: Style,
563 pub line_number_selected: Style,
564 pub selection: Style,
565 pub menu: Style,
566 pub menu_selected: Style,
567 pub window: Style,
568 pub popup: Style,
569 pub help: Style,
570 pub statusline: Style,
571 pub statusline_inactive: Style,
572 pub statusline_normal: Style,
573 pub statusline_insert: Style,
574 pub statusline_select: Style,
575 pub cursor: Style,
576 pub cursor_normal: Style,
577 pub cursor_insert: Style,
578 pub cursor_select: Style,
579 pub cursorline: Style,
580 pub warning: Style,
581 pub error: Style,
582 pub info: Style,
583 pub hint: Style,
584 pub success: Style,
585}
586
587impl ThemeStyles {
588 pub fn from_theme(theme: &Theme) -> Self {
590 Self {
591 background: theme.role(ThemeRole::Background),
592 text: theme.role(ThemeRole::Text),
593 text_focus: theme.role(ThemeRole::TextFocus),
594 text_inactive: theme.role(ThemeRole::TextInactive),
595 muted: theme.role(ThemeRole::Muted),
596 line_number_selected: theme.role(ThemeRole::LineNumberSelected),
597 selection: theme.role(ThemeRole::Selection),
598 menu: theme.role(ThemeRole::Menu),
599 menu_selected: theme.role(ThemeRole::MenuSelected),
600 window: theme.role(ThemeRole::Window),
601 popup: theme.role(ThemeRole::Popup),
602 help: theme.role(ThemeRole::Help),
603 statusline: theme.role(ThemeRole::Statusline),
604 statusline_inactive: theme.role(ThemeRole::StatuslineInactive),
605 statusline_normal: theme.role(ThemeRole::StatuslineNormal),
606 statusline_insert: theme.role(ThemeRole::StatuslineInsert),
607 statusline_select: theme.role(ThemeRole::StatuslineSelect),
608 cursor: theme.role(ThemeRole::Cursor),
609 cursor_normal: theme.role(ThemeRole::CursorNormal),
610 cursor_insert: theme.role(ThemeRole::CursorInsert),
611 cursor_select: theme.role(ThemeRole::CursorSelect),
612 cursorline: theme.role(ThemeRole::Cursorline),
613 warning: theme.role(ThemeRole::Warning),
614 error: theme.role(ThemeRole::Error),
615 info: theme.role(ThemeRole::Info),
616 hint: theme.role(ThemeRole::Hint),
617 success: theme.role(ThemeRole::Success),
618 }
619 }
620
621 pub fn get(&self, role: ThemeRole) -> Style {
623 match role {
624 ThemeRole::Background => self.background,
625 ThemeRole::Text => self.text,
626 ThemeRole::TextFocus => self.text_focus,
627 ThemeRole::TextInactive => self.text_inactive,
628 ThemeRole::Muted => self.muted,
629 ThemeRole::LineNumberSelected => self.line_number_selected,
630 ThemeRole::Selection => self.selection,
631 ThemeRole::Menu => self.menu,
632 ThemeRole::MenuSelected => self.menu_selected,
633 ThemeRole::Window => self.window,
634 ThemeRole::Popup => self.popup,
635 ThemeRole::Help => self.help,
636 ThemeRole::Statusline => self.statusline,
637 ThemeRole::StatuslineInactive => self.statusline_inactive,
638 ThemeRole::StatuslineNormal => self.statusline_normal,
639 ThemeRole::StatuslineInsert => self.statusline_insert,
640 ThemeRole::StatuslineSelect => self.statusline_select,
641 ThemeRole::Cursor => self.cursor,
642 ThemeRole::CursorNormal => self.cursor_normal,
643 ThemeRole::CursorInsert => self.cursor_insert,
644 ThemeRole::CursorSelect => self.cursor_select,
645 ThemeRole::Cursorline => self.cursorline,
646 ThemeRole::Warning => self.warning,
647 ThemeRole::Error => self.error,
648 ThemeRole::Info => self.info,
649 ThemeRole::Hint => self.hint,
650 ThemeRole::Success => self.success,
651 }
652 }
653}
654
655impl Default for ThemeStyles {
656 fn default() -> Self {
657 Self::from_theme(&Theme::empty())
658 }
659}
660
661#[derive(Debug, Clone)]
663pub struct ThemeManager {
664 loader: ThemeLoader,
665 current: Theme,
666 styles: ThemeStyles,
667}
668
669impl ThemeManager {
670 pub fn new(loader: ThemeLoader) -> Self {
672 let current = Theme::empty();
673 let styles = ThemeStyles::from_theme(¤t);
674 Self {
675 loader,
676 current,
677 styles,
678 }
679 }
680
681 pub fn with_theme(loader: ThemeLoader, current: Theme) -> Self {
683 let styles = ThemeStyles::from_theme(¤t);
684 Self {
685 loader,
686 current,
687 styles,
688 }
689 }
690
691 pub fn default_search_paths(app_theme_dir: impl Into<PathBuf>) -> Self {
694 Self::new(ThemeLoader::default_search_paths(app_theme_dir))
695 }
696
697 pub fn load_ref(&mut self, theme_ref: &str) -> Result<(), ThemeError> {
700 let next = self.loader.load_ref(theme_ref)?;
701 self.styles = ThemeStyles::from_theme(&next);
702 self.current = next;
703 Ok(())
704 }
705
706 pub fn loaded(mut self, theme_ref: &str) -> Result<Self, ThemeError> {
708 self.load_ref(theme_ref)?;
709 Ok(self)
710 }
711
712 pub fn current(&self) -> &Theme {
714 &self.current
715 }
716
717 pub fn styles(&self) -> &ThemeStyles {
719 &self.styles
720 }
721
722 pub fn get(&self, role: ThemeRole) -> Style {
724 self.styles.get(role)
725 }
726
727 pub fn scope(&self, scope: &str) -> Style {
729 self.current.get(scope)
730 }
731
732 pub fn style_from_scopes(&self, scopes: &[&str]) -> Style {
734 self.current.style_from_scopes(scopes)
735 }
736}
737
738#[derive(Debug, Clone)]
751pub struct ThemeLoader {
752 theme_dirs: Vec<PathBuf>,
753}
754
755impl ThemeLoader {
756 pub fn new<I, P>(theme_dirs: I) -> Self
760 where
761 I: IntoIterator<Item = P>,
762 P: Into<PathBuf>,
763 {
764 Self {
765 theme_dirs: theme_dirs.into_iter().map(Into::into).collect(),
766 }
767 }
768
769 pub fn default_search_paths(app_theme_dir: impl Into<PathBuf>) -> Self {
773 let mut theme_dirs = vec![app_theme_dir.into()];
774 if let Ok(config_home) = env::var("XDG_CONFIG_HOME") {
775 theme_dirs.push(PathBuf::from(config_home).join("helix").join("themes"));
776 } else if let Ok(home) = env::var("HOME") {
777 theme_dirs.push(PathBuf::from(home).join(".config").join("helix").join("themes"));
778 }
779 Self { theme_dirs }
780 }
781
782 pub fn load(&self, name: &str) -> Result<Theme, ThemeError> {
784 let path = self.find_theme_path(name)?;
785 self.load_path(path)
786 }
787
788 pub fn load_ref(&self, theme_ref: &str) -> Result<Theme, ThemeError> {
795 let path = Path::new(theme_ref);
796 if path.is_absolute() || path.components().count() > 1 {
797 return self.load_path(path);
798 }
799 if path.extension().is_some() {
800 let path = self.find_theme_file(theme_ref)?;
801 return self.load_path(path);
802 }
803 self.load(theme_ref)
804 }
805
806 pub fn load_path(&self, path: impl AsRef<Path>) -> Result<Theme, ThemeError> {
808 let path = path.as_ref();
809 let raw = fs::read_to_string(path).map_err(|source| ThemeError::Io {
810 path: path.to_path_buf(),
811 source,
812 })?;
813 let root = toml::from_str::<toml::Value>(&raw).map_err(|source| {
814 ThemeError::ParseToml {
815 path: path.to_path_buf(),
816 source,
817 }
818 })?;
819 self.theme_from_raw(path, root)
820 }
821
822 pub fn read_names(&self) -> Vec<String> {
824 let mut names = HashSet::new();
825 for dir in &self.theme_dirs {
826 let Ok(entries) = fs::read_dir(dir) else {
827 continue;
828 };
829 for entry in entries.flatten() {
830 let fpath = entry.path();
831 if fpath.extension().map_or(false, |e| e == "toml") {
832 if let Some(stem) = fpath.file_stem().and_then(|s| s.to_str()) {
833 names.insert(stem.to_string());
834 }
835 }
836 }
837 }
838 let mut sorted: Vec<String> = names.into_iter().collect();
839 sorted.sort();
840 sorted
841 }
842
843 fn find_theme_path(&self, name: &str) -> Result<PathBuf, ThemeError> {
846 for dir in &self.theme_dirs {
847 let candidate = dir.join(format!("{name}.toml"));
848 if candidate.exists() {
849 return Ok(candidate);
850 }
851 }
852 Err(ThemeError::MissingTheme { name: name.into() })
853 }
854
855 fn find_theme_file(&self, file_name: &str) -> Result<PathBuf, ThemeError> {
856 for dir in &self.theme_dirs {
857 let candidate = dir.join(file_name);
858 if candidate.exists() {
859 return Ok(candidate);
860 }
861 }
862 Err(ThemeError::MissingTheme {
863 name: file_name.into(),
864 })
865 }
866
867 fn theme_from_raw(&self, path: &Path, root: toml::Value) -> Result<Theme, ThemeError> {
868 let merged = self.resolve_inheritance(&root, path)?;
869 let name = path
870 .file_stem()
871 .and_then(|s| s.to_str())
872 .unwrap_or("unknown")
873 .to_string();
874 let theme = parse_theme_root(&name, &merged)?;
875 Ok(theme)
876 }
877
878 fn resolve_inheritance(
879 &self,
880 root: &toml::Value,
881 path: &Path,
882 ) -> Result<toml::Value, ThemeError> {
883 let mut visited = HashSet::new();
884 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
885 visited.insert(name.to_string());
886 }
887 self.load_value_inner(root, &mut visited)
888 }
889
890 fn load_value_inner(
891 &self,
892 value: &toml::Value,
893 visited: &mut HashSet<String>,
894 ) -> Result<toml::Value, ThemeError> {
895 let table = value.as_table().ok_or(ThemeError::InvalidThemeRoot)?;
896
897 let mut current = value.clone();
898
899 if let Some(parent_name) = table.get("inherits").and_then(|v| v.as_str()) {
900 if !visited.insert(parent_name.to_string()) {
901 return Err(ThemeError::InheritanceCycle {
902 name: parent_name.to_string(),
903 });
904 }
905 let parent_path = self.find_theme_path(parent_name)?;
906 let parent_raw =
907 fs::read_to_string(&parent_path).map_err(|source| ThemeError::Io {
908 path: parent_path.clone(),
909 source,
910 })?;
911 let parent_root =
912 toml::from_str::<toml::Value>(&parent_raw).map_err(|source| {
913 ThemeError::ParseToml {
914 path: parent_path.clone(),
915 source,
916 }
917 })?;
918 let parent_resolved = self.load_value_inner(&parent_root, visited)?;
919 current = merge_theme_values(parent_resolved, current);
920 visited.remove(parent_name);
921 }
922
923 Ok(current)
924 }
925}
926
927fn parse_theme_root(name: &str, root: &toml::Value) -> Result<Theme, ThemeError> {
932 let table = root.as_table().ok_or(ThemeError::InvalidThemeRoot)?;
933
934 let mut palette = builtin_palette();
936 if let Some(pal) = table.get("palette").and_then(|v| v.as_table()) {
937 for (key, value) in pal {
938 let color_str =
939 value
940 .as_str()
941 .ok_or_else(|| ThemeError::InvalidPaletteEntry {
942 name: key.clone(),
943 value: value.clone(),
944 })?;
945 let color =
946 resolve_color(color_str, &palette).map_err(|_| ThemeError::InvalidPaletteEntry {
947 name: key.clone(),
948 value: value.clone(),
949 })?;
950 palette.insert(key.clone(), color);
951 }
952 }
953
954 let mut styles = HashMap::new();
956 for (key, value) in table {
957 if is_theme_metadata_key(key) {
958 continue;
959 }
960 let style = parse_style(key, value, &palette)?;
961 styles.insert(key.clone(), style);
962 }
963
964 Ok(Theme {
965 name: name.into(),
966 styles,
967 })
968}
969
970fn is_theme_metadata_key(key: &str) -> bool {
971 matches!(key, "palette" | "inherits" | "rainbow")
972}
973
974#[cfg(test)]
979mod tests {
980 use super::*;
981 use std::io::Write;
982
983 fn loader_with(dir: &tempfile::TempDir, files: &[(&str, &str)]) -> ThemeLoader {
988 for (name, content) in files {
989 let path = dir.path().join(name);
990 let mut f = std::fs::File::create(&path).unwrap();
991 write!(f, "{content}").unwrap();
992 }
993 ThemeLoader::new([dir.path().to_path_buf()])
994 }
995
996 fn test_loader(files: &[(&str, &str)]) -> (tempfile::TempDir, ThemeLoader) {
997 let dir = tempfile::TempDir::new().unwrap();
998 let loader = loader_with(&dir, files);
999 (dir, loader)
1000 }
1001
1002 #[test]
1007 fn string_style() {
1008 let (_dir, loader) = test_loader(&[("test.toml", r#""ui.text" = "red""#)]);
1009 let theme = loader.load("test").unwrap();
1010 let style = theme.get("ui.text");
1011 assert_eq!(style.fg, Some(Color::Red));
1012 assert_eq!(style.bg, None);
1013 }
1014
1015 #[test]
1020 fn table_style_with_all_fields() {
1021 let (_dir, loader) = test_loader(&[(
1022 "test.toml",
1023 r##""ui.text.focus" = { fg = "#ffffff", bg = "0", modifiers = ["bold", "italic"] }"##,
1024 )]);
1025 let theme = loader.load("test").unwrap();
1026 let style = theme.get("ui.text.focus");
1027 assert_eq!(style.fg, Some(Color::Rgb(255, 255, 255)));
1028 assert_eq!(style.bg, Some(Color::Indexed(0)));
1029 assert!(style.add_modifier.contains(Modifier::BOLD));
1030 assert!(style.add_modifier.contains(Modifier::ITALIC));
1031 }
1032
1033 #[test]
1038 fn palette_reference() {
1039 let (_dir, loader) = test_loader(&[(
1040 "test.toml",
1041 r##""ui.text" = { fg = "text" }
1042[palette]
1043text = "#cdd6f4"
1044"##,
1045 )]);
1046 let theme = loader.load("test").unwrap();
1047 let style = theme.get("ui.text");
1048 assert_eq!(style.fg, Some(Color::Rgb(205, 214, 244)));
1049 }
1050
1051 #[test]
1056 fn dot_fallback() {
1057 let (_dir, loader) = test_loader(&[("test.toml", r#""ui.text" = "green""#)]);
1058 let theme = loader.load("test").unwrap();
1059 assert_eq!(theme.get("ui.text").fg, Some(Color::Green));
1061 assert_eq!(theme.get("ui.text.focus").fg, Some(Color::Green));
1063 assert_eq!(theme.get("ui.border"), Style::default());
1065 }
1066
1067 #[test]
1068 fn dot_fallback_two_levels() {
1069 let (_dir, loader) = test_loader(&[("test.toml", r#""ui" = { fg = "blue" }"#)]);
1070 let theme = loader.load("test").unwrap();
1071 assert_eq!(theme.get("ui").fg, Some(Color::Blue));
1072 assert_eq!(theme.get("ui.text").fg, Some(Color::Blue));
1073 assert_eq!(theme.get("ui.text.focus").fg, Some(Color::Blue));
1074 }
1075
1076 #[test]
1077 fn dot_fallback_most_specific_wins() {
1078 let (_dir, loader) = test_loader(&[(
1079 "test.toml",
1080 r#""ui" = "blue"
1081"ui.text" = "green"
1082"ui.text.focus" = "red"
1083"#,
1084 )]);
1085 let theme = loader.load("test").unwrap();
1086 assert_eq!(theme.get("ui.text.focus").fg, Some(Color::Red));
1087 assert_eq!(theme.get("ui.text").fg, Some(Color::Green));
1088 assert_eq!(theme.get("ui").fg, Some(Color::Blue));
1089 assert_eq!(theme.get("ui.border").fg, Some(Color::Blue));
1090 }
1091
1092 #[test]
1097 fn inheritance_basic() {
1098 let (_dir, loader) = test_loader(&[
1099 (
1100 "parent.toml",
1101 r##""ui.text" = { fg = "text" }
1102[palette]
1103text = "#ffffff"
1104base = "#000000"
1105"##,
1106 ),
1107 (
1108 "child.toml",
1109 r##"inherits = "parent"
1110[palette]
1111text = "#eeeeee"
1112"##,
1113 ),
1114 ]);
1115 let theme = loader.load("child").unwrap();
1116 let style = theme.get("ui.text");
1118 assert_eq!(style.fg, Some(Color::Rgb(238, 238, 238)));
1119 }
1120
1121 #[test]
1122 fn inheritance_child_adds_own_styles() {
1123 let (_dir, loader) = test_loader(&[
1124 (
1125 "parent.toml",
1126 r##""ui.text" = { fg = "text" }
1127[palette]
1128text = "#ffffff"
1129"##,
1130 ),
1131 (
1132 "child.toml",
1133 r#"inherits = "parent"
1134"ui.border" = "red"
1135"#,
1136 ),
1137 ]);
1138 let theme = loader.load("child").unwrap();
1139 assert_eq!(theme.get("ui.text").fg, Some(Color::Rgb(255, 255, 255)));
1140 assert_eq!(theme.get("ui.border").fg, Some(Color::Red));
1141 }
1142
1143 #[test]
1148 fn inheritance_cycle() {
1149 let dir = tempfile::TempDir::new().unwrap();
1150 std::fs::write(dir.path().join("a.toml"), "inherits = \"b\"\n").unwrap();
1151 std::fs::write(dir.path().join("b.toml"), "inherits = \"a\"\n").unwrap();
1152 let loader = ThemeLoader::new([dir.path().to_path_buf()]);
1153 let err = loader.load("a").unwrap_err();
1154 match err {
1155 ThemeError::InheritanceCycle { name } => {
1156 assert!(name == "a" || name == "b");
1157 }
1158 _ => panic!("expected InheritanceCycle, got {err:?}"),
1159 }
1160 }
1161
1162 #[test]
1167 fn unknown_style_key() {
1168 let (_dir, loader) = test_loader(&[("test.toml", r#""ui.text" = { nope = "red" }"#)]);
1169 let err = loader.load("test").unwrap_err();
1170 match err {
1171 ThemeError::UnknownKey { scope, key } => {
1172 assert_eq!(scope, "ui.text");
1173 assert_eq!(key, "nope");
1174 }
1175 _ => panic!("expected UnknownKey, got {err:?}"),
1176 }
1177 }
1178
1179 #[test]
1184 fn underline_color() {
1185 let (_dir, loader) = test_loader(&[(
1186 "test.toml",
1187 r#""ui.text" = { fg = "red", underline = { color = "blue" } }"#,
1188 )]);
1189 let theme = loader.load("test").unwrap();
1190 let style = theme.get("ui.text");
1191 assert_eq!(style.fg, Some(Color::Red));
1192 assert_eq!(style.underline_color, Some(Color::Blue));
1193 assert!(style.add_modifier.contains(Modifier::UNDERLINED));
1194 }
1195
1196 #[test]
1201 fn modifier_kebab_aliases() {
1202 let (_dir, loader) = test_loader(&[(
1203 "test.toml",
1204 r#""ui.text" = { modifiers = ["slow-blink", "rapid-blink", "crossed-out"] }"#,
1205 )]);
1206 let theme = loader.load("test").unwrap();
1207 let style = theme.get("ui.text");
1208 assert!(style.add_modifier.contains(Modifier::SLOW_BLINK));
1209 assert!(style.add_modifier.contains(Modifier::RAPID_BLINK));
1210 assert!(style.add_modifier.contains(Modifier::CROSSED_OUT));
1211 }
1212
1213 #[test]
1218 fn builtin_palette_names() {
1219 let (_dir, loader) =
1220 test_loader(&[("test.toml", r#""ui.text" = "light-gray""#)]);
1221 let theme = loader.load("test").unwrap();
1222 assert_eq!(theme.get("ui.text").fg, Some(Color::DarkGray));
1224 }
1225
1226 #[test]
1231 fn missing_theme() {
1232 let loader = ThemeLoader::new::<[PathBuf; 0], PathBuf>([]);
1233 let err = loader.load("nonexistent").unwrap_err();
1234 assert!(matches!(err, ThemeError::MissingTheme { .. }));
1235 }
1236
1237 #[test]
1242 fn load_path_direct() {
1243 let (_dir, loader) =
1244 test_loader(&[("mytheme.toml", r#""ui.text" = "cyan""#)]);
1245 let path = _dir.path().join("mytheme.toml");
1246 let theme = loader.load_path(&path).unwrap();
1247 assert_eq!(theme.get("ui.text").fg, Some(Color::Cyan));
1248 }
1249
1250 #[test]
1255 fn read_names_lists_theme_stems() {
1256 let (_dir, loader) = test_loader(&[
1257 ("foo.toml", r#""ui.text" = "red""#),
1258 ("bar.toml", r#""ui.text" = "green""#),
1259 ("baz.txt", "not a theme"),
1260 ]);
1261 let names = loader.read_names();
1262 assert_eq!(names, vec!["bar", "foo"]);
1263 }
1264
1265 #[test]
1270 fn invalid_theme_root_during_inheritance() {
1271 }
1285
1286 #[test]
1291 fn invalid_palette_entry_not_string() {
1292 let (_dir, loader) = test_loader(&[(
1293 "test.toml",
1294 r#"[palette]
1295bad = 42
1296"#,
1297 )]);
1298 let err = loader.load("test").unwrap_err();
1299 assert!(matches!(err, ThemeError::InvalidPaletteEntry { .. }));
1300 }
1301
1302 #[test]
1307 fn non_string_inherits_is_harmlessly_ignored() {
1308 let (_dir, loader) = test_loader(&[(
1309 "test.toml",
1310 r#"inherits = 42
1311"ui.text" = "red"
1312"#,
1313 )]);
1314 let theme = loader.load("test").unwrap();
1315 assert_eq!(theme.get("ui.text").fg, Some(Color::Red));
1316 }
1317
1318 #[test]
1323 fn unknown_modifier_errors() {
1324 let (_dir, loader) = test_loader(&[(
1325 "test.toml",
1326 r#""ui.text" = { modifiers = ["bold", "notamodifier"] }"#,
1327 )]);
1328 let err = loader.load("test").unwrap_err();
1329 assert!(matches!(err, ThemeError::InvalidStyle { .. }));
1330 }
1331
1332 #[test]
1337 fn hex_color_parsing_via_palette() {
1338 let (_dir, loader) = test_loader(&[(
1339 "test.toml",
1340 r##""ui.background" = { bg = "bg" }
1341"ui.text" = { fg = "fg" }
1342[palette]
1343bg = "#1e1e2e"
1344fg = "#89b4fa"
1345"##,
1346 )]);
1347 let theme = loader.load("test").unwrap();
1348 assert_eq!(
1349 theme.get("ui.background").bg,
1350 Some(Color::Rgb(0x1e, 0x1e, 0x2e))
1351 );
1352 assert_eq!(
1353 theme.get("ui.text").fg,
1354 Some(Color::Rgb(0x89, 0xb4, 0xfa))
1355 );
1356 }
1357
1358 #[test]
1359 fn ignores_helix_rainbow_metadata() {
1360 let (_dir, loader) = test_loader(&[(
1361 "test.toml",
1362 r##""ui.text" = { fg = "fg" }
1363rainbow = ["red", "yellow", "green"]
1364
1365[palette]
1366fg = "#89b4fa"
1367"##,
1368 )]);
1369 let theme = loader.load("test").unwrap();
1370
1371 assert_eq!(
1372 theme.get("ui.text").fg,
1373 Some(Color::Rgb(0x89, 0xb4, 0xfa))
1374 );
1375 assert!(theme.try_get_exact("rainbow").is_none());
1376 }
1377
1378 #[test]
1379 fn load_ref_accepts_names_and_filenames() {
1380 let (_dir, loader) = test_loader(&[("test.toml", r#""ui.text" = "cyan""#)]);
1381
1382 assert_eq!(loader.load_ref("test").unwrap().get("ui.text").fg, Some(Color::Cyan));
1383 assert_eq!(
1384 loader.load_ref("test.toml").unwrap().get("ui.text").fg,
1385 Some(Color::Cyan)
1386 );
1387 }
1388
1389 #[test]
1390 fn typed_roles_resolve_from_helix_scopes() {
1391 let (_dir, loader) = test_loader(&[(
1392 "test.toml",
1393 r##""ui.text" = { fg = "text" }
1394"ui.selection" = { bg = "selection" }
1395"ui.menu.selected" = { fg = "text", bg = "menu_selected", modifiers = ["bold"] }
1396"ui.linenr" = { fg = "line" }
1397"ui.linenr.selected" = { fg = "line_selected" }
1398
1399[palette]
1400text = "#111111"
1401selection = "#222222"
1402menu_selected = "#333333"
1403line = "#444444"
1404line_selected = "#555555"
1405"##,
1406 )]);
1407 let theme = loader.load("test").unwrap();
1408 let styles = ThemeStyles::from_theme(&theme);
1409
1410 assert_eq!(styles.text.fg, Some(Color::Rgb(0x11, 0x11, 0x11)));
1411 assert_eq!(styles.selection.bg, Some(Color::Rgb(0x22, 0x22, 0x22)));
1412 assert_eq!(styles.menu_selected.bg, Some(Color::Rgb(0x33, 0x33, 0x33)));
1413 assert!(styles.menu_selected.add_modifier.contains(Modifier::BOLD));
1414 assert_eq!(styles.muted.fg, Some(Color::Rgb(0x44, 0x44, 0x44)));
1415 assert_eq!(
1416 styles.line_number_selected.fg,
1417 Some(Color::Rgb(0x55, 0x55, 0x55))
1418 );
1419 }
1420
1421 #[test]
1422 fn theme_manager_refreshes_typed_styles_on_load_ref() {
1423 let (_dir, loader) = test_loader(&[
1424 (
1425 "one.toml",
1426 r##""ui.text" = { fg = "one" }
1427[palette]
1428one = "#111111"
1429"##,
1430 ),
1431 (
1432 "two.toml",
1433 r##""ui.text" = { fg = "two" }
1434[palette]
1435two = "#222222"
1436"##,
1437 ),
1438 ]);
1439 let mut manager = ThemeManager::new(loader);
1440
1441 manager.load_ref("one").unwrap();
1442 assert_eq!(manager.styles().text.fg, Some(Color::Rgb(0x11, 0x11, 0x11)));
1443
1444 manager.load_ref("two").unwrap();
1445 assert_eq!(manager.styles().text.fg, Some(Color::Rgb(0x22, 0x22, 0x22)));
1446 assert_eq!(manager.get(ThemeRole::Text).fg, Some(Color::Rgb(0x22, 0x22, 0x22)));
1447 }
1448}