use std::fmt::Write as FmtWrite;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Color {
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
pub fn from_hex(s: &str) -> Option<Self> {
let s = s.strip_prefix('#').unwrap_or(s);
if s.len() != 6 {
return None;
}
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some(Self { r, g, b })
}
pub fn to_hex(&self) -> String {
format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
}
pub fn lighten(&self, factor: f32) -> Self {
let factor = factor.clamp(0.0, 1.0);
Self {
r: (self.r as f32 + (255.0 - self.r as f32) * factor).round() as u8,
g: (self.g as f32 + (255.0 - self.g as f32) * factor).round() as u8,
b: (self.b as f32 + (255.0 - self.b as f32) * factor).round() as u8,
}
}
pub fn darken(&self, factor: f32) -> Self {
let factor = factor.clamp(0.0, 1.0);
Self {
r: (self.r as f32 * (1.0 - factor)).round() as u8,
g: (self.g as f32 * (1.0 - factor)).round() as u8,
b: (self.b as f32 * (1.0 - factor)).round() as u8,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Modifiers {
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strikethrough: bool,
}
#[derive(Debug, Clone, Default)]
pub struct Style {
pub fg: Option<Color>,
pub bg: Option<Color>,
pub modifiers: Modifiers,
}
impl Style {
pub const fn new() -> Self {
Self {
fg: None,
bg: None,
modifiers: Modifiers {
bold: false,
italic: false,
underline: false,
strikethrough: false,
},
}
}
pub const fn fg(mut self, color: Color) -> Self {
self.fg = Some(color);
self
}
pub const fn bold(mut self) -> Self {
self.modifiers.bold = true;
self
}
pub const fn italic(mut self) -> Self {
self.modifiers.italic = true;
self
}
pub const fn underline(mut self) -> Self {
self.modifiers.underline = true;
self
}
pub const fn strikethrough(mut self) -> Self {
self.modifiers.strikethrough = true;
self
}
pub fn is_empty(&self) -> bool {
self.fg.is_none()
&& self.bg.is_none()
&& !self.modifiers.bold
&& !self.modifiers.italic
&& !self.modifiers.underline
&& !self.modifiers.strikethrough
}
}
#[derive(Debug, Clone)]
pub struct Theme {
pub name: String,
pub is_dark: bool,
pub source_url: Option<String>,
pub background: Option<Color>,
pub foreground: Option<Color>,
pub styles: [Style; crate::highlights::COUNT],
}
impl Default for Theme {
fn default() -> Self {
Self {
name: String::new(),
is_dark: true,
source_url: None,
background: None,
foreground: None,
styles: std::array::from_fn(|_| Style::new()),
}
}
}
impl Theme {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
..Default::default()
}
}
pub fn style(&self, index: usize) -> Option<&Style> {
self.styles.get(index)
}
pub fn set_style(&mut self, index: usize, style: Style) {
if index < self.styles.len() {
self.styles[index] = style;
}
}
#[cfg(feature = "toml")]
pub fn from_toml(toml_str: &str) -> Result<Self, ThemeError> {
let value: toml::Value = toml_str
.parse()
.map_err(|e| ThemeError::Parse(format!("{e}")))?;
let table = value
.as_table()
.ok_or(ThemeError::Parse("Expected table".into()))?;
let mut theme = Theme::default();
if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
theme.name = name.to_string();
}
if let Some(variant) = table.get("variant").and_then(|v| v.as_str()) {
theme.is_dark = variant != "light";
}
if let Some(source) = table.get("source").and_then(|v| v.as_str()) {
theme.source_url = Some(source.to_string());
}
let palette: std::collections::HashMap<&str, Color> = table
.get("palette")
.and_then(|v| v.as_table())
.map(|t| {
t.iter()
.filter_map(|(k, v)| {
v.as_str()
.and_then(Color::from_hex)
.map(|c| (k.as_str(), c))
})
.collect()
})
.unwrap_or_default();
let resolve_color =
|s: &str| -> Option<Color> { Color::from_hex(s).or_else(|| palette.get(s).copied()) };
if let Some(bg) = table.get("ui.background")
&& let Some(bg_table) = bg.as_table()
&& let Some(bg_str) = bg_table.get("bg").and_then(|v| v.as_str())
{
theme.background = resolve_color(bg_str);
}
if let Some(bg_str) = table.get("background").and_then(|v| v.as_str()) {
theme.background = resolve_color(bg_str);
}
if let Some(fg) = table.get("ui.foreground") {
if let Some(fg_str) = fg.as_str() {
theme.foreground = resolve_color(fg_str);
} else if let Some(fg_table) = fg.as_table()
&& let Some(fg_str) = fg_table.get("fg").and_then(|v| v.as_str())
{
theme.foreground = resolve_color(fg_str);
}
}
if let Some(fg_str) = table.get("foreground").and_then(|v| v.as_str()) {
theme.foreground = resolve_color(fg_str);
}
use crate::highlights::HIGHLIGHTS;
for (i, def) in HIGHLIGHTS.iter().enumerate() {
if let Some(rule) = table.get(def.name) {
let style = parse_style_value(rule, &resolve_color)?;
theme.styles[i] = style;
continue;
}
for alias in def.aliases {
if let Some(rule) = table.get(*alias) {
let style = parse_style_value(rule, &resolve_color)?;
theme.styles[i] = style;
break;
}
}
}
let extra_mappings: &[(&str, &str)] = &[
("keyword.control", "keyword"),
("keyword.storage", "keyword"),
("comment.line", "comment"),
("comment.block", "comment"),
("function.macro", "macro"),
];
for (helix_name, our_name) in extra_mappings {
if let Some(rule) = table.get(*helix_name) {
if let Some(i) = HIGHLIGHTS.iter().position(|h| h.name == *our_name) {
if theme.styles[i].is_empty() {
let style = parse_style_value(rule, &resolve_color)?;
theme.styles[i] = style;
}
}
}
}
Ok(theme)
}
pub fn to_css(&self, selector_prefix: &str) -> String {
use crate::highlights::HIGHLIGHTS;
use std::collections::HashMap;
let mut css = String::new();
writeln!(css, "{selector_prefix} {{").unwrap();
if let Some(bg) = &self.background {
writeln!(css, " background: {};", bg.to_hex()).unwrap();
writeln!(css, " --bg: {};", bg.to_hex()).unwrap();
let surface = if self.is_dark {
bg.lighten(0.08)
} else {
bg.darken(0.05)
};
writeln!(css, " --surface: {};", surface.to_hex()).unwrap();
}
if let Some(fg) = &self.foreground {
writeln!(css, " color: {};", fg.to_hex()).unwrap();
writeln!(css, " --fg: {};", fg.to_hex()).unwrap();
}
let function_idx = HIGHLIGHTS.iter().position(|h| h.name == "function");
let keyword_idx = HIGHLIGHTS.iter().position(|h| h.name == "keyword");
let comment_idx = HIGHLIGHTS.iter().position(|h| h.name == "comment");
let accent_color = function_idx
.and_then(|i| self.styles[i].fg.as_ref())
.or_else(|| keyword_idx.and_then(|i| self.styles[i].fg.as_ref()))
.or(self.foreground.as_ref());
if let Some(accent) = accent_color {
writeln!(css, " --accent: {};", accent.to_hex()).unwrap();
}
let muted_color = comment_idx.and_then(|i| self.styles[i].fg.as_ref());
if let Some(muted) = muted_color {
writeln!(css, " --muted: {};", muted.to_hex()).unwrap();
} else if let Some(fg) = &self.foreground {
let muted = if self.is_dark {
fg.darken(0.3)
} else {
fg.lighten(0.3)
};
writeln!(css, " --muted: {};", muted.to_hex()).unwrap();
}
let mut tag_to_style: HashMap<&str, &Style> = HashMap::new();
for (i, def) in HIGHLIGHTS.iter().enumerate() {
if !def.tag.is_empty() && !self.styles[i].is_empty() {
tag_to_style.insert(def.tag, &self.styles[i]);
}
}
let mut emitted_tags: std::collections::HashSet<&str> = std::collections::HashSet::new();
for (i, def) in HIGHLIGHTS.iter().enumerate() {
if def.tag.is_empty() || emitted_tags.contains(def.tag) {
continue; }
let style = if !self.styles[i].is_empty() {
&self.styles[i]
} else if !def.parent_tag.is_empty() {
tag_to_style
.get(def.parent_tag)
.copied()
.unwrap_or(&self.styles[i])
} else {
continue; };
if style.is_empty() {
continue;
}
emitted_tags.insert(def.tag);
write!(css, " a-{} {{", def.tag).unwrap();
if let Some(fg) = &style.fg {
write!(css, " color: {};", fg.to_hex()).unwrap();
}
if let Some(bg) = &style.bg {
write!(css, " background: {};", bg.to_hex()).unwrap();
}
let mut decorations = Vec::new();
if style.modifiers.underline {
decorations.push("underline");
}
if style.modifiers.strikethrough {
decorations.push("line-through");
}
if !decorations.is_empty() {
write!(css, " text-decoration: {};", decorations.join(" ")).unwrap();
}
if style.modifiers.bold {
write!(css, " font-weight: bold;").unwrap();
}
if style.modifiers.italic {
write!(css, " font-style: italic;").unwrap();
}
writeln!(css, " }}").unwrap();
}
writeln!(css, "}}").unwrap();
css
}
pub fn ansi_style(&self, index: usize) -> String {
let Some(style) = self.styles.get(index) else {
return String::new();
};
if style.is_empty() {
return String::new();
}
let mut codes = Vec::new();
if style.modifiers.bold {
codes.push("1".to_string());
}
if style.modifiers.italic {
codes.push("3".to_string());
}
if style.modifiers.underline {
codes.push("4".to_string());
}
if style.modifiers.strikethrough {
codes.push("9".to_string());
}
if let Some(fg) = &style.fg {
codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
}
if let Some(bg) = &style.bg {
codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
}
if codes.is_empty() {
String::new()
} else {
format!("\x1b[{}m", codes.join(";"))
}
}
pub fn ansi_style_with_base_bg(&self, index: usize) -> String {
let Some(style) = self.styles.get(index) else {
return String::new();
};
if style.is_empty() {
return String::new();
}
let mut codes = Vec::new();
if style.modifiers.bold {
codes.push("1".to_string());
}
if style.modifiers.italic {
codes.push("3".to_string());
}
if style.modifiers.underline {
codes.push("4".to_string());
}
if style.modifiers.strikethrough {
codes.push("9".to_string());
}
if let Some(fg) = &style.fg {
codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
} else if let Some(fg) = &self.foreground {
codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
}
if let Some(bg) = &style.bg {
codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
} else if let Some(bg) = &self.background {
codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
}
if codes.is_empty() {
String::new()
} else {
format!("\x1b[{}m", codes.join(";"))
}
}
pub fn ansi_base_style(&self) -> String {
let mut codes = Vec::new();
if let Some(fg) = &self.foreground {
codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
}
if let Some(bg) = &self.background {
codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
}
if codes.is_empty() {
String::new()
} else {
format!("\x1b[{}m", codes.join(";"))
}
}
pub fn ansi_border_style(&self) -> String {
let Some(bg) = &self.background else {
return String::new();
};
let border = if self.is_dark {
Color::new(
bg.r.saturating_add(30),
bg.g.saturating_add(30),
bg.b.saturating_add(30),
)
} else {
Color::new(
bg.r.saturating_sub(30),
bg.g.saturating_sub(30),
bg.b.saturating_sub(30),
)
};
format!("\x1b[38;2;{};{};{}m", border.r, border.g, border.b)
}
pub const ANSI_RESET: &'static str = "\x1b[0m";
}
#[cfg(feature = "toml")]
fn parse_style_value(
value: &toml::Value,
resolve_color: &impl Fn(&str) -> Option<Color>,
) -> Result<Style, ThemeError> {
let mut style = Style::new();
match value {
toml::Value::String(s) => {
style.fg = resolve_color(s);
}
toml::Value::Table(t) => {
if let Some(fg) = t.get("fg").and_then(|v| v.as_str()) {
style.fg = resolve_color(fg);
}
if let Some(bg) = t.get("bg").and_then(|v| v.as_str()) {
style.bg = resolve_color(bg);
}
if let Some(mods) = t.get("modifiers").and_then(|v| v.as_array()) {
for m in mods {
if let Some(s) = m.as_str() {
match s {
"bold" => style.modifiers.bold = true,
"italic" => style.modifiers.italic = true,
"underlined" | "underline" => style.modifiers.underline = true,
"crossed_out" | "strikethrough" => style.modifiers.strikethrough = true,
_ => {}
}
}
}
}
}
_ => {}
}
Ok(style)
}
#[derive(Debug)]
pub enum ThemeError {
Parse(String),
}
impl std::fmt::Display for ThemeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ThemeError::Parse(msg) => write!(f, "Theme parse error: {msg}"),
}
}
}
impl std::error::Error for ThemeError {}
pub mod builtin {
include!("builtin_generated.rs");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_color_from_hex() {
assert_eq!(Color::from_hex("#ff0000"), Some(Color::new(255, 0, 0)));
assert_eq!(Color::from_hex("00ff00"), Some(Color::new(0, 255, 0)));
assert_eq!(Color::from_hex("#invalid"), None);
}
#[test]
fn test_color_to_hex() {
assert_eq!(Color::new(255, 0, 0).to_hex(), "#ff0000");
assert_eq!(Color::new(0, 255, 0).to_hex(), "#00ff00");
}
}