1use crate::{Color, Hyperlink, TextAttribute, TextAttributes, Underline, UnderlineStyle};
5
6#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct TextStyle {
35 pub(crate) foreground: Option<Color>,
36 pub(crate) background: Option<Color>,
37 pub(crate) underline: Option<Underline>,
38 pub(crate) hyperlink: Option<Hyperlink>,
39 pub(crate) attributes: TextAttributes,
40}
41
42impl TextStyle {
43 pub fn new() -> Self {
44 Self::default()
45 }
46
47 pub fn foreground(mut self, color: impl Into<Color>) -> Self {
49 self.foreground = Some(color.into());
50 self
51 }
52
53 pub fn reset_foreground(mut self) -> Self {
55 self.foreground = None;
56 self
57 }
58
59 pub fn background(mut self, color: impl Into<Color>) -> Self {
61 self.background = Some(color.into());
62 self
63 }
64
65 pub fn reset_background(mut self) -> Self {
67 self.background = None;
68 self
69 }
70
71 pub fn add_attribute(mut self, attribute: TextAttribute) -> Self {
73 self.attributes = self.attributes.union(attribute.into());
74 self
75 }
76
77 pub fn add_attributes(mut self, attributes: TextAttributes) -> Self {
79 self.attributes = self.attributes.union(attributes);
80 self
81 }
82
83 pub fn remove_attribute(mut self, attribute: TextAttribute) -> Self {
85 self.attributes = self.attributes.difference(attribute.into());
86 self
87 }
88
89 pub fn remove_attributes(mut self, attributes: TextAttributes) -> Self {
91 self.attributes = self.attributes.difference(attributes);
92 self
93 }
94
95 pub fn reset_attributes(mut self) -> Self {
97 self.attributes = TextAttributes::empty();
98 self
99 }
100
101 pub fn bold(self) -> Self {
102 self.add_attribute(TextAttribute::Bold)
103 }
104
105 pub fn dim(self) -> Self {
106 self.add_attribute(TextAttribute::Dim)
107 }
108
109 pub fn italic(self) -> Self {
110 self.add_attribute(TextAttribute::Italic)
111 }
112
113 pub fn underlined(self) -> Self {
118 self.underline(Underline::default())
119 }
120
121 pub fn underline_style(self, style: UnderlineStyle) -> Self {
124 let mut underline = Underline::new(style);
125 if let Some(color) = self.underline.and_then(Underline::get_color) {
126 underline = underline.color(color);
127 }
128 self.underline(underline)
129 }
130
131 pub fn underline_color(self, color: impl Into<Color>) -> Self {
137 let underline = self.underline.unwrap_or_default().color(color.into());
138 self.underline(underline)
139 }
140
141 pub fn underline(mut self, underline: Underline) -> Self {
143 self.underline = Some(underline);
144 self
145 }
146
147 pub fn reset_underline(mut self) -> Self {
149 self.underline = None;
150 self
151 }
152
153 pub fn hyperlink(mut self, hyperlink: impl Into<Hyperlink>) -> Self {
158 self.hyperlink = Some(hyperlink.into());
159 self
160 }
161
162 pub fn reset_hyperlink(mut self) -> Self {
164 self.hyperlink = None;
165 self
166 }
167
168 pub fn blink(self) -> Self {
169 self.add_attribute(TextAttribute::SlowBlink)
170 }
171
172 pub fn reverse(self) -> Self {
173 self.add_attribute(TextAttribute::Reversed)
174 }
175
176 pub fn hide(self) -> Self {
177 self.add_attribute(TextAttribute::Hidden)
178 }
179
180 pub fn strikethrough(self) -> Self {
181 self.add_attribute(TextAttribute::CrossedOut)
182 }
183
184 pub const fn get_foreground(&self) -> Option<Color> {
186 self.foreground
187 }
188
189 pub const fn get_background(&self) -> Option<Color> {
191 self.background
192 }
193
194 pub const fn get_underline(&self) -> Option<Underline> {
196 self.underline
197 }
198
199 pub fn get_hyperlink(&self) -> Option<&Hyperlink> {
201 self.hyperlink.as_ref()
202 }
203
204 pub const fn get_attributes(&self) -> TextAttributes {
206 self.attributes
207 }
208
209 pub(crate) fn overlay(mut self, contribution: &Self) -> Self {
210 let Self {
211 foreground,
212 background,
213 underline,
214 hyperlink,
215 attributes,
216 } = contribution;
217 if let Some(color) = foreground {
218 self.foreground = Some(*color);
219 }
220 if let Some(color) = background {
221 self.background = Some(*color);
222 }
223 if let Some(underline) = underline {
224 self.underline = Some(*underline);
225 }
226 if let Some(hyperlink) = hyperlink {
227 self.hyperlink = Some(hyperlink.clone());
228 }
229 self.attributes = self.attributes.union(*attributes);
230 self
231 }
232
233 pub(crate) fn canonical(mut self) -> Self {
254 if let Some(underline) = self.underline
255 && underline.get_color().is_some()
256 && underline.get_color() == self.foreground
257 {
258 self.underline = Some(underline.reset_color());
259 }
260 self
261 }
262
263 pub(crate) fn map_colors(mut self, map: impl Fn(Color) -> Color) -> Self {
265 self.foreground = self.foreground.map(&map);
266 self.background = self.background.map(&map);
267 self.underline = self.underline.map(|underline| {
268 underline
269 .get_color()
270 .map(&map)
271 .map_or_else(|| underline.reset_color(), |color| underline.color(color))
272 });
273 self
274 }
275
276 pub(crate) fn without_colors(mut self) -> Self {
281 self.foreground = None;
282 self.background = None;
283 self.underline = self.underline.map(Underline::reset_color);
284 self
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::test_support::render_style;
292
293 #[test]
294 fn named_operations_preserve_effective_value_semantics() {
295 let style = TextStyle::new()
296 .bold()
297 .add_attribute(TextAttribute::Italic)
298 .foreground(Color::CYAN)
299 .remove_attribute(TextAttribute::Italic)
300 .reset_foreground();
301
302 assert_eq!(style.get_attributes(), TextAttribute::Bold.into());
303 assert_eq!(style.get_foreground(), None);
304 }
305
306 #[test]
307 fn reset_builders_restore_every_property_default() {
308 let style = TextStyle::new()
309 .foreground(Color::RED)
310 .background(Color::BLUE)
311 .add_attributes(TextAttribute::Bold | TextAttribute::Italic)
312 .underline_color(Color::GREEN)
313 .hyperlink("https://example.com")
314 .reset_foreground()
315 .reset_background()
316 .reset_attributes()
317 .reset_underline()
318 .reset_hyperlink();
319
320 assert_eq!(style, TextStyle::new());
321 }
322
323 #[test]
324 fn hyperlink_is_one_replaceable_and_removable_property() {
325 let style = TextStyle::new()
326 .hyperlink("https://first.example")
327 .hyperlink(Hyperlink::new("https://second.example").parameter("id", "docs"));
328
329 assert_eq!(
330 style.get_hyperlink(),
331 Some(&Hyperlink::new("https://second.example").parameter("id", "docs"))
332 );
333 assert_eq!(render_style(&style.reset_hyperlink(), "link"), "link");
334 }
335
336 #[test]
337 fn hyperlink_scope_contains_sgr_and_closes_after_its_reset() {
338 assert_eq!(
339 render_style(
340 &TextStyle::new()
341 .hyperlink(Hyperlink::new("https://example.com").parameter("id", "docs"))
342 .bold(),
343 "link",
344 ),
345 "\x1b]8;id=docs;https://example.com\x1b\\\x1b[1mlink\x1b[0m\x1b]8;;\x1b\\"
346 );
347 }
348
349 #[test]
350 fn hyperlink_on_empty_text_emits_nothing() {
351 assert_eq!(
352 render_style(&TextStyle::new().hyperlink("https://example.com"), ""),
353 ""
354 );
355 }
356
357 #[test]
358 fn multiline_hyperlink_closes_before_each_newline() {
359 let style = TextStyle::new().hyperlink("https://example.com");
360 let open = "\x1b]8;;https://example.com\x1b\\";
361 let close = "\x1b]8;;\x1b\\";
362
363 assert_eq!(
364 render_style(&style, "first\n\nsecond\nthird\n"),
365 format!("{open}first{close}\n\n{open}second{close}\n{open}third{close}\n")
366 );
367 }
368
369 #[test]
370 fn removing_an_attribute_removes_it_from_painted_value() {
371 let style = TextStyle::new()
372 .bold()
373 .dim()
374 .remove_attribute(TextAttribute::Bold);
375
376 assert_eq!(render_style(&style, "text"), "\x1b[2mtext\x1b[0m");
377 assert_eq!(
378 render_style(
379 &TextStyle::new().remove_attributes(TextAttributes::all()),
380 "text",
381 ),
382 "text"
383 );
384 }
385
386 #[test]
387 fn singleton_properties_replace_and_remove_to_defaults() {
388 let style = TextStyle::new()
389 .foreground(Color::RED)
390 .foreground(Color::BLUE)
391 .background(Color::GREEN)
392 .reset_background();
393
394 assert_eq!(style.get_foreground(), Some(Color::BLUE));
395 assert_eq!(style.get_background(), None);
396 }
397
398 #[test]
399 fn every_underline_shape_paints_its_own_sgr_parameter() {
400 let painted = [
401 UnderlineStyle::Single,
402 UnderlineStyle::Double,
403 UnderlineStyle::Curly,
404 UnderlineStyle::Dotted,
405 UnderlineStyle::Dashed,
406 ]
407 .map(|style| render_style(&TextStyle::new().underline_style(style), "t"));
408
409 assert_eq!(
410 painted,
411 [
412 "\x1b[4mt\x1b[0m",
413 "\x1b[4:2mt\x1b[0m",
414 "\x1b[4:3mt\x1b[0m",
415 "\x1b[4:4mt\x1b[0m",
416 "\x1b[4:5mt\x1b[0m",
417 ]
418 );
419 }
420
421 #[test]
422 fn an_underline_color_paints_sgr_fifty_eight_in_its_indexed_or_rgb_form() {
423 assert_eq!(
424 render_style(&TextStyle::new().underline_color(Color::RED), "t"),
425 "\x1b[4;58;5;1mt\x1b[0m"
426 );
427 assert_eq!(
428 render_style(&TextStyle::new().underline_color(Color::Ansi256(212)), "t",),
429 "\x1b[4;58;5;212mt\x1b[0m"
430 );
431 assert_eq!(
432 render_style(
433 &TextStyle::new()
434 .underline_style(UnderlineStyle::Curly)
435 .underline_color(Color::Rgb(1, 2, 3)),
436 "t",
437 ),
438 "\x1b[4:3;58;2;1;2;3mt\x1b[0m"
439 );
440 }
441
442 #[test]
443 fn an_absent_underline_color_paints_no_underline_color_parameter() {
444 let painted = render_style(
448 &TextStyle::new()
449 .foreground(Color::RED)
450 .underline_style(UnderlineStyle::Double),
451 "t",
452 );
453
454 assert_eq!(painted, "\x1b[4:2;31mt\x1b[0m");
455 assert!(!painted.contains("58"));
456 assert!(!painted.contains("59"));
457 }
458
459 #[test]
460 fn hidden_paints_sgr_eight() {
461 assert_eq!(
462 render_style(&TextStyle::new().hide(), "t"),
463 "\x1b[8mt\x1b[0m"
464 );
465 assert_eq!(
466 render_style(
467 &TextStyle::new()
468 .hide()
469 .add_attribute(TextAttribute::Reversed),
470 "t",
471 ),
472 "\x1b[7;8mt\x1b[0m"
473 );
474 }
475
476 #[test]
477 fn shared_terminal_attributes_keep_their_ansi_meaning() {
478 let attributes = TextAttribute::RapidBlink
479 | TextAttribute::Fraktur
480 | TextAttribute::Framed
481 | TextAttribute::Encircled
482 | TextAttribute::Overlined;
483
484 assert_eq!(
485 render_style(&TextStyle::new().add_attributes(attributes), "t"),
486 "\x1b[6;20;51;52;53mt\x1b[0m"
487 );
488 }
489
490 #[test]
491 fn an_underline_color_is_only_reachable_through_an_underline() {
492 let style = TextStyle::new().underline_color(Color::RED);
495
496 assert_eq!(
497 style.get_underline(),
498 Some(Underline::default().color(Color::RED))
499 );
500 assert_eq!(
501 render_style(
502 &TextStyle::new()
503 .underline_color(Color::RED)
504 .reset_underline(),
505 "t",
506 ),
507 "t"
508 );
509 }
510
511 #[test]
512 fn setting_a_shape_keeps_the_color_and_setting_a_color_keeps_the_shape() {
513 let expected = Some(Underline::new(UnderlineStyle::Dotted).color(Color::GREEN));
514
515 assert_eq!(
516 TextStyle::new()
517 .underline_color(Color::GREEN)
518 .underline_style(UnderlineStyle::Dotted)
519 .get_underline(),
520 expected
521 );
522 assert_eq!(
523 TextStyle::new()
524 .underline_style(UnderlineStyle::Dotted)
525 .underline_color(Color::GREEN)
526 .get_underline(),
527 expected
528 );
529 assert_eq!(
530 TextStyle::new()
531 .underline_color(Color::RED)
532 .underline(Underline::new(UnderlineStyle::Double))
533 .get_underline(),
534 Some(Underline::new(UnderlineStyle::Double))
535 );
536 }
537
538 #[test]
539 fn canonical_folds_an_underline_color_equal_to_the_foreground() {
540 let folded = TextStyle::new()
541 .foreground(Color::RED)
542 .underline_style(UnderlineStyle::Curly)
543 .underline_color(Color::RED)
544 .canonical();
545
546 assert_eq!(
547 folded,
548 TextStyle::new()
549 .foreground(Color::RED)
550 .underline_style(UnderlineStyle::Curly)
551 );
552 assert_eq!(render_style(&folded, "t"), "\x1b[4:3;31mt\x1b[0m");
553 }
554
555 #[test]
556 fn canonical_folds_only_what_the_terminal_cannot_draw_differently() {
557 let distinct_spellings = TextStyle::new()
561 .foreground(Color::Ansi(1))
562 .underline_color(Color::Rgb(255, 0, 0));
563 let reversed = TextStyle::new()
564 .foreground(Color::RED)
565 .background(Color::BLUE)
566 .reverse();
567
568 assert_eq!(
569 distinct_spellings.clone().canonical(),
570 distinct_spellings.clone()
571 );
572 assert_eq!(reversed.clone().canonical(), reversed);
573 let default_foreground = TextStyle::new().underline_color(Color::RED);
576 assert_eq!(
577 default_foreground.clone().canonical(),
578 default_foreground.clone()
579 );
580 }
581}