use ratatui::style::{Color, Modifier, Style};
use crate::options::SkimOptions;
#[derive(Copy, Clone, Debug)]
pub struct ColorTheme {
pub normal: Style,
pub matched: Style,
pub current: Style,
pub current_match: Style,
pub query: Style,
pub spinner: Style,
pub info: Style,
pub prompt: Style,
pub cursor: Style,
pub selected: Style,
pub header: Style,
pub border: Style,
}
impl Default for ColorTheme {
fn default() -> Self {
ColorTheme::dark256()
}
}
#[allow(dead_code)]
impl ColorTheme {
pub fn init_from_options(options: &SkimOptions) -> ColorTheme {
if let Some(color) = options.color.clone() {
ColorTheme::from_options(&color)
} else {
match std::env::var_os("NO_COLOR") {
Some(no_color) if !no_color.is_empty() => ColorTheme::none(),
_ => ColorTheme::dark256(),
}
}
}
fn none() -> Self {
let def = Style::default();
Self {
spinner: Style::default().bold(),
normal: def,
matched: def,
current: def,
current_match: def,
query: def,
info: def,
prompt: def,
cursor: def,
selected: def,
header: def,
border: def,
}
}
fn bw() -> Self {
let base = ColorTheme::none();
ColorTheme {
matched: base.matched.underlined(),
current: base.current.reversed(),
current_match: base.current_match.reversed().underlined(),
..base
}
}
fn default16() -> Self {
let base = ColorTheme::none();
ColorTheme {
matched: base.matched.fg(Color::Green),
current: base.current.fg(Color::Yellow),
current_match: base.current_match.fg(Color::Green),
spinner: base.spinner.fg(Color::Green),
info: base.info.fg(Color::White),
prompt: base.prompt.fg(Color::Blue),
cursor: base.cursor.fg(Color::Red),
selected: base.selected.fg(Color::Magenta),
header: base.header.fg(Color::Cyan),
border: base.border.fg(Color::Black),
..base
}
}
fn dark256() -> Self {
let base = ColorTheme::none();
ColorTheme {
matched: base.matched.fg(Color::Indexed(108)).bg(Color::Indexed(0)),
current: base.current.bg(Color::Indexed(236)),
current_match: base.current_match.fg(Color::Indexed(151)).bg(Color::Indexed(236)),
spinner: base.spinner.fg(Color::Indexed(148)),
info: base.info.fg(Color::Indexed(144)),
prompt: base.prompt.fg(Color::Indexed(110)),
cursor: base.cursor.fg(Color::Indexed(161)),
selected: base.selected.fg(Color::Indexed(168)),
header: base.header.fg(Color::Indexed(109)),
border: base.border.fg(Color::Indexed(59)),
..base
}
}
fn molokai256() -> Self {
let base = ColorTheme::none();
ColorTheme {
matched: base.matched.fg(Color::Indexed(234)).bg(Color::Indexed(186)),
current: base.current.bg(Color::Indexed(236)),
current_match: base.current_match.fg(Color::Indexed(234)).bg(Color::Indexed(186)),
spinner: base.spinner.fg(Color::Indexed(148)),
info: base.info.fg(Color::Indexed(144)),
prompt: base.prompt.fg(Color::Indexed(110)),
cursor: base.cursor.fg(Color::Indexed(161)),
selected: base.selected.fg(Color::Indexed(168)),
header: base.header.fg(Color::Indexed(109)),
border: base.border.fg(Color::Indexed(59)),
..base
}
}
fn light256() -> Self {
let base = ColorTheme::none();
ColorTheme {
matched: base.matched.fg(Color::Indexed(0)).bg(Color::Indexed(220)),
current: base.current.bg(Color::Indexed(251)),
current_match: base.current_match.fg(Color::Indexed(66)).bg(Color::Indexed(251)),
spinner: base.spinner.fg(Color::Indexed(65)),
info: base.info.fg(Color::Indexed(101)),
prompt: base.prompt.fg(Color::Indexed(25)),
cursor: base.cursor.fg(Color::Indexed(161)),
selected: base.selected.fg(Color::Indexed(168)),
header: base.header.fg(Color::Indexed(31)),
border: base.border.fg(Color::Indexed(145)),
..base
}
}
#[allow(unused_variables)]
fn catppuccin_mocha() -> Self {
let base = ColorTheme::none();
let text = Color::Rgb(205, 214, 244);
let subtext0 = Color::Rgb(166, 173, 200);
let subtext1 = Color::Rgb(186, 194, 222);
let overlay0 = Color::Rgb(108, 112, 134);
let surface0 = Color::Rgb(49, 50, 68);
let blue = Color::Rgb(137, 180, 250);
let red = Color::Rgb(243, 139, 168);
let lavender = Color::Rgb(180, 190, 254);
let sapphire = Color::Rgb(116, 199, 236);
Self {
normal: base.normal.fg(text),
matched: base.matched.fg(blue).underlined(),
current: base.current.bg(surface0),
current_match: base.current_match.fg(red).underlined(),
query: base.query.fg(text),
spinner: base.spinner.fg(subtext1).bold(),
info: base.info.fg(subtext1),
prompt: base.prompt.fg(lavender),
cursor: base.cursor.fg(red),
selected: base.selected.fg(red),
header: base.header.fg(subtext1),
border: base.header.fg(lavender),
}
}
#[allow(unused_variables)]
fn catppuccin_macchiato() -> Self {
let base = ColorTheme::none();
let text = Color::Rgb(202, 211, 245);
let subtext0 = Color::Rgb(165, 173, 203);
let subtext1 = Color::Rgb(184, 192, 224);
let overlay0 = Color::Rgb(110, 115, 141);
let surface0 = Color::Rgb(54, 58, 79);
let blue = Color::Rgb(138, 173, 244);
let red = Color::Rgb(237, 135, 150);
let lavender = Color::Rgb(183, 189, 248);
let sapphire = Color::Rgb(125, 196, 228);
Self {
normal: base.normal.fg(text),
matched: base.matched.fg(blue).underlined(),
current: base.current.bg(surface0),
current_match: base.current_match.fg(red).underlined(),
query: base.query.fg(text),
spinner: base.spinner.fg(subtext1).bold(),
info: base.info.fg(subtext1),
prompt: base.prompt.fg(lavender),
cursor: base.cursor.fg(red),
selected: base.selected.fg(red),
header: base.header.fg(subtext1),
border: base.header.fg(lavender),
}
}
#[allow(unused_variables)]
fn catppuccin_latte() -> Self {
let base = ColorTheme::none();
let text = Color::Rgb(76, 79, 105);
let subtext0 = Color::Rgb(108, 111, 133);
let subtext1 = Color::Rgb(92, 95, 119);
let overlay0 = Color::Rgb(156, 160, 176);
let surface0 = Color::Rgb(204, 208, 218);
let blue = Color::Rgb(30, 102, 245);
let red = Color::Rgb(210, 15, 57);
let lavender = Color::Rgb(114, 135, 253);
let sapphire = Color::Rgb(32, 159, 181);
Self {
normal: base.normal.fg(text),
matched: base.matched.fg(blue).underlined(),
current: base.current.bg(surface0),
current_match: base.current_match.fg(red).underlined(),
query: base.query.fg(text),
spinner: base.spinner.fg(subtext1).bold(),
info: base.info.fg(subtext1),
prompt: base.prompt.fg(lavender),
cursor: base.cursor.fg(red),
selected: base.selected.fg(red),
header: base.header.fg(subtext1),
border: base.header.fg(lavender),
}
}
#[allow(unused_variables)]
fn catppuccin_frappe() -> Self {
let base = ColorTheme::none();
let text = Color::Rgb(198, 208, 245);
let subtext0 = Color::Rgb(165, 173, 206);
let subtext1 = Color::Rgb(181, 191, 226);
let overlay0 = Color::Rgb(115, 121, 148);
let surface0 = Color::Rgb(65, 69, 89);
let blue = Color::Rgb(140, 170, 238);
let red = Color::Rgb(231, 130, 132);
let lavender = Color::Rgb(186, 187, 241);
let sapphire = Color::Rgb(133, 193, 220);
Self {
normal: base.normal.fg(text),
matched: base.matched.fg(blue).underlined(),
current: base.current.bg(surface0),
current_match: base.current_match.fg(red).underlined(),
query: base.query.fg(text),
spinner: base.spinner.fg(subtext1).bold(),
info: base.info.fg(subtext1),
prompt: base.prompt.fg(lavender),
cursor: base.cursor.fg(red),
selected: base.selected.fg(red),
header: base.header.fg(subtext1),
border: base.header.fg(lavender),
}
}
fn set_color(&mut self, name: &str, spec: &str) {
let spec_parts: Vec<_> = spec.split(&['+', ':']).collect();
let mut modifier = Modifier::empty();
for part in spec_parts.iter().skip(1) {
if matches!(*part, "x" | "regular") {
modifier = Modifier::empty()
} else {
modifier |= match *part {
"b" | "bold" => Modifier::BOLD,
"u" | "underlined" => Modifier::UNDERLINED,
"c" | "crossed-out" => Modifier::CROSSED_OUT,
"d" | "dim" => Modifier::DIM,
"i" | "italic" => Modifier::ITALIC,
"r" | "reverse" => Modifier::REVERSED,
m => {
debug!("Unknown modifier '{m}'");
Modifier::empty()
}
};
}
}
let (component_name, layer) = if name.ends_with("_fg") || name.ends_with("-fg") {
(&name[..name.len() - 3], "fg")
} else if name.ends_with("_bg") || name.ends_with("-bg") {
(&name[..name.len() - 3], "bg")
} else if name.ends_with("_u") || name.ends_with("-u") {
(&name[..name.len() - 2], "u")
} else if name.ends_with("_underline") || name.ends_with("-underline") {
(&name[..name.len() - 10], "underline")
} else if name == "bg" {
("", "bg")
} else {
(name, "fg")
};
let target_style = match component_name {
"" | "normal" => &mut self.normal,
"matched" | "hl" => &mut self.matched,
"current" | "fg+" => &mut self.current,
"bg+" => &mut self.current,
"current_match" | "hl+" => &mut self.current_match,
"query" => &mut self.query,
"spinner" => &mut self.spinner,
"info" => &mut self.info,
"prompt" => &mut self.prompt,
"cursor" | "pointer" => &mut self.cursor,
"selected" | "marker" => &mut self.selected,
"header" => &mut self.header,
"border" => &mut self.border,
_ => return,
};
let raw_color = spec_parts[0];
let new_color = if raw_color.len() == 7 && raw_color.starts_with('#') {
let r = u8::from_str_radix(&raw_color[1..3], 16).unwrap_or(255);
let g = u8::from_str_radix(&raw_color[3..5], 16).unwrap_or(255);
let b = u8::from_str_radix(&raw_color[5..7], 16).unwrap_or(255);
Some(Color::Rgb(r, g, b))
} else if raw_color == "-1" {
Some(Color::Reset)
} else {
raw_color.parse::<u8>().ok().map(Color::Indexed).or_else(|| {
if !raw_color.is_empty() {
debug!("Unknown color '{}'", spec_parts[0]);
}
None
})
};
let layer_override = if component_name == "bg+" { "bg" } else { layer };
set_style(target_style, layer_override, new_color, modifier);
}
fn from_options(color: &str) -> Self {
let mut theme = ColorTheme::dark256();
for pair in color.split(',') {
if let Some((name, spec)) = pair.split_once(':') {
theme.set_color(name, spec);
} else {
theme = match color {
"molokai" => ColorTheme::molokai256(),
"light" => ColorTheme::light256(),
"16" => ColorTheme::default16(),
"bw" => ColorTheme::bw(),
"none" | "empty" => ColorTheme::none(),
"dark" | "default" => ColorTheme::dark256(),
"catppuccin_mocha" | "catppuccin-mocha" => ColorTheme::catppuccin_mocha(),
"catppuccin_macchiato" | "catppuccin-macchiato" => ColorTheme::catppuccin_macchiato(),
"catppuccin_latte" | "catppuccin-latte" => ColorTheme::catppuccin_latte(),
"catppuccin_frappe" | "catppuccin-frappe" => ColorTheme::catppuccin_frappe(),
t => {
debug!("Unknown color theme '{t}'");
ColorTheme::dark256()
}
};
}
}
theme
}
}
fn set_style(s: &mut Style, layer: &str, color: Option<Color>, modifier: Modifier) {
if let Some(c) = color {
*s = match layer {
"fg" => s.fg(c),
"bg" => s.bg(c),
"u" | "underline" => s.underline_color(c),
_ => *s,
}
}
*s = s.add_modifier(modifier);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_base_themes() {
let none = ColorTheme::none();
assert!(none.spinner.add_modifier.contains(Modifier::BOLD));
let bw = ColorTheme::bw();
assert!(bw.matched.add_modifier.contains(Modifier::UNDERLINED));
assert!(bw.current.add_modifier.contains(Modifier::REVERSED));
let theme_16 = ColorTheme::default16();
assert_eq!(theme_16.matched.fg, Some(Color::Green));
assert_eq!(theme_16.matched.bg, None);
let dark = ColorTheme::dark256();
assert_eq!(dark.matched.fg, Some(Color::Indexed(108)));
assert_eq!(dark.matched.bg, Some(Color::Indexed(0)));
let molokai = ColorTheme::molokai256();
assert_eq!(molokai.matched.fg, Some(Color::Indexed(234)));
assert_eq!(molokai.matched.bg, Some(Color::Indexed(186)));
let light = ColorTheme::light256();
assert_eq!(light.matched.fg, Some(Color::Indexed(0)));
assert_eq!(light.matched.bg, Some(Color::Indexed(220)));
}
#[test]
fn test_from_options_base_themes() {
let dark = ColorTheme::from_options("dark");
assert!(dark.matched.fg.is_some());
let molokai = ColorTheme::from_options("molokai");
assert!(molokai.matched.fg.is_some());
let light = ColorTheme::from_options("light");
assert!(light.matched.fg.is_some());
let theme_16 = ColorTheme::from_options("16");
assert!(theme_16.matched.fg.is_some());
let bw = ColorTheme::from_options("bw");
assert!(bw.matched.add_modifier.contains(Modifier::UNDERLINED));
let none = ColorTheme::from_options("none");
assert!(none.spinner.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn test_ansi_color_parsing() {
let theme = ColorTheme::from_options("matched:108");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
let theme = ColorTheme::from_options("prompt:25");
assert_eq!(theme.prompt.fg, Some(Color::Indexed(25)));
}
#[test]
fn test_rgb_hex_color_parsing() {
let theme = ColorTheme::from_options("matched:#ff0000");
assert_eq!(theme.matched.fg, Some(Color::Rgb(255, 0, 0)));
let theme = ColorTheme::from_options("prompt:#00ff00");
assert_eq!(theme.prompt.fg, Some(Color::Rgb(0, 255, 0)));
let theme = ColorTheme::from_options("info:#0000ff");
assert_eq!(theme.info.fg, Some(Color::Rgb(0, 0, 255)));
}
#[test]
fn test_color_with_modifiers() {
let theme = ColorTheme::from_options("matched:108:bold");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
let theme = ColorTheme::from_options("matched:108:underlined");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert!(theme.matched.add_modifier.contains(Modifier::UNDERLINED));
let theme = ColorTheme::from_options("matched:108:bold:underlined");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
assert!(theme.matched.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn test_modifier_shortcuts() {
let theme = ColorTheme::from_options("matched:108:b");
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
let theme = ColorTheme::from_options("matched:108:u");
assert!(theme.matched.add_modifier.contains(Modifier::UNDERLINED));
let theme = ColorTheme::from_options("matched:108:i");
assert!(theme.matched.add_modifier.contains(Modifier::ITALIC));
let theme = ColorTheme::from_options("matched:108:r");
assert!(theme.matched.add_modifier.contains(Modifier::REVERSED));
let theme = ColorTheme::from_options("matched:108:d");
assert!(theme.matched.add_modifier.contains(Modifier::DIM));
let theme = ColorTheme::from_options("matched:108:c");
assert!(theme.matched.add_modifier.contains(Modifier::CROSSED_OUT));
}
#[test]
fn test_regular_modifier_reset() {
let theme = ColorTheme::from_options("matched:108:x:bold");
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
assert!(!theme.matched.add_modifier.contains(Modifier::ITALIC));
let theme = ColorTheme::from_options("matched:108:regular:underlined");
assert!(theme.matched.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn test_multiple_color_components() {
let theme = ColorTheme::from_options("matched:108,prompt:25");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert_eq!(theme.prompt.fg, Some(Color::Indexed(25)));
let theme = ColorTheme::from_options("matched:#ff0000:bold,prompt:#00ff00:underlined");
assert_eq!(theme.matched.fg, Some(Color::Rgb(255, 0, 0)));
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
assert_eq!(theme.prompt.fg, Some(Color::Rgb(0, 255, 0)));
assert!(theme.prompt.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn test_component_name_aliases() {
let theme = ColorTheme::from_options("hl:108");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
let theme = ColorTheme::from_options("fg+:254");
assert_eq!(theme.current.fg, Some(Color::Indexed(254)));
let theme = ColorTheme::from_options("bg+:236");
assert_eq!(theme.current.bg, Some(Color::Indexed(236)));
let theme = ColorTheme::from_options("hl+:151");
assert_eq!(theme.current_match.fg, Some(Color::Indexed(151)));
let theme = ColorTheme::from_options("pointer:161");
assert_eq!(theme.cursor.fg, Some(Color::Indexed(161)));
let theme = ColorTheme::from_options("marker:168");
assert_eq!(theme.selected.fg, Some(Color::Indexed(168)));
}
#[test]
fn test_background_color() {
let theme = ColorTheme::from_options("matched_bg:0");
assert_eq!(theme.matched.bg, Some(Color::Indexed(0)));
let theme = ColorTheme::from_options("matched-bg:236");
assert_eq!(theme.matched.bg, Some(Color::Indexed(236)));
}
#[test]
fn test_base_theme_with_overrides() {
let theme = ColorTheme::from_options("dark,matched:200");
assert_eq!(theme.matched.fg, Some(Color::Indexed(200)));
assert!(theme.prompt.fg.is_some());
}
#[test]
fn test_all_component_names() {
let theme = ColorTheme::from_options("normal:108");
assert_eq!(theme.normal.fg, Some(Color::Indexed(108)));
let theme = ColorTheme::from_options("matched:109");
assert_eq!(theme.matched.fg, Some(Color::Indexed(109)));
let theme = ColorTheme::from_options("current:110");
assert_eq!(theme.current.fg, Some(Color::Indexed(110)));
let theme = ColorTheme::from_options("current_match:111");
assert_eq!(theme.current_match.fg, Some(Color::Indexed(111)));
let theme = ColorTheme::from_options("query:112");
assert_eq!(theme.query.fg, Some(Color::Indexed(112)));
let theme = ColorTheme::from_options("spinner:113");
assert_eq!(theme.spinner.fg, Some(Color::Indexed(113)));
let theme = ColorTheme::from_options("info:114");
assert_eq!(theme.info.fg, Some(Color::Indexed(114)));
let theme = ColorTheme::from_options("prompt:115");
assert_eq!(theme.prompt.fg, Some(Color::Indexed(115)));
let theme = ColorTheme::from_options("cursor:116");
assert_eq!(theme.cursor.fg, Some(Color::Indexed(116)));
let theme = ColorTheme::from_options("selected:117");
assert_eq!(theme.selected.fg, Some(Color::Indexed(117)));
let theme = ColorTheme::from_options("header:118");
assert_eq!(theme.header.fg, Some(Color::Indexed(118)));
let theme = ColorTheme::from_options("border:119");
assert_eq!(theme.border.fg, Some(Color::Indexed(119)));
}
#[test]
fn test_invalid_color_graceful_handling() {
let theme = ColorTheme::from_options("matched:invalid");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert_eq!(theme.matched.bg, Some(Color::Indexed(0)));
let theme = ColorTheme::from_options("matched:#gggggg");
assert_eq!(theme.matched.fg, Some(Color::Rgb(255, 255, 255)));
assert_eq!(theme.matched.bg, Some(Color::Indexed(0)));
}
#[test]
fn test_init_from_options() {
let opts = crate::options::SkimOptionsBuilder::default()
.color("matched:108")
.build()
.unwrap();
let theme = ColorTheme::init_from_options(&opts);
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
}
#[test]
fn test_complex_color_spec() {
let theme =
ColorTheme::from_options("dark,matched:#00ff00:bold,prompt:#0000ff:underlined,current:#ffff00:italic");
assert_eq!(theme.matched.fg, Some(Color::Rgb(0, 255, 0)));
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
assert_eq!(theme.prompt.fg, Some(Color::Rgb(0, 0, 255)));
assert!(theme.prompt.add_modifier.contains(Modifier::UNDERLINED));
assert_eq!(theme.current.fg, Some(Color::Rgb(255, 255, 0)));
assert!(theme.current.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn test_minus_one_color_reset() {
let theme = ColorTheme::from_options("dark,hl:-1:reverse,hl-bg:-1,hl+:-1:bold,bg+:-1");
assert_eq!(theme.matched.fg, Some(Color::Reset));
assert_eq!(theme.matched.bg, Some(Color::Reset));
assert!(theme.matched.add_modifier.contains(Modifier::REVERSED));
assert_eq!(theme.current_match.fg, Some(Color::Reset));
assert_ne!(theme.current_match.bg, Some(Color::Reset));
assert!(theme.current_match.add_modifier.contains(Modifier::BOLD));
let theme = ColorTheme::from_options("dark,prompt:-1:underlined");
assert_eq!(theme.prompt.fg, Some(Color::Reset));
assert!(theme.prompt.add_modifier.contains(Modifier::UNDERLINED));
let theme = ColorTheme::from_options("dark,bg+:-1");
assert_eq!(theme.current.bg, Some(Color::Reset));
}
}