1use crate::color::{Color, ColorSystem};
10use crate::errors::{Result, RichError};
11
12const ATTR_COUNT: usize = 13;
14
15const ATTR_SGR: [&str; ATTR_COUNT] = [
17 "1", "2", "3", "4", "5", "6", "7", "8", "9", "21", "51", "52", "53",
18];
19
20const ATTR_NAMES: [&str; ATTR_COUNT] = [
22 "bold",
23 "dim",
24 "italic",
25 "underline",
26 "blink",
27 "blink2",
28 "reverse",
29 "conceal",
30 "strike",
31 "underline2",
32 "frame",
33 "encircle",
34 "overline",
35];
36
37fn attribute_index(word: &str) -> Option<usize> {
39 let canonical = match word {
40 "b" => "bold",
41 "d" => "dim",
42 "i" => "italic",
43 "u" => "underline",
44 "r" => "reverse",
45 "c" => "conceal",
46 "s" => "strike",
47 "uu" => "underline2",
48 "o" => "overline",
49 other => other,
50 };
51 ATTR_NAMES.iter().position(|&n| n == canonical)
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum StyleType {
64 Name(String),
67 Style(Style),
69}
70
71impl Default for StyleType {
72 fn default() -> Self {
73 StyleType::Style(Style::new())
74 }
75}
76
77impl StyleType {
78 pub fn is_null_style(&self) -> bool {
81 matches!(self, StyleType::Style(style) if style.is_null())
82 }
83}
84
85impl From<Style> for StyleType {
86 fn from(style: Style) -> Self {
87 StyleType::Style(style)
88 }
89}
90
91impl From<&Style> for StyleType {
92 fn from(style: &Style) -> Self {
93 StyleType::Style(style.clone())
94 }
95}
96
97impl From<String> for StyleType {
98 fn from(name: String) -> Self {
99 StyleType::Name(name)
100 }
101}
102
103impl From<&str> for StyleType {
104 fn from(name: &str) -> Self {
105 StyleType::Name(name.to_string())
106 }
107}
108
109#[derive(Debug, Clone)]
115pub enum MetaValue {
116 None,
118 Bool(bool),
119 Int(i64),
120 Float(f64),
121 Str(String),
122 List(Vec<MetaValue>),
124}
125
126impl PartialEq for MetaValue {
127 fn eq(&self, other: &Self) -> bool {
128 match (self, other) {
129 (MetaValue::None, MetaValue::None) => true,
130 (MetaValue::Bool(a), MetaValue::Bool(b)) => a == b,
131 (MetaValue::Int(a), MetaValue::Int(b)) => a == b,
132 (MetaValue::Float(a), MetaValue::Float(b)) => a.to_bits() == b.to_bits(),
133 (MetaValue::Str(a), MetaValue::Str(b)) => a == b,
134 (MetaValue::List(a), MetaValue::List(b)) => a == b,
135 _ => false,
136 }
137 }
138}
139
140impl Eq for MetaValue {}
141
142impl std::hash::Hash for MetaValue {
143 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
144 std::mem::discriminant(self).hash(state);
145 match self {
146 MetaValue::None => {}
147 MetaValue::Bool(value) => value.hash(state),
148 MetaValue::Int(value) => value.hash(state),
149 MetaValue::Float(value) => value.to_bits().hash(state),
150 MetaValue::Str(value) => value.hash(state),
151 MetaValue::List(values) => values.hash(state),
152 }
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
161pub struct Meta {
162 entries: Vec<(String, MetaValue)>,
163}
164
165impl Meta {
166 pub fn new() -> Self {
168 Meta::default()
169 }
170
171 pub fn insert(&mut self, key: impl Into<String>, value: MetaValue) {
173 let key = key.into();
174 match self
175 .entries
176 .iter_mut()
177 .find(|(existing, _)| *existing == key)
178 {
179 Some((_, slot)) => *slot = value,
180 None => self.entries.push((key, value)),
181 }
182 }
183
184 pub fn get(&self, key: &str) -> Option<&MetaValue> {
186 self.entries
187 .iter()
188 .find(|(existing, _)| existing == key)
189 .map(|(_, value)| value)
190 }
191
192 pub fn update(&mut self, other: &Meta) {
194 for (key, value) in &other.entries {
195 self.insert(key.clone(), value.clone());
196 }
197 }
198
199 pub fn iter(&self) -> impl Iterator<Item = (&str, &MetaValue)> {
201 self.entries
202 .iter()
203 .map(|(key, value)| (key.as_str(), value))
204 }
205
206 pub fn len(&self) -> usize {
207 self.entries.len()
208 }
209
210 pub fn is_empty(&self) -> bool {
211 self.entries.is_empty()
212 }
213}
214
215impl<K: Into<String>> FromIterator<(K, MetaValue)> for Meta {
216 fn from_iter<I: IntoIterator<Item = (K, MetaValue)>>(iter: I) -> Self {
217 let mut meta = Meta::new();
218 for (key, value) in iter {
219 meta.insert(key, value);
220 }
221 meta
222 }
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Default)]
227pub struct Style {
228 color: Option<Color>,
229 bgcolor: Option<Color>,
230 attrs: [Option<bool>; ATTR_COUNT],
231 link: Option<String>,
233 meta: Option<Meta>,
236}
237
238impl Style {
239 pub fn new() -> Self {
241 Style::default()
242 }
243
244 pub fn from_color(color: Option<Color>, bgcolor: Option<Color>) -> Self {
247 Style {
248 color,
249 bgcolor,
250 attrs: [None; ATTR_COUNT],
251 link: None,
252 meta: None,
253 }
254 }
255
256 pub fn with_color(mut self, color: Color) -> Self {
257 self.color = Some(color);
258 self
259 }
260
261 pub fn with_link(mut self, url: impl Into<String>) -> Self {
263 self.link = Some(url.into());
264 self
265 }
266
267 pub fn link(&self) -> Option<&str> {
269 self.link.as_deref()
270 }
271
272 pub fn update_link(&self, link: Option<String>) -> Style {
275 let mut style = self.clone();
276 style.link = link;
277 style
278 }
279
280 pub fn with_bgcolor(mut self, color: Color) -> Self {
281 self.bgcolor = Some(color);
282 self
283 }
284
285 pub fn color(&self) -> Option<&Color> {
286 self.color.as_ref()
287 }
288
289 pub fn bgcolor(&self) -> Option<&Color> {
290 self.bgcolor.as_ref()
291 }
292
293 pub fn without_color(&self) -> Style {
296 Style {
297 color: None,
298 bgcolor: None,
299 attrs: self.attrs,
300 link: self.link.clone(),
301 meta: self.meta.clone(),
302 }
303 }
304
305 pub fn attr(&self, index: usize) -> Option<bool> {
308 self.attrs.get(index).copied().flatten()
309 }
310
311 pub fn is_null(&self) -> bool {
314 self.color.is_none()
315 && self.bgcolor.is_none()
316 && self.link.is_none()
317 && self.meta.as_ref().is_none_or(Meta::is_empty)
318 && self.attrs.iter().all(Option::is_none)
319 }
320
321 pub fn with_meta(mut self, meta: Meta) -> Self {
325 self.meta = Some(meta);
326 self
327 }
328
329 pub fn meta(&self) -> Meta {
332 self.meta.clone().unwrap_or_default()
333 }
334
335 pub fn meta_ref(&self) -> Option<&Meta> {
337 self.meta.as_ref()
338 }
339
340 pub fn from_meta(meta: Meta) -> Style {
344 Style::new().with_meta(meta)
345 }
346
347 pub fn on(meta: Option<Meta>, handlers: &[(&str, MetaValue)]) -> Style {
350 let mut meta = meta.unwrap_or_default();
351 for (name, value) in handlers {
352 meta.insert(format!("@{name}"), value.clone());
353 }
354 Style::from_meta(meta)
355 }
356
357 pub fn clear_meta_and_links(&self) -> Style {
359 let mut style = self.clone();
360 style.link = None;
361 style.meta = None;
362 style
363 }
364
365 pub fn definition(&self) -> String {
372 let mut parts: Vec<String> = Vec::new();
373 for (index, name) in ATTR_NAMES.iter().enumerate() {
374 match self.attrs[index] {
375 Some(true) => parts.push((*name).to_string()),
376 Some(false) => parts.push(format!("not {name}")),
377 None => {}
378 }
379 }
380 if let Some(color) = &self.color {
381 parts.push(color.name.clone());
382 }
383 if let Some(bgcolor) = &self.bgcolor {
384 parts.push("on".to_string());
385 parts.push(bgcolor.name.clone());
386 }
387 if let Some(link) = &self.link {
388 parts.push("link".to_string());
389 parts.push(link.clone());
390 }
391 if parts.is_empty() {
392 "none".to_string()
393 } else {
394 parts.join(" ")
395 }
396 }
397
398 pub fn normalize(definition: &str) -> String {
410 match Style::parse(definition) {
411 Ok(style) => style.definition(),
412 Err(_) => definition.trim().to_lowercase(),
413 }
414 }
415
416 pub fn parse(definition: &str) -> Result<Self> {
421 if definition.is_empty() || definition.trim() == "none" {
428 return Ok(Style::new());
429 }
430 let mut style = Style::new();
431 let mut words = definition.split_whitespace();
432 while let Some(raw) = words.next() {
433 let word = raw.to_ascii_lowercase();
434 match word.as_str() {
435 "on" => {
436 let color_word = words.next().ok_or_else(|| {
437 RichError::StyleSyntax("color expected after 'on'".to_string())
438 })?;
439 style.bgcolor = Some(Color::parse(color_word)?);
440 }
441 "not" => {
442 let attr_word = words.next().ok_or_else(|| {
443 RichError::StyleSyntax("attribute expected after 'not'".to_string())
444 })?;
445 let idx = attribute_index(attr_word).ok_or_else(|| {
449 RichError::StyleSyntax(format!(
450 "{attr_word:?} is not a recognized attribute"
451 ))
452 })?;
453 style.attrs[idx] = Some(false);
454 }
455 "link" => {
456 let url = words.next().filter(|url| !url.is_empty()).ok_or_else(|| {
459 RichError::StyleSyntax("URL expected after 'link'".to_string())
460 })?;
461 style.link = Some(url.to_string());
462 }
463 _ => {
464 if let Some(idx) = attribute_index(&word) {
465 style.attrs[idx] = Some(true);
466 } else {
467 style.color = Some(Color::parse(&word)?);
468 }
469 }
470 }
471 }
472 Ok(style)
473 }
474
475 pub fn combine(&self, other: &Style) -> Style {
479 let mut attrs = self.attrs;
480 for (slot, over) in attrs.iter_mut().zip(other.attrs.iter()) {
481 if over.is_some() {
482 *slot = *over;
483 }
484 }
485 Style {
486 color: other.color.clone().or_else(|| self.color.clone()),
487 bgcolor: other.bgcolor.clone().or_else(|| self.bgcolor.clone()),
488 attrs,
489 link: other.link.clone().or_else(|| self.link.clone()),
490 meta: match (&self.meta, &other.meta) {
493 (Some(mine), Some(theirs)) => {
494 let mut merged = mine.clone();
495 merged.update(theirs);
496 Some(merged)
497 }
498 (mine, theirs) => theirs.clone().or_else(|| mine.clone()),
499 },
500 }
501 }
502
503 pub fn ansi_codes(&self, system: ColorSystem) -> String {
507 let mut sgr: Vec<String> = Vec::new();
508 for (idx, attr) in self.attrs.iter().enumerate() {
509 if *attr == Some(true) {
510 sgr.push(ATTR_SGR[idx].to_string());
511 }
512 }
513 if let Some(color) = &self.color {
514 sgr.extend(color.downgrade(system).ansi_codes(true));
515 }
516 if let Some(bgcolor) = &self.bgcolor {
517 sgr.extend(bgcolor.downgrade(system).ansi_codes(false));
518 }
519 sgr.join(";")
520 }
521
522 pub fn get_html_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
525 use crate::terminal_theme::blend_rgb;
526 let mut css: Vec<String> = Vec::new();
527
528 let mut color = self.color.clone();
529 let mut bgcolor = self.bgcolor.clone();
530 if self.attrs[6] == Some(true) {
532 std::mem::swap(&mut color, &mut bgcolor);
533 }
534 if self.attrs[1] == Some(true) {
536 let fg = match &color {
537 Some(c) => theme.resolve(c, true),
538 None => theme.foreground,
539 };
540 let blended = blend_rgb(fg, theme.background, 0.5);
541 color = Some(Color::from_rgb(blended.red, blended.green, blended.blue));
542 }
543
544 if let Some(c) = &color {
545 let hex = theme.resolve(c, true).hex();
546 css.push(format!("color: {hex}"));
547 css.push(format!("text-decoration-color: {hex}"));
548 }
549 if let Some(c) = &bgcolor {
550 let hex = theme.resolve(c, false).hex();
551 css.push(format!("background-color: {hex}"));
552 }
553 if self.attrs[0] == Some(true) {
554 css.push("font-weight: bold".to_string());
555 }
556 if self.attrs[2] == Some(true) {
557 css.push("font-style: italic".to_string());
558 }
559 if self.attrs[3] == Some(true) {
560 css.push("text-decoration: underline".to_string());
561 }
562 if self.attrs[8] == Some(true) {
563 css.push("text-decoration: line-through".to_string());
564 }
565 if self.attrs[12] == Some(true) {
566 css.push("text-decoration: overline".to_string());
567 }
568 css.join("; ")
569 }
570
571 pub fn get_svg_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
578 use crate::terminal_theme::blend_rgb;
579 let mut color = self
582 .color
583 .as_ref()
584 .map_or(theme.foreground, |c| theme.resolve(c, true));
585 let mut bgcolor = self
586 .bgcolor
587 .as_ref()
588 .map_or(theme.background, |c| theme.resolve(c, false));
589 if self.attrs[6] == Some(true) {
590 std::mem::swap(&mut color, &mut bgcolor);
591 }
592 if self.attrs[1] == Some(true) {
593 color = blend_rgb(color, bgcolor, 0.4);
594 }
595 let mut rules = vec![format!("fill: {}", color.hex())];
596 if self.attrs[0] == Some(true) {
597 rules.push("font-weight: bold".to_string());
598 }
599 if self.attrs[2] == Some(true) {
600 rules.push("font-style: italic;".to_string());
601 }
602 if self.attrs[3] == Some(true) {
603 rules.push("text-decoration: underline;".to_string());
604 }
605 if self.attrs[8] == Some(true) {
606 rules.push("text-decoration: line-through;".to_string());
607 }
608 rules.join(";")
609 }
610
611 pub fn render(&self, text: &str, system: Option<ColorSystem>) -> String {
621 let Some(system) = system else {
622 return text.to_string();
623 };
624 if text.is_empty() {
625 return text.to_string();
626 }
627 let codes = self.ansi_codes(system);
628 let rendered = if codes.is_empty() {
629 text.to_string()
630 } else {
631 format!("\x1b[{codes}m{text}\x1b[0m")
632 };
633 match &self.link {
634 Some(url) => format!("\x1b]8;;{url}\x1b\\{rendered}\x1b]8;;\x1b\\"),
635 None => rendered,
636 }
637 }
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 #[test]
646 fn meta_matches_upstream() {
647 let meta = |entries: &[(&str, MetaValue)]| -> Meta { entries.iter().cloned().collect() };
648 let a = Style::parse("bold")
649 .unwrap()
650 .with_meta(meta(&[("x", MetaValue::Int(1)), ("y", MetaValue::Int(2))]));
651 let b = Style::new().with_meta(meta(&[("y", MetaValue::Int(3)), ("z", MetaValue::Int(4))]));
652 assert_eq!(
654 a.combine(&b).meta(),
655 meta(&[
656 ("x", MetaValue::Int(1)),
657 ("y", MetaValue::Int(3)),
658 ("z", MetaValue::Int(4))
659 ])
660 );
661 assert!(Style::new().with_meta(Meta::new()).is_null());
662 assert!(Style::from_meta(Meta::new()).is_null());
663 let ab = meta(&[("a", MetaValue::Int(1)), ("b", MetaValue::Int(2))]);
664 let ba = meta(&[("b", MetaValue::Int(2)), ("a", MetaValue::Int(1))]);
665 assert_ne!(Style::new().with_meta(ab), Style::new().with_meta(ba));
666 assert_ne!(
667 Style::new().with_meta(meta(&[("a", MetaValue::Bool(true))])),
668 Style::new().with_meta(meta(&[("a", MetaValue::Int(1))]))
669 );
670 assert_eq!(
671 Style::on(
672 Some(meta(&[("k", MetaValue::Int(1))])),
673 &[("click", MetaValue::Str("go".into()))]
674 )
675 .meta(),
676 meta(&[
677 ("k", MetaValue::Int(1)),
678 ("@click", MetaValue::Str("go".into()))
679 ])
680 );
681 let rich = Style::parse("bold link x")
682 .unwrap()
683 .with_meta(meta(&[("a", MetaValue::Int(1))]));
684 assert_eq!(rich.definition(), "bold link x");
685 assert_eq!(rich.clear_meta_and_links(), Style::parse("bold").unwrap());
686 assert_eq!(
688 rich.ansi_codes(ColorSystem::Truecolor),
689 Style::parse("bold")
690 .unwrap()
691 .ansi_codes(ColorSystem::Truecolor)
692 );
693 }
694
695 #[test]
699 fn normalize_matches_upstream() {
700 for (input, expected) in [
701 ("b", "bold"),
702 ("bold", "bold"),
703 ("BOLD", "bold"),
704 (" Bold ", "bold"),
705 ("dim i", "dim italic"),
706 ("not bold", "not bold"),
707 ("bold red", "bold red"),
708 ("red on blue", "red on blue"),
709 ("link https://x", "link https://x"),
710 ("nope", "nope"),
713 ("REPR.Number", "repr.number"),
714 ("not BOLD", "not bold"),
717 ] {
718 assert_eq!(Style::normalize(input), expected, "normalize({input:?})");
719 }
720 }
721
722 #[test]
724 fn definition_of_null_style_is_none() {
725 assert_eq!(Style::new().definition(), "none");
726 assert_eq!(Style::parse("none").unwrap().definition(), "none");
727 }
728
729 #[test]
733 fn not_operand_is_case_sensitive() {
734 assert!(Style::parse("not bold").is_ok());
735 assert!(Style::parse("not BOLD").is_err());
736 }
737
738 #[test]
741 fn parse_understands_link() {
742 let style = Style::parse("link https://example.com").expect("link parses");
743 assert_eq!(style.link.as_deref(), Some("https://example.com"));
744 assert_eq!(style.definition(), "link https://example.com");
745 assert!(Style::parse("link").is_err());
748 }
749
750 #[test]
751 fn parse_bold_red() {
752 let style = Style::parse("bold red").unwrap();
753 assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "1;31");
754 assert_eq!(
755 style.render("hello", Some(ColorSystem::Truecolor)),
756 "\x1b[1;31mhello\x1b[0m"
757 );
758 }
759
760 #[test]
761 fn svg_style_matches_upstream() {
762 use crate::terminal_theme::SVG_EXPORT_THEME as theme;
766 let svg = |spec: &str| Style::parse(spec).unwrap().get_svg_style(&theme);
767 assert_eq!(Style::new().get_svg_style(&theme), "fill: #c5c8c6");
768 assert_eq!(svg("bold red"), "fill: #cc555a;font-weight: bold");
769 assert_eq!(svg("italic green"), "fill: #98a84b;font-style: italic;");
770 assert_eq!(svg("dim"), "fill: #868887");
771 assert_eq!(
772 svg("underline blue on yellow"),
773 "fill: #608ab1;text-decoration: underline;"
774 );
775 assert_eq!(svg("reverse"), "fill: #292929");
776 }
777
778 #[test]
779 fn link_wraps_in_osc8() {
780 let style = Style::parse("underline blue")
781 .unwrap()
782 .with_link("https://example.com");
783 assert_eq!(
784 style.render("click", Some(ColorSystem::Truecolor)),
785 "\x1b]8;;https://example.com\x1b\\\x1b[4;34mclick\x1b[0m\x1b]8;;\x1b\\"
786 );
787 let bare = Style::new().with_link("https://x.com");
789 assert_eq!(
790 bare.render("y", Some(ColorSystem::Truecolor)),
791 "\x1b]8;;https://x.com\x1b\\y\x1b]8;;\x1b\\"
792 );
793 assert!(!bare.is_null());
794 }
795
796 #[test]
797 fn parse_fg_on_bg() {
798 let style = Style::parse("white on blue").unwrap();
799 assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "37;44");
800 }
801
802 #[test]
803 fn combine_overrides() {
804 let base = Style::parse("bold red").unwrap();
805 let over = Style::parse("blue").unwrap();
806 let combined = base.combine(&over);
807 assert_eq!(combined.ansi_codes(ColorSystem::Truecolor), "1;34");
809 }
810
811 #[test]
812 fn no_color_system_is_plaintext() {
813 let style = Style::parse("bold red").unwrap();
814 assert_eq!(style.render("hello", None), "hello");
815 }
816
817 #[test]
818 fn null_style_does_not_wrap() {
819 let style = Style::new();
820 assert_eq!(style.render("hello", Some(ColorSystem::Truecolor)), "hello");
821 }
822}