use ratatui::style::Color;
use std::sync::OnceLock;
use syntect::highlighting::{Color as SynColor, Highlighter as SynHighlighter, Theme as SynTheme};
use syntect::parsing::Scope;
#[derive(Clone, Copy)]
pub struct Theme {
pub bg: Color,
pub add_bg: Color,
pub del_bg: Color,
pub cursor_bg: Color,
pub unfocus_bg: Color,
pub file_header_bg: Color,
pub comment_bg: Color,
pub subtle: Color,
pub scrollbar_thumb: Color,
pub border_focus: Color,
pub border_unfocus: Color,
pub text: Color,
pub text_strong: Color,
pub muted: Color,
pub faint: Color,
pub accent: Color,
pub warn: Color,
pub added: Color,
pub removed: Color,
pub none: Color,
}
static FALLBACK: Theme = Theme {
bg: Color::Rgb(26, 27, 38),
add_bg: Color::Rgb(32, 44, 38),
del_bg: Color::Rgb(55, 32, 42),
cursor_bg: Color::Rgb(54, 74, 124),
unfocus_bg: Color::Rgb(41, 46, 66),
file_header_bg: Color::Rgb(41, 46, 66),
comment_bg: Color::Rgb(31, 35, 53),
subtle: Color::Rgb(41, 46, 66),
scrollbar_thumb: Color::Rgb(86, 95, 137),
border_focus: Color::Rgb(122, 162, 247),
border_unfocus: Color::Rgb(86, 95, 137),
text: Color::Rgb(169, 177, 214),
text_strong: Color::Rgb(192, 202, 245),
muted: Color::Rgb(86, 95, 137),
faint: Color::Rgb(115, 122, 162),
accent: Color::Rgb(125, 207, 255),
warn: Color::Rgb(224, 175, 104),
added: Color::Rgb(158, 206, 106),
removed: Color::Rgb(247, 118, 142),
none: Color::Reset,
};
static TRUECOLOR: OnceLock<bool> = OnceLock::new();
static ACTIVE: OnceLock<Theme> = OnceLock::new();
pub fn init_theme(syntect_theme: &SynTheme, truecolor: bool) {
let derived = Theme::from_syntect(syntect_theme);
let _ = TRUECOLOR.set(truecolor);
let _ = ACTIVE.set(derived.adapt(truecolor));
}
pub fn theme() -> &'static Theme {
ACTIVE.get().unwrap_or(&FALLBACK)
}
pub fn adapt_color(c: Color) -> Color {
down(c, *TRUECOLOR.get().unwrap_or(&true))
}
fn mix(a: (u8, u8, u8), b: (u8, u8, u8), t: f32) -> (u8, u8, u8) {
let f = |x: u8, y: u8| {
(x as f32 + (y as f32 - x as f32) * t)
.round()
.clamp(0.0, 255.0) as u8
};
(f(a.0, b.0), f(a.1, b.1), f(a.2, b.2))
}
fn over(bg: (u8, u8, u8), c: SynColor) -> (u8, u8, u8) {
mix(bg, (c.r, c.g, c.b), c.a as f32 / 255.0)
}
fn dist2(a: (u8, u8, u8), b: (u8, u8, u8)) -> i32 {
let d = |x: u8, y: u8| (x as i32 - y as i32).pow(2);
d(a.0, b.0) + d(a.1, b.1) + d(a.2, b.2)
}
impl Theme {
pub fn from_syntect(syn: &SynTheme) -> Theme {
let s = &syn.settings;
let rgb = |c: SynColor| (c.r, c.g, c.b);
let bg = s.background.map(rgb).unwrap_or((26, 27, 38));
let fg = s.foreground.map(rgb).unwrap_or((192, 202, 245));
let hl = SynHighlighter::new(syn);
let scope = |name: &str| -> Option<(u8, u8, u8)> {
let sc = Scope::new(name).ok()?;
let c = hl.style_for_stack(&[sc]).foreground;
let t = (c.r, c.g, c.b);
(t != fg).then_some(t)
};
let comment = scope("comment").unwrap_or_else(|| mix(bg, fg, 0.45));
let accent = s.accent.map(rgb).or_else(|| scope("keyword")).unwrap_or(fg);
let warn = scope("string")
.or_else(|| scope("constant.numeric"))
.unwrap_or((224, 175, 104));
let added = scope("markup.inserted")
.or_else(|| scope("diff.inserted"))
.unwrap_or((158, 206, 106));
let removed = scope("markup.deleted")
.or_else(|| scope("diff.deleted"))
.unwrap_or((247, 118, 142));
let sel = s
.line_highlight
.or(s.selection)
.map(|c| over(bg, c))
.unwrap_or_else(|| mix(bg, fg, 0.22));
const CURSOR_BG_MIN_DIST2: i32 = 30 * 30;
let cursor_bg = if dist2(sel, bg) < CURSOR_BG_MIN_DIST2 {
mix(bg, fg, 0.22)
} else {
sel
};
let t = |x: (u8, u8, u8)| Color::Rgb(x.0, x.1, x.2);
Theme {
bg: t(bg),
add_bg: t(mix(bg, added, 0.16)),
del_bg: t(mix(bg, removed, 0.16)),
cursor_bg: t(cursor_bg),
unfocus_bg: t(mix(bg, fg, 0.10)),
file_header_bg: t(mix(bg, fg, 0.12)),
comment_bg: t(mix(bg, fg, 0.05)),
subtle: t(mix(bg, fg, 0.12)),
scrollbar_thumb: t(comment),
border_focus: t(accent),
border_unfocus: t(comment),
text: t(mix(fg, bg, 0.12)),
text_strong: t(fg),
muted: t(comment),
faint: t(mix(comment, fg, 0.25)),
accent: t(accent),
warn: t(warn),
added: t(added),
removed: t(removed),
none: Color::Reset,
}
}
fn adapt(&self, truecolor: bool) -> Theme {
let d = |c: Color| down(c, truecolor);
Theme {
bg: d(self.bg),
add_bg: d(self.add_bg),
del_bg: d(self.del_bg),
cursor_bg: d(self.cursor_bg),
unfocus_bg: d(self.unfocus_bg),
file_header_bg: d(self.file_header_bg),
comment_bg: d(self.comment_bg),
subtle: d(self.subtle),
scrollbar_thumb: d(self.scrollbar_thumb),
border_focus: d(self.border_focus),
border_unfocus: d(self.border_unfocus),
text: d(self.text),
text_strong: d(self.text_strong),
muted: d(self.muted),
faint: d(self.faint),
accent: d(self.accent),
warn: d(self.warn),
added: d(self.added),
removed: d(self.removed),
none: d(self.none),
}
}
}
fn down(c: Color, truecolor: bool) -> Color {
match c {
Color::Rgb(r, g, b) if !truecolor => Color::Indexed(rgb_to_ansi256(r, g, b)),
other => other,
}
}
fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
const CUBE: [i32; 6] = [0, 95, 135, 175, 215, 255];
let nearest_cube = |v: i32| -> usize {
CUBE.iter()
.enumerate()
.min_by_key(|(_, &lvl)| (v - lvl).abs())
.map(|(i, _)| i)
.unwrap()
};
let (r, g, b) = (r as i32, g as i32, b as i32);
let dist = |a: (i32, i32, i32), x: (i32, i32, i32)| {
let (dr, dg, db) = (a.0 - x.0, a.1 - x.1, a.2 - x.2);
dr * dr + dg * dg + db * db
};
let (ci, cj, ck) = (nearest_cube(r), nearest_cube(g), nearest_cube(b));
let cube_rgb = (CUBE[ci], CUBE[cj], CUBE[ck]);
let cube_idx = 16 + 36 * ci + 6 * cj + ck;
let avg = (r + g + b) / 3;
let gray_n = (((avg - 8).max(0) + 5) / 10).clamp(0, 23);
let gray_v = 8 + 10 * gray_n;
let gray_idx = 232 + gray_n as usize;
if dist((r, g, b), cube_rgb) <= dist((r, g, b), (gray_v, gray_v, gray_v)) {
cube_idx as u8
} else {
gray_idx as u8
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truecolor_passes_rgb_through_unchanged() {
assert_eq!(down(Color::Rgb(20, 42, 24), true), Color::Rgb(20, 42, 24));
assert_eq!(down(Color::Red, false), Color::Red);
assert_eq!(down(Color::Reset, false), Color::Reset);
}
#[test]
fn non_truecolor_downsamples_rgb_to_indexed() {
assert_eq!(down(Color::Rgb(0, 0, 0), false), Color::Indexed(16));
assert_eq!(down(Color::Rgb(255, 255, 255), false), Color::Indexed(231));
assert_eq!(down(Color::Rgb(255, 0, 0), false), Color::Indexed(196));
assert_eq!(down(Color::Rgb(0, 255, 0), false), Color::Indexed(46));
assert_eq!(down(Color::Rgb(0, 0, 255), false), Color::Indexed(21));
}
#[test]
fn mid_gray_prefers_the_grayscale_ramp() {
let idx = match down(Color::Rgb(128, 128, 128), false) {
Color::Indexed(i) => i,
other => panic!("expected indexed, got {other:?}"),
};
assert!(
(232..=255).contains(&idx),
"expected grayscale ramp, got {idx}"
);
}
#[test]
fn grayscale_ramp_rounds_to_nearest_step() {
assert_eq!(rgb_to_ansi256(17, 17, 17), 233);
assert_eq!(rgb_to_ansi256(12, 12, 12), 232);
}
#[test]
fn theme_falls_back_without_locking() {
assert!(std::ptr::eq(theme(), &FALLBACK));
assert_eq!(theme().added, FALLBACK.added);
}
#[test]
fn adapt_downsamples_rgb_fields_but_keeps_non_rgb() {
let dark = FALLBACK.adapt(false);
assert!(matches!(dark.cursor_bg, Color::Indexed(_)));
assert!(matches!(dark.removed, Color::Indexed(_)));
assert_eq!(dark.none, Color::Reset);
let bright = FALLBACK.adapt(true);
assert_eq!(bright.cursor_bg, FALLBACK.cursor_bg);
}
#[test]
fn derives_chrome_from_syntect_theme() {
let syn = crate::ui::highlight::default_theme();
let t = Theme::from_syntect(&syn);
if let Some(bg) = syn.settings.background {
assert_eq!(
t.bg,
Color::Rgb(bg.r, bg.g, bg.b),
"bg must mirror the theme"
);
}
assert_ne!(t.added, t.bg, "added must be a visible color");
assert_ne!(t.removed, t.bg, "removed must be a visible color");
assert_ne!(t.cursor_bg, t.bg, "focused current line must stand out");
}
}