Skip to main content

saorsa_core/
style.rs

1//! Text style type for terminal rendering.
2
3use crate::color::Color;
4
5/// Style attributes for a piece of text.
6#[derive(Clone, Debug, Default, PartialEq, Eq)]
7pub struct Style {
8    /// Foreground color.
9    pub fg: Option<Color>,
10    /// Background color.
11    pub bg: Option<Color>,
12    /// Bold text.
13    pub bold: bool,
14    /// Italic text.
15    pub italic: bool,
16    /// Underlined text.
17    pub underline: bool,
18    /// Strikethrough text.
19    pub strikethrough: bool,
20    /// Dim/faint text.
21    pub dim: bool,
22    /// Reverse video.
23    pub reverse: bool,
24    /// OSC 8 hyperlink URL.
25    pub link: Option<String>,
26}
27
28impl Style {
29    /// Create an empty style with no attributes.
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    /// Set the foreground color.
35    #[must_use]
36    pub fn fg(mut self, color: Color) -> Self {
37        self.fg = Some(color);
38        self
39    }
40
41    /// Set the background color.
42    #[must_use]
43    pub fn bg(mut self, color: Color) -> Self {
44        self.bg = Some(color);
45        self
46    }
47
48    /// Set bold.
49    #[must_use]
50    pub fn bold(mut self, val: bool) -> Self {
51        self.bold = val;
52        self
53    }
54
55    /// Set italic.
56    #[must_use]
57    pub fn italic(mut self, val: bool) -> Self {
58        self.italic = val;
59        self
60    }
61
62    /// Set underline.
63    #[must_use]
64    pub fn underline(mut self, val: bool) -> Self {
65        self.underline = val;
66        self
67    }
68
69    /// Set strikethrough.
70    #[must_use]
71    pub fn strikethrough(mut self, val: bool) -> Self {
72        self.strikethrough = val;
73        self
74    }
75
76    /// Set dim.
77    #[must_use]
78    pub fn dim(mut self, val: bool) -> Self {
79        self.dim = val;
80        self
81    }
82
83    /// Set reverse video.
84    #[must_use]
85    pub fn reverse(mut self, val: bool) -> Self {
86        self.reverse = val;
87        self
88    }
89
90    /// Set hyperlink URL.
91    #[must_use]
92    pub fn link(mut self, url: impl Into<String>) -> Self {
93        self.link = Some(url.into());
94        self
95    }
96
97    /// Merge another style on top of this one. The `other` style's
98    /// set values take priority.
99    #[must_use]
100    pub fn merge(&self, other: &Style) -> Style {
101        Style {
102            fg: other.fg.clone().or_else(|| self.fg.clone()),
103            bg: other.bg.clone().or_else(|| self.bg.clone()),
104            bold: if other.bold { true } else { self.bold },
105            italic: if other.italic { true } else { self.italic },
106            underline: if other.underline {
107                true
108            } else {
109                self.underline
110            },
111            strikethrough: if other.strikethrough {
112                true
113            } else {
114                self.strikethrough
115            },
116            dim: if other.dim { true } else { self.dim },
117            reverse: if other.reverse { true } else { self.reverse },
118            link: other.link.clone().or_else(|| self.link.clone()),
119        }
120    }
121
122    /// Returns true if no attributes are set.
123    pub fn is_empty(&self) -> bool {
124        *self == Self::default()
125    }
126}
127
128impl From<&Style> for crossterm::style::ContentStyle {
129    fn from(style: &Style) -> Self {
130        use crossterm::style::{Attribute, ContentStyle};
131
132        let mut cs = ContentStyle::new();
133        if let Some(ref fg) = style.fg {
134            cs.foreground_color = Some(fg.into());
135        }
136        if let Some(ref bg) = style.bg {
137            cs.background_color = Some(bg.into());
138        }
139        if style.bold {
140            cs.attributes.set(Attribute::Bold);
141        }
142        if style.italic {
143            cs.attributes.set(Attribute::Italic);
144        }
145        if style.underline {
146            cs.attributes.set(Attribute::Underlined);
147        }
148        if style.strikethrough {
149            cs.attributes.set(Attribute::CrossedOut);
150        }
151        if style.dim {
152            cs.attributes.set(Attribute::Dim);
153        }
154        if style.reverse {
155            cs.attributes.set(Attribute::Reverse);
156        }
157        cs
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::color::NamedColor;
165
166    #[test]
167    fn builder_pattern() {
168        let s = Style::new()
169            .fg(Color::Named(NamedColor::Red))
170            .bold(true)
171            .italic(true);
172        assert_eq!(s.fg, Some(Color::Named(NamedColor::Red)));
173        assert!(s.bold);
174        assert!(s.italic);
175        assert!(!s.underline);
176    }
177
178    #[test]
179    fn default_is_empty() {
180        assert!(Style::new().is_empty());
181    }
182
183    #[test]
184    fn non_empty_style() {
185        assert!(!Style::new().bold(true).is_empty());
186    }
187
188    #[test]
189    fn merge_fg_override() {
190        let base = Style::new().fg(Color::Named(NamedColor::Red));
191        let over = Style::new().fg(Color::Named(NamedColor::Blue));
192        let merged = base.merge(&over);
193        assert_eq!(merged.fg, Some(Color::Named(NamedColor::Blue)));
194    }
195
196    #[test]
197    fn merge_preserves_base() {
198        let base = Style::new().fg(Color::Named(NamedColor::Red)).bold(true);
199        let over = Style::new().italic(true);
200        let merged = base.merge(&over);
201        assert_eq!(merged.fg, Some(Color::Named(NamedColor::Red)));
202        assert!(merged.bold);
203        assert!(merged.italic);
204    }
205
206    #[test]
207    fn crossterm_conversion() {
208        let s = Style::new().fg(Color::Rgb { r: 1, g: 2, b: 3 }).bold(true);
209        let cs: crossterm::style::ContentStyle = (&s).into();
210        assert_eq!(
211            cs.foreground_color,
212            Some(crossterm::style::Color::Rgb { r: 1, g: 2, b: 3 })
213        );
214    }
215}