use ratatui::style::{Color, Modifier, Style};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette {
pub name: &'static str,
pub text: Color,
pub subtle: Color,
pub muted: Color,
pub surface: Color,
pub border: Color,
pub accent: Color,
pub accent2: Color,
pub success: Color,
pub warning: Color,
pub error: Color,
pub info: Color,
pub ink: Color,
pub truecolor: bool,
}
const fn rgb(hex: u32) -> Color {
Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
}
impl Palette {
pub const GESTALT: Self = Self {
name: "gestalt",
text: rgb(0xcdd6f4),
subtle: rgb(0xa6adc8),
muted: rgb(0x6c7086),
surface: rgb(0x313244),
border: rgb(0x45475a),
accent: rgb(0xb4befe),
accent2: rgb(0xcba6f7),
success: rgb(0xa6e3a1),
warning: rgb(0xf9e2af),
error: rgb(0xf38ba8),
info: rgb(0x89b4fa),
ink: rgb(0x11111b),
truecolor: true,
};
pub const LATTE: Self = Self {
name: "latte",
text: rgb(0x4c4f69),
subtle: rgb(0x6c6f85),
muted: rgb(0x9ca0b0),
surface: rgb(0xccd0da),
border: rgb(0xbcc0cc),
accent: rgb(0x7287fd),
accent2: rgb(0x8839ef),
success: rgb(0x40a02b),
warning: rgb(0xdf8e1d),
error: rgb(0xd20f39),
info: rgb(0x1e66f5),
ink: rgb(0xeff1f5),
truecolor: true,
};
pub const NORD: Self = Self {
name: "nord",
text: rgb(0xeceff4),
subtle: rgb(0xd8dee9),
muted: rgb(0x616e88),
surface: rgb(0x3b4252),
border: rgb(0x434c5e),
accent: rgb(0x88c0d0),
accent2: rgb(0x81a1c1),
success: rgb(0xa3be8c),
warning: rgb(0xebcb8b),
error: rgb(0xbf616a),
info: rgb(0x5e81ac),
ink: rgb(0x2e3440),
truecolor: true,
};
pub const DRACULA: Self = Self {
name: "dracula",
text: rgb(0xf8f8f2),
subtle: rgb(0xd6d6d0),
muted: rgb(0x6272a4),
surface: rgb(0x44475a),
border: rgb(0x44475a),
accent: rgb(0xbd93f9),
accent2: rgb(0xff79c6),
success: rgb(0x50fa7b),
warning: rgb(0xf1fa8c),
error: rgb(0xff5555),
info: rgb(0x8be9fd),
ink: rgb(0x282a36),
truecolor: true,
};
pub const ANSI: Self = Self {
name: "ansi",
text: Color::Reset,
subtle: Color::Gray,
muted: Color::DarkGray,
surface: Color::DarkGray,
border: Color::DarkGray,
accent: Color::Cyan,
accent2: Color::Magenta,
success: Color::Green,
warning: Color::Yellow,
error: Color::Red,
info: Color::Blue,
ink: Color::Black,
truecolor: false,
};
pub const ALL: [Self; 5] = [
Self::GESTALT,
Self::LATTE,
Self::NORD,
Self::DRACULA,
Self::ANSI,
];
pub fn named(name: &str) -> Option<Self> {
match name {
"dark" => Some(Self::GESTALT),
"light" => Some(Self::LATTE),
_ => Self::ALL.into_iter().find(|palette| palette.name == name),
}
}
}
fn supports_truecolor() -> bool {
std::env::var("COLORTERM").is_ok_and(|value| value == "truecolor" || value == "24bit")
|| std::env::var("TERM_PROGRAM").is_ok_and(|value| {
matches!(
value.as_str(),
"iTerm.app" | "WezTerm" | "ghostty" | "vscode" | "WarpTerminal"
)
})
}
#[derive(Debug, Clone)]
pub struct Theme {
pub name: &'static str,
pub background: Color,
pub foreground: Color,
pub muted: Color,
pub selected: Style,
pub hover: Style,
pub size_huge: Color, pub size_large: Color, pub size_medium: Color, pub size_small: Color, pub size_tiny: Color,
pub success: Color,
pub warning: Color,
pub error: Color,
pub info: Color,
pub ink: Color,
pub border: Style,
pub title: Style,
pub help_key: Style,
pub help_desc: Style,
pub tree_lines: Style,
pub directory: Style,
pub file: Style,
pub symlink: Style,
pub executable: Style,
pub header: Style,
pub footer: Style,
pub help_key_bg: Color,
pub help_key_fg: Color,
pub help_desc_fg: Color,
pub footer_bg: Color,
pub git_modified: Color,
pub git_staged: Color,
pub git_untracked: Color,
pub git_ignored: Color,
pub git_conflict: Color,
}
impl Theme {
pub fn named(name: &str) -> Option<Self> {
let palette = Palette::named(name)?;
let shown = if palette.truecolor && !supports_truecolor() {
Palette::ANSI
} else {
palette
};
Some(Self {
name: palette.name,
..Self::from_palette(shown)
})
}
pub fn dark() -> Self {
Self::from_palette(Palette::GESTALT)
}
pub fn next(&self) -> Self {
let index = Palette::ALL
.iter()
.position(|palette| palette.name == self.name)
.map_or(0, |index| (index + 1) % Palette::ALL.len());
Self::named(Palette::ALL[index].name).unwrap_or_else(Self::dark)
}
pub fn from_palette(palette: Palette) -> Self {
let p = palette;
Self {
name: palette.name,
background: Color::Reset,
foreground: p.text,
muted: p.muted,
selected: Style::new()
.bg(p.surface)
.fg(p.text)
.add_modifier(Modifier::BOLD),
hover: Style::new().bg(p.surface),
size_huge: p.error,
size_large: p.warning,
size_medium: p.success,
size_small: p.info,
size_tiny: p.muted,
success: p.success,
warning: p.warning,
error: p.error,
info: p.info,
ink: p.ink,
border: Style::new().fg(p.border),
title: Style::new().fg(p.accent).add_modifier(Modifier::BOLD),
help_key: Style::new().fg(p.accent).add_modifier(Modifier::BOLD),
help_desc: Style::new().fg(p.subtle),
tree_lines: Style::new().fg(p.muted),
directory: Style::new().fg(p.info).add_modifier(Modifier::BOLD),
file: Style::new().fg(p.text),
symlink: Style::new().fg(p.accent2),
executable: Style::new().fg(p.success),
header: Style::new().bg(p.surface).fg(p.text),
footer: Style::new().bg(p.surface).fg(p.subtle),
help_key_bg: p.border,
help_key_fg: p.accent,
help_desc_fg: p.subtle,
footer_bg: p.surface,
git_modified: p.warning,
git_staged: p.success,
git_untracked: p.accent2,
git_ignored: p.muted,
git_conflict: p.error,
}
}
pub fn size_color(&self, ratio: f64) -> Color {
match ratio {
r if r > 0.50 => self.size_huge,
r if r > 0.25 => self.size_large,
r if r > 0.10 => self.size_medium,
r if r > 0.01 => self.size_small,
_ => self.size_tiny,
}
}
pub fn size_bar_style(&self, ratio: f64) -> Style {
Style::new().fg(self.size_color(ratio))
}
pub fn help_key_style(&self) -> Style {
Style::new()
.bg(self.help_key_bg)
.fg(self.help_key_fg)
.add_modifier(Modifier::BOLD)
}
pub fn help_desc_style(&self) -> Style {
Style::new().fg(self.help_desc_fg)
}
pub fn footer_style(&self) -> Style {
Style::new().bg(self.footer_bg)
}
pub fn git_status_color(&self, status: gravityfile_core::GitStatus) -> Color {
use gravityfile_core::GitStatus;
match status {
GitStatus::Modified => self.git_modified,
GitStatus::Staged => self.git_staged,
GitStatus::Untracked => self.git_untracked,
GitStatus::Ignored => self.git_ignored,
GitStatus::Conflict => self.git_conflict,
GitStatus::Clean => self.muted,
}
}
}
impl Default for Theme {
fn default() -> Self {
Self::dark()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_palette_is_reachable_by_name_and_by_cycling() {
for palette in Palette::ALL {
assert_eq!(Palette::named(palette.name), Some(palette));
}
assert_eq!(Palette::named("dark"), Some(Palette::GESTALT));
assert_eq!(Palette::named("light"), Some(Palette::LATTE));
assert_eq!(Palette::named("solarized"), None);
let mut theme = Theme::from_palette(Palette::ANSI);
let mut seen = Vec::new();
for _ in 0..Palette::ALL.len() {
theme = theme.next();
seen.push(theme.name);
}
let names: Vec<_> = Palette::ALL.iter().map(|palette| palette.name).collect();
assert_eq!(seen, names);
}
#[test]
fn backgrounds_are_the_terminals_own() {
for palette in Palette::ALL {
assert_eq!(Theme::from_palette(palette).background, Color::Reset);
}
}
}