use ratatui::style::Color;
use crate::color::dim;
use ratatui::symbols::border;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum BorderStyle {
Single,
Rounded,
Double,
Heavy,
}
impl BorderStyle {
#[must_use]
pub const fn to_border_set(self) -> border::Set<'static> {
match self {
Self::Single => border::PLAIN,
Self::Rounded => border::ROUNDED,
Self::Double => border::DOUBLE,
Self::Heavy => border::THICK,
}
}
}
const PRESETS: [Theme; 7] = [
Theme::default_dark(),
Theme::terminal(),
Theme::catppuccin(),
Theme::gruvbox(),
Theme::nord(),
Theme::tokyo_night(),
Theme::solarized(),
];
const RING_DIM: u16 = 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Theme {
pub name: &'static str,
pub foreground: Color,
pub muted_foreground: Color,
pub background: Color,
pub surface: Color,
pub field: Color,
pub primary: Color,
pub primary_foreground: Color,
pub secondary: Color,
pub secondary_foreground: Color,
pub accent: Color,
pub destructive: Color,
pub destructive_foreground: Color,
pub warning: Color,
pub border: Color,
pub ring: Color,
pub cursor: Color,
}
impl Default for Theme {
fn default() -> Self {
Self::default_dark()
}
}
impl Theme {
#[must_use]
pub const fn default_dark() -> Self {
let primary = Color::Rgb(229, 229, 229);
let background = Color::Rgb(10, 10, 10);
Self {
name: "Default",
foreground: Color::Rgb(250, 250, 250),
muted_foreground: Color::Rgb(161, 161, 161),
background,
surface: Color::Rgb(18, 18, 18),
field: Color::Rgb(31, 31, 31),
primary,
primary_foreground: Color::Rgb(23, 23, 23),
secondary: Color::Rgb(38, 38, 38),
secondary_foreground: Color::Rgb(250, 250, 250),
accent: Color::Rgb(139, 92, 246),
destructive: Color::Rgb(255, 100, 103),
destructive_foreground: Color::Rgb(250, 250, 250),
warning: Color::Rgb(250, 204, 21),
border: Color::Rgb(94, 94, 94),
ring: dim(primary, background, RING_DIM),
cursor: Color::Rgb(115, 115, 115),
}
}
#[must_use]
pub const fn terminal() -> Self {
let primary = Color::LightBlue;
let background = Color::Reset;
Self {
name: "Terminal",
foreground: Color::Reset,
muted_foreground: Color::Gray,
background,
surface: Color::Rgb(18, 18, 18),
field: Color::Rgb(31, 31, 31),
primary,
primary_foreground: Color::Black,
secondary: Color::Rgb(38, 38, 38),
secondary_foreground: Color::Reset,
accent: Color::LightMagenta,
destructive: Color::LightRed,
destructive_foreground: Color::Black,
warning: Color::Yellow,
border: Color::DarkGray,
ring: dim(primary, background, RING_DIM),
cursor: Color::LightBlue,
}
}
#[must_use]
pub const fn catppuccin() -> Self {
let primary = Color::Rgb(137, 180, 250);
let background = Color::Rgb(17, 17, 27);
Self {
name: "Catppuccin",
foreground: Color::Rgb(205, 214, 244),
muted_foreground: Color::Rgb(166, 173, 200),
background,
surface: Color::Rgb(30, 30, 46),
field: Color::Rgb(43, 43, 61),
primary,
primary_foreground: Color::Rgb(17, 17, 27),
secondary: Color::Rgb(49, 50, 68),
secondary_foreground: Color::Rgb(205, 214, 244),
accent: Color::Rgb(203, 166, 247),
destructive: Color::Rgb(243, 139, 168),
destructive_foreground: Color::Rgb(17, 17, 27),
warning: Color::Rgb(250, 179, 135),
border: Color::Rgb(94, 97, 122),
ring: dim(primary, background, RING_DIM),
cursor: Color::Rgb(137, 180, 250),
}
}
#[must_use]
pub const fn gruvbox() -> Self {
let primary = Color::Rgb(250, 189, 47);
let background = Color::Rgb(40, 40, 40);
Self {
name: "Gruvbox",
foreground: Color::Rgb(235, 219, 178),
muted_foreground: Color::Rgb(168, 153, 132),
background,
surface: Color::Rgb(60, 56, 54),
field: Color::Rgb(73, 67, 64),
primary,
primary_foreground: Color::Rgb(40, 40, 40),
secondary: Color::Rgb(80, 73, 69),
secondary_foreground: Color::Rgb(235, 219, 178),
accent: Color::Rgb(254, 128, 25),
destructive: Color::Rgb(251, 73, 52),
destructive_foreground: Color::Rgb(40, 40, 40),
warning: Color::Rgb(250, 189, 47),
border: Color::Rgb(124, 112, 102),
ring: dim(primary, background, RING_DIM),
cursor: Color::Rgb(250, 189, 47),
}
}
#[must_use]
pub const fn nord() -> Self {
let primary = Color::Rgb(136, 192, 208);
let background = Color::Rgb(46, 52, 64);
Self {
name: "Nord",
foreground: Color::Rgb(216, 222, 233),
muted_foreground: Color::Rgb(129, 161, 193),
background,
surface: Color::Rgb(59, 66, 82),
field: Color::Rgb(64, 73, 90),
primary,
primary_foreground: Color::Rgb(46, 52, 64),
secondary: Color::Rgb(67, 76, 94),
secondary_foreground: Color::Rgb(216, 222, 233),
accent: Color::Rgb(180, 142, 173),
destructive: Color::Rgb(191, 97, 106),
destructive_foreground: Color::Rgb(46, 52, 64),
warning: Color::Rgb(235, 203, 139),
border: Color::Rgb(112, 126, 155),
ring: dim(primary, background, RING_DIM),
cursor: Color::Rgb(136, 192, 208),
}
}
#[must_use]
pub const fn tokyo_night() -> Self {
let primary = Color::Rgb(122, 162, 247);
let background = Color::Rgb(26, 27, 38);
Self {
name: "Tokyo Night",
foreground: Color::Rgb(192, 202, 245),
muted_foreground: Color::Rgb(120, 134, 180),
background,
surface: Color::Rgb(36, 40, 59),
field: Color::Rgb(55, 61, 89),
primary,
primary_foreground: Color::Rgb(26, 27, 38),
secondary: Color::Rgb(65, 72, 104),
secondary_foreground: Color::Rgb(192, 202, 245),
accent: Color::Rgb(187, 154, 247),
destructive: Color::Rgb(247, 118, 142),
destructive_foreground: Color::Rgb(26, 27, 38),
warning: Color::Rgb(224, 175, 104),
border: Color::Rgb(92, 102, 146),
ring: dim(primary, background, RING_DIM),
cursor: Color::Rgb(122, 162, 247),
}
}
#[must_use]
pub const fn solarized() -> Self {
let primary = Color::Rgb(39, 139, 211);
let background = Color::Rgb(0, 20, 26);
Self {
name: "Solarized",
foreground: Color::Rgb(159, 171, 173),
muted_foreground: Color::Rgb(88, 110, 117),
background,
surface: Color::Rgb(0, 45, 56),
field: Color::Rgb(0, 58, 63),
primary,
primary_foreground: Color::Rgb(253, 246, 227),
secondary: Color::Rgb(7, 54, 66),
secondary_foreground: Color::Rgb(173, 184, 184),
accent: Color::Rgb(211, 54, 130),
destructive: Color::Rgb(220, 49, 46),
destructive_foreground: Color::Rgb(253, 246, 227),
warning: Color::Rgb(181, 137, 0),
border: Color::Rgb(94, 117, 125),
ring: dim(primary, background, RING_DIM),
cursor: Color::Rgb(42, 162, 152),
}
}
#[must_use]
pub const fn presets() -> &'static [Self] {
&PRESETS
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::{FIELD_FOCUS_LIGHTEN, lighten};
fn luminance(color: Color) -> f64 {
let Color::Rgb(r, g, b) = color else {
panic!("expected an rgb color, got {color:?}");
};
let channel = |v: u8| {
let c = f64::from(v) / 255.0;
if c <= 0.039_28 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
}
fn contrast(a: Color, b: Color) -> f64 {
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
#[test]
fn preset_borders_meet_non_text_contrast_against_their_background() {
for theme in Theme::presets() {
if theme.name == "Terminal" {
continue;
}
let ratio = contrast(theme.border, theme.background);
assert!(
ratio >= 3.0,
"{} border contrast is {ratio:.2}:1, below the 3:1 floor",
theme.name
);
}
}
#[test]
fn preset_fields_are_distinct_on_backgrounds_and_surfaces() {
for theme in Theme::presets() {
if theme.name == "Terminal" {
continue;
}
for (role, backdrop) in [("background", theme.background), ("surface", theme.surface)] {
let ratio = contrast(theme.field, backdrop);
assert!(
ratio >= 1.1,
"{} field contrast on {role} is {ratio:.2}:1, below the 1.1:1 floor",
theme.name
);
}
}
}
#[test]
fn terminal_theme_has_derived_component_surfaces() {
let theme = Theme::terminal();
assert_eq!(theme.background, Color::Reset);
assert!(matches!(theme.surface, Color::Rgb(_, _, _)));
assert!(matches!(theme.field, Color::Rgb(_, _, _)));
assert_ne!(lighten(theme.field, FIELD_FOCUS_LIGHTEN), theme.field);
}
}