use std::sync::atomic::{AtomicUsize, Ordering};
use ratatui::style::Color;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Theme {
#[default]
Pico8,
Dmg,
}
impl Theme {
pub const ALL: [Theme; 2] = [Theme::Pico8, Theme::Dmg];
pub fn code(self) -> &'static str {
match self {
Theme::Pico8 => "pico8",
Theme::Dmg => "dmg",
}
}
pub fn from_code(code: &str) -> Option<Self> {
Theme::ALL.into_iter().find(|theme| theme.code() == code)
}
pub fn label(self) -> &'static str {
match self {
Theme::Pico8 => "PICO-8",
Theme::Dmg => "Game Boy",
}
}
pub fn palette(self) -> &'static Palette {
match self {
Theme::Pico8 => &PICO8,
Theme::Dmg => &DMG,
}
}
}
pub struct Palette {
pub base: Rgb,
pub surface: Rgb,
pub overlay: Rgb,
pub text: Rgb,
pub subtext: Rgb,
pub mauve: Rgb,
pub blue: Rgb,
pub sapphire: Rgb,
pub red: Rgb,
pub peach: Rgb,
pub yellow: Rgb,
pub green: Rgb,
pub teal: Rgb,
pub pink: Rgb,
pub lavender: Rgb,
pub ink: fn(Rgb) -> Rgb,
pub type_accent: fn(&str) -> Rgb,
}
pub type Rgb = (u8, u8, u8);
static PICO8: Palette = Palette {
base: (29, 43, 83),
surface: (41, 54, 111),
overlay: (131, 118, 156),
text: (255, 241, 232),
subtext: (194, 195, 199),
mauve: (255, 236, 39),
blue: (41, 173, 255),
sapphire: (41, 173, 255),
red: (255, 0, 77),
peach: (255, 163, 0),
yellow: (255, 236, 39),
green: (0, 228, 54),
teal: (43, 210, 200),
pink: (255, 119, 168),
lavender: (255, 204, 170),
ink: |rgb| rgb,
type_accent: pico8_type_accent,
};
fn pico8_type_accent(type_name: &str) -> Rgb {
match type_name {
"normal" => (194, 195, 199),
"fire" => PICO8.peach,
"water" => PICO8.blue,
"electric" => PICO8.yellow,
"grass" => PICO8.green,
"ice" => (130, 220, 255),
"fighting" => PICO8.red,
"poison" => (199, 87, 197),
"ground" => (171, 82, 54),
"flying" => (160, 200, 255),
"psychic" => PICO8.pink,
"bug" => (140, 200, 60),
"rock" => (171, 140, 100),
"ghost" => (131, 118, 156),
"dragon" => (120, 130, 240),
"dark" => (130, 120, 140),
"steel" => (160, 178, 196),
"fairy" => PICO8.pink,
_ => PICO8.subtext,
}
}
const DMG_SHADES: [Rgb; 4] = [(15, 56, 15), (48, 98, 48), (139, 172, 15), (155, 188, 15)];
static DMG: Palette = Palette {
base: DMG_SHADES[0],
surface: (34, 74, 26),
overlay: (90, 130, 40),
text: DMG_SHADES[3],
subtext: DMG_SHADES[2],
mauve: DMG_SHADES[3],
blue: DMG_SHADES[2],
sapphire: DMG_SHADES[2],
red: (70, 110, 35),
peach: (110, 150, 30),
yellow: (130, 165, 22),
green: DMG_SHADES[2],
teal: DMG_SHADES[3],
pink: DMG_SHADES[2],
lavender: DMG_SHADES[3],
ink: dmg_quantise,
type_accent: |_| DMG_SHADES[2],
};
fn dmg_quantise((r, g, b): Rgb) -> Rgb {
let luma = (299 * u32::from(r) + 587 * u32::from(g) + 114 * u32::from(b)) / 1000;
DMG_SHADES[(luma as usize * DMG_SHADES.len() / 256).min(DMG_SHADES.len() - 1)]
}
static CURRENT: AtomicUsize = AtomicUsize::new(0);
pub fn use_theme(theme: Theme) {
let index = Theme::ALL.iter().position(|&t| t == theme).unwrap_or(0);
CURRENT.store(index, Ordering::Relaxed);
}
pub fn current() -> Theme {
Theme::ALL[CURRENT.load(Ordering::Relaxed).min(Theme::ALL.len() - 1)]
}
fn palette() -> &'static Palette {
current().palette()
}
fn color(rgb: Rgb) -> Color {
Color::Rgb(rgb.0, rgb.1, rgb.2)
}
pub fn base_rgb() -> Rgb {
palette().base
}
pub fn base() -> Color {
color(palette().base)
}
pub fn surface() -> Color {
color(palette().surface)
}
pub fn overlay() -> Color {
color(palette().overlay)
}
pub fn text() -> Color {
color(palette().text)
}
pub fn subtext() -> Color {
color(palette().subtext)
}
pub fn mauve() -> Color {
color(palette().mauve)
}
pub fn sapphire() -> Color {
color(palette().sapphire)
}
pub fn red() -> Color {
color(palette().red)
}
pub fn peach() -> Color {
color(palette().peach)
}
pub fn yellow() -> Color {
color(palette().yellow)
}
pub fn green() -> Color {
color(palette().green)
}
pub fn teal() -> Color {
color(palette().teal)
}
pub fn pink() -> Color {
color(palette().pink)
}
pub fn lavender() -> Color {
color(palette().lavender)
}
pub fn ink(rgb: Rgb) -> Color {
color((palette().ink)(rgb))
}
pub fn stat_color(base: u16) -> Color {
let p = palette();
color(match base {
0..=49 => p.red,
50..=89 => p.peach,
90..=119 => p.yellow,
120..=149 => p.green,
_ => p.teal,
})
}
pub fn type_color(type_name: &str) -> Color {
color((palette().type_accent)(type_name))
}
#[cfg(test)]
mod tests {
use super::*;
fn luma((r, g, b): Rgb) -> u32 {
299 * u32::from(r) + 587 * u32::from(g) + 114 * u32::from(b)
}
#[test]
fn every_theme_reads_back_out_of_its_code() {
for theme in Theme::ALL {
assert_eq!(Theme::from_code(theme.code()), Some(theme));
}
assert_eq!(Theme::from_code("gameboy"), None);
assert_eq!(Theme::from_code(""), None);
}
#[test]
fn the_default_palette_is_the_one_the_interface_was_designed_in() {
assert_eq!(Theme::default(), Theme::Pico8);
assert_eq!(current(), Theme::Pico8, "and nothing has switched it");
assert_eq!(base(), Color::Rgb(29, 43, 83));
}
fn ramp(theme: Theme) -> [Rgb; 5] {
let p = theme.palette();
[p.red, p.peach, p.yellow, p.green, p.teal]
}
#[test]
fn no_rung_of_a_stat_bar_disappears_into_the_panel() {
for theme in Theme::ALL {
for rung in ramp(theme) {
assert!(
luma(rung) > luma(theme.palette().base) + 20_000,
"{} loses {rung:?} against its background",
theme.label()
);
}
}
}
#[test]
fn the_game_boys_stat_bar_carries_the_scale_in_brightness() {
for pair in ramp(Theme::Dmg).windows(2) {
assert!(
luma(pair[0]) < luma(pair[1]),
"the ramp goes backwards at {pair:?}"
);
}
}
#[test]
fn every_palette_keeps_its_text_off_its_background() {
for theme in Theme::ALL {
let p = theme.palette();
for (name, slot) in [("text", p.text), ("subtext", p.subtext), ("mauve", p.mauve)] {
assert!(
luma(slot) > luma(p.base) + 40_000,
"{} draws {name} too close to its background",
theme.label()
);
}
assert!(luma(p.base) < luma(p.overlay) && luma(p.overlay) < luma(p.text));
}
}
#[test]
fn a_type_chip_is_legible_in_every_palette() {
for theme in Theme::ALL {
let p = theme.palette();
for slug in [
"normal", "fire", "water", "electric", "grass", "ice", "fighting", "poison",
"ground", "flying", "psychic", "bug", "rock", "ghost", "dragon", "dark", "steel",
"fairy", "stellar",
] {
let chip = (p.type_accent)(slug);
assert!(
luma(chip) > luma(p.base) + 40_000,
"{} draws a {slug} chip you cannot read",
theme.label()
);
}
}
}
#[test]
fn the_game_boy_has_only_its_four_shades_to_draw_a_sprite_with() {
let quantised: Vec<Rgb> = [
(0, 0, 0),
(255, 255, 255),
(255, 0, 77),
(41, 173, 255),
(130, 130, 130),
]
.into_iter()
.map(dmg_quantise)
.collect();
for shade in &quantised {
assert!(
DMG_SHADES.contains(shade),
"{shade:?} is not a Game Boy green"
);
}
assert_eq!(quantised[0], DMG_SHADES[0]);
assert_eq!(quantised[1], DMG_SHADES[3]);
assert_eq!((PICO8.ink)((255, 0, 77)), (255, 0, 77));
}
}