Skip to main content

urushi/style/
text.rs

1//! The [`TextStyle`] builder: everything a terminal can express about a run of
2//! text.
3
4use crate::{Color, Hyperlink, TextAttribute, TextAttributes, Underline, UnderlineStyle};
5
6/// A reusable set of text styling rules.
7///
8/// A `TextStyle` carries no geometry. A position that renders inline text cannot
9/// honor padding, a border, or a dimension, so those properties live on
10/// [`BlockStyle`](crate::BlockStyle) instead and the illegal combination is
11/// unrepresentable rather than merely discouraged.
12///
13/// A `TextStyle` is an immutable value: builder methods consume and return it, so
14/// styles can be stored, cloned, and extended without affecting each other.
15///
16/// ```
17/// use urushi::{Color, TextStyle};
18///
19/// let base = TextStyle::new().foreground(Color::CYAN);
20/// let emphasized = base.clone().bold();
21///
22/// let text = urushi::StyledText::new("hello", emphasized);
23/// let _ = text;
24/// ```
25///
26/// Geometry is not merely discouraged here, it is unrepresentable:
27///
28/// ```compile_fail
29/// use urushi::{Border, TextStyle};
30///
31/// let _ = TextStyle::new().border(Border::ROUNDED);
32/// ```
33#[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    /// Sets the text foreground color.
48    pub fn foreground(mut self, color: impl Into<Color>) -> Self {
49        self.foreground = Some(color.into());
50        self
51    }
52
53    /// Returns the text foreground color to the terminal default.
54    pub fn reset_foreground(mut self) -> Self {
55        self.foreground = None;
56        self
57    }
58
59    /// Sets the text background color.
60    pub fn background(mut self, color: impl Into<Color>) -> Self {
61        self.background = Some(color.into());
62        self
63    }
64
65    /// Returns the text background color to the terminal default.
66    pub fn reset_background(mut self) -> Self {
67        self.background = None;
68        self
69    }
70
71    /// Adds one active text attribute.
72    pub fn add_attribute(mut self, attribute: TextAttribute) -> Self {
73        self.attributes = self.attributes.union(attribute.into());
74        self
75    }
76
77    /// Adds a set of active text attributes.
78    pub fn add_attributes(mut self, attributes: TextAttributes) -> Self {
79        self.attributes = self.attributes.union(attributes);
80        self
81    }
82
83    /// Removes one active text attribute.
84    pub fn remove_attribute(mut self, attribute: TextAttribute) -> Self {
85        self.attributes = self.attributes.difference(attribute.into());
86        self
87    }
88
89    /// Removes a set of active text attributes.
90    pub fn remove_attributes(mut self, attributes: TextAttributes) -> Self {
91        self.attributes = self.attributes.difference(attributes);
92        self
93    }
94
95    /// Removes every active text attribute.
96    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    /// Underlines the text with a single line in the foreground color.
114    ///
115    /// This sets the complete default underline value; the other two builders
116    /// below refine an underline that may already be set.
117    pub fn underlined(self) -> Self {
118        self.underline(Underline::default())
119    }
120
121    /// Sets the shape the underline is drawn with, adding an underline in the
122    /// foreground color when the style has none.
123    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    /// Sets the color the underline is drawn in, adding a single underline when
132    /// the style has none.
133    ///
134    /// A color is only reachable through an underline, so a style cannot carry
135    /// an underline color that nothing draws.
136    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    /// Replaces the complete underline value.
142    pub fn underline(mut self, underline: Underline) -> Self {
143        self.underline = Some(underline);
144        self
145    }
146
147    /// Removes the underline, including its color.
148    pub fn reset_underline(mut self) -> Self {
149        self.underline = None;
150        self
151    }
152
153    /// Attaches an OSC 8 hyperlink to this text.
154    ///
155    /// A URI converts directly for the ordinary case. Use [`Hyperlink`] when
156    /// the link needs parameters such as `id`.
157    pub fn hyperlink(mut self, hyperlink: impl Into<Hyperlink>) -> Self {
158        self.hyperlink = Some(hyperlink.into());
159        self
160    }
161
162    /// Removes the OSC 8 hyperlink from this text.
163    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    /// Returns the foreground color instruction, if this style sets one.
185    pub const fn get_foreground(&self) -> Option<Color> {
186        self.foreground
187    }
188
189    /// Returns the background color instruction, if this style sets one.
190    pub const fn get_background(&self) -> Option<Color> {
191        self.background
192    }
193
194    /// Returns the underline instruction, if this style sets one.
195    pub const fn get_underline(&self) -> Option<Underline> {
196        self.underline
197    }
198
199    /// Returns the hyperlink attached to this text, if any.
200    pub fn get_hyperlink(&self) -> Option<&Hyperlink> {
201        self.hyperlink.as_ref()
202    }
203
204    /// Returns the active text attributes.
205    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    /// Folds values that cannot reach the output, so that two styles with the
234    /// same appearance are the same value.
235    ///
236    /// One fold exists: an underline color equal to the foreground draws
237    /// exactly what an absent one draws, since an absent one means "the
238    /// foreground color". Nothing else is folded — a value is dropped only when
239    /// doing so cannot change the output whatever the terminal does, which is
240    /// why reversed video, whose equivalence assumes how a terminal implements
241    /// `dim`, stays as written.
242    ///
243    /// Applied once the style is final: [`RenderSettings`](crate::RenderSettings)
244    /// calls it as its last step, after degradation, because degradation is what
245    /// makes two logical colors equal. A `TextStyle` is an immutable value built
246    /// by consuming builders, so any earlier fold is undone by the next call that
247    /// changes the foreground, which is why this is not part of the public
248    /// builder surface.
249    ///
250    /// One residue is not closable: when the foreground is absent its concrete
251    /// color is the terminal's default and unknown here, so an underline color
252    /// equal to it cannot be recognized.
253    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    /// Replaces every color property while preserving the rest of the style.
264    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    /// Removes every color while preserving attributes and the underline shape.
277    ///
278    /// An underline survives colorless render settings — it is a shape, not a
279    /// color — but its color does not, exactly as a foreground does not.
280    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        // A style holds effective values and text rendering closes with a reset, so
445        // the terminal default is expressed by emitting nothing — there is no
446        // SGR 59 to restore it, and no color to spell it with.
447        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        // Setting a color on a style with no underline adds the underline that
493        // draws it, so "invisible underline color" is not a value that exists.
494        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        // Equal appearance is not the admission rule: a color the terminal
558        // resolves separately is not folded, and neither is reversed video,
559        // whose equivalence assumes how a terminal implements `dim`.
560        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        // An absent foreground is the terminal's default, whose concrete color
574        // is unknown here, so a matching underline color cannot be recognized.
575        let default_foreground = TextStyle::new().underline_color(Color::RED);
576        assert_eq!(
577            default_foreground.clone().canonical(),
578            default_foreground.clone()
579        );
580    }
581}