use ratatui::style::{Color, Modifier, Style};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Ink {
pub rgb: (u8, u8, u8),
pub xterm: u8,
pub ansi: Color,
}
const fn ink(r: u8, g: u8, b: u8, xterm: u8, ansi: Color) -> Ink {
Ink { rgb: (r, g, b), xterm, ansi }
}
const VOID: Ink = ink(0x08, 0x06, 0x0F, 232, Color::Black);
const PANEL: Ink = ink(0x0F, 0x0B, 0x1A, 233, Color::Black);
const SELECT: Ink = ink(0x1E, 0x14, 0x38, 236, Color::Black);
const RULE: Ink = ink(0x2A, 0x1F, 0x45, 238, Color::DarkGray);
const RULE_HOT: Ink = ink(0x6D, 0x40, 0xC4, 98, Color::Magenta);
const TEXT: Ink = ink(0xE6, 0xE0, 0xF5, 189, Color::White);
const MUTED: Ink = ink(0x8A, 0x81, 0xA8, 103, Color::Gray);
const GHOST: Ink = ink(0x54, 0x4C, 0x6C, 60, Color::DarkGray);
const VIOLET: Ink = ink(0xA9, 0x6B, 0xFF, 141, Color::Magenta);
const VIOLET_LO: Ink = ink(0x7C, 0x4D, 0xD8, 98, Color::Magenta);
const VIOLET_HI: Ink = ink(0xCB, 0xA6, 0xFF, 183, Color::LightMagenta);
const AZURE: Ink = ink(0x7D, 0xD3, 0xFC, 117, Color::LightCyan);
const AZURE_LO: Ink = ink(0x4B, 0xA3, 0xD8, 74, Color::Cyan);
const AMBER: Ink = ink(0xF2, 0xB1, 0x5C, 179, Color::Yellow);
const ROSE: Ink = ink(0xFF, 0x7B, 0x9C, 204, Color::LightRed);
const MINT: Ink = ink(0x86, 0xE5, 0xC0, 115, Color::Green);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
Ground,
Panel,
Selection,
Chrome,
ChromeFocus,
Body,
Label,
Hint,
Measured,
Unmeasured,
Heading,
HeadingActive,
Chosen,
Runner,
Excluded,
Abstained,
Risk,
Opportunity,
Estimated,
Live,
Stale,
Absent,
Simulated,
Valid,
Invalid,
Working,
}
impl Role {
pub const ALL: [Role; 26] = [
Role::Ground, Role::Panel, Role::Selection, Role::Chrome, Role::ChromeFocus,
Role::Body, Role::Label, Role::Hint, Role::Measured, Role::Unmeasured,
Role::Heading, Role::HeadingActive, Role::Chosen, Role::Runner, Role::Excluded,
Role::Abstained, Role::Risk, Role::Opportunity, Role::Estimated, Role::Live,
Role::Stale, Role::Absent, Role::Simulated, Role::Valid, Role::Invalid,
Role::Working,
];
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Depth {
True,
Xterm,
Ansi,
Mono,
}
impl Depth {
pub fn detect(forced_mono: bool) -> Depth {
if forced_mono || std::env::var_os("NO_COLOR").is_some() {
return Depth::Mono;
}
if matches!(
std::env::var("COLORTERM").ok().as_deref(),
Some("truecolor") | Some("24bit")
) {
return Depth::True;
}
let term = std::env::var("TERM").unwrap_or_default();
if term == "dumb" {
return Depth::Mono;
}
if term.contains("256") {
return Depth::Xterm;
}
if matches!(term.as_str(), "linux" | "ansi" | "vt100" | "vt220" | "cons25")
|| term.contains("16color")
{
return Depth::Ansi;
}
Depth::True
}
}
#[derive(Clone, Copy, Debug)]
pub struct Theme {
pub depth: Depth,
}
impl Theme {
pub fn new(depth: Depth) -> Self {
Theme { depth }
}
fn colour(&self, i: Ink) -> Option<Color> {
match self.depth {
Depth::True => Some(Color::Rgb(i.rgb.0, i.rgb.1, i.rgb.2)),
Depth::Xterm => Some(Color::Indexed(i.xterm)),
Depth::Ansi => Some(i.ansi),
Depth::Mono => None,
}
}
pub fn style(&self, role: Role) -> Style {
let (ink, modifier) = match role {
Role::Ground => (VOID, Modifier::empty()),
Role::Panel => (PANEL, Modifier::empty()),
Role::Selection => (SELECT, Modifier::empty()),
Role::Chrome => (RULE, Modifier::DIM),
Role::ChromeFocus => (RULE_HOT, Modifier::BOLD),
Role::Body => (TEXT, Modifier::empty()),
Role::Label => (MUTED, Modifier::empty()),
Role::Hint => (MUTED, Modifier::DIM),
Role::Measured => (VIOLET, Modifier::empty()),
Role::Unmeasured => (GHOST, Modifier::DIM),
Role::Heading => (VIOLET_LO, Modifier::BOLD),
Role::HeadingActive => (VIOLET_HI, Modifier::BOLD),
Role::Chosen => (AZURE, Modifier::BOLD),
Role::Runner => (TEXT, Modifier::empty()),
Role::Excluded => (GHOST, Modifier::DIM),
Role::Abstained => (AMBER, Modifier::BOLD),
Role::Risk => (ROSE, Modifier::empty()),
Role::Opportunity => (MINT, Modifier::empty()),
Role::Estimated => (AMBER, Modifier::DIM),
Role::Live => (MINT, Modifier::empty()),
Role::Stale => (AMBER, Modifier::empty()),
Role::Absent => (GHOST, Modifier::DIM),
Role::Simulated => (AZURE_LO, Modifier::ITALIC),
Role::Valid => (AZURE, Modifier::BOLD),
Role::Invalid => (ROSE, Modifier::BOLD | Modifier::REVERSED),
Role::Working => (VIOLET_HI, Modifier::BOLD),
};
match self.colour(ink) {
Some(c) => Style::default().fg(c).add_modifier(modifier),
None => Style::default().add_modifier(modifier),
}
}
pub fn bg(&self, role: Role) -> Style {
let ink = match role {
Role::Ground => VOID,
Role::Selection => SELECT,
_ => PANEL,
};
match self.colour(ink) {
Some(c) => Style::default().bg(c),
None => Style::default(),
}
}
pub fn on(&self, fg: Role, bg: Role) -> Style {
let f = self.style(fg);
match self.bg(bg).bg {
Some(c) => f.bg(c),
None => f,
}
}
}
impl Default for Theme {
fn default() -> Self {
Theme::new(Depth::detect(false))
}
}
#[cfg(test)]
mod tests {
use super::*;
const BASELINE: [Role; 6] =
[Role::Body, Role::Runner, Role::Ground, Role::Panel, Role::Selection, Role::Label];
const TEXT_CARRIED: [Role; 5] =
[Role::Measured, Role::Risk, Role::Opportunity, Role::Live, Role::Stale];
#[test]
fn every_role_survives_a_monochrome_terminal() {
let t = Theme::new(Depth::Mono);
for r in Role::ALL {
let s = t.style(r);
assert!(s.fg.is_none(), "{r:?} must not emit a colour in Mono");
if BASELINE.contains(&r) || TEXT_CARRIED.contains(&r) {
continue;
}
assert!(
s.add_modifier != Modifier::empty(),
"{r:?} has no colour and no modifier in Mono — it would vanish"
);
}
}
#[test]
fn unmeasured_is_dim_at_every_depth_not_merely_a_different_hue() {
for d in [Depth::True, Depth::Xterm, Depth::Ansi, Depth::Mono] {
let s = Theme::new(d).style(Role::Unmeasured);
assert!(s.add_modifier.contains(Modifier::DIM), "unmeasured at {d:?}");
}
}
#[test]
fn the_azure_accent_is_reserved_for_claims() {
let t = Theme::new(Depth::True);
let azure = t.style(Role::Chosen).fg;
assert_eq!(t.style(Role::Valid).fg, azure);
assert_ne!(t.style(Role::Opportunity).fg, azure);
assert_ne!(t.style(Role::Live).fg, azure);
assert_ne!(t.style(Role::Measured).fg, azure);
}
#[test]
fn the_violets_stay_distinct_on_a_256_colour_terminal() {
let t = Theme::new(Depth::Xterm);
let mut seen: Vec<_> = [Role::Measured, Role::Heading, Role::HeadingActive, Role::Unmeasured]
.iter()
.map(|r| format!("{:?}", t.style(*r).fg))
.collect();
seen.sort();
seen.dedup();
assert_eq!(seen.len(), 4, "the violets must not collapse at 256 colours");
}
#[test]
fn no_color_beats_every_other_signal() {
assert_eq!(Depth::detect(true), Depth::Mono);
}
}