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, PartialEq, Eq, Default)]
111pub struct Style {
112 color: Option<Color>,
113 bgcolor: Option<Color>,
114 attrs: [Option<bool>; ATTR_COUNT],
115 link: Option<String>,
117}
118
119impl Style {
120 pub fn new() -> Self {
122 Style::default()
123 }
124
125 pub fn from_color(color: Option<Color>, bgcolor: Option<Color>) -> Self {
128 Style {
129 color,
130 bgcolor,
131 attrs: [None; ATTR_COUNT],
132 link: None,
133 }
134 }
135
136 pub fn with_color(mut self, color: Color) -> Self {
137 self.color = Some(color);
138 self
139 }
140
141 pub fn with_link(mut self, url: impl Into<String>) -> Self {
143 self.link = Some(url.into());
144 self
145 }
146
147 pub fn link(&self) -> Option<&str> {
149 self.link.as_deref()
150 }
151
152 pub fn update_link(&self, link: Option<String>) -> Style {
155 let mut style = self.clone();
156 style.link = link;
157 style
158 }
159
160 pub fn with_bgcolor(mut self, color: Color) -> Self {
161 self.bgcolor = Some(color);
162 self
163 }
164
165 pub fn color(&self) -> Option<&Color> {
166 self.color.as_ref()
167 }
168
169 pub fn bgcolor(&self) -> Option<&Color> {
170 self.bgcolor.as_ref()
171 }
172
173 pub fn without_color(&self) -> Style {
176 Style {
177 color: None,
178 bgcolor: None,
179 attrs: self.attrs,
180 link: self.link.clone(),
181 }
182 }
183
184 pub fn attr(&self, index: usize) -> Option<bool> {
187 self.attrs.get(index).copied().flatten()
188 }
189
190 pub fn is_null(&self) -> bool {
192 self.color.is_none()
193 && self.bgcolor.is_none()
194 && self.link.is_none()
195 && self.attrs.iter().all(Option::is_none)
196 }
197
198 pub fn definition(&self) -> String {
205 let mut parts: Vec<String> = Vec::new();
206 for (index, name) in ATTR_NAMES.iter().enumerate() {
207 match self.attrs[index] {
208 Some(true) => parts.push((*name).to_string()),
209 Some(false) => parts.push(format!("not {name}")),
210 None => {}
211 }
212 }
213 if let Some(color) = &self.color {
214 parts.push(color.name.clone());
215 }
216 if let Some(bgcolor) = &self.bgcolor {
217 parts.push("on".to_string());
218 parts.push(bgcolor.name.clone());
219 }
220 if let Some(link) = &self.link {
221 parts.push("link".to_string());
222 parts.push(link.clone());
223 }
224 if parts.is_empty() {
225 "none".to_string()
226 } else {
227 parts.join(" ")
228 }
229 }
230
231 pub fn normalize(definition: &str) -> String {
243 match Style::parse(definition) {
244 Ok(style) => style.definition(),
245 Err(_) => definition.trim().to_lowercase(),
246 }
247 }
248
249 pub fn parse(definition: &str) -> Result<Self> {
254 if definition.is_empty() || definition.trim() == "none" {
261 return Ok(Style::new());
262 }
263 let mut style = Style::new();
264 let mut words = definition.split_whitespace();
265 while let Some(raw) = words.next() {
266 let word = raw.to_ascii_lowercase();
267 match word.as_str() {
268 "on" => {
269 let color_word = words.next().ok_or_else(|| {
270 RichError::StyleSyntax("color expected after 'on'".to_string())
271 })?;
272 style.bgcolor = Some(Color::parse(color_word)?);
273 }
274 "not" => {
275 let attr_word = words.next().ok_or_else(|| {
276 RichError::StyleSyntax("attribute expected after 'not'".to_string())
277 })?;
278 let idx = attribute_index(attr_word).ok_or_else(|| {
282 RichError::StyleSyntax(format!(
283 "{attr_word:?} is not a recognized attribute"
284 ))
285 })?;
286 style.attrs[idx] = Some(false);
287 }
288 "link" => {
289 let url = words.next().filter(|url| !url.is_empty()).ok_or_else(|| {
292 RichError::StyleSyntax("URL expected after 'link'".to_string())
293 })?;
294 style.link = Some(url.to_string());
295 }
296 _ => {
297 if let Some(idx) = attribute_index(&word) {
298 style.attrs[idx] = Some(true);
299 } else {
300 style.color = Some(Color::parse(&word)?);
301 }
302 }
303 }
304 }
305 Ok(style)
306 }
307
308 pub fn combine(&self, other: &Style) -> Style {
312 let mut attrs = self.attrs;
313 for (slot, over) in attrs.iter_mut().zip(other.attrs.iter()) {
314 if over.is_some() {
315 *slot = *over;
316 }
317 }
318 Style {
319 color: other.color.clone().or_else(|| self.color.clone()),
320 bgcolor: other.bgcolor.clone().or_else(|| self.bgcolor.clone()),
321 attrs,
322 link: other.link.clone().or_else(|| self.link.clone()),
323 }
324 }
325
326 pub fn ansi_codes(&self, system: ColorSystem) -> String {
330 let mut sgr: Vec<String> = Vec::new();
331 for (idx, attr) in self.attrs.iter().enumerate() {
332 if *attr == Some(true) {
333 sgr.push(ATTR_SGR[idx].to_string());
334 }
335 }
336 if let Some(color) = &self.color {
337 sgr.extend(color.downgrade(system).ansi_codes(true));
338 }
339 if let Some(bgcolor) = &self.bgcolor {
340 sgr.extend(bgcolor.downgrade(system).ansi_codes(false));
341 }
342 sgr.join(";")
343 }
344
345 pub fn get_html_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
348 use crate::terminal_theme::blend_rgb;
349 let mut css: Vec<String> = Vec::new();
350
351 let mut color = self.color.clone();
352 let mut bgcolor = self.bgcolor.clone();
353 if self.attrs[6] == Some(true) {
355 std::mem::swap(&mut color, &mut bgcolor);
356 }
357 if self.attrs[1] == Some(true) {
359 let fg = match &color {
360 Some(c) => theme.resolve(c, true),
361 None => theme.foreground,
362 };
363 let blended = blend_rgb(fg, theme.background, 0.5);
364 color = Some(Color::from_rgb(blended.red, blended.green, blended.blue));
365 }
366
367 if let Some(c) = &color {
368 let hex = theme.resolve(c, true).hex();
369 css.push(format!("color: {hex}"));
370 css.push(format!("text-decoration-color: {hex}"));
371 }
372 if let Some(c) = &bgcolor {
373 let hex = theme.resolve(c, false).hex();
374 css.push(format!("background-color: {hex}"));
375 }
376 if self.attrs[0] == Some(true) {
377 css.push("font-weight: bold".to_string());
378 }
379 if self.attrs[2] == Some(true) {
380 css.push("font-style: italic".to_string());
381 }
382 if self.attrs[3] == Some(true) {
383 css.push("text-decoration: underline".to_string());
384 }
385 if self.attrs[8] == Some(true) {
386 css.push("text-decoration: line-through".to_string());
387 }
388 if self.attrs[12] == Some(true) {
389 css.push("text-decoration: overline".to_string());
390 }
391 css.join("; ")
392 }
393
394 pub fn get_svg_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
401 use crate::terminal_theme::blend_rgb;
402 let mut color = self
405 .color
406 .as_ref()
407 .map_or(theme.foreground, |c| theme.resolve(c, true));
408 let mut bgcolor = self
409 .bgcolor
410 .as_ref()
411 .map_or(theme.background, |c| theme.resolve(c, false));
412 if self.attrs[6] == Some(true) {
413 std::mem::swap(&mut color, &mut bgcolor);
414 }
415 if self.attrs[1] == Some(true) {
416 color = blend_rgb(color, bgcolor, 0.4);
417 }
418 let mut rules = vec![format!("fill: {}", color.hex())];
419 if self.attrs[0] == Some(true) {
420 rules.push("font-weight: bold".to_string());
421 }
422 if self.attrs[2] == Some(true) {
423 rules.push("font-style: italic;".to_string());
424 }
425 if self.attrs[3] == Some(true) {
426 rules.push("text-decoration: underline;".to_string());
427 }
428 if self.attrs[8] == Some(true) {
429 rules.push("text-decoration: line-through;".to_string());
430 }
431 rules.join(";")
432 }
433
434 pub fn render(&self, text: &str, system: Option<ColorSystem>) -> String {
444 let Some(system) = system else {
445 return text.to_string();
446 };
447 if text.is_empty() {
448 return text.to_string();
449 }
450 let codes = self.ansi_codes(system);
451 let rendered = if codes.is_empty() {
452 text.to_string()
453 } else {
454 format!("\x1b[{codes}m{text}\x1b[0m")
455 };
456 match &self.link {
457 Some(url) => format!("\x1b]8;;{url}\x1b\\{rendered}\x1b]8;;\x1b\\"),
458 None => rendered,
459 }
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
471 fn normalize_matches_upstream() {
472 for (input, expected) in [
473 ("b", "bold"),
474 ("bold", "bold"),
475 ("BOLD", "bold"),
476 (" Bold ", "bold"),
477 ("dim i", "dim italic"),
478 ("not bold", "not bold"),
479 ("bold red", "bold red"),
480 ("red on blue", "red on blue"),
481 ("link https://x", "link https://x"),
482 ("nope", "nope"),
485 ("REPR.Number", "repr.number"),
486 ("not BOLD", "not bold"),
489 ] {
490 assert_eq!(Style::normalize(input), expected, "normalize({input:?})");
491 }
492 }
493
494 #[test]
496 fn definition_of_null_style_is_none() {
497 assert_eq!(Style::new().definition(), "none");
498 assert_eq!(Style::parse("none").unwrap().definition(), "none");
499 }
500
501 #[test]
505 fn not_operand_is_case_sensitive() {
506 assert!(Style::parse("not bold").is_ok());
507 assert!(Style::parse("not BOLD").is_err());
508 }
509
510 #[test]
513 fn parse_understands_link() {
514 let style = Style::parse("link https://example.com").expect("link parses");
515 assert_eq!(style.link.as_deref(), Some("https://example.com"));
516 assert_eq!(style.definition(), "link https://example.com");
517 assert!(Style::parse("link").is_err());
520 }
521
522 #[test]
523 fn parse_bold_red() {
524 let style = Style::parse("bold red").unwrap();
525 assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "1;31");
526 assert_eq!(
527 style.render("hello", Some(ColorSystem::Truecolor)),
528 "\x1b[1;31mhello\x1b[0m"
529 );
530 }
531
532 #[test]
533 fn svg_style_matches_upstream() {
534 use crate::terminal_theme::SVG_EXPORT_THEME as theme;
538 let svg = |spec: &str| Style::parse(spec).unwrap().get_svg_style(&theme);
539 assert_eq!(Style::new().get_svg_style(&theme), "fill: #c5c8c6");
540 assert_eq!(svg("bold red"), "fill: #cc555a;font-weight: bold");
541 assert_eq!(svg("italic green"), "fill: #98a84b;font-style: italic;");
542 assert_eq!(svg("dim"), "fill: #868887");
543 assert_eq!(
544 svg("underline blue on yellow"),
545 "fill: #608ab1;text-decoration: underline;"
546 );
547 assert_eq!(svg("reverse"), "fill: #292929");
548 }
549
550 #[test]
551 fn link_wraps_in_osc8() {
552 let style = Style::parse("underline blue")
553 .unwrap()
554 .with_link("https://example.com");
555 assert_eq!(
556 style.render("click", Some(ColorSystem::Truecolor)),
557 "\x1b]8;;https://example.com\x1b\\\x1b[4;34mclick\x1b[0m\x1b]8;;\x1b\\"
558 );
559 let bare = Style::new().with_link("https://x.com");
561 assert_eq!(
562 bare.render("y", Some(ColorSystem::Truecolor)),
563 "\x1b]8;;https://x.com\x1b\\y\x1b]8;;\x1b\\"
564 );
565 assert!(!bare.is_null());
566 }
567
568 #[test]
569 fn parse_fg_on_bg() {
570 let style = Style::parse("white on blue").unwrap();
571 assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "37;44");
572 }
573
574 #[test]
575 fn combine_overrides() {
576 let base = Style::parse("bold red").unwrap();
577 let over = Style::parse("blue").unwrap();
578 let combined = base.combine(&over);
579 assert_eq!(combined.ansi_codes(ColorSystem::Truecolor), "1;34");
581 }
582
583 #[test]
584 fn no_color_system_is_plaintext() {
585 let style = Style::parse("bold red").unwrap();
586 assert_eq!(style.render("hello", None), "hello");
587 }
588
589 #[test]
590 fn null_style_does_not_wrap() {
591 let style = Style::new();
592 assert_eq!(style.render("hello", Some(ColorSystem::Truecolor)), "hello");
593 }
594}