1use std::collections::HashMap;
27use std::fmt::Write as FmtWrite;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct Color {
32 pub r: u8,
33 pub g: u8,
34 pub b: u8,
35}
36
37impl Color {
38 pub const fn new(r: u8, g: u8, b: u8) -> Self {
39 Self { r, g, b }
40 }
41
42 pub fn from_hex(s: &str) -> Option<Self> {
44 let s = s.strip_prefix('#').unwrap_or(s);
45 if s.len() != 6 {
46 return None;
47 }
48 let r = u8::from_str_radix(&s[0..2], 16).ok()?;
49 let g = u8::from_str_radix(&s[2..4], 16).ok()?;
50 let b = u8::from_str_radix(&s[4..6], 16).ok()?;
51 Some(Self { r, g, b })
52 }
53
54 pub fn to_hex(&self) -> String {
56 format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
57 }
58}
59
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
62pub struct Modifiers {
63 pub bold: bool,
64 pub italic: bool,
65 pub underline: bool,
66 pub strikethrough: bool,
67}
68
69#[derive(Debug, Clone, Default)]
71pub struct Style {
72 pub fg: Option<Color>,
73 pub bg: Option<Color>,
74 pub modifiers: Modifiers,
75}
76
77impl Style {
78 pub const fn new() -> Self {
79 Self {
80 fg: None,
81 bg: None,
82 modifiers: Modifiers {
83 bold: false,
84 italic: false,
85 underline: false,
86 strikethrough: false,
87 },
88 }
89 }
90
91 pub const fn fg(mut self, color: Color) -> Self {
92 self.fg = Some(color);
93 self
94 }
95
96 pub const fn bold(mut self) -> Self {
97 self.modifiers.bold = true;
98 self
99 }
100
101 pub const fn italic(mut self) -> Self {
102 self.modifiers.italic = true;
103 self
104 }
105
106 pub const fn underline(mut self) -> Self {
107 self.modifiers.underline = true;
108 self
109 }
110
111 pub const fn strikethrough(mut self) -> Self {
112 self.modifiers.strikethrough = true;
113 self
114 }
115
116 pub fn is_empty(&self) -> bool {
118 self.fg.is_none()
119 && self.bg.is_none()
120 && !self.modifiers.bold
121 && !self.modifiers.italic
122 && !self.modifiers.underline
123 && !self.modifiers.strikethrough
124 }
125}
126
127#[derive(Debug, Clone)]
129pub struct Theme {
130 pub name: String,
132 pub is_dark: bool,
134 pub background: Option<Color>,
136 pub foreground: Option<Color>,
138 styles: [Style; crate::highlights::COUNT],
140}
141
142impl Default for Theme {
143 fn default() -> Self {
144 Self {
145 name: String::new(),
146 is_dark: true,
147 background: None,
148 foreground: None,
149 styles: std::array::from_fn(|_| Style::new()),
150 }
151 }
152}
153
154impl Theme {
155 pub fn new(name: impl Into<String>) -> Self {
157 Self {
158 name: name.into(),
159 ..Default::default()
160 }
161 }
162
163 pub fn style(&self, index: usize) -> Option<&Style> {
165 self.styles.get(index)
166 }
167
168 pub fn set_style(&mut self, index: usize, style: Style) {
170 if index < self.styles.len() {
171 self.styles[index] = style;
172 }
173 }
174
175 pub fn from_toml(toml_str: &str) -> Result<Self, ThemeError> {
177 let value: toml::Value = toml_str
178 .parse()
179 .map_err(|e| ThemeError::Parse(format!("{e}")))?;
180 let table = value
181 .as_table()
182 .ok_or(ThemeError::Parse("Expected table".into()))?;
183
184 let mut theme = Theme::default();
185
186 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
188 theme.name = name.to_string();
189 }
190 if let Some(variant) = table.get("variant").and_then(|v| v.as_str()) {
191 theme.is_dark = variant != "light";
192 }
193
194 let palette: HashMap<&str, Color> = table
196 .get("palette")
197 .and_then(|v| v.as_table())
198 .map(|t| {
199 t.iter()
200 .filter_map(|(k, v)| {
201 v.as_str()
202 .and_then(Color::from_hex)
203 .map(|c| (k.as_str(), c))
204 })
205 .collect()
206 })
207 .unwrap_or_default();
208
209 let resolve_color =
211 |s: &str| -> Option<Color> { Color::from_hex(s).or_else(|| palette.get(s).copied()) };
212
213 if let Some(bg) = table.get("ui.background")
215 && let Some(bg_table) = bg.as_table()
216 && let Some(bg_str) = bg_table.get("bg").and_then(|v| v.as_str())
217 {
218 theme.background = resolve_color(bg_str);
219 }
220 if let Some(bg_str) = table.get("background").and_then(|v| v.as_str()) {
222 theme.background = resolve_color(bg_str);
223 }
224
225 if let Some(fg) = table.get("ui.foreground") {
226 if let Some(fg_str) = fg.as_str() {
227 theme.foreground = resolve_color(fg_str);
228 } else if let Some(fg_table) = fg.as_table()
229 && let Some(fg_str) = fg_table.get("fg").and_then(|v| v.as_str())
230 {
231 theme.foreground = resolve_color(fg_str);
232 }
233 }
234 if let Some(fg_str) = table.get("foreground").and_then(|v| v.as_str()) {
236 theme.foreground = resolve_color(fg_str);
237 }
238
239 use crate::highlights::HIGHLIGHTS;
241
242 for (i, def) in HIGHLIGHTS.iter().enumerate() {
244 if let Some(rule) = table.get(def.name) {
246 let style = parse_style_value(rule, &resolve_color)?;
247 theme.styles[i] = style;
248 continue;
249 }
250
251 for alias in def.aliases {
253 if let Some(rule) = table.get(*alias) {
254 let style = parse_style_value(rule, &resolve_color)?;
255 theme.styles[i] = style;
256 break;
257 }
258 }
259 }
260
261 let extra_mappings: &[(&str, &str)] = &[
263 ("keyword.control", "keyword"),
264 ("keyword.storage", "keyword"),
265 ("comment.line", "comment"),
266 ("comment.block", "comment"),
267 ("function.macro", "macro"),
268 ];
269
270 for (helix_name, our_name) in extra_mappings {
271 if let Some(rule) = table.get(*helix_name) {
272 if let Some(i) = HIGHLIGHTS.iter().position(|h| h.name == *our_name) {
274 if theme.styles[i].is_empty() {
276 let style = parse_style_value(rule, &resolve_color)?;
277 theme.styles[i] = style;
278 }
279 }
280 }
281 }
282
283 Ok(theme)
284 }
285
286 pub fn to_css(&self, selector_prefix: &str) -> String {
291 use crate::highlights::HIGHLIGHTS;
292 use std::collections::HashMap;
293
294 let mut css = String::new();
295
296 writeln!(css, "{selector_prefix} {{").unwrap();
297
298 if let Some(bg) = &self.background {
300 writeln!(css, " background: {};", bg.to_hex()).unwrap();
301 }
302 if let Some(fg) = &self.foreground {
303 writeln!(css, " color: {};", fg.to_hex()).unwrap();
304 }
305
306 let mut tag_to_style: HashMap<&str, &Style> = HashMap::new();
308 for (i, def) in HIGHLIGHTS.iter().enumerate() {
309 if !def.tag.is_empty() && !self.styles[i].is_empty() {
310 tag_to_style.insert(def.tag, &self.styles[i]);
311 }
312 }
313
314 for (i, def) in HIGHLIGHTS.iter().enumerate() {
316 if def.tag.is_empty() {
317 continue; }
319
320 let style = if !self.styles[i].is_empty() {
322 &self.styles[i]
323 } else if !def.parent_tag.is_empty() {
324 tag_to_style
326 .get(def.parent_tag)
327 .copied()
328 .unwrap_or(&self.styles[i])
329 } else {
330 continue; };
332
333 if style.is_empty() {
334 continue;
335 }
336
337 write!(css, " a-{} {{", def.tag).unwrap();
338
339 if let Some(fg) = &style.fg {
340 write!(css, " color: {};", fg.to_hex()).unwrap();
341 }
342 if let Some(bg) = &style.bg {
343 write!(css, " background: {};", bg.to_hex()).unwrap();
344 }
345
346 let mut decorations = Vec::new();
347 if style.modifiers.underline {
348 decorations.push("underline");
349 }
350 if style.modifiers.strikethrough {
351 decorations.push("line-through");
352 }
353 if !decorations.is_empty() {
354 write!(css, " text-decoration: {};", decorations.join(" ")).unwrap();
355 }
356
357 if style.modifiers.bold {
358 write!(css, " font-weight: bold;").unwrap();
359 }
360 if style.modifiers.italic {
361 write!(css, " font-style: italic;").unwrap();
362 }
363
364 writeln!(css, " }}").unwrap();
365 }
366
367 writeln!(css, "}}").unwrap();
368
369 css
370 }
371
372 pub fn ansi_style(&self, index: usize) -> String {
374 let Some(style) = self.styles.get(index) else {
375 return String::new();
376 };
377
378 if style.is_empty() {
379 return String::new();
380 }
381
382 let mut codes = Vec::new();
383
384 if style.modifiers.bold {
385 codes.push("1".to_string());
386 }
387 if style.modifiers.italic {
388 codes.push("3".to_string());
389 }
390 if style.modifiers.underline {
391 codes.push("4".to_string());
392 }
393 if style.modifiers.strikethrough {
394 codes.push("9".to_string());
395 }
396
397 if let Some(fg) = &style.fg {
398 codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
399 }
400 if let Some(bg) = &style.bg {
401 codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
402 }
403
404 if codes.is_empty() {
405 String::new()
406 } else {
407 format!("\x1b[{}m", codes.join(";"))
408 }
409 }
410
411 pub const ANSI_RESET: &'static str = "\x1b[0m";
413}
414
415fn parse_style_value(
417 value: &toml::Value,
418 resolve_color: &impl Fn(&str) -> Option<Color>,
419) -> Result<Style, ThemeError> {
420 let mut style = Style::new();
421
422 match value {
423 toml::Value::String(s) => {
425 style.fg = resolve_color(s);
426 }
427 toml::Value::Table(t) => {
429 if let Some(fg) = t.get("fg").and_then(|v| v.as_str()) {
430 style.fg = resolve_color(fg);
431 }
432 if let Some(bg) = t.get("bg").and_then(|v| v.as_str()) {
433 style.bg = resolve_color(bg);
434 }
435 if let Some(mods) = t.get("modifiers").and_then(|v| v.as_array()) {
436 for m in mods {
437 if let Some(s) = m.as_str() {
438 match s {
439 "bold" => style.modifiers.bold = true,
440 "italic" => style.modifiers.italic = true,
441 "underlined" | "underline" => style.modifiers.underline = true,
442 "crossed_out" | "strikethrough" => style.modifiers.strikethrough = true,
443 _ => {}
444 }
445 }
446 }
447 }
448 }
449 _ => {}
450 }
451
452 Ok(style)
453}
454
455#[derive(Debug)]
457pub enum ThemeError {
458 Parse(String),
459}
460
461impl std::fmt::Display for ThemeError {
462 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463 match self {
464 ThemeError::Parse(msg) => write!(f, "Theme parse error: {msg}"),
465 }
466 }
467}
468
469impl std::error::Error for ThemeError {}
470
471macro_rules! builtin_theme {
476 ($name:ident, $file:literal) => {
477 pub fn $name() -> &'static Theme {
478 use std::sync::OnceLock;
479 static THEME: OnceLock<Theme> = OnceLock::new();
480 THEME.get_or_init(|| {
481 Theme::from_toml(include_str!(concat!("../themes/", $file)))
482 .expect(concat!("Failed to parse built-in theme: ", $file))
483 })
484 }
485 };
486}
487
488pub mod builtin {
490 use super::Theme;
491
492 builtin_theme!(catppuccin_mocha, "catppuccin-mocha.toml");
493 builtin_theme!(catppuccin_latte, "catppuccin-latte.toml");
494 builtin_theme!(catppuccin_frappe, "catppuccin-frappe.toml");
495 builtin_theme!(catppuccin_macchiato, "catppuccin-macchiato.toml");
496 builtin_theme!(dracula, "dracula.toml");
497 builtin_theme!(tokyo_night, "tokyo-night.toml");
498 builtin_theme!(nord, "nord.toml");
499 builtin_theme!(one_dark, "one-dark.toml");
500 builtin_theme!(github_dark, "github-dark.toml");
501 builtin_theme!(github_light, "github-light.toml");
502 builtin_theme!(gruvbox_dark, "gruvbox-dark.toml");
503 builtin_theme!(gruvbox_light, "gruvbox-light.toml");
504 builtin_theme!(monokai, "monokai.toml");
505 builtin_theme!(kanagawa_dragon, "kanagawa-dragon.toml");
506 builtin_theme!(rose_pine_moon, "rose-pine-moon.toml");
507 builtin_theme!(ayu_dark, "ayu-dark.toml");
508 builtin_theme!(ayu_light, "ayu-light.toml");
509 builtin_theme!(solarized_dark, "solarized-dark.toml");
510 builtin_theme!(solarized_light, "solarized-light.toml");
511 builtin_theme!(ef_melissa_dark, "ef-melissa-dark.toml");
512 builtin_theme!(melange_dark, "melange-dark.toml");
513 builtin_theme!(melange_light, "melange-light.toml");
514 builtin_theme!(light_owl, "light-owl.toml");
515 builtin_theme!(lucius_light, "lucius-light.toml");
516
517 pub fn all() -> Vec<&'static Theme> {
519 vec![
520 catppuccin_mocha(),
521 catppuccin_latte(),
522 catppuccin_frappe(),
523 catppuccin_macchiato(),
524 dracula(),
525 tokyo_night(),
526 nord(),
527 one_dark(),
528 github_dark(),
529 github_light(),
530 gruvbox_dark(),
531 gruvbox_light(),
532 monokai(),
533 kanagawa_dragon(),
534 rose_pine_moon(),
535 ayu_dark(),
536 ayu_light(),
537 solarized_dark(),
538 solarized_light(),
539 ef_melissa_dark(),
540 melange_dark(),
541 melange_light(),
542 light_owl(),
543 lucius_light(),
544 ]
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 #[test]
553 fn test_color_from_hex() {
554 assert_eq!(Color::from_hex("#ff0000"), Some(Color::new(255, 0, 0)));
555 assert_eq!(Color::from_hex("00ff00"), Some(Color::new(0, 255, 0)));
556 assert_eq!(Color::from_hex("#invalid"), None);
557 }
558
559 #[test]
560 fn test_color_to_hex() {
561 assert_eq!(Color::new(255, 0, 0).to_hex(), "#ff0000");
562 assert_eq!(Color::new(0, 255, 0).to_hex(), "#00ff00");
563 }
564}