1use std::fmt::Write as FmtWrite;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct Color {
31 pub r: u8,
32 pub g: u8,
33 pub b: u8,
34}
35
36impl Color {
37 pub const fn new(r: u8, g: u8, b: u8) -> Self {
38 Self { r, g, b }
39 }
40
41 pub fn from_hex(s: &str) -> Option<Self> {
43 let s = s.strip_prefix('#').unwrap_or(s);
44 if s.len() != 6 {
45 return None;
46 }
47 let r = u8::from_str_radix(&s[0..2], 16).ok()?;
48 let g = u8::from_str_radix(&s[2..4], 16).ok()?;
49 let b = u8::from_str_radix(&s[4..6], 16).ok()?;
50 Some(Self { r, g, b })
51 }
52
53 pub fn to_hex(&self) -> String {
55 format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
56 }
57
58 pub fn lighten(&self, factor: f32) -> Self {
60 let factor = factor.clamp(0.0, 1.0);
61 Self {
62 r: (self.r as f32 + (255.0 - self.r as f32) * factor).round() as u8,
63 g: (self.g as f32 + (255.0 - self.g as f32) * factor).round() as u8,
64 b: (self.b as f32 + (255.0 - self.b as f32) * factor).round() as u8,
65 }
66 }
67
68 pub fn darken(&self, factor: f32) -> Self {
70 let factor = factor.clamp(0.0, 1.0);
71 Self {
72 r: (self.r as f32 * (1.0 - factor)).round() as u8,
73 g: (self.g as f32 * (1.0 - factor)).round() as u8,
74 b: (self.b as f32 * (1.0 - factor)).round() as u8,
75 }
76 }
77}
78
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
81pub struct Modifiers {
82 pub bold: bool,
83 pub italic: bool,
84 pub underline: bool,
85 pub strikethrough: bool,
86}
87
88#[derive(Debug, Clone, Default)]
90pub struct Style {
91 pub fg: Option<Color>,
92 pub bg: Option<Color>,
93 pub modifiers: Modifiers,
94}
95
96impl Style {
97 pub const fn new() -> Self {
98 Self {
99 fg: None,
100 bg: None,
101 modifiers: Modifiers {
102 bold: false,
103 italic: false,
104 underline: false,
105 strikethrough: false,
106 },
107 }
108 }
109
110 pub const fn fg(mut self, color: Color) -> Self {
111 self.fg = Some(color);
112 self
113 }
114
115 pub const fn bold(mut self) -> Self {
116 self.modifiers.bold = true;
117 self
118 }
119
120 pub const fn italic(mut self) -> Self {
121 self.modifiers.italic = true;
122 self
123 }
124
125 pub const fn underline(mut self) -> Self {
126 self.modifiers.underline = true;
127 self
128 }
129
130 pub const fn strikethrough(mut self) -> Self {
131 self.modifiers.strikethrough = true;
132 self
133 }
134
135 pub fn is_empty(&self) -> bool {
137 self.fg.is_none()
138 && self.bg.is_none()
139 && !self.modifiers.bold
140 && !self.modifiers.italic
141 && !self.modifiers.underline
142 && !self.modifiers.strikethrough
143 }
144}
145
146#[derive(Debug, Clone)]
148pub struct Theme {
149 pub name: String,
151 pub is_dark: bool,
153 pub source_url: Option<String>,
155 pub background: Option<Color>,
157 pub foreground: Option<Color>,
159 pub styles: [Style; crate::highlights::COUNT],
161}
162
163impl Default for Theme {
164 fn default() -> Self {
165 Self {
166 name: String::new(),
167 is_dark: true,
168 source_url: None,
169 background: None,
170 foreground: None,
171 styles: std::array::from_fn(|_| Style::new()),
172 }
173 }
174}
175
176impl Theme {
177 pub fn new(name: impl Into<String>) -> Self {
179 Self {
180 name: name.into(),
181 ..Default::default()
182 }
183 }
184
185 pub fn style(&self, index: usize) -> Option<&Style> {
187 self.styles.get(index)
188 }
189
190 pub fn set_style(&mut self, index: usize, style: Style) {
192 if index < self.styles.len() {
193 self.styles[index] = style;
194 }
195 }
196
197 #[cfg(feature = "toml")]
201 pub fn from_toml(toml_str: &str) -> Result<Self, ThemeError> {
202 let value: toml::Value = toml_str
203 .parse()
204 .map_err(|e| ThemeError::Parse(format!("{e}")))?;
205 let table = value
206 .as_table()
207 .ok_or(ThemeError::Parse("Expected table".into()))?;
208
209 let mut theme = Theme::default();
210
211 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
213 theme.name = name.to_string();
214 }
215 if let Some(variant) = table.get("variant").and_then(|v| v.as_str()) {
216 theme.is_dark = variant != "light";
217 }
218 if let Some(source) = table.get("source").and_then(|v| v.as_str()) {
219 theme.source_url = Some(source.to_string());
220 }
221
222 let palette: std::collections::HashMap<&str, Color> = table
224 .get("palette")
225 .and_then(|v| v.as_table())
226 .map(|t| {
227 t.iter()
228 .filter_map(|(k, v)| {
229 v.as_str()
230 .and_then(Color::from_hex)
231 .map(|c| (k.as_str(), c))
232 })
233 .collect()
234 })
235 .unwrap_or_default();
236
237 let resolve_color =
239 |s: &str| -> Option<Color> { Color::from_hex(s).or_else(|| palette.get(s).copied()) };
240
241 if let Some(bg) = table.get("ui.background")
243 && let Some(bg_table) = bg.as_table()
244 && let Some(bg_str) = bg_table.get("bg").and_then(|v| v.as_str())
245 {
246 theme.background = resolve_color(bg_str);
247 }
248 if let Some(bg_str) = table.get("background").and_then(|v| v.as_str()) {
250 theme.background = resolve_color(bg_str);
251 }
252
253 if let Some(fg) = table.get("ui.foreground") {
254 if let Some(fg_str) = fg.as_str() {
255 theme.foreground = resolve_color(fg_str);
256 } else if let Some(fg_table) = fg.as_table()
257 && let Some(fg_str) = fg_table.get("fg").and_then(|v| v.as_str())
258 {
259 theme.foreground = resolve_color(fg_str);
260 }
261 }
262 if let Some(fg_str) = table.get("foreground").and_then(|v| v.as_str()) {
264 theme.foreground = resolve_color(fg_str);
265 }
266
267 use crate::highlights::HIGHLIGHTS;
269
270 for (i, def) in HIGHLIGHTS.iter().enumerate() {
272 if let Some(rule) = table.get(def.name) {
274 let style = parse_style_value(rule, &resolve_color)?;
275 theme.styles[i] = style;
276 continue;
277 }
278
279 for alias in def.aliases {
281 if let Some(rule) = table.get(*alias) {
282 let style = parse_style_value(rule, &resolve_color)?;
283 theme.styles[i] = style;
284 break;
285 }
286 }
287 }
288
289 let extra_mappings: &[(&str, &str)] = &[
291 ("keyword.control", "keyword"),
292 ("keyword.storage", "keyword"),
293 ("comment.line", "comment"),
294 ("comment.block", "comment"),
295 ("function.macro", "macro"),
296 ];
297
298 for (helix_name, our_name) in extra_mappings {
299 if let Some(rule) = table.get(*helix_name) {
300 if let Some(i) = HIGHLIGHTS.iter().position(|h| h.name == *our_name) {
302 if theme.styles[i].is_empty() {
304 let style = parse_style_value(rule, &resolve_color)?;
305 theme.styles[i] = style;
306 }
307 }
308 }
309 }
310
311 Ok(theme)
312 }
313
314 pub fn to_css(&self, selector_prefix: &str) -> String {
319 use crate::highlights::HIGHLIGHTS;
320 use std::collections::HashMap;
321
322 let mut css = String::new();
323
324 writeln!(css, "{selector_prefix} {{").unwrap();
325
326 if let Some(bg) = &self.background {
328 writeln!(css, " background: {};", bg.to_hex()).unwrap();
329 writeln!(css, " --bg: {};", bg.to_hex()).unwrap();
330 let surface = if self.is_dark {
332 bg.lighten(0.08)
333 } else {
334 bg.darken(0.05)
335 };
336 writeln!(css, " --surface: {};", surface.to_hex()).unwrap();
337 }
338 if let Some(fg) = &self.foreground {
339 writeln!(css, " color: {};", fg.to_hex()).unwrap();
340 writeln!(css, " --fg: {};", fg.to_hex()).unwrap();
341 }
342
343 let function_idx = HIGHLIGHTS.iter().position(|h| h.name == "function");
345 let keyword_idx = HIGHLIGHTS.iter().position(|h| h.name == "keyword");
346 let comment_idx = HIGHLIGHTS.iter().position(|h| h.name == "comment");
347
348 let accent_color = function_idx
350 .and_then(|i| self.styles[i].fg.as_ref())
351 .or_else(|| keyword_idx.and_then(|i| self.styles[i].fg.as_ref()))
352 .or(self.foreground.as_ref());
353 if let Some(accent) = accent_color {
354 writeln!(css, " --accent: {};", accent.to_hex()).unwrap();
355 }
356
357 let muted_color = comment_idx.and_then(|i| self.styles[i].fg.as_ref());
359 if let Some(muted) = muted_color {
360 writeln!(css, " --muted: {};", muted.to_hex()).unwrap();
361 } else if let Some(fg) = &self.foreground {
362 let muted = if self.is_dark {
363 fg.darken(0.3)
364 } else {
365 fg.lighten(0.3)
366 };
367 writeln!(css, " --muted: {};", muted.to_hex()).unwrap();
368 }
369
370 let mut tag_to_style: HashMap<&str, &Style> = HashMap::new();
372 for (i, def) in HIGHLIGHTS.iter().enumerate() {
373 if !def.tag.is_empty() && !self.styles[i].is_empty() {
374 tag_to_style.insert(def.tag, &self.styles[i]);
375 }
376 }
377
378 let mut emitted_tags: std::collections::HashSet<&str> = std::collections::HashSet::new();
381 for (i, def) in HIGHLIGHTS.iter().enumerate() {
382 if def.tag.is_empty() || emitted_tags.contains(def.tag) {
383 continue; }
385
386 let style = if !self.styles[i].is_empty() {
388 &self.styles[i]
389 } else if !def.parent_tag.is_empty() {
390 tag_to_style
392 .get(def.parent_tag)
393 .copied()
394 .unwrap_or(&self.styles[i])
395 } else {
396 continue; };
398
399 if style.is_empty() {
400 continue;
401 }
402
403 emitted_tags.insert(def.tag);
404
405 write!(css, " a-{} {{", def.tag).unwrap();
406
407 if let Some(fg) = &style.fg {
408 write!(css, " color: {};", fg.to_hex()).unwrap();
409 }
410 if let Some(bg) = &style.bg {
411 write!(css, " background: {};", bg.to_hex()).unwrap();
412 }
413
414 let mut decorations = Vec::new();
415 if style.modifiers.underline {
416 decorations.push("underline");
417 }
418 if style.modifiers.strikethrough {
419 decorations.push("line-through");
420 }
421 if !decorations.is_empty() {
422 write!(css, " text-decoration: {};", decorations.join(" ")).unwrap();
423 }
424
425 if style.modifiers.bold {
426 write!(css, " font-weight: bold;").unwrap();
427 }
428 if style.modifiers.italic {
429 write!(css, " font-style: italic;").unwrap();
430 }
431
432 writeln!(css, " }}").unwrap();
433 }
434
435 writeln!(css, "}}").unwrap();
436
437 css
438 }
439
440 pub fn ansi_style(&self, index: usize) -> String {
442 let Some(style) = self.styles.get(index) else {
443 return String::new();
444 };
445
446 if style.is_empty() {
447 return String::new();
448 }
449
450 let mut codes = Vec::new();
451
452 if style.modifiers.bold {
453 codes.push("1".to_string());
454 }
455 if style.modifiers.italic {
456 codes.push("3".to_string());
457 }
458 if style.modifiers.underline {
459 codes.push("4".to_string());
460 }
461 if style.modifiers.strikethrough {
462 codes.push("9".to_string());
463 }
464
465 if let Some(fg) = &style.fg {
466 codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
467 }
468 if let Some(bg) = &style.bg {
469 codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
470 }
471
472 if codes.is_empty() {
473 String::new()
474 } else {
475 format!("\x1b[{}m", codes.join(";"))
476 }
477 }
478
479 pub fn ansi_style_with_base_bg(&self, index: usize) -> String {
487 let Some(style) = self.styles.get(index) else {
488 return String::new();
489 };
490
491 if style.is_empty() {
492 return String::new();
493 }
494
495 let mut codes = Vec::new();
496
497 if style.modifiers.bold {
498 codes.push("1".to_string());
499 }
500 if style.modifiers.italic {
501 codes.push("3".to_string());
502 }
503 if style.modifiers.underline {
504 codes.push("4".to_string());
505 }
506 if style.modifiers.strikethrough {
507 codes.push("9".to_string());
508 }
509
510 if let Some(fg) = &style.fg {
512 codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
513 } else if let Some(fg) = &self.foreground {
514 codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
515 }
516
517 if let Some(bg) = &style.bg {
519 codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
520 } else if let Some(bg) = &self.background {
521 codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
522 }
523
524 if codes.is_empty() {
525 String::new()
526 } else {
527 format!("\x1b[{}m", codes.join(";"))
528 }
529 }
530
531 pub fn ansi_base_style(&self) -> String {
536 let mut codes = Vec::new();
537
538 if let Some(fg) = &self.foreground {
539 codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
540 }
541 if let Some(bg) = &self.background {
542 codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
543 }
544
545 if codes.is_empty() {
546 String::new()
547 } else {
548 format!("\x1b[{}m", codes.join(";"))
549 }
550 }
551
552 pub fn ansi_border_style(&self) -> String {
557 let Some(bg) = &self.background else {
558 return String::new();
559 };
560
561 let border = if self.is_dark {
563 Color::new(
564 bg.r.saturating_add(30),
565 bg.g.saturating_add(30),
566 bg.b.saturating_add(30),
567 )
568 } else {
569 Color::new(
570 bg.r.saturating_sub(30),
571 bg.g.saturating_sub(30),
572 bg.b.saturating_sub(30),
573 )
574 };
575
576 format!("\x1b[38;2;{};{};{}m", border.r, border.g, border.b)
577 }
578
579 pub const ANSI_RESET: &'static str = "\x1b[0m";
581}
582
583#[cfg(feature = "toml")]
585fn parse_style_value(
586 value: &toml::Value,
587 resolve_color: &impl Fn(&str) -> Option<Color>,
588) -> Result<Style, ThemeError> {
589 let mut style = Style::new();
590
591 match value {
592 toml::Value::String(s) => {
594 style.fg = resolve_color(s);
595 }
596 toml::Value::Table(t) => {
598 if let Some(fg) = t.get("fg").and_then(|v| v.as_str()) {
599 style.fg = resolve_color(fg);
600 }
601 if let Some(bg) = t.get("bg").and_then(|v| v.as_str()) {
602 style.bg = resolve_color(bg);
603 }
604 if let Some(mods) = t.get("modifiers").and_then(|v| v.as_array()) {
605 for m in mods {
606 if let Some(s) = m.as_str() {
607 match s {
608 "bold" => style.modifiers.bold = true,
609 "italic" => style.modifiers.italic = true,
610 "underlined" | "underline" => style.modifiers.underline = true,
611 "crossed_out" | "strikethrough" => style.modifiers.strikethrough = true,
612 _ => {}
613 }
614 }
615 }
616 }
617 }
618 _ => {}
619 }
620
621 Ok(style)
622}
623
624#[derive(Debug)]
626pub enum ThemeError {
627 Parse(String),
628}
629
630impl std::fmt::Display for ThemeError {
631 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
632 match self {
633 ThemeError::Parse(msg) => write!(f, "Theme parse error: {msg}"),
634 }
635 }
636}
637
638impl std::error::Error for ThemeError {}
639
640pub mod builtin {
649 include!("builtin_generated.rs");
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655
656 #[test]
657 fn test_color_from_hex() {
658 assert_eq!(Color::from_hex("#ff0000"), Some(Color::new(255, 0, 0)));
659 assert_eq!(Color::from_hex("00ff00"), Some(Color::new(0, 255, 0)));
660 assert_eq!(Color::from_hex("#invalid"), None);
661 }
662
663 #[test]
664 fn test_color_to_hex() {
665 assert_eq!(Color::new(255, 0, 0).to_hex(), "#ff0000");
666 assert_eq!(Color::new(0, 255, 0).to_hex(), "#00ff00");
667 }
668}