Skip to main content

urushi_terminal/
style.rs

1//! Backend-independent terminal style primitives.
2
3/// A color addressable by terminal text commands.
4#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
5pub enum Color {
6    /// Standard 4-bit ANSI color (0–15). Values 8–15 are bright variants.
7    Ansi(u8),
8    /// An entry in the xterm 256-color palette.
9    Ansi256(u8),
10    /// A 24-bit color.
11    Rgb(u8, u8, u8),
12}
13
14impl Color {
15    pub const BLACK: Self = Self::Ansi(0);
16    pub const RED: Self = Self::Ansi(1);
17    pub const GREEN: Self = Self::Ansi(2);
18    pub const YELLOW: Self = Self::Ansi(3);
19    pub const BLUE: Self = Self::Ansi(4);
20    pub const MAGENTA: Self = Self::Ansi(5);
21    pub const CYAN: Self = Self::Ansi(6);
22    pub const WHITE: Self = Self::Ansi(7);
23    pub const BRIGHT_BLACK: Self = Self::Ansi(8);
24    pub const BRIGHT_RED: Self = Self::Ansi(9);
25    pub const BRIGHT_GREEN: Self = Self::Ansi(10);
26    pub const BRIGHT_YELLOW: Self = Self::Ansi(11);
27    pub const BRIGHT_BLUE: Self = Self::Ansi(12);
28    pub const BRIGHT_MAGENTA: Self = Self::Ansi(13);
29    pub const BRIGHT_CYAN: Self = Self::Ansi(14);
30    pub const BRIGHT_WHITE: Self = Self::Ansi(15);
31
32    /// Parses `#rgb`, `#rrggbb`, or a decimal palette index from 0 to 255.
33    pub fn parse(value: &str) -> Option<Self> {
34        if let Some(hex) = value.strip_prefix('#') {
35            return match hex.len() {
36                3 => {
37                    let mut digits = hex
38                        .chars()
39                        .map(|character| character.to_digit(16).map(|digit| (digit * 17) as u8));
40                    Some(Self::Rgb(digits.next()??, digits.next()??, digits.next()??))
41                }
42                6 => Some(Self::Rgb(
43                    u8::from_str_radix(&hex[0..2], 16).ok()?,
44                    u8::from_str_radix(&hex[2..4], 16).ok()?,
45                    u8::from_str_radix(&hex[4..6], 16).ok()?,
46                )),
47                _ => None,
48            };
49        }
50
51        value.parse::<u8>().ok().map(Self::from)
52    }
53}
54
55impl From<u8> for Color {
56    fn from(index: u8) -> Self {
57        if index < 16 {
58            Self::Ansi(index)
59        } else {
60            Self::Ansi256(index)
61        }
62    }
63}
64
65impl From<(u8, u8, u8)> for Color {
66    fn from((red, green, blue): (u8, u8, u8)) -> Self {
67        Self::Rgb(red, green, blue)
68    }
69}
70
71/// One independently selectable terminal text attribute.
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
73pub enum TextAttribute {
74    Bold,
75    Dim,
76    Italic,
77    SlowBlink,
78    RapidBlink,
79    Reversed,
80    Hidden,
81    CrossedOut,
82    Fraktur,
83    Framed,
84    Encircled,
85    Overlined,
86}
87
88impl TextAttribute {
89    const COUNT: usize = 12;
90
91    const fn bit(self) -> u16 {
92        1 << self as u16
93    }
94
95    const fn from_index(index: u32) -> Self {
96        match index {
97            0 => Self::Bold,
98            1 => Self::Dim,
99            2 => Self::Italic,
100            3 => Self::SlowBlink,
101            4 => Self::RapidBlink,
102            5 => Self::Reversed,
103            6 => Self::Hidden,
104            7 => Self::CrossedOut,
105            8 => Self::Fraktur,
106            9 => Self::Framed,
107            10 => Self::Encircled,
108            11 => Self::Overlined,
109            _ => unreachable!(),
110        }
111    }
112}
113
114/// A set of terminal text attributes with no duplicates or ordering.
115#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
116pub struct TextAttributes(u16);
117
118impl TextAttributes {
119    const ALL_BITS: u16 = (1 << TextAttribute::COUNT) - 1;
120
121    pub const fn empty() -> Self {
122        Self(0)
123    }
124
125    pub const fn all() -> Self {
126        Self(Self::ALL_BITS)
127    }
128
129    pub const fn from_attribute(attribute: TextAttribute) -> Self {
130        Self(attribute.bit())
131    }
132
133    pub const fn union(self, other: Self) -> Self {
134        Self(self.0 | other.0)
135    }
136
137    pub const fn difference(self, other: Self) -> Self {
138        Self(self.0 & (Self::ALL_BITS ^ other.0))
139    }
140
141    pub const fn intersection(self, other: Self) -> Self {
142        Self(self.0 & other.0)
143    }
144
145    pub const fn contains(self, attribute: TextAttribute) -> bool {
146        self.0 & attribute.bit() != 0
147    }
148
149    pub const fn contains_all(self, other: Self) -> bool {
150        self.0 & other.0 == other.0
151    }
152
153    pub const fn is_empty(self) -> bool {
154        self.0 == 0
155    }
156
157    pub const fn iter(self) -> TextAttributeIter {
158        TextAttributeIter { remaining: self.0 }
159    }
160}
161
162impl From<TextAttribute> for TextAttributes {
163    fn from(attribute: TextAttribute) -> Self {
164        Self::from_attribute(attribute)
165    }
166}
167
168impl std::ops::BitOr for TextAttribute {
169    type Output = TextAttributes;
170
171    fn bitor(self, rhs: Self) -> Self::Output {
172        TextAttributes::from_attribute(self).union(TextAttributes::from_attribute(rhs))
173    }
174}
175
176impl std::ops::BitOr<TextAttribute> for TextAttributes {
177    type Output = Self;
178
179    fn bitor(self, rhs: TextAttribute) -> Self::Output {
180        self.union(rhs.into())
181    }
182}
183
184impl std::ops::BitOr for TextAttributes {
185    type Output = Self;
186
187    fn bitor(self, rhs: Self) -> Self::Output {
188        self.union(rhs)
189    }
190}
191
192/// Iterator over the attributes present in a [`TextAttributes`] value.
193pub struct TextAttributeIter {
194    remaining: u16,
195}
196
197impl Iterator for TextAttributeIter {
198    type Item = TextAttribute;
199
200    fn next(&mut self) -> Option<Self::Item> {
201        if self.remaining == 0 {
202            return None;
203        }
204        let index = self.remaining.trailing_zeros();
205        self.remaining &= self.remaining - 1;
206        Some(TextAttribute::from_index(index))
207    }
208
209    fn size_hint(&self) -> (usize, Option<usize>) {
210        let remaining = self.remaining.count_ones() as usize;
211        (remaining, Some(remaining))
212    }
213}
214
215impl ExactSizeIterator for TextAttributeIter {}
216impl std::iter::FusedIterator for TextAttributeIter {}
217
218impl IntoIterator for TextAttributes {
219    type Item = TextAttribute;
220    type IntoIter = TextAttributeIter;
221
222    fn into_iter(self) -> Self::IntoIter {
223        self.iter()
224    }
225}
226
227/// The shape of an underline drawn by the terminal.
228#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
229pub enum UnderlineStyle {
230    #[default]
231    Single,
232    Double,
233    Curly,
234    Dotted,
235    Dashed,
236}
237
238/// An underline and its optional color independent of the foreground.
239#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
240pub struct Underline {
241    style: UnderlineStyle,
242    color: Option<Color>,
243}
244
245impl Underline {
246    pub const fn new(style: UnderlineStyle) -> Self {
247        Self { style, color: None }
248    }
249
250    pub const fn style(mut self, style: UnderlineStyle) -> Self {
251        self.style = style;
252        self
253    }
254
255    pub const fn reset_style(mut self) -> Self {
256        self.style = UnderlineStyle::Single;
257        self
258    }
259
260    pub fn color(mut self, color: impl Into<Color>) -> Self {
261        self.color = Some(color.into());
262        self
263    }
264
265    pub const fn reset_color(mut self) -> Self {
266        self.color = None;
267        self
268    }
269
270    pub const fn get_style(self) -> UnderlineStyle {
271        self.style
272    }
273
274    pub const fn get_color(self) -> Option<Color> {
275        self.color
276    }
277}
278
279impl From<UnderlineStyle> for Underline {
280    fn from(style: UnderlineStyle) -> Self {
281        Self::new(style)
282    }
283}
284
285/// The complete physical text style applied to terminal output.
286#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
287pub struct TerminalStyle {
288    pub foreground: Option<Color>,
289    pub background: Option<Color>,
290    pub attributes: TextAttributes,
291    pub underline: Option<Underline>,
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn parses_colors_without_a_rendering_dependency() {
300        assert_eq!(Color::parse("#fff"), Some(Color::Rgb(255, 255, 255)));
301        assert_eq!(Color::parse("#ff88cc"), Some(Color::Rgb(255, 136, 204)));
302        assert_eq!(Color::parse("1"), Some(Color::Ansi(1)));
303        assert_eq!(Color::parse("212"), Some(Color::Ansi256(212)));
304        assert_eq!(Color::parse("256"), None);
305    }
306
307    #[test]
308    fn attribute_sets_support_style_composition() {
309        let emphasis = TextAttribute::Bold | TextAttribute::Italic;
310
311        assert_eq!(
312            emphasis.difference(TextAttribute::Italic.into()),
313            TextAttribute::Bold.into()
314        );
315        assert_eq!(
316            emphasis.intersection(TextAttribute::Italic | TextAttribute::Dim),
317            TextAttribute::Italic.into()
318        );
319        assert_eq!(
320            emphasis.iter().collect::<Vec<_>>(),
321            [TextAttribute::Bold, TextAttribute::Italic]
322        );
323    }
324}