use std::ffi::{OsStr, OsString};
use ratatui::style::{Color, Modifier, Style};
use tracing::Level;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ColorLevel {
Mono,
Named,
Indexed,
TrueColor,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct LookEnv {
pub(super) no_color: Option<OsString>,
pub(super) color: Option<OsString>,
pub(super) colorterm: Option<OsString>,
pub(super) term: Option<OsString>,
pub(super) motion: Option<OsString>,
pub(super) ssh: bool,
}
impl LookEnv {
pub(super) fn from_process() -> Self {
Self {
no_color: std::env::var_os("NO_COLOR"),
color: std::env::var_os("OVERBRAINER_TUI_COLOR"),
colorterm: std::env::var_os("COLORTERM"),
term: std::env::var_os("TERM"),
motion: std::env::var_os("OVERBRAINER_TUI_MOTION"),
ssh: over_ssh(
std::env::var_os("SSH_CONNECTION").as_deref(),
std::env::var_os("SSH_TTY").as_deref(),
),
}
}
pub(super) fn warnings(&self) -> Vec<String> {
let mut warnings = Vec::new();
if let Some(value) = filled(self.color.as_deref())
&& ColorLevel::forced(value).is_none()
{
warnings.push(format!(
"OVERBRAINER_TUI_COLOR={} is not truecolor, 256 or 16: ignored",
value.display()
));
}
if let Some(value) = filled(self.motion.as_deref())
&& super::motion::MotionLevel::chosen(value).is_none()
{
warnings.push(format!(
"OVERBRAINER_TUI_MOTION={} is not on, reduced or off: ignored",
value.display()
));
}
warnings
}
}
fn over_ssh(connection: Option<&OsStr>, tty: Option<&OsStr>) -> bool {
filled(connection).is_some() || filled(tty).is_some()
}
fn filled(value: Option<&OsStr>) -> Option<&OsStr> {
value.filter(|value| !value.is_empty())
}
impl ColorLevel {
pub(super) fn detect(env: &LookEnv) -> Self {
if filled(env.no_color.as_deref()).is_some() {
return Self::Mono;
}
if let Some(level) = filled(env.color.as_deref()).and_then(Self::forced) {
return level;
}
let colorterm = env.colorterm.as_deref().and_then(OsStr::to_str);
if matches!(colorterm, Some("truecolor" | "24bit")) {
return Self::TrueColor;
}
let term = env.term.as_deref().and_then(OsStr::to_str);
if term.is_some_and(|term| term.contains("256color")) {
return Self::Indexed;
}
Self::Named
}
fn forced(value: &OsStr) -> Option<Self> {
match value.to_str()? {
"truecolor" => Some(Self::TrueColor),
"256" => Some(Self::Indexed),
"16" => Some(Self::Named),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy)]
struct Tone {
rgb: u32,
index: u8,
named: Color,
}
impl Tone {
fn at(self, level: ColorLevel) -> Option<Color> {
match level {
ColorLevel::Mono => None,
ColorLevel::Named => Some(self.named),
ColorLevel::Indexed => Some(Color::Indexed(self.index)),
ColorLevel::TrueColor => Some(Color::from_u32(self.rgb)),
}
}
fn painted(self, level: ColorLevel) -> Option<Color> {
match level {
ColorLevel::Mono | ColorLevel::Named => None,
_ => self.at(level),
}
}
fn fg(self, level: ColorLevel) -> Style {
let style = Style::new();
match self.at(level) {
Some(color) => style.fg(color),
None => style,
}
}
}
const fn tone(rgb: u32, index: u8, named: Color) -> Tone {
Tone { rgb, index, named }
}
const BG: Tone = tone(0x0019_1114, 233, Color::Reset);
const SURFACE: Tone = tone(0x0020_1318, 234, Color::Reset);
const SELECTION: Tone = tone(0x004A_1A30, 236, Color::Magenta);
const BORDER: Tone = tone(0x006D_2545, 53, Color::DarkGray);
const BORDER_FOCUS: Tone = tone(0x00B0_436E, 131, Color::Magenta);
const ACCENT: Tone = tone(0x00E9_3D82, 168, Color::Magenta);
const ACCENT_HI: Tone = tone(0x00FF_92AD, 211, Color::LightMagenta);
const ACCENT_DIM: Tone = tone(0x00B0_436E, 131, Color::Magenta);
const TEXT: Tone = tone(0x00E8_D8DF, 254, Color::Reset);
const TEXT_HI: Tone = tone(0x00FD_D3E8, 224, Color::White);
const TEXT_MUTED: Tone = tone(0x00A0_808E, 138, Color::DarkGray);
const OK: Tone = tone(0x00A6_E3A1, 151, Color::Green);
const WARN: Tone = tone(0x00F9_E2AF, 223, Color::Yellow);
const ERROR: Tone = tone(0x00FF_7A5C, 209, Color::LightRed);
const INFO: Tone = tone(0x0089_DCEB, 116, Color::Cyan);
const LR: Tone = tone(0x00B4_BEFE, 147, Color::LightBlue);
const GRAD_NORM: Tone = tone(0x0094_E2D5, 115, Color::Cyan);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct Theme {
pub(super) level: ColorLevel,
pub(super) base: Style,
pub(super) surface: Style,
pub(super) border: Style,
pub(super) border_focus: Style,
pub(super) accent: Style,
pub(super) accent_dim: Style,
pub(super) title: Style,
pub(super) tab: Style,
pub(super) selected: Style,
pub(super) text_hi: Style,
pub(super) dim: Style,
pub(super) ok: Style,
pub(super) warn: Style,
pub(super) error: Style,
pub(super) info: Style,
pub(super) key: Style,
pub(super) locked: Style,
pub(super) gauge: Style,
pub(super) loss: Style,
pub(super) eval_loss: Style,
pub(super) lr: Style,
pub(super) grad_norm: Style,
}
impl Theme {
pub(super) fn new(level: ColorLevel) -> Self {
if level == ColorLevel::Mono {
return Self::mono();
}
let painted = |tone: Tone| {
let style = TEXT.fg(level);
match tone.painted(level) {
Some(color) => style.bg(color),
None => style,
}
};
let bold = Modifier::BOLD;
let selected = if level == ColorLevel::Named {
Style::new().fg(Color::Black).bg(Color::Magenta)
} else {
let style = TEXT_HI.fg(level);
match SELECTION.at(level) {
Some(color) => style.bg(color),
None => style,
}
};
Self {
level,
base: painted(BG),
surface: painted(SURFACE),
border: BORDER.fg(level),
border_focus: BORDER_FOCUS.fg(level),
accent: ACCENT.fg(level),
accent_dim: ACCENT_DIM.fg(level),
title: ACCENT_HI.fg(level).add_modifier(bold),
tab: ACCENT_HI
.fg(level)
.add_modifier(bold | Modifier::UNDERLINED),
selected: selected.add_modifier(bold),
text_hi: TEXT_HI.fg(level),
dim: TEXT_MUTED.fg(level),
ok: OK.fg(level),
warn: WARN.fg(level),
error: ERROR.fg(level).add_modifier(bold),
info: INFO.fg(level),
key: ACCENT_HI.fg(level).add_modifier(bold),
locked: TEXT_MUTED.fg(level).add_modifier(Modifier::CROSSED_OUT),
gauge: ACCENT.fg(level),
loss: ACCENT_HI.fg(level),
eval_loss: WARN.fg(level),
lr: LR.fg(level),
grad_norm: GRAD_NORM.fg(level),
}
}
pub(super) fn mono() -> Self {
let plain = Style::new();
let bold = plain.add_modifier(Modifier::BOLD);
Self {
level: ColorLevel::Mono,
base: plain,
surface: plain,
border: plain,
border_focus: bold,
accent: plain,
accent_dim: plain,
title: bold,
tab: plain.add_modifier(Modifier::REVERSED),
selected: plain.add_modifier(Modifier::REVERSED),
text_hi: plain,
dim: plain.add_modifier(Modifier::DIM),
ok: plain,
warn: bold,
error: plain.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
info: plain,
key: bold,
locked: plain.add_modifier(Modifier::DIM | Modifier::CROSSED_OUT),
gauge: bold,
loss: plain,
eval_loss: bold,
lr: plain,
grad_norm: plain,
}
}
pub(super) fn level(&self, level: Level) -> Style {
match level {
Level::ERROR => self.error,
Level::WARN => self.warn,
Level::INFO => self.info,
_ => self.dim,
}
}
}
#[cfg(test)]
mod tests {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use super::*;
fn env(pairs: &[(&str, &str)]) -> LookEnv {
let mut env = LookEnv::default();
for (name, value) in pairs {
let value = Some(OsString::from(value));
match *name {
"NO_COLOR" => env.no_color = value,
"OVERBRAINER_TUI_COLOR" => env.color = value,
"COLORTERM" => env.colorterm = value,
"TERM" => env.term = value,
_ => {},
}
}
env
}
#[test]
fn an_empty_ssh_variable_is_unset() {
let set = Some(OsStr::new("10.0.0.2 51234 10.0.0.1 22"));
let empty = Some(OsStr::new(""));
assert!(over_ssh(set, None));
assert!(over_ssh(None, Some(OsStr::new("/dev/pts/3"))));
assert!(!over_ssh(None, None));
assert!(!over_ssh(empty, empty), "empty is unset");
assert!(over_ssh(empty, Some(OsStr::new("/dev/pts/3"))));
}
#[test]
fn no_color_wins_then_the_override_then_the_terminal() {
let detect = |pairs: &[(&str, &str)]| ColorLevel::detect(&env(pairs));
assert_eq!(detect(&[]), ColorLevel::Named);
assert_eq!(detect(&[("NO_COLOR", "1")]), ColorLevel::Mono);
assert_eq!(detect(&[("NO_COLOR", "0")]), ColorLevel::Mono);
assert_eq!(detect(&[("NO_COLOR", "")]), ColorLevel::Named);
assert_eq!(
detect(&[("NO_COLOR", "1"), ("OVERBRAINER_TUI_COLOR", "truecolor")]),
ColorLevel::Mono
);
assert_eq!(detect(&[("COLORTERM", "truecolor")]), ColorLevel::TrueColor);
assert_eq!(detect(&[("COLORTERM", "24bit")]), ColorLevel::TrueColor);
assert_eq!(detect(&[("TERM", "tmux-256color")]), ColorLevel::Indexed);
assert_eq!(detect(&[("TERM", "xterm")]), ColorLevel::Named);
let forced = |value| {
detect(&[
("COLORTERM", "truecolor"),
("TERM", "xterm-256color"),
("OVERBRAINER_TUI_COLOR", value),
])
};
assert_eq!(forced("16"), ColorLevel::Named);
assert_eq!(forced("256"), ColorLevel::Indexed);
assert_eq!(forced("truecolor"), ColorLevel::TrueColor);
assert_eq!(forced("purple"), ColorLevel::TrueColor, "unknown: ignored");
}
#[test]
fn an_unknown_override_is_warned_about() {
assert_eq!(
env(&[("OVERBRAINER_TUI_COLOR", "256")]).warnings(),
Vec::<String>::new()
);
assert_eq!(
env(&[("OVERBRAINER_TUI_COLOR", "")]).warnings(),
Vec::<String>::new()
);
assert_eq!(
env(&[("OVERBRAINER_TUI_COLOR", "purple")]).warnings(),
["OVERBRAINER_TUI_COLOR=purple is not truecolor, 256 or 16: ignored"]
);
let motion = LookEnv {
motion: Some(OsString::from("fast")),
..LookEnv::default()
};
assert_eq!(
motion.warnings(),
["OVERBRAINER_TUI_MOTION=fast is not on, reduced or off: ignored"]
);
}
#[test]
fn each_level_has_its_own_colors() {
let accent = |level| Theme::new(level).accent.fg;
assert_eq!(
accent(ColorLevel::TrueColor),
Some(Color::Rgb(0xE9, 0x3D, 0x82))
);
assert_eq!(accent(ColorLevel::Indexed), Some(Color::Indexed(168)));
assert_eq!(accent(ColorLevel::Named), Some(Color::Magenta));
assert_eq!(accent(ColorLevel::Mono), None);
assert_eq!(Theme::new(ColorLevel::Mono), Theme::mono());
}
#[test]
fn the_background_is_painted_on_truecolor_and_256_only() {
let bg = |level| Theme::new(level).base.bg;
assert_eq!(
bg(ColorLevel::TrueColor),
Some(Color::Rgb(0x19, 0x11, 0x14))
);
assert_eq!(bg(ColorLevel::Indexed), Some(Color::Indexed(233)));
assert_eq!(bg(ColorLevel::Named), None);
assert_eq!(bg(ColorLevel::Mono), None);
let surface = Theme::new(ColorLevel::TrueColor).surface.bg;
assert_eq!(surface, Some(Color::Rgb(0x20, 0x13, 0x18)));
}
#[test]
fn a_buffer_drawn_with_the_theme_carries_its_styles() {
let theme = Theme::new(ColorLevel::TrueColor);
let area = Rect::new(0, 0, 12, 1);
let mut buffer = Buffer::empty(area);
Line::from(vec![
Span::styled("✗ no", theme.error),
Span::raw(" "),
Span::styled("e edit", theme.locked),
])
.render(area, &mut buffer);
let error = &buffer[(0, 0)];
assert_eq!(error.fg, Color::Rgb(0xFF, 0x7A, 0x5C));
assert!(error.modifier.contains(Modifier::BOLD));
let locked = &buffer[(5, 0)];
assert_eq!(locked.symbol(), "e");
assert_eq!(locked.fg, Color::Rgb(0xA0, 0x80, 0x8E));
assert!(locked.modifier.contains(Modifier::CROSSED_OUT));
let named = Theme::new(ColorLevel::Named).selected;
assert_eq!(
(named.fg, named.bg),
(Some(Color::Black), Some(Color::Magenta))
);
}
}