#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Rgb(
pub u8,
pub u8,
pub u8,
);
impl Rgb {
#[must_use]
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self(r, g, b)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Theme {
pub background: Rgb,
pub surface: Rgb,
pub overlay_bg: Rgb,
pub title_bg: Rgb,
pub status_bg: Rgb,
pub gauge_bg: Rgb,
pub text: Rgb,
pub muted: Rgb,
pub dim: Rgb,
pub border: Rgb,
pub accent: Rgb,
pub accent_soft: Rgb,
pub title_accent: Rgb,
pub pink: Rgb,
pub success: Rgb,
pub warning: Rgb,
pub error: Rgb,
pub info: Rgb,
pub gauge_fg: Rgb,
pub directory: Rgb,
pub executable: Rgb,
pub symlink: Rgb,
pub device: Rgb,
pub device_warn: Rgb,
pub hidden: Rgb,
pub selection_bg: Rgb,
pub selection_fg: Rgb,
pub cursor_bg: Rgb,
pub cursor_fg: Rgb,
pub marked_bg: Rgb,
pub marked_fg: Rgb,
pub buttonbar_bg: Rgb,
pub buttonbar_fg: Rgb,
}
impl Theme {
#[must_use]
pub const fn dark() -> Self {
REDBEAR_DARK
}
#[must_use]
pub const fn light() -> Self {
REDBEAR_LIGHT
}
#[must_use]
pub fn from_env() -> Self {
if let Ok(v) = std::env::var("REDBEAR_TUI_THEME") {
return match v.to_ascii_lowercase().as_str() {
"light" | "day" => Self::light(),
"dark" | "night" | "" => Self::dark(),
_ => Self::dark(),
};
}
if let Ok(cfbg) = std::env::var("COLORFGBG") {
if let Some(bg) = cfbg.split(';').next_back() {
if let Ok(bg_idx) = bg.parse::<u8>() {
if bg_idx >= 8 {
return Self::light();
}
}
}
}
Self::dark()
}
}
pub const REDBEAR_DARK: Theme = Theme {
background: Rgb(0x0E, 0x0E, 0x12),
surface: Rgb(0x18, 0x18, 0x1C),
overlay_bg: Rgb(0x12, 0x12, 0x18),
title_bg: Rgb(0x14, 0x14, 0x1A),
status_bg: Rgb(0x14, 0x14, 0x1A),
gauge_bg: Rgb(0x24, 0x24, 0x2A),
gauge_fg: Rgb(0xB5, 0x24, 0x30),
text: Rgb(0xF4, 0xF4, 0xF4),
muted: Rgb(0xAA, 0xAA, 0xB0),
dim: Rgb(0x5C, 0x5C, 0x64),
border: Rgb(0x7A, 0x7A, 0x82),
accent: Rgb(0xB5, 0x24, 0x30),
accent_soft: Rgb(0x7A, 0x26, 0x2E),
title_accent: Rgb(0xC8, 0x32, 0x3C),
pink: Rgb(0xDC, 0x8C, 0xA0),
success: Rgb(0x74, 0xC7, 0x93),
warning: Rgb(0xFF, 0xBF, 0x57),
error: Rgb(0xEF, 0x53, 0x50),
info: Rgb(0x78, 0xB4, 0xDC),
directory: Rgb(0x78, 0xB4, 0xDC),
executable: Rgb(0x74, 0xC7, 0x93),
symlink: Rgb(0xC8, 0xAA, 0x78),
device: Rgb(0xC8, 0xAA, 0x78),
device_warn: Rgb(0xE8, 0x9A, 0x5A),
hidden: Rgb(0x5C, 0x5C, 0x64),
selection_bg: Rgb(0x36, 0x52, 0xA0),
selection_fg: Rgb(0xF4, 0xF4, 0xF4),
cursor_bg: Rgb(0xFF, 0xDC, 0x5A),
cursor_fg: Rgb(0x0E, 0x0E, 0x12),
marked_bg: Rgb(0x78, 0xD2, 0xD2),
marked_fg: Rgb(0x0E, 0x0E, 0x12),
buttonbar_bg: Rgb(0x14, 0x14, 0x1A),
buttonbar_fg: Rgb(0xF4, 0xF4, 0xF4),
};
pub const REDBEAR_LIGHT: Theme = Theme {
background: Rgb(0xF8, 0xF7, 0xF5),
surface: Rgb(0xF0, 0xEF, 0xEC),
overlay_bg: Rgb(0xFF, 0xFF, 0xFD),
title_bg: Rgb(0xEB, 0xEA, 0xE6),
status_bg: Rgb(0xEB, 0xEA, 0xE6),
gauge_bg: Rgb(0xDC, 0xDB, 0xD7),
gauge_fg: Rgb(0xB5, 0x24, 0x30),
text: Rgb(0x1C, 0x1C, 0x20),
muted: Rgb(0x60, 0x60, 0x68),
dim: Rgb(0x9C, 0x9C, 0xA0),
border: Rgb(0xA8, 0xA6, 0xA0),
accent: Rgb(0xB5, 0x24, 0x30),
accent_soft: Rgb(0xD6, 0x7B, 0x85),
title_accent: Rgb(0x9E, 0x1F, 0x2A),
pink: Rgb(0xB0, 0x34, 0x60),
success: Rgb(0x1C, 0x6C, 0x34),
warning: Rgb(0xA0, 0x64, 0x12),
error: Rgb(0xB0, 0x20, 0x28),
info: Rgb(0x24, 0x60, 0xA0),
directory: Rgb(0x24, 0x60, 0xA0),
executable: Rgb(0x1C, 0x6C, 0x34),
symlink: Rgb(0x88, 0x66, 0x20),
device: Rgb(0x88, 0x66, 0x20),
device_warn: Rgb(0xB0, 0x50, 0x10),
hidden: Rgb(0x9C, 0x9C, 0xA0),
selection_bg: Rgb(0x3D, 0x62, 0xB4),
selection_fg: Rgb(0xF4, 0xF4, 0xF4),
cursor_bg: Rgb(0xF0, 0xC6, 0x40),
cursor_fg: Rgb(0x1C, 0x1C, 0x20),
marked_bg: Rgb(0x3F, 0xA0, 0xA0),
marked_fg: Rgb(0xF4, 0xF4, 0xF4),
buttonbar_bg: Rgb(0xEB, 0xEA, 0xE6),
buttonbar_fg: Rgb(0x1C, 0x1C, 0x20),
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dark_text_on_background_passes_aa_body() {
let ratio = contrast_ratio(REDBEAR_DARK.text, REDBEAR_DARK.background);
assert!(ratio >= 4.5, "text/background must be AA-body; got {ratio:.2}");
}
#[test]
fn dark_accent_with_text_passes_aa_body() {
let ratio = contrast_ratio(REDBEAR_DARK.text, REDBEAR_DARK.accent);
assert!(ratio >= 4.5, "selection text/accent must be AA-body; got {ratio:.2}");
}
#[test]
fn light_text_on_background_passes_aa_body() {
let ratio = contrast_ratio(REDBEAR_LIGHT.text, REDBEAR_LIGHT.background);
assert!(ratio >= 4.5, "light text/background must be AA-body; got {ratio:.2}");
}
#[test]
fn dim_intentionally_below_aa_body() {
let ratio = contrast_ratio(REDBEAR_DARK.dim, REDBEAR_DARK.background);
assert!(ratio < 4.5, "dim must be intentionally below AA-body; got {ratio:.2}");
}
#[test]
fn brand_red_identical_in_both_themes() {
assert_eq!(REDBEAR_DARK.accent, REDBEAR_LIGHT.accent);
}
#[test]
fn from_env_dark_default() {
let t = Theme::from_env();
let _ = t;
}
fn contrast_ratio(fg: Rgb, bg: Rgb) -> f32 {
let l1 = relative_luminance(fg);
let l2 = relative_luminance(bg);
let (hi, lo) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
(hi + 0.05) / (lo + 0.05)
}
fn relative_luminance(c: Rgb) -> f32 {
fn lin(byte: u8) -> f32 {
let c = f32::from(byte) / 255.0;
if c <= 0.03928 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
0.2126 * lin(c.0) + 0.7152 * lin(c.1) + 0.0722 * lin(c.2)
}
}