use ftui_render::cell::PackedRgba;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThemePreset {
Dark,
Light,
CyberpunkAurora,
Darcula,
NordicFrost,
LumenLight,
}
impl ThemePreset {
pub const ALL: [Self; 6] = [
Self::Dark,
Self::Light,
Self::CyberpunkAurora,
Self::Darcula,
Self::NordicFrost,
Self::LumenLight,
];
#[must_use]
pub const fn is_light(self) -> bool {
matches!(self, Self::Light | Self::LumenLight)
}
#[must_use]
pub const fn next(self) -> Self {
match self {
Self::Dark => Self::Light,
Self::Light => Self::CyberpunkAurora,
Self::CyberpunkAurora => Self::Darcula,
Self::Darcula => Self::NordicFrost,
Self::NordicFrost => Self::LumenLight,
Self::LumenLight => Self::Dark,
}
}
}
impl std::fmt::Display for ThemePreset {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Dark => write!(f, "dark"),
Self::Light => write!(f, "light"),
Self::CyberpunkAurora => write!(f, "cyberpunk_aurora"),
Self::Darcula => write!(f, "darcula"),
Self::NordicFrost => write!(f, "nordic_frost"),
Self::LumenLight => write!(f, "lumen_light"),
}
}
}
pub trait ColorScheme {
fn bg(&self) -> PackedRgba;
fn fg(&self) -> PackedRgba;
fn status_bar_bg(&self) -> PackedRgba;
fn status_bar_fg(&self) -> PackedRgba;
fn highlight_bg(&self) -> PackedRgba;
fn highlight_fg(&self) -> PackedRgba;
fn border(&self) -> PackedRgba;
fn muted(&self) -> PackedRgba;
fn error(&self) -> PackedRgba;
fn warning(&self) -> PackedRgba;
fn success(&self) -> PackedRgba;
fn info(&self) -> PackedRgba;
fn accent(&self) -> PackedRgba {
self.info()
}
fn surface(&self) -> PackedRgba {
self.bg()
}
fn surface_dim(&self) -> PackedRgba {
self.bg()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Theme {
pub preset: ThemePreset,
pub bg: SerColor,
pub fg: SerColor,
pub status_bar_bg: SerColor,
pub status_bar_fg: SerColor,
pub highlight_bg: SerColor,
pub highlight_fg: SerColor,
pub border: SerColor,
pub muted: SerColor,
pub error: SerColor,
pub warning: SerColor,
pub success: SerColor,
pub info: SerColor,
pub accent: SerColor,
pub surface: SerColor,
pub surface_dim: SerColor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SerColor {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl SerColor {
#[must_use]
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
#[must_use]
pub const fn to_color(self) -> PackedRgba {
PackedRgba::rgb(self.r, self.g, self.b)
}
}
impl Theme {
#[must_use]
pub const fn dark() -> Self {
Self {
preset: ThemePreset::Dark,
bg: SerColor::new(0x1a, 0x1b, 0x26), fg: SerColor::new(0xc0, 0xca, 0xf5), status_bar_bg: SerColor::new(0x24, 0x28, 0x3b), status_bar_fg: SerColor::new(0x7a, 0xa2, 0xf7), highlight_bg: SerColor::new(0x33, 0x46, 0x7c), highlight_fg: SerColor::new(0xff, 0xff, 0xff), border: SerColor::new(0x3b, 0x40, 0x61), muted: SerColor::new(0x56, 0x5f, 0x89), error: SerColor::new(0xf7, 0x76, 0x8e), warning: SerColor::new(0xe0, 0xaf, 0x68), success: SerColor::new(0x9e, 0xce, 0x6a), info: SerColor::new(0x7d, 0xcf, 0xff), accent: SerColor::new(0x7d, 0xcf, 0xff), surface: SerColor::new(0x24, 0x28, 0x3b), surface_dim: SerColor::new(0x1a, 0x1b, 0x26), }
}
#[must_use]
pub const fn light() -> Self {
Self {
preset: ThemePreset::Light,
bg: SerColor::new(0xf5, 0xf5, 0xf5),
fg: SerColor::new(0x34, 0x35, 0x4e),
status_bar_bg: SerColor::new(0xe1, 0xe2, 0xe7),
status_bar_fg: SerColor::new(0x34, 0x54, 0x8a),
highlight_bg: SerColor::new(0xb6, 0xd4, 0xf0),
highlight_fg: SerColor::new(0x00, 0x00, 0x00),
border: SerColor::new(0xc8, 0xc8, 0xd0),
muted: SerColor::new(0x8c, 0x8c, 0xa0),
error: SerColor::new(0xc0, 0x3c, 0x3c),
warning: SerColor::new(0x96, 0x5f, 0x00),
success: SerColor::new(0x40, 0x7f, 0x00),
info: SerColor::new(0x00, 0x6f, 0xaf),
accent: SerColor::new(0x00, 0x6f, 0xaf), surface: SerColor::new(0xff, 0xff, 0xff), surface_dim: SerColor::new(0xf5, 0xf5, 0xf5), }
}
#[must_use]
pub const fn cyberpunk_aurora() -> Self {
Self {
preset: ThemePreset::CyberpunkAurora,
bg: SerColor::new(0x13, 0x05, 0x2a), fg: SerColor::new(0x00, 0xf0, 0xd0), status_bar_bg: SerColor::new(0x1e, 0x0a, 0x3e), status_bar_fg: SerColor::new(0xff, 0x6e, 0xb4), highlight_bg: SerColor::new(0x3a, 0x15, 0x6a), highlight_fg: SerColor::new(0xff, 0xff, 0xff), border: SerColor::new(0x2d, 0x10, 0x52), muted: SerColor::new(0x6a, 0x4c, 0x93), error: SerColor::new(0xff, 0x30, 0x60), warning: SerColor::new(0xff, 0xd7, 0x00), success: SerColor::new(0x39, 0xff, 0x14), info: SerColor::new(0x00, 0xf0, 0xd0), accent: SerColor::new(0xff, 0x6e, 0xb4), surface: SerColor::new(0x1e, 0x0a, 0x3e), surface_dim: SerColor::new(0x13, 0x05, 0x2a), }
}
#[must_use]
pub const fn darcula() -> Self {
Self {
preset: ThemePreset::Darcula,
bg: SerColor::new(0x2b, 0x2b, 0x2b), fg: SerColor::new(0xa9, 0xb7, 0xc6), status_bar_bg: SerColor::new(0x3c, 0x3f, 0x41), status_bar_fg: SerColor::new(0x68, 0x97, 0xbb), highlight_bg: SerColor::new(0x21, 0x4a, 0x83), highlight_fg: SerColor::new(0xff, 0xff, 0xff), border: SerColor::new(0x4b, 0x4b, 0x4b), muted: SerColor::new(0x78, 0x78, 0x78), error: SerColor::new(0xbc, 0x35, 0x51), warning: SerColor::new(0xbb, 0xb5, 0x29), success: SerColor::new(0x6a, 0x87, 0x59), info: SerColor::new(0x68, 0x97, 0xbb), accent: SerColor::new(0x68, 0x97, 0xbb), surface: SerColor::new(0x31, 0x31, 0x35), surface_dim: SerColor::new(0x2b, 0x2b, 0x2b), }
}
#[must_use]
pub const fn nordic_frost() -> Self {
Self {
preset: ThemePreset::NordicFrost,
bg: SerColor::new(0x2e, 0x34, 0x40), fg: SerColor::new(0xec, 0xef, 0xf4), status_bar_bg: SerColor::new(0x3b, 0x42, 0x52), status_bar_fg: SerColor::new(0x88, 0xc0, 0xd0), highlight_bg: SerColor::new(0x43, 0x4c, 0x5e), highlight_fg: SerColor::new(0xec, 0xef, 0xf4), border: SerColor::new(0x4c, 0x56, 0x6a), muted: SerColor::new(0x61, 0x6e, 0x88), error: SerColor::new(0xbf, 0x61, 0x6a), warning: SerColor::new(0xeb, 0xcb, 0x8b), success: SerColor::new(0xa3, 0xbe, 0x8c), info: SerColor::new(0x88, 0xc0, 0xd0), accent: SerColor::new(0x5e, 0x81, 0xac), surface: SerColor::new(0x3b, 0x42, 0x52), surface_dim: SerColor::new(0x2e, 0x34, 0x40), }
}
#[must_use]
pub const fn lumen_light() -> Self {
Self {
preset: ThemePreset::LumenLight,
bg: SerColor::new(0xfd, 0xf6, 0xe3), fg: SerColor::new(0x3b, 0x38, 0x30), status_bar_bg: SerColor::new(0xee, 0xe8, 0xd5), status_bar_fg: SerColor::new(0xcb, 0x76, 0x16), highlight_bg: SerColor::new(0xf5, 0xdc, 0xa0), highlight_fg: SerColor::new(0x2a, 0x27, 0x20), border: SerColor::new(0xd6, 0xd0, 0xc0), muted: SerColor::new(0x93, 0xa1, 0xa1), error: SerColor::new(0xdc, 0x32, 0x2f), warning: SerColor::new(0xcb, 0x76, 0x16), success: SerColor::new(0x85, 0x99, 0x00), info: SerColor::new(0x26, 0x8b, 0xd2), accent: SerColor::new(0xcb, 0x76, 0x16), surface: SerColor::new(0xff, 0xff, 0xf0), surface_dim: SerColor::new(0xfd, 0xf6, 0xe3), }
}
#[must_use]
pub const fn from_preset(preset: ThemePreset) -> Self {
match preset {
ThemePreset::Dark => Self::dark(),
ThemePreset::Light => Self::light(),
ThemePreset::CyberpunkAurora => Self::cyberpunk_aurora(),
ThemePreset::Darcula => Self::darcula(),
ThemePreset::NordicFrost => Self::nordic_frost(),
ThemePreset::LumenLight => Self::lumen_light(),
}
}
}
impl ColorScheme for Theme {
fn bg(&self) -> PackedRgba {
self.bg.to_color()
}
fn fg(&self) -> PackedRgba {
self.fg.to_color()
}
fn status_bar_bg(&self) -> PackedRgba {
self.status_bar_bg.to_color()
}
fn status_bar_fg(&self) -> PackedRgba {
self.status_bar_fg.to_color()
}
fn highlight_bg(&self) -> PackedRgba {
self.highlight_bg.to_color()
}
fn highlight_fg(&self) -> PackedRgba {
self.highlight_fg.to_color()
}
fn border(&self) -> PackedRgba {
self.border.to_color()
}
fn muted(&self) -> PackedRgba {
self.muted.to_color()
}
fn error(&self) -> PackedRgba {
self.error.to_color()
}
fn warning(&self) -> PackedRgba {
self.warning.to_color()
}
fn success(&self) -> PackedRgba {
self.success.to_color()
}
fn info(&self) -> PackedRgba {
self.info.to_color()
}
fn accent(&self) -> PackedRgba {
self.accent.to_color()
}
fn surface(&self) -> PackedRgba {
self.surface.to_color()
}
fn surface_dim(&self) -> PackedRgba {
self.surface_dim.to_color()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dark_theme_colors() {
let theme = Theme::dark();
assert_eq!(theme.preset, ThemePreset::Dark);
assert_ne!(theme.bg, theme.fg);
}
#[test]
fn light_theme_colors() {
let theme = Theme::light();
assert_eq!(theme.preset, ThemePreset::Light);
}
#[test]
fn theme_serde_roundtrip() {
let theme = Theme::dark();
let json = serde_json::to_string(&theme).unwrap();
let decoded: Theme = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, theme);
}
#[test]
fn preset_serde_roundtrip() {
for preset in ThemePreset::ALL {
let json = serde_json::to_string(&preset).unwrap();
let decoded: ThemePreset = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, preset);
}
}
#[test]
fn from_preset_matches_direct() {
assert_eq!(Theme::from_preset(ThemePreset::Dark), Theme::dark());
assert_eq!(Theme::from_preset(ThemePreset::Light), Theme::light());
assert_eq!(
Theme::from_preset(ThemePreset::CyberpunkAurora),
Theme::cyberpunk_aurora()
);
assert_eq!(Theme::from_preset(ThemePreset::Darcula), Theme::darcula());
assert_eq!(
Theme::from_preset(ThemePreset::NordicFrost),
Theme::nordic_frost()
);
assert_eq!(
Theme::from_preset(ThemePreset::LumenLight),
Theme::lumen_light()
);
}
#[test]
fn ser_color_to_color() {
let c = SerColor::new(0xff, 0x00, 0x80);
assert_eq!(c.to_color(), PackedRgba::rgb(0xff, 0x00, 0x80));
}
#[test]
fn color_scheme_trait() {
let theme = Theme::dark();
assert_ne!(theme.bg(), theme.error());
assert_ne!(theme.success(), theme.warning());
}
#[test]
fn preset_cycling_wraps() {
let mut preset = ThemePreset::Dark;
for _ in 0..ThemePreset::ALL.len() {
preset = preset.next();
}
assert_eq!(
preset,
ThemePreset::Dark,
"cycling should wrap back to start"
);
}
#[test]
fn preset_cycling_visits_all() {
let mut visited = std::collections::HashSet::new();
let mut preset = ThemePreset::Dark;
for _ in 0..ThemePreset::ALL.len() {
visited.insert(preset);
preset = preset.next();
}
assert_eq!(visited.len(), ThemePreset::ALL.len());
}
#[test]
fn accent_colors_distinct_per_theme() {
let accents: Vec<SerColor> = ThemePreset::ALL
.iter()
.map(|p| Theme::from_preset(*p).accent)
.collect();
let unique: std::collections::HashSet<_> =
accents.iter().map(|c| (c.r, c.g, c.b)).collect();
assert!(
unique.len() >= 4,
"expected at least 4 distinct accents, got {}",
unique.len()
);
}
#[test]
fn all_presets_have_all_const() {
assert_eq!(ThemePreset::ALL.len(), 6);
}
}