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 attr(&self, index: usize) -> Option<bool> {
176 self.attrs.get(index).copied().flatten()
177 }
178
179 pub fn is_null(&self) -> bool {
181 self.color.is_none()
182 && self.bgcolor.is_none()
183 && self.link.is_none()
184 && self.attrs.iter().all(Option::is_none)
185 }
186
187 pub fn definition(&self) -> String {
194 let mut parts: Vec<String> = Vec::new();
195 for (index, name) in ATTR_NAMES.iter().enumerate() {
196 match self.attrs[index] {
197 Some(true) => parts.push((*name).to_string()),
198 Some(false) => parts.push(format!("not {name}")),
199 None => {}
200 }
201 }
202 if let Some(color) = &self.color {
203 parts.push(color.name.clone());
204 }
205 if let Some(bgcolor) = &self.bgcolor {
206 parts.push("on".to_string());
207 parts.push(bgcolor.name.clone());
208 }
209 if let Some(link) = &self.link {
210 parts.push("link".to_string());
211 parts.push(link.clone());
212 }
213 if parts.is_empty() {
214 "none".to_string()
215 } else {
216 parts.join(" ")
217 }
218 }
219
220 pub fn normalize(definition: &str) -> String {
232 match Style::parse(definition) {
233 Ok(style) => style.definition(),
234 Err(_) => definition.trim().to_lowercase(),
235 }
236 }
237
238 pub fn parse(definition: &str) -> Result<Self> {
243 if definition.is_empty() || definition.trim() == "none" {
250 return Ok(Style::new());
251 }
252 let mut style = Style::new();
253 let mut words = definition.split_whitespace();
254 while let Some(raw) = words.next() {
255 let word = raw.to_ascii_lowercase();
256 match word.as_str() {
257 "on" => {
258 let color_word = words.next().ok_or_else(|| {
259 RichError::StyleSyntax("color expected after 'on'".to_string())
260 })?;
261 style.bgcolor = Some(Color::parse(color_word)?);
262 }
263 "not" => {
264 let attr_word = words.next().ok_or_else(|| {
265 RichError::StyleSyntax("attribute expected after 'not'".to_string())
266 })?;
267 let idx = attribute_index(attr_word).ok_or_else(|| {
271 RichError::StyleSyntax(format!(
272 "{attr_word:?} is not a recognized attribute"
273 ))
274 })?;
275 style.attrs[idx] = Some(false);
276 }
277 "link" => {
278 let url = words.next().filter(|url| !url.is_empty()).ok_or_else(|| {
281 RichError::StyleSyntax("URL expected after 'link'".to_string())
282 })?;
283 style.link = Some(url.to_string());
284 }
285 _ => {
286 if let Some(idx) = attribute_index(&word) {
287 style.attrs[idx] = Some(true);
288 } else {
289 style.color = Some(Color::parse(&word)?);
290 }
291 }
292 }
293 }
294 Ok(style)
295 }
296
297 pub fn combine(&self, other: &Style) -> Style {
301 let mut attrs = self.attrs;
302 for (slot, over) in attrs.iter_mut().zip(other.attrs.iter()) {
303 if over.is_some() {
304 *slot = *over;
305 }
306 }
307 Style {
308 color: other.color.clone().or_else(|| self.color.clone()),
309 bgcolor: other.bgcolor.clone().or_else(|| self.bgcolor.clone()),
310 attrs,
311 link: other.link.clone().or_else(|| self.link.clone()),
312 }
313 }
314
315 pub fn ansi_codes(&self, system: ColorSystem) -> String {
319 let mut sgr: Vec<String> = Vec::new();
320 for (idx, attr) in self.attrs.iter().enumerate() {
321 if *attr == Some(true) {
322 sgr.push(ATTR_SGR[idx].to_string());
323 }
324 }
325 if let Some(color) = &self.color {
326 sgr.extend(color.downgrade(system).ansi_codes(true));
327 }
328 if let Some(bgcolor) = &self.bgcolor {
329 sgr.extend(bgcolor.downgrade(system).ansi_codes(false));
330 }
331 sgr.join(";")
332 }
333
334 pub fn get_html_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
337 use crate::terminal_theme::blend_rgb;
338 let mut css: Vec<String> = Vec::new();
339
340 let mut color = self.color.clone();
341 let mut bgcolor = self.bgcolor.clone();
342 if self.attrs[6] == Some(true) {
344 std::mem::swap(&mut color, &mut bgcolor);
345 }
346 if self.attrs[1] == Some(true) {
348 let fg = match &color {
349 Some(c) => theme.resolve(c, true),
350 None => theme.foreground,
351 };
352 let blended = blend_rgb(fg, theme.background, 0.5);
353 color = Some(Color::from_rgb(blended.red, blended.green, blended.blue));
354 }
355
356 if let Some(c) = &color {
357 let hex = theme.resolve(c, true).hex();
358 css.push(format!("color: {hex}"));
359 css.push(format!("text-decoration-color: {hex}"));
360 }
361 if let Some(c) = &bgcolor {
362 let hex = theme.resolve(c, false).hex();
363 css.push(format!("background-color: {hex}"));
364 }
365 if self.attrs[0] == Some(true) {
366 css.push("font-weight: bold".to_string());
367 }
368 if self.attrs[2] == Some(true) {
369 css.push("font-style: italic".to_string());
370 }
371 if self.attrs[3] == Some(true) {
372 css.push("text-decoration: underline".to_string());
373 }
374 if self.attrs[8] == Some(true) {
375 css.push("text-decoration: line-through".to_string());
376 }
377 if self.attrs[12] == Some(true) {
378 css.push("text-decoration: overline".to_string());
379 }
380 css.join("; ")
381 }
382
383 pub fn get_svg_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
390 use crate::terminal_theme::blend_rgb;
391 let mut color = self
394 .color
395 .as_ref()
396 .map_or(theme.foreground, |c| theme.resolve(c, true));
397 let mut bgcolor = self
398 .bgcolor
399 .as_ref()
400 .map_or(theme.background, |c| theme.resolve(c, false));
401 if self.attrs[6] == Some(true) {
402 std::mem::swap(&mut color, &mut bgcolor);
403 }
404 if self.attrs[1] == Some(true) {
405 color = blend_rgb(color, bgcolor, 0.4);
406 }
407 let mut rules = vec![format!("fill: {}", color.hex())];
408 if self.attrs[0] == Some(true) {
409 rules.push("font-weight: bold".to_string());
410 }
411 if self.attrs[2] == Some(true) {
412 rules.push("font-style: italic;".to_string());
413 }
414 if self.attrs[3] == Some(true) {
415 rules.push("text-decoration: underline;".to_string());
416 }
417 if self.attrs[8] == Some(true) {
418 rules.push("text-decoration: line-through;".to_string());
419 }
420 rules.join(";")
421 }
422
423 pub fn render(&self, text: &str, system: Option<ColorSystem>) -> String {
433 let Some(system) = system else {
434 return text.to_string();
435 };
436 if text.is_empty() {
437 return text.to_string();
438 }
439 let codes = self.ansi_codes(system);
440 let rendered = if codes.is_empty() {
441 text.to_string()
442 } else {
443 format!("\x1b[{codes}m{text}\x1b[0m")
444 };
445 match &self.link {
446 Some(url) => format!("\x1b]8;;{url}\x1b\\{rendered}\x1b]8;;\x1b\\"),
447 None => rendered,
448 }
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
460 fn normalize_matches_upstream() {
461 for (input, expected) in [
462 ("b", "bold"),
463 ("bold", "bold"),
464 ("BOLD", "bold"),
465 (" Bold ", "bold"),
466 ("dim i", "dim italic"),
467 ("not bold", "not bold"),
468 ("bold red", "bold red"),
469 ("red on blue", "red on blue"),
470 ("link https://x", "link https://x"),
471 ("nope", "nope"),
474 ("REPR.Number", "repr.number"),
475 ("not BOLD", "not bold"),
478 ] {
479 assert_eq!(Style::normalize(input), expected, "normalize({input:?})");
480 }
481 }
482
483 #[test]
485 fn definition_of_null_style_is_none() {
486 assert_eq!(Style::new().definition(), "none");
487 assert_eq!(Style::parse("none").unwrap().definition(), "none");
488 }
489
490 #[test]
494 fn not_operand_is_case_sensitive() {
495 assert!(Style::parse("not bold").is_ok());
496 assert!(Style::parse("not BOLD").is_err());
497 }
498
499 #[test]
502 fn parse_understands_link() {
503 let style = Style::parse("link https://example.com").expect("link parses");
504 assert_eq!(style.link.as_deref(), Some("https://example.com"));
505 assert_eq!(style.definition(), "link https://example.com");
506 assert!(Style::parse("link").is_err());
509 }
510
511 #[test]
512 fn parse_bold_red() {
513 let style = Style::parse("bold red").unwrap();
514 assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "1;31");
515 assert_eq!(
516 style.render("hello", Some(ColorSystem::Truecolor)),
517 "\x1b[1;31mhello\x1b[0m"
518 );
519 }
520
521 #[test]
522 fn svg_style_matches_upstream() {
523 use crate::terminal_theme::SVG_EXPORT_THEME as theme;
527 let svg = |spec: &str| Style::parse(spec).unwrap().get_svg_style(&theme);
528 assert_eq!(Style::new().get_svg_style(&theme), "fill: #c5c8c6");
529 assert_eq!(svg("bold red"), "fill: #cc555a;font-weight: bold");
530 assert_eq!(svg("italic green"), "fill: #98a84b;font-style: italic;");
531 assert_eq!(svg("dim"), "fill: #868887");
532 assert_eq!(
533 svg("underline blue on yellow"),
534 "fill: #608ab1;text-decoration: underline;"
535 );
536 assert_eq!(svg("reverse"), "fill: #292929");
537 }
538
539 #[test]
540 fn link_wraps_in_osc8() {
541 let style = Style::parse("underline blue")
542 .unwrap()
543 .with_link("https://example.com");
544 assert_eq!(
545 style.render("click", Some(ColorSystem::Truecolor)),
546 "\x1b]8;;https://example.com\x1b\\\x1b[4;34mclick\x1b[0m\x1b]8;;\x1b\\"
547 );
548 let bare = Style::new().with_link("https://x.com");
550 assert_eq!(
551 bare.render("y", Some(ColorSystem::Truecolor)),
552 "\x1b]8;;https://x.com\x1b\\y\x1b]8;;\x1b\\"
553 );
554 assert!(!bare.is_null());
555 }
556
557 #[test]
558 fn parse_fg_on_bg() {
559 let style = Style::parse("white on blue").unwrap();
560 assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "37;44");
561 }
562
563 #[test]
564 fn combine_overrides() {
565 let base = Style::parse("bold red").unwrap();
566 let over = Style::parse("blue").unwrap();
567 let combined = base.combine(&over);
568 assert_eq!(combined.ansi_codes(ColorSystem::Truecolor), "1;34");
570 }
571
572 #[test]
573 fn no_color_system_is_plaintext() {
574 let style = Style::parse("bold red").unwrap();
575 assert_eq!(style.render("hello", None), "hello");
576 }
577
578 #[test]
579 fn null_style_does_not_wrap() {
580 let style = Style::new();
581 assert_eq!(style.render("hello", Some(ColorSystem::Truecolor)), "hello");
582 }
583}