mod contrast;
mod spacing;
mod themes;
mod tokens;
pub use spacing::Spacing;
pub use tokens::Tokens;
use ratatui::style::{Color, Modifier, Style};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Theme {
#[default]
Default,
Dracula,
SolarizedDark,
SolarizedLight,
Nord,
GruvboxDark,
GruvboxLight,
GithubLight,
}
impl Theme {
pub const ALL: &'static [Theme] = &[
Theme::Default,
Theme::Dracula,
Theme::SolarizedDark,
Theme::SolarizedLight,
Theme::Nord,
Theme::GruvboxDark,
Theme::GruvboxLight,
Theme::GithubLight,
];
pub fn label(self) -> &'static str {
match self {
Theme::Default => "Default",
Theme::Dracula => "Dracula",
Theme::SolarizedDark => "Solarized Dark",
Theme::SolarizedLight => "Solarized Light",
Theme::Nord => "Nord",
Theme::GruvboxDark => "Gruvbox Dark",
Theme::GruvboxLight => "Gruvbox Light",
Theme::GithubLight => "GitHub Light",
}
}
pub fn syntax_theme_name(self) -> &'static str {
match self {
Theme::Default | Theme::SolarizedDark | Theme::Nord => "base16-ocean.dark",
Theme::Dracula | Theme::GruvboxDark => "base16-eighties.dark",
Theme::SolarizedLight | Theme::GruvboxLight | Theme::GithubLight => "InspiredGitHub",
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy)]
pub struct Palette {
pub background: Color,
pub foreground: Color,
pub dim: Color,
pub border: Color,
pub border_focused: Color,
pub accent: Color,
pub accent_alt: Color,
pub selection_bg: Color,
pub selection_fg: Color,
pub on_accent_fg: Color,
pub title: Color,
pub h1: Color,
pub h2: Color,
pub h3: Color,
pub heading_other: Color,
pub inline_code: Color,
pub code_fg: Color,
pub code_bg: Color,
pub code_border: Color,
pub link: Color,
pub list_marker: Color,
pub task_marker: Color,
pub block_quote_fg: Color,
pub block_quote_border: Color,
pub table_header: Color,
pub table_border: Color,
pub search_match_bg: Color,
pub current_match_bg: Color,
pub match_fg: Color,
pub gutter: Color,
pub status_bar_bg: Color,
pub status_bar_fg: Color,
pub help_bg: Color,
pub git_new: Color,
pub git_modified: Color,
}
impl From<Tokens> for Palette {
fn from(t: Tokens) -> Self {
Palette {
background: t.surface.base,
foreground: t.text.primary,
dim: t.text.muted,
border: t.surface.border,
border_focused: t.state.focus,
accent: t.accent.primary,
accent_alt: t.accent.alt,
selection_bg: t.state.selection_bg,
selection_fg: t.state.selection_fg,
on_accent_fg: t.text.on_accent,
title: t.text.title,
h1: t.heading.h1,
h2: t.heading.h2,
h3: t.heading.h3,
heading_other: t.heading.other,
inline_code: t.syntax.inline_code,
code_fg: t.syntax.code_fg,
code_bg: t.surface.raised,
code_border: t.syntax.code_border,
link: t.accent.link,
list_marker: t.list.marker,
task_marker: t.list.task_marker,
block_quote_fg: t.list.block_quote_fg,
block_quote_border: t.list.block_quote_border,
table_header: t.table.header,
table_border: t.table.border,
search_match_bg: t.state.search_bg,
current_match_bg: t.state.current_match_bg,
match_fg: t.state.match_fg,
gutter: t.status.gutter,
status_bar_bg: t.status.bg,
status_bar_fg: t.status.fg,
help_bg: t.status.help_bg,
git_new: t.git.new,
git_modified: t.git.modified,
}
}
}
impl Palette {
#[must_use]
pub fn from_theme(theme: Theme) -> Self {
Tokens::from_theme(theme).into()
}
pub fn border_style(self) -> Style {
Style::new().fg(self.border)
}
pub fn border_focused_style(self) -> Style {
Style::new().fg(self.border_focused)
}
pub fn title_style(self) -> Style {
Style::new().fg(self.title).add_modifier(Modifier::BOLD)
}
pub fn selected_style(self) -> Style {
Style::new()
.bg(self.selection_bg)
.fg(self.selection_fg)
.add_modifier(Modifier::BOLD)
}
pub fn dim_style(self) -> Style {
Style::new().fg(self.dim)
}
}
impl Default for Palette {
fn default() -> Self {
Self::from_theme(Theme::Default)
}
}
#[cfg(test)]
mod tests {
use super::contrast::contrast_ratio;
use super::*;
const AA_NORMAL: f64 = 4.5;
#[test]
fn on_accent_fg_contrasts_with_accent() {
for &theme in Theme::ALL {
let p = Palette::from_theme(theme);
assert_ne!(
p.on_accent_fg, p.accent,
"Theme {theme:?}: on_accent_fg == accent — text would be invisible",
);
}
}
#[test]
fn highlight_bgs_differ_from_surfaces() {
let mut failures: Vec<String> = Vec::new();
for &theme in Theme::ALL {
let p = Palette::from_theme(theme);
for (a_name, a, b_name, b) in [
("selection_bg", p.selection_bg, "code_bg", p.code_bg),
("selection_bg", p.selection_bg, "background", p.background),
("current_match_bg", p.current_match_bg, "code_bg", p.code_bg),
(
"current_match_bg",
p.current_match_bg,
"background",
p.background,
),
] {
if a == b {
failures.push(format!(
" {theme:?}: {a_name} == {b_name} ({a:?}) — highlight invisible",
));
}
}
}
assert!(
failures.is_empty(),
"highlight backgrounds collide with surfaces:\n{}",
failures.join("\n"),
);
}
#[test]
fn reading_text_meets_wcag_aa() {
let mut failures: Vec<String> = Vec::new();
for &theme in Theme::ALL {
let p = Palette::from_theme(theme);
let pairs: &[(&str, Color, Color)] = &[
("foreground/background", p.foreground, p.background),
("code_fg/code_bg", p.code_fg, p.code_bg),
("selection_fg/selection_bg", p.selection_fg, p.selection_bg),
("on_accent_fg/accent", p.on_accent_fg, p.accent),
("match_fg/search_match_bg", p.match_fg, p.search_match_bg),
("match_fg/current_match_bg", p.match_fg, p.current_match_bg),
(
"status_bar_fg/status_bar_bg",
p.status_bar_fg,
p.status_bar_bg,
),
];
for (name, fg, bg) in pairs {
if let Some(ratio) = contrast_ratio(*fg, *bg)
&& ratio < AA_NORMAL
{
failures.push(format!(" {theme:?} {name}: {ratio:.2}:1 < {AA_NORMAL}:1",));
}
}
}
assert!(
failures.is_empty(),
"reading-text pairs fail WCAG AA:\n{}",
failures.join("\n"),
);
}
}