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 source_url: Option<String>,
136 pub background: Option<Color>,
138 pub foreground: Option<Color>,
140 styles: [Style; crate::highlights::COUNT],
142}
143
144impl Default for Theme {
145 fn default() -> Self {
146 Self {
147 name: String::new(),
148 is_dark: true,
149 source_url: None,
150 background: None,
151 foreground: None,
152 styles: std::array::from_fn(|_| Style::new()),
153 }
154 }
155}
156
157impl Theme {
158 pub fn new(name: impl Into<String>) -> Self {
160 Self {
161 name: name.into(),
162 ..Default::default()
163 }
164 }
165
166 pub fn style(&self, index: usize) -> Option<&Style> {
168 self.styles.get(index)
169 }
170
171 pub fn set_style(&mut self, index: usize, style: Style) {
173 if index < self.styles.len() {
174 self.styles[index] = style;
175 }
176 }
177
178 pub fn from_toml(toml_str: &str) -> Result<Self, ThemeError> {
180 let value: toml::Value = toml_str
181 .parse()
182 .map_err(|e| ThemeError::Parse(format!("{e}")))?;
183 let table = value
184 .as_table()
185 .ok_or(ThemeError::Parse("Expected table".into()))?;
186
187 let mut theme = Theme::default();
188
189 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
191 theme.name = name.to_string();
192 }
193 if let Some(variant) = table.get("variant").and_then(|v| v.as_str()) {
194 theme.is_dark = variant != "light";
195 }
196 if let Some(source) = table.get("source").and_then(|v| v.as_str()) {
197 theme.source_url = Some(source.to_string());
198 }
199
200 let palette: HashMap<&str, Color> = table
202 .get("palette")
203 .and_then(|v| v.as_table())
204 .map(|t| {
205 t.iter()
206 .filter_map(|(k, v)| {
207 v.as_str()
208 .and_then(Color::from_hex)
209 .map(|c| (k.as_str(), c))
210 })
211 .collect()
212 })
213 .unwrap_or_default();
214
215 let resolve_color =
217 |s: &str| -> Option<Color> { Color::from_hex(s).or_else(|| palette.get(s).copied()) };
218
219 if let Some(bg) = table.get("ui.background")
221 && let Some(bg_table) = bg.as_table()
222 && let Some(bg_str) = bg_table.get("bg").and_then(|v| v.as_str())
223 {
224 theme.background = resolve_color(bg_str);
225 }
226 if let Some(bg_str) = table.get("background").and_then(|v| v.as_str()) {
228 theme.background = resolve_color(bg_str);
229 }
230
231 if let Some(fg) = table.get("ui.foreground") {
232 if let Some(fg_str) = fg.as_str() {
233 theme.foreground = resolve_color(fg_str);
234 } else if let Some(fg_table) = fg.as_table()
235 && let Some(fg_str) = fg_table.get("fg").and_then(|v| v.as_str())
236 {
237 theme.foreground = resolve_color(fg_str);
238 }
239 }
240 if let Some(fg_str) = table.get("foreground").and_then(|v| v.as_str()) {
242 theme.foreground = resolve_color(fg_str);
243 }
244
245 use crate::highlights::HIGHLIGHTS;
247
248 for (i, def) in HIGHLIGHTS.iter().enumerate() {
250 if let Some(rule) = table.get(def.name) {
252 let style = parse_style_value(rule, &resolve_color)?;
253 theme.styles[i] = style;
254 continue;
255 }
256
257 for alias in def.aliases {
259 if let Some(rule) = table.get(*alias) {
260 let style = parse_style_value(rule, &resolve_color)?;
261 theme.styles[i] = style;
262 break;
263 }
264 }
265 }
266
267 let extra_mappings: &[(&str, &str)] = &[
269 ("keyword.control", "keyword"),
270 ("keyword.storage", "keyword"),
271 ("comment.line", "comment"),
272 ("comment.block", "comment"),
273 ("function.macro", "macro"),
274 ];
275
276 for (helix_name, our_name) in extra_mappings {
277 if let Some(rule) = table.get(*helix_name) {
278 if let Some(i) = HIGHLIGHTS.iter().position(|h| h.name == *our_name) {
280 if theme.styles[i].is_empty() {
282 let style = parse_style_value(rule, &resolve_color)?;
283 theme.styles[i] = style;
284 }
285 }
286 }
287 }
288
289 Ok(theme)
290 }
291
292 pub fn to_css(&self, selector_prefix: &str) -> String {
297 use crate::highlights::HIGHLIGHTS;
298 use std::collections::HashMap;
299
300 let mut css = String::new();
301
302 writeln!(css, "{selector_prefix} {{").unwrap();
303
304 if let Some(bg) = &self.background {
306 writeln!(css, " background: {};", bg.to_hex()).unwrap();
307 }
308 if let Some(fg) = &self.foreground {
309 writeln!(css, " color: {};", fg.to_hex()).unwrap();
310 }
311
312 let mut tag_to_style: HashMap<&str, &Style> = HashMap::new();
314 for (i, def) in HIGHLIGHTS.iter().enumerate() {
315 if !def.tag.is_empty() && !self.styles[i].is_empty() {
316 tag_to_style.insert(def.tag, &self.styles[i]);
317 }
318 }
319
320 for (i, def) in HIGHLIGHTS.iter().enumerate() {
322 if def.tag.is_empty() {
323 continue; }
325
326 let style = if !self.styles[i].is_empty() {
328 &self.styles[i]
329 } else if !def.parent_tag.is_empty() {
330 tag_to_style
332 .get(def.parent_tag)
333 .copied()
334 .unwrap_or(&self.styles[i])
335 } else {
336 continue; };
338
339 if style.is_empty() {
340 continue;
341 }
342
343 write!(css, " a-{} {{", def.tag).unwrap();
344
345 if let Some(fg) = &style.fg {
346 write!(css, " color: {};", fg.to_hex()).unwrap();
347 }
348 if let Some(bg) = &style.bg {
349 write!(css, " background: {};", bg.to_hex()).unwrap();
350 }
351
352 let mut decorations = Vec::new();
353 if style.modifiers.underline {
354 decorations.push("underline");
355 }
356 if style.modifiers.strikethrough {
357 decorations.push("line-through");
358 }
359 if !decorations.is_empty() {
360 write!(css, " text-decoration: {};", decorations.join(" ")).unwrap();
361 }
362
363 if style.modifiers.bold {
364 write!(css, " font-weight: bold;").unwrap();
365 }
366 if style.modifiers.italic {
367 write!(css, " font-style: italic;").unwrap();
368 }
369
370 writeln!(css, " }}").unwrap();
371 }
372
373 writeln!(css, "}}").unwrap();
374
375 css
376 }
377
378 pub fn ansi_style(&self, index: usize) -> String {
380 let Some(style) = self.styles.get(index) else {
381 return String::new();
382 };
383
384 if style.is_empty() {
385 return String::new();
386 }
387
388 let mut codes = Vec::new();
389
390 if style.modifiers.bold {
391 codes.push("1".to_string());
392 }
393 if style.modifiers.italic {
394 codes.push("3".to_string());
395 }
396 if style.modifiers.underline {
397 codes.push("4".to_string());
398 }
399 if style.modifiers.strikethrough {
400 codes.push("9".to_string());
401 }
402
403 if let Some(fg) = &style.fg {
404 codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
405 }
406 if let Some(bg) = &style.bg {
407 codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
408 }
409
410 if codes.is_empty() {
411 String::new()
412 } else {
413 format!("\x1b[{}m", codes.join(";"))
414 }
415 }
416
417 pub const ANSI_RESET: &'static str = "\x1b[0m";
419}
420
421fn parse_style_value(
423 value: &toml::Value,
424 resolve_color: &impl Fn(&str) -> Option<Color>,
425) -> Result<Style, ThemeError> {
426 let mut style = Style::new();
427
428 match value {
429 toml::Value::String(s) => {
431 style.fg = resolve_color(s);
432 }
433 toml::Value::Table(t) => {
435 if let Some(fg) = t.get("fg").and_then(|v| v.as_str()) {
436 style.fg = resolve_color(fg);
437 }
438 if let Some(bg) = t.get("bg").and_then(|v| v.as_str()) {
439 style.bg = resolve_color(bg);
440 }
441 if let Some(mods) = t.get("modifiers").and_then(|v| v.as_array()) {
442 for m in mods {
443 if let Some(s) = m.as_str() {
444 match s {
445 "bold" => style.modifiers.bold = true,
446 "italic" => style.modifiers.italic = true,
447 "underlined" | "underline" => style.modifiers.underline = true,
448 "crossed_out" | "strikethrough" => style.modifiers.strikethrough = true,
449 _ => {}
450 }
451 }
452 }
453 }
454 }
455 _ => {}
456 }
457
458 Ok(style)
459}
460
461#[derive(Debug)]
463pub enum ThemeError {
464 Parse(String),
465}
466
467impl std::fmt::Display for ThemeError {
468 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
469 match self {
470 ThemeError::Parse(msg) => write!(f, "Theme parse error: {msg}"),
471 }
472 }
473}
474
475impl std::error::Error for ThemeError {}
476
477macro_rules! builtin_theme {
482 ($name:ident, $file:literal) => {
483 pub fn $name() -> &'static Theme {
484 use std::sync::OnceLock;
485 static THEME: OnceLock<Theme> = OnceLock::new();
486 THEME.get_or_init(|| {
487 Theme::from_toml(include_str!(concat!("../themes/", $file)))
488 .expect(concat!("Failed to parse built-in theme: ", $file))
489 })
490 }
491 };
492}
493
494pub mod builtin {
496 use super::Theme;
497
498 builtin_theme!(catppuccin_mocha, "catppuccin-mocha.toml");
499 builtin_theme!(catppuccin_latte, "catppuccin-latte.toml");
500 builtin_theme!(catppuccin_frappe, "catppuccin-frappe.toml");
501 builtin_theme!(catppuccin_macchiato, "catppuccin-macchiato.toml");
502 builtin_theme!(dracula, "dracula.toml");
503 builtin_theme!(tokyo_night, "tokyo-night.toml");
504 builtin_theme!(nord, "nord.toml");
505 builtin_theme!(one_dark, "one-dark.toml");
506 builtin_theme!(github_dark, "github-dark.toml");
507 builtin_theme!(github_light, "github-light.toml");
508 builtin_theme!(gruvbox_dark, "gruvbox-dark.toml");
509 builtin_theme!(gruvbox_light, "gruvbox-light.toml");
510 builtin_theme!(monokai, "monokai.toml");
511 builtin_theme!(kanagawa_dragon, "kanagawa-dragon.toml");
512 builtin_theme!(rose_pine_moon, "rose-pine-moon.toml");
513 builtin_theme!(ayu_dark, "ayu-dark.toml");
514 builtin_theme!(ayu_light, "ayu-light.toml");
515 builtin_theme!(solarized_dark, "solarized-dark.toml");
516 builtin_theme!(solarized_light, "solarized-light.toml");
517 builtin_theme!(ef_melissa_dark, "ef-melissa-dark.toml");
518 builtin_theme!(melange_dark, "melange-dark.toml");
519 builtin_theme!(melange_light, "melange-light.toml");
520 builtin_theme!(light_owl, "light-owl.toml");
521 builtin_theme!(lucius_light, "lucius-light.toml");
522 builtin_theme!(rustdoc_light, "rustdoc-light.toml");
523 builtin_theme!(rustdoc_dark, "rustdoc-dark.toml");
524 builtin_theme!(rustdoc_ayu, "rustdoc-ayu.toml");
525 builtin_theme!(dayfox, "dayfox.toml");
526 builtin_theme!(alabaster, "alabaster.toml");
527 builtin_theme!(cobalt2, "cobalt2.toml");
528 builtin_theme!(zenburn, "zenburn.toml");
529 builtin_theme!(desert256, "desert256.toml");
530
531 pub fn all() -> Vec<&'static Theme> {
533 vec![
534 catppuccin_mocha(),
535 catppuccin_latte(),
536 catppuccin_frappe(),
537 catppuccin_macchiato(),
538 dracula(),
539 tokyo_night(),
540 nord(),
541 one_dark(),
542 github_dark(),
543 github_light(),
544 gruvbox_dark(),
545 gruvbox_light(),
546 monokai(),
547 kanagawa_dragon(),
548 rose_pine_moon(),
549 ayu_dark(),
550 ayu_light(),
551 solarized_dark(),
552 solarized_light(),
553 ef_melissa_dark(),
554 melange_dark(),
555 melange_light(),
556 light_owl(),
557 lucius_light(),
558 rustdoc_light(),
559 rustdoc_dark(),
560 rustdoc_ayu(),
561 dayfox(),
562 alabaster(),
563 cobalt2(),
564 zenburn(),
565 desert256(),
566 ]
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 #[test]
575 fn test_color_from_hex() {
576 assert_eq!(Color::from_hex("#ff0000"), Some(Color::new(255, 0, 0)));
577 assert_eq!(Color::from_hex("00ff00"), Some(Color::new(0, 255, 0)));
578 assert_eq!(Color::from_hex("#invalid"), None);
579 }
580
581 #[test]
582 fn test_color_to_hex() {
583 assert_eq!(Color::new(255, 0, 0).to_hex(), "#ff0000");
584 assert_eq!(Color::new(0, 255, 0).to_hex(), "#00ff00");
585 }
586}