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 for (i, def) in HIGHLIGHTS.iter().enumerate() {
380 if def.tag.is_empty() {
381 continue; }
383
384 let style = if !self.styles[i].is_empty() {
386 &self.styles[i]
387 } else if !def.parent_tag.is_empty() {
388 tag_to_style
390 .get(def.parent_tag)
391 .copied()
392 .unwrap_or(&self.styles[i])
393 } else {
394 continue; };
396
397 if style.is_empty() {
398 continue;
399 }
400
401 write!(css, " a-{} {{", def.tag).unwrap();
402
403 if let Some(fg) = &style.fg {
404 write!(css, " color: {};", fg.to_hex()).unwrap();
405 }
406 if let Some(bg) = &style.bg {
407 write!(css, " background: {};", bg.to_hex()).unwrap();
408 }
409
410 let mut decorations = Vec::new();
411 if style.modifiers.underline {
412 decorations.push("underline");
413 }
414 if style.modifiers.strikethrough {
415 decorations.push("line-through");
416 }
417 if !decorations.is_empty() {
418 write!(css, " text-decoration: {};", decorations.join(" ")).unwrap();
419 }
420
421 if style.modifiers.bold {
422 write!(css, " font-weight: bold;").unwrap();
423 }
424 if style.modifiers.italic {
425 write!(css, " font-style: italic;").unwrap();
426 }
427
428 writeln!(css, " }}").unwrap();
429 }
430
431 writeln!(css, "}}").unwrap();
432
433 css
434 }
435
436 pub fn ansi_style(&self, index: usize) -> String {
438 let Some(style) = self.styles.get(index) else {
439 return String::new();
440 };
441
442 if style.is_empty() {
443 return String::new();
444 }
445
446 let mut codes = Vec::new();
447
448 if style.modifiers.bold {
449 codes.push("1".to_string());
450 }
451 if style.modifiers.italic {
452 codes.push("3".to_string());
453 }
454 if style.modifiers.underline {
455 codes.push("4".to_string());
456 }
457 if style.modifiers.strikethrough {
458 codes.push("9".to_string());
459 }
460
461 if let Some(fg) = &style.fg {
462 codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
463 }
464 if let Some(bg) = &style.bg {
465 codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
466 }
467
468 if codes.is_empty() {
469 String::new()
470 } else {
471 format!("\x1b[{}m", codes.join(";"))
472 }
473 }
474
475 pub fn ansi_style_with_base_bg(&self, index: usize) -> String {
483 let Some(style) = self.styles.get(index) else {
484 return String::new();
485 };
486
487 if style.is_empty() {
488 return String::new();
489 }
490
491 let mut codes = Vec::new();
492
493 if style.modifiers.bold {
494 codes.push("1".to_string());
495 }
496 if style.modifiers.italic {
497 codes.push("3".to_string());
498 }
499 if style.modifiers.underline {
500 codes.push("4".to_string());
501 }
502 if style.modifiers.strikethrough {
503 codes.push("9".to_string());
504 }
505
506 if let Some(fg) = &style.fg {
508 codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
509 } else if let Some(fg) = &self.foreground {
510 codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
511 }
512
513 if let Some(bg) = &style.bg {
515 codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
516 } else if let Some(bg) = &self.background {
517 codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
518 }
519
520 if codes.is_empty() {
521 String::new()
522 } else {
523 format!("\x1b[{}m", codes.join(";"))
524 }
525 }
526
527 pub fn ansi_base_style(&self) -> String {
532 let mut codes = Vec::new();
533
534 if let Some(fg) = &self.foreground {
535 codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
536 }
537 if let Some(bg) = &self.background {
538 codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
539 }
540
541 if codes.is_empty() {
542 String::new()
543 } else {
544 format!("\x1b[{}m", codes.join(";"))
545 }
546 }
547
548 pub fn ansi_border_style(&self) -> String {
553 let Some(bg) = &self.background else {
554 return String::new();
555 };
556
557 let border = if self.is_dark {
559 Color::new(
560 bg.r.saturating_add(30),
561 bg.g.saturating_add(30),
562 bg.b.saturating_add(30),
563 )
564 } else {
565 Color::new(
566 bg.r.saturating_sub(30),
567 bg.g.saturating_sub(30),
568 bg.b.saturating_sub(30),
569 )
570 };
571
572 format!("\x1b[38;2;{};{};{}m", border.r, border.g, border.b)
573 }
574
575 pub const ANSI_RESET: &'static str = "\x1b[0m";
577}
578
579#[cfg(feature = "toml")]
581fn parse_style_value(
582 value: &toml::Value,
583 resolve_color: &impl Fn(&str) -> Option<Color>,
584) -> Result<Style, ThemeError> {
585 let mut style = Style::new();
586
587 match value {
588 toml::Value::String(s) => {
590 style.fg = resolve_color(s);
591 }
592 toml::Value::Table(t) => {
594 if let Some(fg) = t.get("fg").and_then(|v| v.as_str()) {
595 style.fg = resolve_color(fg);
596 }
597 if let Some(bg) = t.get("bg").and_then(|v| v.as_str()) {
598 style.bg = resolve_color(bg);
599 }
600 if let Some(mods) = t.get("modifiers").and_then(|v| v.as_array()) {
601 for m in mods {
602 if let Some(s) = m.as_str() {
603 match s {
604 "bold" => style.modifiers.bold = true,
605 "italic" => style.modifiers.italic = true,
606 "underlined" | "underline" => style.modifiers.underline = true,
607 "crossed_out" | "strikethrough" => style.modifiers.strikethrough = true,
608 _ => {}
609 }
610 }
611 }
612 }
613 }
614 _ => {}
615 }
616
617 Ok(style)
618}
619
620#[derive(Debug)]
622pub enum ThemeError {
623 Parse(String),
624}
625
626impl std::fmt::Display for ThemeError {
627 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
628 match self {
629 ThemeError::Parse(msg) => write!(f, "Theme parse error: {msg}"),
630 }
631 }
632}
633
634impl std::error::Error for ThemeError {}
635
636pub mod builtin {
645 include!("builtin_generated.rs");
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 #[test]
653 fn test_color_from_hex() {
654 assert_eq!(Color::from_hex("#ff0000"), Some(Color::new(255, 0, 0)));
655 assert_eq!(Color::from_hex("00ff00"), Some(Color::new(0, 255, 0)));
656 assert_eq!(Color::from_hex("#invalid"), None);
657 }
658
659 #[test]
660 fn test_color_to_hex() {
661 assert_eq!(Color::new(255, 0, 0).to_hex(), "#ff0000");
662 assert_eq!(Color::new(0, 255, 0).to_hex(), "#00ff00");
663 }
664}