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 Tuple(Vec<MetaValue>),
127}
128
129impl MetaValue {
130 pub fn repr(&self) -> String {
132 let items =
133 |values: &[MetaValue]| -> Vec<String> { values.iter().map(MetaValue::repr).collect() };
134 match self {
135 MetaValue::None => "None".to_string(),
136 MetaValue::Bool(true) => "True".to_string(),
137 MetaValue::Bool(false) => "False".to_string(),
138 MetaValue::Int(value) => value.to_string(),
139 MetaValue::Float(value) => crate::pyformat::float_repr(*value),
140 MetaValue::Str(value) => crate::markup::py_repr_str(value),
141 MetaValue::List(values) => format!("[{}]", items(values).join(", ")),
142 MetaValue::Tuple(values) if values.len() == 1 => format!("({},)", values[0].repr()),
143 MetaValue::Tuple(values) => format!("({})", items(values).join(", ")),
144 }
145 }
146}
147
148impl PartialEq for MetaValue {
149 fn eq(&self, other: &Self) -> bool {
150 match (self, other) {
151 (MetaValue::None, MetaValue::None) => true,
152 (MetaValue::Bool(a), MetaValue::Bool(b)) => a == b,
153 (MetaValue::Int(a), MetaValue::Int(b)) => a == b,
154 (MetaValue::Float(a), MetaValue::Float(b)) => a.to_bits() == b.to_bits(),
155 (MetaValue::Str(a), MetaValue::Str(b)) => a == b,
156 (MetaValue::List(a), MetaValue::List(b)) => a == b,
157 (MetaValue::Tuple(a), MetaValue::Tuple(b)) => a == b,
158 _ => false,
159 }
160 }
161}
162
163impl Eq for MetaValue {}
164
165impl std::hash::Hash for MetaValue {
166 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
167 std::mem::discriminant(self).hash(state);
168 match self {
169 MetaValue::None => {}
170 MetaValue::Bool(value) => value.hash(state),
171 MetaValue::Int(value) => value.hash(state),
172 MetaValue::Float(value) => value.to_bits().hash(state),
173 MetaValue::Str(value) => value.hash(state),
174 MetaValue::List(values) | MetaValue::Tuple(values) => values.hash(state),
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
184pub struct Meta {
185 entries: Vec<(String, MetaValue)>,
186}
187
188impl Meta {
189 pub fn repr(&self) -> String {
191 let entries: Vec<String> = self
192 .entries
193 .iter()
194 .map(|(key, value)| format!("{}: {}", crate::markup::py_repr_str(key), value.repr()))
195 .collect();
196 format!("{{{}}}", entries.join(", "))
197 }
198
199 pub fn new() -> Self {
201 Meta::default()
202 }
203
204 pub fn insert(&mut self, key: impl Into<String>, value: MetaValue) {
206 let key = key.into();
207 match self
208 .entries
209 .iter_mut()
210 .find(|(existing, _)| *existing == key)
211 {
212 Some((_, slot)) => *slot = value,
213 None => self.entries.push((key, value)),
214 }
215 }
216
217 pub fn get(&self, key: &str) -> Option<&MetaValue> {
219 self.entries
220 .iter()
221 .find(|(existing, _)| existing == key)
222 .map(|(_, value)| value)
223 }
224
225 pub fn update(&mut self, other: &Meta) {
227 for (key, value) in &other.entries {
228 self.insert(key.clone(), value.clone());
229 }
230 }
231
232 pub fn iter(&self) -> impl Iterator<Item = (&str, &MetaValue)> {
234 self.entries
235 .iter()
236 .map(|(key, value)| (key.as_str(), value))
237 }
238
239 pub fn len(&self) -> usize {
240 self.entries.len()
241 }
242
243 pub fn is_empty(&self) -> bool {
244 self.entries.is_empty()
245 }
246}
247
248impl<K: Into<String>> FromIterator<(K, MetaValue)> for Meta {
249 fn from_iter<I: IntoIterator<Item = (K, MetaValue)>>(iter: I) -> Self {
250 let mut meta = Meta::new();
251 for (key, value) in iter {
252 meta.insert(key, value);
253 }
254 meta
255 }
256}
257
258pub(crate) struct ShortKeyHasher(u64);
261
262impl Default for ShortKeyHasher {
263 fn default() -> Self {
264 ShortKeyHasher(0xcbf2_9ce4_8422_2325)
265 }
266}
267
268impl std::hash::Hasher for ShortKeyHasher {
269 fn finish(&self) -> u64 {
270 self.0
271 }
272
273 fn write(&mut self, bytes: &[u8]) {
274 for &byte in bytes {
275 self.0 = (self.0 ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3);
276 }
277 }
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, Default)]
282pub struct Style {
283 color: Option<Color>,
284 bgcolor: Option<Color>,
285 attrs: [Option<bool>; ATTR_COUNT],
286 link: Option<String>,
288 meta: Option<Meta>,
291}
292
293impl Style {
294 pub fn new() -> Self {
296 Style::default()
297 }
298
299 pub fn from_color(color: Option<Color>, bgcolor: Option<Color>) -> Self {
302 Style {
303 color,
304 bgcolor,
305 attrs: [None; ATTR_COUNT],
306 link: None,
307 meta: None,
308 }
309 }
310
311 pub fn with_color(mut self, color: Color) -> Self {
312 self.color = Some(color);
313 self
314 }
315
316 pub fn with_link(mut self, url: impl Into<String>) -> Self {
318 self.link = Some(url.into());
319 self
320 }
321
322 pub fn link(&self) -> Option<&str> {
324 self.link.as_deref()
325 }
326
327 pub fn update_link(&self, link: Option<String>) -> Style {
330 let mut style = self.clone();
331 style.link = link;
332 style
333 }
334
335 pub fn with_bgcolor(mut self, color: Color) -> Self {
336 self.bgcolor = Some(color);
337 self
338 }
339
340 pub fn color(&self) -> Option<&Color> {
341 self.color.as_ref()
342 }
343
344 pub fn bgcolor(&self) -> Option<&Color> {
345 self.bgcolor.as_ref()
346 }
347
348 pub fn without_color(&self) -> Style {
351 if self.is_null() {
354 return Style::new();
355 }
356 Style {
357 color: None,
358 bgcolor: None,
359 attrs: self.attrs,
360 link: self.link.clone(),
361 meta: None,
362 }
363 }
364
365 pub fn attr(&self, index: usize) -> Option<bool> {
368 self.attrs.get(index).copied().flatten()
369 }
370
371 pub fn is_null(&self) -> bool {
374 self.color.is_none()
375 && self.bgcolor.is_none()
376 && self.link.is_none()
377 && self.meta.as_ref().is_none_or(Meta::is_empty)
378 && self.attrs.iter().all(Option::is_none)
379 }
380
381 pub fn with_meta(mut self, meta: Meta) -> Self {
385 self.meta = Some(meta);
386 self
387 }
388
389 pub fn meta(&self) -> Meta {
392 self.meta.clone().unwrap_or_default()
393 }
394
395 pub fn meta_ref(&self) -> Option<&Meta> {
397 self.meta.as_ref()
398 }
399
400 pub fn from_meta(meta: Meta) -> Style {
404 Style::new().with_meta(meta)
405 }
406
407 pub fn on(meta: Option<Meta>, handlers: &[(&str, MetaValue)]) -> Style {
410 let mut meta = meta.unwrap_or_default();
411 for (name, value) in handlers {
412 meta.insert(format!("@{name}"), value.clone());
413 }
414 Style::from_meta(meta)
415 }
416
417 pub fn clear_meta_and_links(&self) -> Style {
419 let mut style = self.clone();
420 style.link = None;
421 style.meta = None;
422 style
423 }
424
425 pub fn bold(&self) -> Option<bool> {
428 self.attrs[0]
429 }
430
431 pub fn dim(&self) -> Option<bool> {
434 self.attrs[1]
435 }
436
437 pub fn italic(&self) -> Option<bool> {
440 self.attrs[2]
441 }
442
443 pub fn underline(&self) -> Option<bool> {
446 self.attrs[3]
447 }
448
449 pub fn blink(&self) -> Option<bool> {
452 self.attrs[4]
453 }
454
455 pub fn blink2(&self) -> Option<bool> {
458 self.attrs[5]
459 }
460
461 pub fn reverse(&self) -> Option<bool> {
464 self.attrs[6]
465 }
466
467 pub fn conceal(&self) -> Option<bool> {
470 self.attrs[7]
471 }
472
473 pub fn strike(&self) -> Option<bool> {
476 self.attrs[8]
477 }
478
479 pub fn underline2(&self) -> Option<bool> {
482 self.attrs[9]
483 }
484
485 pub fn frame(&self) -> Option<bool> {
488 self.attrs[10]
489 }
490
491 pub fn encircle(&self) -> Option<bool> {
494 self.attrs[11]
495 }
496
497 pub fn overline(&self) -> Option<bool> {
500 self.attrs[12]
501 }
502
503 pub fn null() -> Style {
505 Style::new()
506 }
507
508 pub fn copy(&self) -> Style {
510 self.clone()
511 }
512
513 pub fn transparent_background(&self) -> bool {
516 self.bgcolor.as_ref().is_none_or(Color::is_default)
517 }
518
519 pub fn background_style(&self) -> Style {
522 Style::from_color(None, self.bgcolor.clone())
523 }
524
525 pub fn combine_all<'a>(styles: impl IntoIterator<Item = &'a Style>) -> Option<Style> {
529 let mut styles = styles.into_iter();
530 let first = styles.next()?.clone();
531 Some(styles.fold(first, |combined, style| combined.combine(style)))
532 }
533
534 pub fn pick_first<T: Clone>(values: &[Option<T>]) -> Option<T> {
537 values.iter().flatten().next().cloned()
538 }
539
540 pub fn with_attr(mut self, index: usize, value: Option<bool>) -> Style {
543 if let Some(slot) = self.attrs.get_mut(index) {
544 *slot = value;
545 }
546 self
547 }
548
549 pub fn test(&self, text: Option<&str>) {
552 let definition = self.definition();
553 let text = text.filter(|text| !text.is_empty()).unwrap_or(&definition);
554 println!("{}", self.render(text, Some(ColorSystem::Truecolor)));
555 }
556
557 pub fn definition(&self) -> String {
563 let mut parts: Vec<String> = Vec::new();
564 for (index, name) in ATTR_NAMES.iter().enumerate() {
565 match self.attrs[index] {
566 Some(true) => parts.push((*name).to_string()),
567 Some(false) => parts.push(format!("not {name}")),
568 None => {}
569 }
570 }
571 if let Some(color) = &self.color {
572 parts.push(color.name.to_string());
573 }
574 if let Some(bgcolor) = &self.bgcolor {
575 parts.push("on".to_string());
576 parts.push(bgcolor.name.to_string());
577 }
578 if let Some(link) = self.link.as_ref().filter(|link| !link.is_empty()) {
579 parts.push("link".to_string());
580 parts.push(link.clone());
581 }
582 if parts.is_empty() {
583 "none".to_string()
584 } else {
585 parts.join(" ")
586 }
587 }
588
589 pub fn normalize(definition: &str) -> String {
601 type Cache = std::collections::HashMap<
606 String,
607 String,
608 std::hash::BuildHasherDefault<crate::style::ShortKeyHasher>,
609 >;
610 thread_local! {
611 static CACHE: std::cell::RefCell<Cache> = std::cell::RefCell::new(Cache::default());
612 }
613 if let Some(hit) = CACHE.with(|cache| cache.borrow().get(definition).cloned()) {
614 return hit;
615 }
616 let normalized = Style::normalize_uncached(definition);
617 CACHE.with(|cache| {
618 let mut cache = cache.borrow_mut();
619 if cache.len() >= 1024 {
620 cache.clear();
621 }
622 cache.insert(definition.to_string(), normalized.clone());
623 });
624 normalized
625 }
626
627 fn normalize_uncached(definition: &str) -> String {
630 match Style::parse(definition) {
631 Ok(style) => style.definition(),
632 Err(_) => definition.trim().to_lowercase(),
633 }
634 }
635
636 pub fn parse(definition: &str) -> Result<Self> {
641 if definition.is_empty() || definition.trim() == "none" {
648 return Ok(Style::new());
649 }
650 let mut style = Style::new();
651 let mut words = definition.split_whitespace();
652 while let Some(raw) = words.next() {
653 let word = raw.to_lowercase();
654 match word.as_str() {
655 "on" => {
656 let color_word = words.next().ok_or_else(|| {
657 RichError::StyleSyntax("color expected after 'on'".to_string())
658 })?;
659 style.bgcolor = Some(Color::parse(color_word).map_err(|error| {
660 RichError::StyleSyntax(format!(
661 "unable to parse {} as background color; {}",
662 crate::markup::py_repr_str(color_word),
663 error.message()
664 ))
665 })?);
666 }
667 "not" => {
668 let attr_word = words.next().unwrap_or("");
669 let idx = attribute_index(attr_word).ok_or_else(|| {
673 RichError::StyleSyntax(format!(
674 "expected style attribute after 'not', found {}",
675 crate::markup::py_repr_str(attr_word)
676 ))
677 })?;
678 style.attrs[idx] = Some(false);
679 }
680 "link" => {
681 let url = words.next().filter(|url| !url.is_empty()).ok_or_else(|| {
684 RichError::StyleSyntax("URL expected after 'link'".to_string())
685 })?;
686 style.link = Some(url.to_string());
687 }
688 _ => {
689 if let Some(idx) = attribute_index(&word) {
690 style.attrs[idx] = Some(true);
691 } else {
692 style.color = Some(Color::parse(&word).map_err(|error| {
693 RichError::StyleSyntax(format!(
694 "unable to parse {} as color; {}",
695 crate::markup::py_repr_str(&word),
696 error.message()
697 ))
698 })?);
699 }
700 }
701 }
702 }
703 Ok(style)
704 }
705
706 pub fn combine(&self, other: &Style) -> Style {
710 if other.is_null() {
712 return self.clone();
713 }
714 if self.is_null() {
715 return other.clone();
716 }
717 let mut attrs = self.attrs;
718 for (slot, over) in attrs.iter_mut().zip(other.attrs.iter()) {
719 if over.is_some() {
720 *slot = *over;
721 }
722 }
723 Style {
724 color: other.color.clone().or_else(|| self.color.clone()),
725 bgcolor: other.bgcolor.clone().or_else(|| self.bgcolor.clone()),
726 attrs,
727 link: other
729 .link
730 .clone()
731 .filter(|link| !link.is_empty())
732 .or_else(|| self.link.clone()),
733 meta: match (&self.meta, &other.meta) {
736 (Some(mine), Some(theirs)) => {
737 let mut merged = mine.clone();
738 merged.update(theirs);
739 Some(merged)
740 }
741 (mine, theirs) => theirs.clone().or_else(|| mine.clone()),
742 },
743 }
744 }
745
746 pub fn ansi_codes(&self, system: ColorSystem) -> String {
750 let mut sgr: Vec<String> = Vec::new();
751 for (idx, attr) in self.attrs.iter().enumerate() {
752 if *attr == Some(true) {
753 sgr.push(ATTR_SGR[idx].to_string());
754 }
755 }
756 if let Some(color) = &self.color {
757 sgr.extend(color.downgrade(system).ansi_codes(true));
758 }
759 if let Some(bgcolor) = &self.bgcolor {
760 sgr.extend(bgcolor.downgrade(system).ansi_codes(false));
761 }
762 sgr.join(";")
763 }
764
765 pub fn get_html_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
768 use crate::terminal_theme::blend_rgb;
769 let mut css: Vec<String> = Vec::new();
770
771 let mut color = self.color.clone();
772 let mut bgcolor = self.bgcolor.clone();
773 if self.attrs[6] == Some(true) {
775 std::mem::swap(&mut color, &mut bgcolor);
776 }
777 if self.attrs[1] == Some(true) {
779 let fg = match &color {
780 Some(c) => theme.resolve(c, true),
781 None => theme.foreground,
782 };
783 let blended = blend_rgb(fg, theme.background, 0.5);
784 color = Some(Color::from_rgb(blended.red, blended.green, blended.blue));
785 }
786
787 if let Some(c) = &color {
788 let hex = theme.resolve(c, true).hex();
789 css.push(format!("color: {hex}"));
790 css.push(format!("text-decoration-color: {hex}"));
791 }
792 if let Some(c) = &bgcolor {
793 let hex = theme.resolve(c, false).hex();
794 css.push(format!("background-color: {hex}"));
795 }
796 if self.attrs[0] == Some(true) {
797 css.push("font-weight: bold".to_string());
798 }
799 if self.attrs[2] == Some(true) {
800 css.push("font-style: italic".to_string());
801 }
802 if self.attrs[3] == Some(true) {
803 css.push("text-decoration: underline".to_string());
804 }
805 if self.attrs[8] == Some(true) {
806 css.push("text-decoration: line-through".to_string());
807 }
808 if self.attrs[12] == Some(true) {
809 css.push("text-decoration: overline".to_string());
810 }
811 css.join("; ")
812 }
813
814 pub fn get_svg_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
821 use crate::terminal_theme::blend_rgb;
822 let mut color = self
825 .color
826 .as_ref()
827 .map_or(theme.foreground, |c| theme.resolve(c, true));
828 let mut bgcolor = self
829 .bgcolor
830 .as_ref()
831 .map_or(theme.background, |c| theme.resolve(c, false));
832 if self.attrs[6] == Some(true) {
833 std::mem::swap(&mut color, &mut bgcolor);
834 }
835 if self.attrs[1] == Some(true) {
836 color = blend_rgb(color, bgcolor, 0.4);
837 }
838 let mut rules = vec![format!("fill: {}", color.hex())];
839 if self.attrs[0] == Some(true) {
840 rules.push("font-weight: bold".to_string());
841 }
842 if self.attrs[2] == Some(true) {
843 rules.push("font-style: italic;".to_string());
844 }
845 if self.attrs[3] == Some(true) {
846 rules.push("text-decoration: underline;".to_string());
847 }
848 if self.attrs[8] == Some(true) {
849 rules.push("text-decoration: line-through;".to_string());
850 }
851 rules.join(";")
852 }
853
854 pub fn render(&self, text: &str, system: Option<ColorSystem>) -> String {
864 let Some(system) = system else {
865 return text.to_string();
866 };
867 if text.is_empty() {
868 return text.to_string();
869 }
870 let codes = self.ansi_codes(system);
871 let rendered = if codes.is_empty() {
872 text.to_string()
873 } else {
874 format!("\x1b[{codes}m{text}\x1b[0m")
875 };
876 match &self.link {
877 Some(url) => format!("\x1b]8;;{url}\x1b\\{rendered}\x1b]8;;\x1b\\"),
878 None => rendered,
879 }
880 }
881}
882
883#[derive(Debug, Clone)]
886pub struct StyleStack {
887 stack: Vec<Style>,
888}
889
890impl StyleStack {
891 pub fn new(default_style: Style) -> Self {
893 StyleStack {
894 stack: vec![default_style],
895 }
896 }
897
898 pub fn current(&self) -> &Style {
900 self.stack
901 .last()
902 .expect("the stack keeps its default style")
903 }
904
905 pub fn push(&mut self, style: &Style) {
907 let combined = self.current().combine(style);
908 self.stack.push(combined);
909 }
910
911 pub fn pop(&mut self) -> Result<&Style> {
915 if self.stack.len() == 1 {
916 return Err(RichError::StyleStack(
917 "pop from empty style stack".to_string(),
918 ));
919 }
920 self.stack.pop();
921 Ok(self.current())
922 }
923}
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928
929 #[test]
932 fn normalize_cache_is_transparent() {
933 let definitions = [
934 "b",
935 "BOLD",
936 " bold red on blue ",
937 "repr.number",
938 "Repr.Number ",
939 "not bold",
940 "not BOLD",
941 "link https://x.io",
942 "#ff0000",
943 "rgb(1,2,3)",
944 "",
945 "none",
946 "bold none",
947 "on",
948 "color(300)",
949 "@click",
950 "b i u",
951 "u2",
952 ];
953 for _ in 0..2 {
954 for definition in definitions {
955 assert_eq!(
956 Style::normalize(definition),
957 Style::normalize_uncached(definition),
958 "{definition:?}"
959 );
960 }
961 }
962 for n in 0..1100 {
964 let definition = format!("tag{n}");
965 assert_eq!(Style::normalize(&definition), definition);
966 }
967 assert_eq!(Style::normalize("B"), "bold");
968 }
969
970 #[test]
972 fn meta_matches_upstream() {
973 let meta = |entries: &[(&str, MetaValue)]| -> Meta { entries.iter().cloned().collect() };
974 let a = Style::parse("bold")
975 .unwrap()
976 .with_meta(meta(&[("x", MetaValue::Int(1)), ("y", MetaValue::Int(2))]));
977 let b = Style::new().with_meta(meta(&[("y", MetaValue::Int(3)), ("z", MetaValue::Int(4))]));
978 assert_eq!(
980 a.combine(&b).meta(),
981 meta(&[
982 ("x", MetaValue::Int(1)),
983 ("y", MetaValue::Int(3)),
984 ("z", MetaValue::Int(4))
985 ])
986 );
987 assert!(Style::new().with_meta(Meta::new()).is_null());
988 assert!(Style::from_meta(Meta::new()).is_null());
989 let ab = meta(&[("a", MetaValue::Int(1)), ("b", MetaValue::Int(2))]);
990 let ba = meta(&[("b", MetaValue::Int(2)), ("a", MetaValue::Int(1))]);
991 assert_ne!(Style::new().with_meta(ab), Style::new().with_meta(ba));
992 assert_ne!(
993 Style::new().with_meta(meta(&[("a", MetaValue::Bool(true))])),
994 Style::new().with_meta(meta(&[("a", MetaValue::Int(1))]))
995 );
996 assert_eq!(
997 Style::on(
998 Some(meta(&[("k", MetaValue::Int(1))])),
999 &[("click", MetaValue::Str("go".into()))]
1000 )
1001 .meta(),
1002 meta(&[
1003 ("k", MetaValue::Int(1)),
1004 ("@click", MetaValue::Str("go".into()))
1005 ])
1006 );
1007 let rich = Style::parse("bold link x")
1008 .unwrap()
1009 .with_meta(meta(&[("a", MetaValue::Int(1))]));
1010 assert_eq!(rich.definition(), "bold link x");
1011 assert_eq!(rich.clear_meta_and_links(), Style::parse("bold").unwrap());
1012 assert_eq!(
1014 rich.ansi_codes(ColorSystem::Truecolor),
1015 Style::parse("bold")
1016 .unwrap()
1017 .ansi_codes(ColorSystem::Truecolor)
1018 );
1019 }
1020
1021 #[test]
1025 fn normalize_matches_upstream() {
1026 for (input, expected) in [
1027 ("b", "bold"),
1028 ("bold", "bold"),
1029 ("BOLD", "bold"),
1030 (" Bold ", "bold"),
1031 ("dim i", "dim italic"),
1032 ("not bold", "not bold"),
1033 ("bold red", "bold red"),
1034 ("red on blue", "red on blue"),
1035 ("link https://x", "link https://x"),
1036 ("nope", "nope"),
1039 ("REPR.Number", "repr.number"),
1040 ("not BOLD", "not bold"),
1043 ] {
1044 assert_eq!(Style::normalize(input), expected, "normalize({input:?})");
1045 }
1046 }
1047
1048 #[test]
1050 fn definition_of_null_style_is_none() {
1051 assert_eq!(Style::new().definition(), "none");
1052 assert_eq!(Style::parse("none").unwrap().definition(), "none");
1053 }
1054
1055 #[test]
1059 fn not_operand_is_case_sensitive() {
1060 assert!(Style::parse("not bold").is_ok());
1061 assert!(Style::parse("not BOLD").is_err());
1062 }
1063
1064 #[test]
1067 fn parse_understands_link() {
1068 let style = Style::parse("link https://example.com").expect("link parses");
1069 assert_eq!(style.link.as_deref(), Some("https://example.com"));
1070 assert_eq!(style.definition(), "link https://example.com");
1071 assert!(Style::parse("link").is_err());
1074 }
1075
1076 #[test]
1077 fn parse_bold_red() {
1078 let style = Style::parse("bold red").unwrap();
1079 assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "1;31");
1080 assert_eq!(
1081 style.render("hello", Some(ColorSystem::Truecolor)),
1082 "\x1b[1;31mhello\x1b[0m"
1083 );
1084 }
1085
1086 #[test]
1087 fn svg_style_matches_upstream() {
1088 use crate::terminal_theme::SVG_EXPORT_THEME as theme;
1092 let svg = |spec: &str| Style::parse(spec).unwrap().get_svg_style(&theme);
1093 assert_eq!(Style::new().get_svg_style(&theme), "fill: #c5c8c6");
1094 assert_eq!(svg("bold red"), "fill: #cc555a;font-weight: bold");
1095 assert_eq!(svg("italic green"), "fill: #98a84b;font-style: italic;");
1096 assert_eq!(svg("dim"), "fill: #868887");
1097 assert_eq!(
1098 svg("underline blue on yellow"),
1099 "fill: #608ab1;text-decoration: underline;"
1100 );
1101 assert_eq!(svg("reverse"), "fill: #292929");
1102 }
1103
1104 #[test]
1105 fn link_wraps_in_osc8() {
1106 let style = Style::parse("underline blue")
1107 .unwrap()
1108 .with_link("https://example.com");
1109 assert_eq!(
1110 style.render("click", Some(ColorSystem::Truecolor)),
1111 "\x1b]8;;https://example.com\x1b\\\x1b[4;34mclick\x1b[0m\x1b]8;;\x1b\\"
1112 );
1113 let bare = Style::new().with_link("https://x.com");
1115 assert_eq!(
1116 bare.render("y", Some(ColorSystem::Truecolor)),
1117 "\x1b]8;;https://x.com\x1b\\y\x1b]8;;\x1b\\"
1118 );
1119 assert!(!bare.is_null());
1120 }
1121
1122 #[test]
1123 fn parse_fg_on_bg() {
1124 let style = Style::parse("white on blue").unwrap();
1125 assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "37;44");
1126 }
1127
1128 #[test]
1129 fn combine_overrides() {
1130 let base = Style::parse("bold red").unwrap();
1131 let over = Style::parse("blue").unwrap();
1132 let combined = base.combine(&over);
1133 assert_eq!(combined.ansi_codes(ColorSystem::Truecolor), "1;34");
1135 }
1136
1137 #[test]
1138 fn no_color_system_is_plaintext() {
1139 let style = Style::parse("bold red").unwrap();
1140 assert_eq!(style.render("hello", None), "hello");
1141 }
1142
1143 #[test]
1144 fn null_style_does_not_wrap() {
1145 let style = Style::new();
1146 assert_eq!(style.render("hello", Some(ColorSystem::Truecolor)), "hello");
1147 }
1148}