use std::collections::{HashMap, HashSet};
use std::env;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use ratatui::style::{Color, Modifier, Style};
#[derive(Debug)]
pub enum ThemeError {
Io {
path: PathBuf,
source: std::io::Error,
},
ParseToml {
path: PathBuf,
source: toml::de::Error,
},
MissingTheme {
name: String,
},
InheritanceCycle {
name: String,
},
InvalidThemeRoot,
InvalidInherits {
value: toml::Value,
},
InvalidPaletteEntry {
name: String,
value: toml::Value,
},
InvalidStyle {
scope: String,
reason: String,
},
UnknownKey {
scope: String,
key: String,
},
}
impl fmt::Display for ThemeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, source } => {
write!(f, "failed to read {}: {}", path.display(), source)
}
Self::ParseToml { path, source } => {
write!(f, "failed to parse {}: {}", path.display(), source)
}
Self::MissingTheme { name } => {
write!(f, "theme {name:?} not found in any search directory")
}
Self::InheritanceCycle { name } => {
write!(f, "inheritance cycle detected for theme {name:?}")
}
Self::InvalidThemeRoot => {
write!(f, "theme root must be a TOML table")
}
Self::InvalidInherits { value } => {
write!(f, "inherits must be a string, got {:?}", value.type_str())
}
Self::InvalidPaletteEntry { name, value } => {
write!(
f,
"invalid palette entry {name:?}: expected a string, got {:?}",
value.type_str()
)
}
Self::InvalidStyle { scope, reason } => {
write!(f, "invalid style for scope {scope:?}: {reason}")
}
Self::UnknownKey { scope, key } => {
write!(f, "unknown key {key:?} in style table for scope {scope:?}")
}
}
}
}
impl std::error::Error for ThemeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::ParseToml { source, .. } => Some(source),
_ => None,
}
}
}
fn builtin_palette() -> HashMap<String, Color> {
let mut m = HashMap::new();
m.insert("default".into(), Color::Reset);
m.insert("black".into(), Color::Black);
m.insert("red".into(), Color::Red);
m.insert("green".into(), Color::Green);
m.insert("yellow".into(), Color::Yellow);
m.insert("blue".into(), Color::Blue);
m.insert("magenta".into(), Color::Magenta);
m.insert("cyan".into(), Color::Cyan);
m.insert("gray".into(), Color::Gray);
m.insert("light-red".into(), Color::LightRed);
m.insert("light-green".into(), Color::LightGreen);
m.insert("light-yellow".into(), Color::LightYellow);
m.insert("light-blue".into(), Color::LightBlue);
m.insert("light-magenta".into(), Color::LightMagenta);
m.insert("light-cyan".into(), Color::LightCyan);
m.insert("light-gray".into(), Color::DarkGray);
m.insert("white".into(), Color::White);
m
}
fn parse_palette_color(raw: &str) -> Result<Color, ThemeError> {
if let Some(hex) = raw.strip_prefix('#') {
if hex.len() == 6 {
let r =
u8::from_str_radix(&hex[0..2], 16).map_err(|_| ThemeError::InvalidPaletteEntry {
name: raw.into(),
value: toml::Value::String(raw.into()),
})?;
let g =
u8::from_str_radix(&hex[2..4], 16).map_err(|_| ThemeError::InvalidPaletteEntry {
name: raw.into(),
value: toml::Value::String(raw.into()),
})?;
let b =
u8::from_str_radix(&hex[4..6], 16).map_err(|_| ThemeError::InvalidPaletteEntry {
name: raw.into(),
value: toml::Value::String(raw.into()),
})?;
return Ok(Color::Rgb(r, g, b));
}
return Err(ThemeError::InvalidPaletteEntry {
name: raw.into(),
value: toml::Value::String(raw.into()),
});
}
if let Ok(idx) = raw.parse::<u8>() {
return Ok(Color::Indexed(idx));
}
Err(ThemeError::InvalidPaletteEntry {
name: raw.into(),
value: toml::Value::String(raw.into()),
})
}
fn resolve_color(name: &str, palette: &HashMap<String, Color>) -> Result<Color, ThemeError> {
if let Some(c) = palette.get(name) {
return Ok(*c);
}
parse_palette_color(name)
}
fn parse_modifier(raw: &str) -> Option<Modifier> {
match raw {
"bold" => Some(Modifier::BOLD),
"dim" => Some(Modifier::DIM),
"italic" => Some(Modifier::ITALIC),
"underlined" => Some(Modifier::UNDERLINED),
"slow_blink" | "slow-blink" => Some(Modifier::SLOW_BLINK),
"rapid_blink" | "rapid-blink" => Some(Modifier::RAPID_BLINK),
"reversed" => Some(Modifier::REVERSED),
"hidden" => Some(Modifier::HIDDEN),
"crossed_out" | "crossed-out" => Some(Modifier::CROSSED_OUT),
_ => None,
}
}
fn parse_style(
scope: &str,
value: &toml::Value,
palette: &HashMap<String, Color>,
) -> Result<Style, ThemeError> {
match value {
toml::Value::String(s) => {
let color =
resolve_color(s, palette).map_err(|_| ThemeError::InvalidStyle {
scope: scope.into(),
reason: format!("unknown color {s:?}"),
})?;
Ok(Style::default().fg(color))
}
toml::Value::Table(table) => parse_style_table(scope, table, palette),
_ => Err(ThemeError::InvalidStyle {
scope: scope.into(),
reason: format!("expected string or table, got {:?}", value.type_str()),
}),
}
}
fn parse_style_table(
scope: &str,
table: &toml::map::Map<String, toml::Value>,
palette: &HashMap<String, Color>,
) -> Result<Style, ThemeError> {
let mut style = Style::default();
let known: HashSet<&str> = ["fg", "bg", "modifiers", "underline"]
.iter()
.copied()
.collect();
for key in table.keys() {
if !known.contains(key.as_str()) {
return Err(ThemeError::UnknownKey {
scope: scope.into(),
key: key.clone(),
});
}
}
if let Some(fg) = table.get("fg").and_then(|v| v.as_str()) {
let color =
resolve_color(fg, palette).map_err(|_| ThemeError::InvalidStyle {
scope: scope.into(),
reason: format!("unknown fg color {fg:?}"),
})?;
style = style.fg(color);
}
if let Some(bg) = table.get("bg").and_then(|v| v.as_str()) {
let color =
resolve_color(bg, palette).map_err(|_| ThemeError::InvalidStyle {
scope: scope.into(),
reason: format!("unknown bg color {bg:?}"),
})?;
style = style.bg(color);
}
if let Some(mods) = table.get("modifiers").and_then(|v| v.as_array()) {
for m in mods {
if let Some(name) = m.as_str() {
if let Some(modifier) = parse_modifier(name) {
style = style.add_modifier(modifier);
} else {
return Err(ThemeError::InvalidStyle {
scope: scope.into(),
reason: format!("unknown modifier {name:?}"),
});
}
} else {
return Err(ThemeError::InvalidStyle {
scope: scope.into(),
reason: "modifiers must be strings".into(),
});
}
}
}
if let Some(ul) = table.get("underline").and_then(|v| v.as_table()) {
let ul_known: HashSet<&str> = ["color", "style"].iter().copied().collect();
for key in ul.keys() {
if !ul_known.contains(key.as_str()) {
return Err(ThemeError::UnknownKey {
scope: scope.into(),
key: format!("underline.{key}"),
});
}
}
if let Some(color_name) = ul.get("color").and_then(|v| v.as_str()) {
let color = resolve_color(color_name, palette).map_err(|_| {
ThemeError::InvalidStyle {
scope: scope.into(),
reason: format!("unknown underline color {color_name:?}"),
}
})?;
style = style.underline_color(color);
}
style = style.add_modifier(Modifier::UNDERLINED);
}
Ok(style)
}
fn merge_theme_values(mut parent: toml::Value, child: toml::Value) -> toml::Value {
let Some(parent_table) = parent.as_table_mut() else {
return child;
};
let Some(child_table) = child.as_table() else {
return parent;
};
let parent_palette = parent_table
.get("palette")
.and_then(|v| v.as_table())
.cloned()
.unwrap_or_default();
let child_palette = child_table
.get("palette")
.and_then(|v| v.as_table())
.cloned()
.unwrap_or_default();
let mut merged_palette = parent_palette.clone();
for (k, v) in &child_palette {
merged_palette.insert(k.clone(), v.clone());
}
for (key, val) in child_table {
if key == "palette" {
parent_table.insert("palette".into(), toml::Value::Table(merged_palette.clone()));
} else {
parent_table.insert(key.clone(), val.clone());
}
}
parent
}
#[derive(Debug, Clone)]
pub struct Theme {
name: String,
styles: HashMap<String, Style>,
}
impl Theme {
pub fn empty() -> Self {
Self {
name: String::new(),
styles: HashMap::new(),
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn get(&self, scope: &str) -> Style {
self.try_get(scope).unwrap_or_default()
}
pub fn try_get(&self, scope: &str) -> Option<Style> {
let mut current = scope;
loop {
if let Some(style) = self.try_get_exact(current) {
return Some(style);
}
let Some((parent, _)) = current.rsplit_once('.') else {
return None;
};
current = parent;
}
}
pub fn try_get_exact(&self, scope: &str) -> Option<Style> {
self.styles.get(scope).copied()
}
pub fn styles(&self) -> &HashMap<String, Style> {
&self.styles
}
pub fn role(&self, role: ThemeRole) -> Style {
self.style_from_scopes(role.scopes())
}
pub fn style_from_scopes(&self, scopes: &[&str]) -> Style {
let mut style = Style::default();
for scope in scopes {
if let Some(found) = self.try_get(scope) {
style = patch_missing_style(style, found);
if style.fg.is_some() && style.bg.is_some() {
break;
}
}
}
style
}
}
fn patch_missing_style(mut style: Style, fallback: Style) -> Style {
if style.fg.is_none() {
style.fg = fallback.fg;
}
if style.bg.is_none() {
style.bg = fallback.bg;
}
if style.add_modifier.is_empty() && style.sub_modifier.is_empty() {
style.add_modifier = fallback.add_modifier;
style.sub_modifier = fallback.sub_modifier;
}
style
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ThemeRole {
Background,
Text,
TextFocus,
TextInactive,
Muted,
LineNumberSelected,
Selection,
Menu,
MenuSelected,
Window,
Popup,
Help,
Statusline,
StatuslineInactive,
StatuslineNormal,
StatuslineInsert,
StatuslineSelect,
Cursor,
CursorNormal,
CursorInsert,
CursorSelect,
Cursorline,
Warning,
Error,
Info,
Hint,
Success,
}
impl ThemeRole {
pub fn scopes(self) -> &'static [&'static str] {
match self {
Self::Background => &["ui.background"],
Self::Text => &["ui.text"],
Self::TextFocus => &["ui.text.focus", "ui.text"],
Self::TextInactive => &["ui.text.inactive", "ui.virtual", "comment"],
Self::Muted => &["ui.linenr", "ui.virtual", "comment"],
Self::LineNumberSelected => &["ui.linenr.selected", "ui.linenr", "ui.virtual", "comment"],
Self::Selection => &["ui.selection"],
Self::Menu => &["ui.menu", "ui.popup"],
Self::MenuSelected => &["ui.menu.selected", "ui.selection"],
Self::Window => &["ui.window"],
Self::Popup => &["ui.popup"],
Self::Help => &["ui.help", "ui.popup"],
Self::Statusline => &["ui.statusline"],
Self::StatuslineInactive => &["ui.statusline.inactive", "ui.statusline"],
Self::StatuslineNormal => &["ui.statusline.normal", "ui.statusline"],
Self::StatuslineInsert => &["ui.statusline.insert", "ui.statusline"],
Self::StatuslineSelect => &["ui.statusline.select", "ui.statusline"],
Self::Cursor => &["ui.cursor"],
Self::CursorNormal => &["ui.cursor.primary.normal", "ui.cursor.normal", "ui.cursor"],
Self::CursorInsert => &["ui.cursor.primary.insert", "ui.cursor.insert", "ui.cursor"],
Self::CursorSelect => &["ui.cursor.primary.select", "ui.cursor.select", "ui.cursor"],
Self::Cursorline => &["ui.cursorline.primary", "ui.cursorline"],
Self::Warning => &["warning", "diagnostic.warning"],
Self::Error => &["error", "diagnostic.error"],
Self::Info => &["info", "diagnostic.info"],
Self::Hint => &["hint", "diagnostic.hint"],
Self::Success => &["diagnostic.hint", "info"],
}
}
}
#[derive(Debug, Clone)]
pub struct ThemeStyles {
pub background: Style,
pub text: Style,
pub text_focus: Style,
pub text_inactive: Style,
pub muted: Style,
pub line_number_selected: Style,
pub selection: Style,
pub menu: Style,
pub menu_selected: Style,
pub window: Style,
pub popup: Style,
pub help: Style,
pub statusline: Style,
pub statusline_inactive: Style,
pub statusline_normal: Style,
pub statusline_insert: Style,
pub statusline_select: Style,
pub cursor: Style,
pub cursor_normal: Style,
pub cursor_insert: Style,
pub cursor_select: Style,
pub cursorline: Style,
pub warning: Style,
pub error: Style,
pub info: Style,
pub hint: Style,
pub success: Style,
}
impl ThemeStyles {
pub fn from_theme(theme: &Theme) -> Self {
Self {
background: theme.role(ThemeRole::Background),
text: theme.role(ThemeRole::Text),
text_focus: theme.role(ThemeRole::TextFocus),
text_inactive: theme.role(ThemeRole::TextInactive),
muted: theme.role(ThemeRole::Muted),
line_number_selected: theme.role(ThemeRole::LineNumberSelected),
selection: theme.role(ThemeRole::Selection),
menu: theme.role(ThemeRole::Menu),
menu_selected: theme.role(ThemeRole::MenuSelected),
window: theme.role(ThemeRole::Window),
popup: theme.role(ThemeRole::Popup),
help: theme.role(ThemeRole::Help),
statusline: theme.role(ThemeRole::Statusline),
statusline_inactive: theme.role(ThemeRole::StatuslineInactive),
statusline_normal: theme.role(ThemeRole::StatuslineNormal),
statusline_insert: theme.role(ThemeRole::StatuslineInsert),
statusline_select: theme.role(ThemeRole::StatuslineSelect),
cursor: theme.role(ThemeRole::Cursor),
cursor_normal: theme.role(ThemeRole::CursorNormal),
cursor_insert: theme.role(ThemeRole::CursorInsert),
cursor_select: theme.role(ThemeRole::CursorSelect),
cursorline: theme.role(ThemeRole::Cursorline),
warning: theme.role(ThemeRole::Warning),
error: theme.role(ThemeRole::Error),
info: theme.role(ThemeRole::Info),
hint: theme.role(ThemeRole::Hint),
success: theme.role(ThemeRole::Success),
}
}
pub fn get(&self, role: ThemeRole) -> Style {
match role {
ThemeRole::Background => self.background,
ThemeRole::Text => self.text,
ThemeRole::TextFocus => self.text_focus,
ThemeRole::TextInactive => self.text_inactive,
ThemeRole::Muted => self.muted,
ThemeRole::LineNumberSelected => self.line_number_selected,
ThemeRole::Selection => self.selection,
ThemeRole::Menu => self.menu,
ThemeRole::MenuSelected => self.menu_selected,
ThemeRole::Window => self.window,
ThemeRole::Popup => self.popup,
ThemeRole::Help => self.help,
ThemeRole::Statusline => self.statusline,
ThemeRole::StatuslineInactive => self.statusline_inactive,
ThemeRole::StatuslineNormal => self.statusline_normal,
ThemeRole::StatuslineInsert => self.statusline_insert,
ThemeRole::StatuslineSelect => self.statusline_select,
ThemeRole::Cursor => self.cursor,
ThemeRole::CursorNormal => self.cursor_normal,
ThemeRole::CursorInsert => self.cursor_insert,
ThemeRole::CursorSelect => self.cursor_select,
ThemeRole::Cursorline => self.cursorline,
ThemeRole::Warning => self.warning,
ThemeRole::Error => self.error,
ThemeRole::Info => self.info,
ThemeRole::Hint => self.hint,
ThemeRole::Success => self.success,
}
}
}
impl Default for ThemeStyles {
fn default() -> Self {
Self::from_theme(&Theme::empty())
}
}
#[derive(Debug, Clone)]
pub struct ThemeManager {
loader: ThemeLoader,
current: Theme,
styles: ThemeStyles,
}
impl ThemeManager {
pub fn new(loader: ThemeLoader) -> Self {
let current = Theme::empty();
let styles = ThemeStyles::from_theme(¤t);
Self {
loader,
current,
styles,
}
}
pub fn with_theme(loader: ThemeLoader, current: Theme) -> Self {
let styles = ThemeStyles::from_theme(¤t);
Self {
loader,
current,
styles,
}
}
pub fn default_search_paths(app_theme_dir: impl Into<PathBuf>) -> Self {
Self::new(ThemeLoader::default_search_paths(app_theme_dir))
}
pub fn load_ref(&mut self, theme_ref: &str) -> Result<(), ThemeError> {
let next = self.loader.load_ref(theme_ref)?;
self.styles = ThemeStyles::from_theme(&next);
self.current = next;
Ok(())
}
pub fn loaded(mut self, theme_ref: &str) -> Result<Self, ThemeError> {
self.load_ref(theme_ref)?;
Ok(self)
}
pub fn current(&self) -> &Theme {
&self.current
}
pub fn styles(&self) -> &ThemeStyles {
&self.styles
}
pub fn get(&self, role: ThemeRole) -> Style {
self.styles.get(role)
}
pub fn scope(&self, scope: &str) -> Style {
self.current.get(scope)
}
pub fn style_from_scopes(&self, scopes: &[&str]) -> Style {
self.current.style_from_scopes(scopes)
}
}
#[derive(Debug, Clone)]
pub struct ThemeLoader {
theme_dirs: Vec<PathBuf>,
}
impl ThemeLoader {
pub fn new<I, P>(theme_dirs: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
Self {
theme_dirs: theme_dirs.into_iter().map(Into::into).collect(),
}
}
pub fn default_search_paths(app_theme_dir: impl Into<PathBuf>) -> Self {
let mut theme_dirs = vec![app_theme_dir.into()];
if let Ok(config_home) = env::var("XDG_CONFIG_HOME") {
theme_dirs.push(PathBuf::from(config_home).join("helix").join("themes"));
} else if let Ok(home) = env::var("HOME") {
theme_dirs.push(PathBuf::from(home).join(".config").join("helix").join("themes"));
}
Self { theme_dirs }
}
pub fn load(&self, name: &str) -> Result<Theme, ThemeError> {
let path = self.find_theme_path(name)?;
self.load_path(path)
}
pub fn load_ref(&self, theme_ref: &str) -> Result<Theme, ThemeError> {
let path = Path::new(theme_ref);
if path.is_absolute() || path.components().count() > 1 {
return self.load_path(path);
}
if path.extension().is_some() {
let path = self.find_theme_file(theme_ref)?;
return self.load_path(path);
}
self.load(theme_ref)
}
pub fn load_path(&self, path: impl AsRef<Path>) -> Result<Theme, ThemeError> {
let path = path.as_ref();
let raw = fs::read_to_string(path).map_err(|source| ThemeError::Io {
path: path.to_path_buf(),
source,
})?;
let root = toml::from_str::<toml::Value>(&raw).map_err(|source| {
ThemeError::ParseToml {
path: path.to_path_buf(),
source,
}
})?;
self.theme_from_raw(path, root)
}
pub fn read_names(&self) -> Vec<String> {
let mut names = HashSet::new();
for dir in &self.theme_dirs {
let Ok(entries) = fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let fpath = entry.path();
if fpath.extension().map_or(false, |e| e == "toml") {
if let Some(stem) = fpath.file_stem().and_then(|s| s.to_str()) {
names.insert(stem.to_string());
}
}
}
}
let mut sorted: Vec<String> = names.into_iter().collect();
sorted.sort();
sorted
}
fn find_theme_path(&self, name: &str) -> Result<PathBuf, ThemeError> {
for dir in &self.theme_dirs {
let candidate = dir.join(format!("{name}.toml"));
if candidate.exists() {
return Ok(candidate);
}
}
Err(ThemeError::MissingTheme { name: name.into() })
}
fn find_theme_file(&self, file_name: &str) -> Result<PathBuf, ThemeError> {
for dir in &self.theme_dirs {
let candidate = dir.join(file_name);
if candidate.exists() {
return Ok(candidate);
}
}
Err(ThemeError::MissingTheme {
name: file_name.into(),
})
}
fn theme_from_raw(&self, path: &Path, root: toml::Value) -> Result<Theme, ThemeError> {
let merged = self.resolve_inheritance(&root, path)?;
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let theme = parse_theme_root(&name, &merged)?;
Ok(theme)
}
fn resolve_inheritance(
&self,
root: &toml::Value,
path: &Path,
) -> Result<toml::Value, ThemeError> {
let mut visited = HashSet::new();
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
visited.insert(name.to_string());
}
self.load_value_inner(root, &mut visited)
}
fn load_value_inner(
&self,
value: &toml::Value,
visited: &mut HashSet<String>,
) -> Result<toml::Value, ThemeError> {
let table = value.as_table().ok_or(ThemeError::InvalidThemeRoot)?;
let mut current = value.clone();
if let Some(parent_name) = table.get("inherits").and_then(|v| v.as_str()) {
if !visited.insert(parent_name.to_string()) {
return Err(ThemeError::InheritanceCycle {
name: parent_name.to_string(),
});
}
let parent_path = self.find_theme_path(parent_name)?;
let parent_raw =
fs::read_to_string(&parent_path).map_err(|source| ThemeError::Io {
path: parent_path.clone(),
source,
})?;
let parent_root =
toml::from_str::<toml::Value>(&parent_raw).map_err(|source| {
ThemeError::ParseToml {
path: parent_path.clone(),
source,
}
})?;
let parent_resolved = self.load_value_inner(&parent_root, visited)?;
current = merge_theme_values(parent_resolved, current);
visited.remove(parent_name);
}
Ok(current)
}
}
fn parse_theme_root(name: &str, root: &toml::Value) -> Result<Theme, ThemeError> {
let table = root.as_table().ok_or(ThemeError::InvalidThemeRoot)?;
let mut palette = builtin_palette();
if let Some(pal) = table.get("palette").and_then(|v| v.as_table()) {
for (key, value) in pal {
let color_str =
value
.as_str()
.ok_or_else(|| ThemeError::InvalidPaletteEntry {
name: key.clone(),
value: value.clone(),
})?;
let color =
resolve_color(color_str, &palette).map_err(|_| ThemeError::InvalidPaletteEntry {
name: key.clone(),
value: value.clone(),
})?;
palette.insert(key.clone(), color);
}
}
let mut styles = HashMap::new();
for (key, value) in table {
if is_theme_metadata_key(key) {
continue;
}
let style = parse_style(key, value, &palette)?;
styles.insert(key.clone(), style);
}
Ok(Theme {
name: name.into(),
styles,
})
}
fn is_theme_metadata_key(key: &str) -> bool {
matches!(key, "palette" | "inherits" | "rainbow")
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn loader_with(dir: &tempfile::TempDir, files: &[(&str, &str)]) -> ThemeLoader {
for (name, content) in files {
let path = dir.path().join(name);
let mut f = std::fs::File::create(&path).unwrap();
write!(f, "{content}").unwrap();
}
ThemeLoader::new([dir.path().to_path_buf()])
}
fn test_loader(files: &[(&str, &str)]) -> (tempfile::TempDir, ThemeLoader) {
let dir = tempfile::TempDir::new().unwrap();
let loader = loader_with(&dir, files);
(dir, loader)
}
#[test]
fn string_style() {
let (_dir, loader) = test_loader(&[("test.toml", r#""ui.text" = "red""#)]);
let theme = loader.load("test").unwrap();
let style = theme.get("ui.text");
assert_eq!(style.fg, Some(Color::Red));
assert_eq!(style.bg, None);
}
#[test]
fn table_style_with_all_fields() {
let (_dir, loader) = test_loader(&[(
"test.toml",
r##""ui.text.focus" = { fg = "#ffffff", bg = "0", modifiers = ["bold", "italic"] }"##,
)]);
let theme = loader.load("test").unwrap();
let style = theme.get("ui.text.focus");
assert_eq!(style.fg, Some(Color::Rgb(255, 255, 255)));
assert_eq!(style.bg, Some(Color::Indexed(0)));
assert!(style.add_modifier.contains(Modifier::BOLD));
assert!(style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn palette_reference() {
let (_dir, loader) = test_loader(&[(
"test.toml",
r##""ui.text" = { fg = "text" }
[palette]
text = "#cdd6f4"
"##,
)]);
let theme = loader.load("test").unwrap();
let style = theme.get("ui.text");
assert_eq!(style.fg, Some(Color::Rgb(205, 214, 244)));
}
#[test]
fn dot_fallback() {
let (_dir, loader) = test_loader(&[("test.toml", r#""ui.text" = "green""#)]);
let theme = loader.load("test").unwrap();
assert_eq!(theme.get("ui.text").fg, Some(Color::Green));
assert_eq!(theme.get("ui.text.focus").fg, Some(Color::Green));
assert_eq!(theme.get("ui.border"), Style::default());
}
#[test]
fn dot_fallback_two_levels() {
let (_dir, loader) = test_loader(&[("test.toml", r#""ui" = { fg = "blue" }"#)]);
let theme = loader.load("test").unwrap();
assert_eq!(theme.get("ui").fg, Some(Color::Blue));
assert_eq!(theme.get("ui.text").fg, Some(Color::Blue));
assert_eq!(theme.get("ui.text.focus").fg, Some(Color::Blue));
}
#[test]
fn dot_fallback_most_specific_wins() {
let (_dir, loader) = test_loader(&[(
"test.toml",
r#""ui" = "blue"
"ui.text" = "green"
"ui.text.focus" = "red"
"#,
)]);
let theme = loader.load("test").unwrap();
assert_eq!(theme.get("ui.text.focus").fg, Some(Color::Red));
assert_eq!(theme.get("ui.text").fg, Some(Color::Green));
assert_eq!(theme.get("ui").fg, Some(Color::Blue));
assert_eq!(theme.get("ui.border").fg, Some(Color::Blue));
}
#[test]
fn inheritance_basic() {
let (_dir, loader) = test_loader(&[
(
"parent.toml",
r##""ui.text" = { fg = "text" }
[palette]
text = "#ffffff"
base = "#000000"
"##,
),
(
"child.toml",
r##"inherits = "parent"
[palette]
text = "#eeeeee"
"##,
),
]);
let theme = loader.load("child").unwrap();
let style = theme.get("ui.text");
assert_eq!(style.fg, Some(Color::Rgb(238, 238, 238)));
}
#[test]
fn inheritance_child_adds_own_styles() {
let (_dir, loader) = test_loader(&[
(
"parent.toml",
r##""ui.text" = { fg = "text" }
[palette]
text = "#ffffff"
"##,
),
(
"child.toml",
r#"inherits = "parent"
"ui.border" = "red"
"#,
),
]);
let theme = loader.load("child").unwrap();
assert_eq!(theme.get("ui.text").fg, Some(Color::Rgb(255, 255, 255)));
assert_eq!(theme.get("ui.border").fg, Some(Color::Red));
}
#[test]
fn inheritance_cycle() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("a.toml"), "inherits = \"b\"\n").unwrap();
std::fs::write(dir.path().join("b.toml"), "inherits = \"a\"\n").unwrap();
let loader = ThemeLoader::new([dir.path().to_path_buf()]);
let err = loader.load("a").unwrap_err();
match err {
ThemeError::InheritanceCycle { name } => {
assert!(name == "a" || name == "b");
}
_ => panic!("expected InheritanceCycle, got {err:?}"),
}
}
#[test]
fn unknown_style_key() {
let (_dir, loader) = test_loader(&[("test.toml", r#""ui.text" = { nope = "red" }"#)]);
let err = loader.load("test").unwrap_err();
match err {
ThemeError::UnknownKey { scope, key } => {
assert_eq!(scope, "ui.text");
assert_eq!(key, "nope");
}
_ => panic!("expected UnknownKey, got {err:?}"),
}
}
#[test]
fn underline_color() {
let (_dir, loader) = test_loader(&[(
"test.toml",
r#""ui.text" = { fg = "red", underline = { color = "blue" } }"#,
)]);
let theme = loader.load("test").unwrap();
let style = theme.get("ui.text");
assert_eq!(style.fg, Some(Color::Red));
assert_eq!(style.underline_color, Some(Color::Blue));
assert!(style.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn modifier_kebab_aliases() {
let (_dir, loader) = test_loader(&[(
"test.toml",
r#""ui.text" = { modifiers = ["slow-blink", "rapid-blink", "crossed-out"] }"#,
)]);
let theme = loader.load("test").unwrap();
let style = theme.get("ui.text");
assert!(style.add_modifier.contains(Modifier::SLOW_BLINK));
assert!(style.add_modifier.contains(Modifier::RAPID_BLINK));
assert!(style.add_modifier.contains(Modifier::CROSSED_OUT));
}
#[test]
fn builtin_palette_names() {
let (_dir, loader) =
test_loader(&[("test.toml", r#""ui.text" = "light-gray""#)]);
let theme = loader.load("test").unwrap();
assert_eq!(theme.get("ui.text").fg, Some(Color::DarkGray));
}
#[test]
fn missing_theme() {
let loader = ThemeLoader::new::<[PathBuf; 0], PathBuf>([]);
let err = loader.load("nonexistent").unwrap_err();
assert!(matches!(err, ThemeError::MissingTheme { .. }));
}
#[test]
fn load_path_direct() {
let (_dir, loader) =
test_loader(&[("mytheme.toml", r#""ui.text" = "cyan""#)]);
let path = _dir.path().join("mytheme.toml");
let theme = loader.load_path(&path).unwrap();
assert_eq!(theme.get("ui.text").fg, Some(Color::Cyan));
}
#[test]
fn read_names_lists_theme_stems() {
let (_dir, loader) = test_loader(&[
("foo.toml", r#""ui.text" = "red""#),
("bar.toml", r#""ui.text" = "green""#),
("baz.txt", "not a theme"),
]);
let names = loader.read_names();
assert_eq!(names, vec!["bar", "foo"]);
}
#[test]
fn invalid_theme_root_during_inheritance() {
}
#[test]
fn invalid_palette_entry_not_string() {
let (_dir, loader) = test_loader(&[(
"test.toml",
r#"[palette]
bad = 42
"#,
)]);
let err = loader.load("test").unwrap_err();
assert!(matches!(err, ThemeError::InvalidPaletteEntry { .. }));
}
#[test]
fn non_string_inherits_is_harmlessly_ignored() {
let (_dir, loader) = test_loader(&[(
"test.toml",
r#"inherits = 42
"ui.text" = "red"
"#,
)]);
let theme = loader.load("test").unwrap();
assert_eq!(theme.get("ui.text").fg, Some(Color::Red));
}
#[test]
fn unknown_modifier_errors() {
let (_dir, loader) = test_loader(&[(
"test.toml",
r#""ui.text" = { modifiers = ["bold", "notamodifier"] }"#,
)]);
let err = loader.load("test").unwrap_err();
assert!(matches!(err, ThemeError::InvalidStyle { .. }));
}
#[test]
fn hex_color_parsing_via_palette() {
let (_dir, loader) = test_loader(&[(
"test.toml",
r##""ui.background" = { bg = "bg" }
"ui.text" = { fg = "fg" }
[palette]
bg = "#1e1e2e"
fg = "#89b4fa"
"##,
)]);
let theme = loader.load("test").unwrap();
assert_eq!(
theme.get("ui.background").bg,
Some(Color::Rgb(0x1e, 0x1e, 0x2e))
);
assert_eq!(
theme.get("ui.text").fg,
Some(Color::Rgb(0x89, 0xb4, 0xfa))
);
}
#[test]
fn ignores_helix_rainbow_metadata() {
let (_dir, loader) = test_loader(&[(
"test.toml",
r##""ui.text" = { fg = "fg" }
rainbow = ["red", "yellow", "green"]
[palette]
fg = "#89b4fa"
"##,
)]);
let theme = loader.load("test").unwrap();
assert_eq!(
theme.get("ui.text").fg,
Some(Color::Rgb(0x89, 0xb4, 0xfa))
);
assert!(theme.try_get_exact("rainbow").is_none());
}
#[test]
fn load_ref_accepts_names_and_filenames() {
let (_dir, loader) = test_loader(&[("test.toml", r#""ui.text" = "cyan""#)]);
assert_eq!(loader.load_ref("test").unwrap().get("ui.text").fg, Some(Color::Cyan));
assert_eq!(
loader.load_ref("test.toml").unwrap().get("ui.text").fg,
Some(Color::Cyan)
);
}
#[test]
fn typed_roles_resolve_from_helix_scopes() {
let (_dir, loader) = test_loader(&[(
"test.toml",
r##""ui.text" = { fg = "text" }
"ui.selection" = { bg = "selection" }
"ui.menu.selected" = { fg = "text", bg = "menu_selected", modifiers = ["bold"] }
"ui.linenr" = { fg = "line" }
"ui.linenr.selected" = { fg = "line_selected" }
[palette]
text = "#111111"
selection = "#222222"
menu_selected = "#333333"
line = "#444444"
line_selected = "#555555"
"##,
)]);
let theme = loader.load("test").unwrap();
let styles = ThemeStyles::from_theme(&theme);
assert_eq!(styles.text.fg, Some(Color::Rgb(0x11, 0x11, 0x11)));
assert_eq!(styles.selection.bg, Some(Color::Rgb(0x22, 0x22, 0x22)));
assert_eq!(styles.menu_selected.bg, Some(Color::Rgb(0x33, 0x33, 0x33)));
assert!(styles.menu_selected.add_modifier.contains(Modifier::BOLD));
assert_eq!(styles.muted.fg, Some(Color::Rgb(0x44, 0x44, 0x44)));
assert_eq!(
styles.line_number_selected.fg,
Some(Color::Rgb(0x55, 0x55, 0x55))
);
}
#[test]
fn theme_manager_refreshes_typed_styles_on_load_ref() {
let (_dir, loader) = test_loader(&[
(
"one.toml",
r##""ui.text" = { fg = "one" }
[palette]
one = "#111111"
"##,
),
(
"two.toml",
r##""ui.text" = { fg = "two" }
[palette]
two = "#222222"
"##,
),
]);
let mut manager = ThemeManager::new(loader);
manager.load_ref("one").unwrap();
assert_eq!(manager.styles().text.fg, Some(Color::Rgb(0x11, 0x11, 0x11)));
manager.load_ref("two").unwrap();
assert_eq!(manager.styles().text.fg, Some(Color::Rgb(0x22, 0x22, 0x22)));
assert_eq!(manager.get(ThemeRole::Text).fg, Some(Color::Rgb(0x22, 0x22, 0x22)));
}
}