use std::collections::HashMap;
use std::sync::Mutex;
use std::time::SystemTime;
use ratatui::style::{Color, Modifier, Style};
use super::sections::AppearanceMode;
use super::themes::util::{best_contrast, blend};
const CODE_BG_MIX_TOWARD_BG: f32 = 0.92;
fn dim_color(base: Color, level: u8) -> Color {
if level == 0 {
return base;
}
match base {
Color::Rgb(r, g, b) => {
let factor = 1.0 - 0.18 * level as f32;
let scale = |c: u8| (c as f32 * factor).clamp(0.0, 255.0) as u8;
Color::Rgb(scale(r), scale(g), scale(b))
}
_ => base,
}
}
#[derive(Debug, Clone)]
pub struct Theme {
pub palette: Palette,
pub h1: Style,
pub h1_rule: Style,
pub h2: Style,
pub h3: Style,
pub h4: Style,
pub h5: Style,
pub h6: Style,
pub bold: Style,
pub italic: Style,
pub strikethrough: Style,
pub highlight: Style,
pub code_span: Style,
pub code_span_dim: Style,
pub link_text: Style,
pub link_file: Style,
pub link_heading: Style,
pub image_placeholder: Style,
pub footnote: Style,
pub code_block_border: Style,
pub code_block_lang: Style,
pub code_block_text: Style,
pub blockquote_bar: Style,
pub blockquote_text: Style,
pub rule: Style,
pub list_bullet: Style,
pub list_number: Style,
pub task_unchecked: Style,
pub task_checked: Style,
pub task_complete_text: Style,
pub task_strikethrough: bool,
pub table_border: Style,
pub table_header: Style,
pub table_header_border: Style,
pub table_cell: Style,
pub table_row_even: Style,
pub table_row_odd: Style,
pub table_drop_indicator: Style,
pub table_drop_target: Style,
pub table_handle: Style,
pub table_handle_delete: Style,
pub status_bar: Style,
pub status_mode_preview: Style,
pub status_mode_rendered: Style,
pub status_mode_raw: Style,
pub status_mode_vim_normal: Style,
pub status_mode_vim_insert: Style,
pub status_mode_vim_visual: Style,
pub status_filename: Style,
pub status_info: Style,
pub status_modified: Style,
pub status_breadcrumb_sep: Style,
pub status_breadcrumb_ancestor: Style,
pub status_breadcrumb_current: Style,
pub hint_bar: Style,
pub hint_chord: Style,
pub hint_label: Style,
pub transient_info: Style,
pub transient_success: Style,
pub transient_warning: Style,
pub transient_error: Style,
pub modal_bg: Style,
pub modal_title_normal: Style,
pub modal_title_warning: Style,
pub modal_title_error: Style,
pub modal_close_hint: Style,
pub modal_item: Style,
pub modal_item_hint: Style,
pub modal_item_selected: Style,
pub modal_item_selected_unfocused: Style,
pub modal_item_selected_hint: Style,
pub modal_description: Style,
pub modal_section_heading: Style,
pub modal_input_unfocused: Style,
pub modal_input_focused: Style,
pub modal_button_focused: Style,
pub normal: Style,
pub selection: Style,
pub selection_muted: Style,
pub status_mode_search: Style,
pub active_line: Style,
pub cursor: Style,
pub line_number: Style,
pub scrollbar_track: Style,
pub scrollbar_thumb: Style,
pub scrollbar_thumb_active: Style,
pub diff_add_line: Style,
pub diff_delete_line: Style,
pub diff_add_line_unfocused: Style,
pub diff_delete_line_unfocused: Style,
pub diff_add_inline: Style,
pub diff_delete_inline: Style,
pub diff_add_inline_unfocused: Style,
pub diff_delete_inline_unfocused: Style,
pub diff_decision_pending: Style,
pub diff_decision_accepted: Style,
pub diff_decision_rejected: Style,
pub diff_decision_unfocused: Style,
pub status_mode_diff: Style,
pub status_bar_diff: Style,
pub hint_bar_diff: Style,
}
#[derive(Debug, Clone)]
pub struct Palette {
pub text: Color,
pub text_muted: Color,
pub bg: Color,
pub bg_muted: Color,
pub surface: Color,
pub surface_elevated: Color,
pub primary: Color,
pub secondary: Color,
pub accent: Color,
pub link: Color,
pub success: Color,
pub warning: Color,
pub error: Color,
pub code: Color,
pub diff_add: Color,
pub diff_delete: Color,
pub light: bool,
}
impl Palette {
pub fn appearance(&self) -> AppearanceMode {
if self.light {
AppearanceMode::Light
} else {
AppearanceMode::Dark
}
}
}
impl Default for Palette {
fn default() -> Self {
super::themes::dark_256::palette()
}
}
pub type ThemeCtor = fn() -> Theme;
pub const BUILTIN_THEMES: &[(&str, ThemeCtor)] = &[
("256 Dark", super::themes::dark_256::theme),
("256 Light", super::themes::light_256::theme),
("Monochrome Dark", super::themes::monochrome_dark::theme),
("Ayu", super::themes::ayu::theme),
("Catppuccin", super::themes::catppuccin::theme),
("Catppuccin Latte", super::themes::catppuccin_latte::theme),
("Dracula", super::themes::dracula::theme),
("Edamame", super::themes::edamame::theme),
("Everforest", super::themes::everforest::theme),
("GitHub Dark", super::themes::github_dark::theme),
("GitHub Light", super::themes::github_light::theme),
("Gruvbox", super::themes::gruvbox::theme),
("Gruvbox Light", super::themes::gruvbox_light::theme),
("Kanagawa", super::themes::kanagawa::theme),
("Monokai", super::themes::monokai::theme),
("Nord", super::themes::nord::theme),
("One Dark", super::themes::one_dark::theme),
("Orng", super::themes::orng::theme),
("Rainbow", super::themes::rainbow::theme),
("Rosé Pine", super::themes::rose_pine::theme),
("Rosé Pine Dawn", super::themes::rose_pine_dawn::theme),
("Solarized Dark", super::themes::solarized_dark::theme),
("Solarized Light", super::themes::solarized_light::theme),
("SynthWave '84", super::themes::synthwave84::theme),
("Tokyo Night", super::themes::tokyo_night::theme),
("Tokyo Night Day", super::themes::tokyo_night_day::theme),
("Zenburn", super::themes::zenburn::theme),
];
pub const THEME_COUNTERPARTS: &[(&str, &str)] = &[
("256 Dark", "256 Light"),
("Catppuccin", "Catppuccin Latte"),
("GitHub Dark", "GitHub Light"),
("Gruvbox", "Gruvbox Light"),
("Rosé Pine", "Rosé Pine Dawn"),
("Solarized Dark", "Solarized Light"),
("Tokyo Night", "Tokyo Night Day"),
];
pub const DEFAULT_DARK_THEME: &str = "Edamame";
pub const DEFAULT_LIGHT_THEME: &str = "256 Light";
pub const INDEXED_DARK_THEME: &str = "256 Dark";
pub const INDEXED_LIGHT_THEME: &str = "256 Light";
pub const MONOCHROME_THEME: &str = "Monochrome Dark";
pub const INDEXED_SAFE_THEMES: &[&str] =
&[INDEXED_DARK_THEME, INDEXED_LIGHT_THEME, MONOCHROME_THEME];
pub fn indexed_fallback_theme(current: &str, configured: AppearanceMode) -> Option<&'static str> {
if INDEXED_SAFE_THEMES.contains(¤t) {
return None;
}
Some(match theme_appearance(current).unwrap_or(configured) {
AppearanceMode::Dark => INDEXED_DARK_THEME,
AppearanceMode::Light => INDEXED_LIGHT_THEME,
})
}
pub fn counterpart_theme(name: &str) -> Option<&'static str> {
for (a, b) in THEME_COUNTERPARTS {
if *a == name {
return Some(b);
}
if *b == name {
return Some(a);
}
}
None
}
pub fn resolve_theme_for_mode_switch(current: &str, target: AppearanceMode) -> String {
if let Some(sibling) = counterpart_theme(current) {
if theme_appearance(sibling) == Some(target) {
return sibling.to_owned();
}
}
match target {
AppearanceMode::Dark => DEFAULT_DARK_THEME.to_owned(),
AppearanceMode::Light => DEFAULT_LIGHT_THEME.to_owned(),
}
}
type AppearanceCacheEntry = (Option<SystemTime>, AppearanceMode);
static USER_THEME_APPEARANCE_CACHE: Mutex<Option<HashMap<String, AppearanceCacheEntry>>> =
Mutex::new(None);
pub fn theme_appearance(name: &str) -> Option<AppearanceMode> {
if let Some(t) = Theme::builtin(name) {
return Some(t.palette.appearance());
}
let dir = super::config::Config::config_dir()?;
let path = dir.join("themes").join(format!("{name}.toml"));
let mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
let mut guard = USER_THEME_APPEARANCE_CACHE.lock().ok()?;
let cache = guard.get_or_insert_with(HashMap::new);
if let Some((cached_mtime, cached_mode)) = cache.get(name) {
if *cached_mtime == mtime {
return Some(*cached_mode);
}
}
let text = std::fs::read_to_string(&path).ok()?;
let file: super::theme_file::ThemeFile = toml::from_str(&text).ok()?;
let theme: Theme = (&file).into();
let mode = theme.palette.appearance();
cache.insert(name.to_owned(), (mtime, mode));
Some(mode)
}
pub fn list_theme_names_for_mode(mode: AppearanceMode) -> Vec<String> {
list_theme_names()
.into_iter()
.filter(|name| theme_appearance(name).unwrap_or(AppearanceMode::Dark) == mode)
.collect()
}
pub fn list_theme_names() -> Vec<String> {
let mut out: Vec<String> = BUILTIN_THEMES
.iter()
.map(|(n, _)| (*n).to_owned())
.collect();
let user_themes_dir = super::config::Config::config_dir()
.filter(|_| super::persistence::config_reads_allowed())
.map(|dir| dir.join("themes"));
if let Some(themes) = user_themes_dir {
if let Ok(read) = std::fs::read_dir(&themes) {
let mut user: Vec<String> = read
.flatten()
.filter_map(|entry| {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
return None;
}
let stem = path.file_stem().and_then(|s| s.to_str())?.to_owned();
if out.contains(&stem) {
return None;
}
if stem == "default" {
return None;
}
Some(stem)
})
.collect();
user.sort();
user.dedup();
out.extend(user);
}
}
out
}
impl Theme {
pub fn builtin(name: &str) -> Option<Theme> {
BUILTIN_THEMES
.iter()
.find(|(n, _)| *n == name)
.map(|(_, ctor)| ctor())
}
}
impl Theme {
pub fn from_palette(palette: &Palette) -> Self {
let bold = Modifier::BOLD;
let italic = Modifier::ITALIC;
let underline = Modifier::UNDERLINED;
let p = palette.clone();
let code_bg = blend(p.code, p.bg, CODE_BG_MIX_TOWARD_BG);
let h1c = dim_color(p.primary, 0);
let h2c = dim_color(p.secondary, 0);
let h3c = dim_color(p.primary, 1);
let h4c = dim_color(p.secondary, 1);
let h5c = dim_color(p.primary, 2);
let h6c = dim_color(p.secondary, 2);
Self {
palette: p.clone(),
h1: Style::default().fg(h1c).add_modifier(bold),
h1_rule: Style::default().fg(h1c), h2: Style::default()
.fg(h2c)
.add_modifier(bold)
.add_modifier(underline),
h3: Style::default()
.fg(h3c)
.add_modifier(bold)
.add_modifier(underline),
h4: Style::default()
.fg(h4c)
.add_modifier(bold)
.add_modifier(underline),
h5: Style::default()
.fg(h5c)
.add_modifier(bold)
.add_modifier(underline),
h6: Style::default()
.fg(h6c)
.add_modifier(bold)
.add_modifier(underline),
bold: Style::default().add_modifier(bold),
italic: Style::default().add_modifier(italic),
strikethrough: Style::default()
.fg(p.text_muted)
.add_modifier(Modifier::CROSSED_OUT),
highlight: Style::default().bg(p.warning).fg(p.bg),
code_span: Style::default().fg(p.code).bg(code_bg),
code_span_dim: Style::default()
.fg(p.code)
.bg(code_bg)
.add_modifier(Modifier::DIM),
link_text: Style::default().fg(p.link).add_modifier(underline),
link_file: Style::default().fg(p.link).add_modifier(underline),
link_heading: Style::default().fg(p.link),
image_placeholder: Style::default().fg(p.link).add_modifier(italic),
footnote: Style::default().fg(p.secondary),
code_block_border: Style::default().fg(p.text).bg(code_bg),
code_block_lang: Style::default()
.fg(p.code)
.bg(p.surface)
.add_modifier(italic),
code_block_text: Style::default().fg(p.text).bg(code_bg),
blockquote_bar: Style::default().fg(p.secondary),
blockquote_text: Style::default().add_modifier(italic),
rule: Style::default().fg(p.secondary),
list_bullet: Style::default().fg(p.accent),
list_number: Style::default().fg(p.accent),
task_unchecked: Style::default().fg(p.warning),
task_checked: Style::default().fg(p.success),
task_complete_text: Style::default()
.fg(p.text_muted)
.add_modifier(Modifier::CROSSED_OUT),
task_strikethrough: true,
table_border: Style::default().fg(p.surface_elevated),
table_header: Style::default().add_modifier(bold).fg(p.accent),
table_header_border: Style::default().fg(p.surface_elevated),
table_cell: Style::default(),
table_row_even: Style::default(),
table_row_odd: Style::default().bg(p.bg_muted),
table_drop_indicator: Style::default().fg(p.primary),
table_drop_target: Style::default().fg(p.primary).add_modifier(Modifier::DIM),
table_handle: Style::default().fg(p.primary).add_modifier(Modifier::DIM),
table_handle_delete: Style::default().fg(p.error),
status_bar: Style::default().bg(p.surface).fg(p.text),
status_mode_preview: Style::default()
.bg(p.text_muted)
.fg(p.surface)
.add_modifier(bold),
status_mode_rendered: Style::default().bg(p.primary).fg(p.bg).add_modifier(bold),
status_mode_raw: Style::default().bg(p.warning).fg(p.bg).add_modifier(bold),
status_mode_vim_normal: Style::default().bg(p.primary).fg(p.bg).add_modifier(bold),
status_mode_vim_insert: Style::default().bg(p.success).fg(p.bg).add_modifier(bold),
status_mode_vim_visual: Style::default().bg(p.secondary).fg(p.bg).add_modifier(bold),
status_filename: Style::default().fg(p.text).bg(p.surface).add_modifier(bold),
status_info: Style::default().fg(p.text_muted).bg(p.surface),
status_modified: Style::default()
.fg(p.warning)
.bg(p.surface)
.add_modifier(bold),
status_breadcrumb_sep: Style::default().fg(p.text_muted).bg(p.surface),
status_breadcrumb_ancestor: Style::default().fg(p.text_muted).bg(p.surface),
status_breadcrumb_current: Style::default()
.fg(p.primary)
.bg(p.surface)
.add_modifier(bold),
hint_bar: Style::default().bg(p.surface_elevated).fg(p.text),
hint_chord: Style::default()
.fg(p.primary)
.bg(p.surface_elevated)
.add_modifier(bold),
hint_label: Style::default().fg(p.text).bg(p.surface_elevated),
transient_info: Style::default()
.fg(p.text)
.bg(p.surface_elevated)
.add_modifier(bold),
transient_success: Style::default()
.fg(p.success)
.bg(p.surface_elevated)
.add_modifier(bold),
transient_warning: Style::default()
.fg(p.warning)
.bg(p.surface_elevated)
.add_modifier(bold),
transient_error: Style::default()
.fg(p.error)
.bg(p.surface_elevated)
.add_modifier(bold),
modal_bg: Style::default().bg(p.surface_elevated).fg(p.text),
modal_title_normal: Style::default()
.fg(p.primary)
.bg(p.surface_elevated)
.add_modifier(bold),
modal_title_warning: Style::default()
.fg(p.warning)
.bg(p.surface_elevated)
.add_modifier(bold),
modal_title_error: Style::default()
.fg(p.error)
.bg(p.surface_elevated)
.add_modifier(bold),
modal_close_hint: Style::default().fg(p.text_muted).bg(p.surface_elevated),
modal_item: Style::default().fg(p.text).bg(p.surface_elevated),
modal_item_hint: Style::default().fg(p.primary).bg(p.surface_elevated),
modal_item_selected: Style::default().bg(p.primary).fg(p.bg).add_modifier(bold),
modal_item_selected_unfocused: Style::default()
.fg(p.secondary)
.bg(p.surface_elevated)
.add_modifier(bold),
modal_item_selected_hint: Style::default().fg(p.bg).bg(p.primary),
modal_description: Style::default().fg(p.accent).bg(p.surface_elevated),
modal_section_heading: Style::default()
.fg(p.secondary)
.bg(p.surface_elevated)
.add_modifier(bold),
modal_input_unfocused: Style::default().fg(p.primary).bg(p.surface_elevated),
modal_input_focused: Style::default().fg(p.bg).bg(p.primary).add_modifier(bold),
modal_button_focused: Style::default()
.fg(p.primary)
.add_modifier(Modifier::REVERSED | bold),
normal: Style::default().fg(p.text).bg(p.bg),
selection: Style::default()
.bg(p.accent)
.fg(best_contrast(p.accent, p.text, p.bg)),
selection_muted: {
let bg = blend(p.surface, p.accent, 0.45);
Style::default().bg(bg).fg(best_contrast(bg, p.text, p.bg))
},
status_mode_search: Style::default()
.bg(p.secondary)
.fg(p.bg)
.add_modifier(Modifier::BOLD),
active_line: Style::default(),
cursor: Style::default().bg(p.accent).fg(p.bg),
line_number: Style::default().fg(p.text_muted),
scrollbar_track: Style::default().fg(p.bg_muted),
scrollbar_thumb: Style::default().fg(p.primary),
scrollbar_thumb_active: Style::default().fg(blend(p.primary, p.text, 0.35)),
diff_add_line: Style::default().bg(blend(
blend(p.surface, p.diff_add, 0.42),
p.bg,
0.30,
)),
diff_delete_line: Style::default().bg(blend(
blend(p.surface, p.diff_delete, 0.42),
p.bg,
0.30,
)),
diff_add_line_unfocused: Style::default().bg(blend(p.surface, p.diff_add, 0.07)),
diff_delete_line_unfocused: Style::default().bg(blend(p.surface, p.diff_delete, 0.07)),
diff_add_inline: Style::default()
.bg(blend(p.diff_add, p.bg, 0.35))
.add_modifier(bold),
diff_delete_inline: Style::default()
.bg(blend(p.diff_delete, p.bg, 0.35))
.add_modifier(bold),
diff_add_inline_unfocused: Style::default().bg(blend(p.surface, p.diff_add, 0.20)),
diff_delete_inline_unfocused: Style::default().bg(blend(
p.surface,
p.diff_delete,
0.20,
)),
diff_decision_pending: Style::default().fg(p.secondary).bg(p.surface_elevated),
diff_decision_accepted: Style::default()
.fg(p.diff_add)
.bg(p.surface_elevated)
.add_modifier(bold),
diff_decision_rejected: Style::default()
.fg(p.diff_delete)
.bg(p.surface_elevated)
.add_modifier(bold),
diff_decision_unfocused: Style::default().fg(p.text_muted).bg(p.surface),
status_mode_diff: Style::default().bg(p.warning).fg(p.bg).add_modifier(bold),
status_bar_diff: Style::default()
.bg(blend(p.surface, p.diff_add, 0.22))
.fg(p.text),
hint_bar_diff: Style::default()
.bg(blend(p.surface_elevated, p.diff_delete, 0.22))
.fg(p.text),
}
}
pub fn default_bg(&self) -> Color {
self.palette.bg
}
pub fn text_muted(&self) -> Color {
self.palette.text_muted
}
pub fn heading_style(&self, level: pulldown_cmark::HeadingLevel) -> Style {
use pulldown_cmark::HeadingLevel::*;
match level {
H1 => self.h1,
H2 => self.h2,
H3 => self.h3,
H4 => self.h4,
H5 => self.h5,
H6 => self.h6,
}
}
pub fn status_mode_style(&self, mode: crate::editor::Mode) -> Style {
use crate::editor::Mode::*;
match mode {
Preview => self.status_mode_preview,
Rendered => self.status_mode_rendered,
Raw => self.status_mode_raw,
Diff => self.status_mode_diff,
}
}
pub fn from_file(file: &super::theme_file::ThemeFile, monochrome: bool) -> Self {
if monochrome {
super::themes::monochrome_dark::theme()
} else {
file.into()
}
}
}
impl Default for Theme {
fn default() -> Self {
Self::from_palette(&Palette::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_themes_are_not_listed_while_the_config_dir_is_disabled() {
let _lock = crate::test_env::env_lock();
let dir = tempfile::tempdir().unwrap();
let _xdg = crate::test_env::EnvGuard::set("XDG_CONFIG_HOME", dir.path());
std::fs::create_dir_all(dir.path().join("edamame/themes")).unwrap();
std::fs::write(
dir.path().join("edamame/themes/mine.toml"),
"[h1]\nfg = \"red\"\n",
)
.unwrap();
{
let _disabled = super::super::persistence::SuppressGuard::new();
let names = list_theme_names();
assert!(!names.iter().any(|n| n == "mine"), "{names:?}");
assert_eq!(names.len(), BUILTIN_THEMES.len(), "{names:?}");
}
assert!(list_theme_names().iter().any(|n| n == "mine"));
}
#[test]
fn builtin_themes_are_pinned_for_the_docs() {
let rows: Vec<String> = BUILTIN_THEMES
.iter()
.map(|(name, ctor)| {
let appearance = if ctor().palette.light {
"light"
} else {
"dark"
};
format!("{name} ({appearance})")
})
.collect();
insta::assert_snapshot!(rows.join("\n"));
}
const PALETTE_FIELDS: &[&str] = &[
"text",
"text_muted",
"bg",
"bg_muted",
"surface",
"surface_elevated",
"primary",
"secondary",
"accent",
"link",
"success",
"warning",
"error",
"code",
"diff_add",
"diff_delete",
];
#[test]
fn palette_fields_list_matches_struct() {
let p = Palette::default();
let _all = [
p.text,
p.text_muted,
p.bg,
p.bg_muted,
p.surface,
p.surface_elevated,
p.primary,
p.secondary,
p.accent,
p.link,
p.success,
p.warning,
p.error,
p.code,
p.diff_add,
p.diff_delete,
];
assert_eq!(_all.len(), PALETTE_FIELDS.len());
}
#[test]
fn light_palette_has_distinct_default_bg() {
use super::super::themes::{dark_256, light_256};
assert_ne!(dark_256::palette().bg, light_256::palette().bg);
}
#[test]
fn builtin_lookup_resolves_registered_names() {
assert!(Theme::builtin("256 Dark").is_some());
assert!(Theme::builtin("256 Light").is_some());
assert!(Theme::builtin("nonexistent").is_none());
}
#[test]
fn only_light_256_is_classified_as_light() {
let expected_light: &[&str] = &[
"256 Light",
"Catppuccin Latte",
"GitHub Light",
"Gruvbox Light",
"Rosé Pine Dawn",
"Solarized Light",
"Tokyo Night Day",
];
for (name, ctor) in BUILTIN_THEMES {
let appearance = ctor().palette.appearance();
let should_be_light = expected_light.contains(name);
assert_eq!(
appearance == AppearanceMode::Light,
should_be_light,
"theme {name:?} classified as {appearance:?} but expected light={should_be_light}",
);
}
}
#[test]
fn builtin_palettes_have_no_duplicate_slots() {
for (name, ctor) in BUILTIN_THEMES {
if *name == "Monochrome Dark" {
continue;
}
let p = ctor().palette;
let slots: &[(&str, Color)] = &[
("text", p.text),
("text_muted", p.text_muted),
("bg", p.bg),
("bg_muted", p.bg_muted),
("surface", p.surface),
("surface_elevated", p.surface_elevated),
("primary", p.primary),
("secondary", p.secondary),
("accent", p.accent),
("link", p.link),
("success", p.success),
("warning", p.warning),
("error", p.error),
("code", p.code),
("diff_add", p.diff_add),
("diff_delete", p.diff_delete),
];
for (i, (a, ca)) in slots.iter().enumerate() {
for (b, cb) in &slots[i + 1..] {
assert_ne!(
ca, cb,
"theme {name:?}: slot {a} and {b} share the same color",
);
}
}
}
}
#[test]
fn indexed_fallback_follows_the_current_theme_appearance() {
assert_eq!(
indexed_fallback_theme("Dracula", AppearanceMode::Light),
Some("256 Dark"),
);
assert_eq!(
indexed_fallback_theme("GitHub Light", AppearanceMode::Dark),
Some("256 Light"),
);
}
#[test]
fn indexed_fallback_uses_configured_appearance_for_unknown_themes() {
assert_eq!(
indexed_fallback_theme("no-such-theme", AppearanceMode::Light),
Some("256 Light"),
);
assert_eq!(
indexed_fallback_theme("no-such-theme", AppearanceMode::Dark),
Some("256 Dark"),
);
}
#[test]
fn indexed_fallback_is_a_noop_for_the_indexed_safe_themes() {
for name in INDEXED_SAFE_THEMES {
assert_eq!(indexed_fallback_theme(name, AppearanceMode::Dark), None);
assert_eq!(indexed_fallback_theme(name, AppearanceMode::Light), None);
}
}
#[test]
fn indexed_safe_themes_are_registered() {
for name in INDEXED_SAFE_THEMES {
assert!(
BUILTIN_THEMES.iter().any(|(n, _)| n == name),
"{name} is not a registered built-in",
);
}
}
#[test]
fn counterpart_theme_is_bidirectional() {
assert_eq!(counterpart_theme("256 Dark"), Some("256 Light"));
assert_eq!(counterpart_theme("256 Light"), Some("256 Dark"));
assert_eq!(counterpart_theme("Edamame"), None);
assert_eq!(counterpart_theme("nonexistent"), None);
}
#[test]
fn resolve_theme_for_mode_switch_uses_counterpart_when_available() {
assert_eq!(
resolve_theme_for_mode_switch("256 Dark", AppearanceMode::Light),
"256 Light",
);
assert_eq!(
resolve_theme_for_mode_switch("256 Light", AppearanceMode::Dark),
"256 Dark",
);
}
#[test]
fn resolve_theme_for_mode_switch_falls_back_to_default() {
assert_eq!(
resolve_theme_for_mode_switch("Edamame", AppearanceMode::Light),
DEFAULT_LIGHT_THEME,
);
assert_eq!(
resolve_theme_for_mode_switch("Dracula", AppearanceMode::Light),
DEFAULT_LIGHT_THEME,
);
}
#[test]
fn list_theme_names_for_mode_filters() {
let dark = list_theme_names_for_mode(AppearanceMode::Dark);
let light = list_theme_names_for_mode(AppearanceMode::Light);
assert!(dark.iter().any(|n| n == "Edamame"));
assert!(!dark.iter().any(|n| n == "256 Light"));
assert!(light.iter().any(|n| n == "256 Light"));
assert!(!light.iter().any(|n| n == "Edamame"));
}
}