Skip to main content

retroglyph/
text.rs

1//! Styled text primitives: [`Span`] and [`Line`].
2
3use crate::style::Style;
4use alloc::string::String;
5use alloc::vec::Vec;
6use unicode_width::UnicodeWidthStr;
7
8/// A string with an associated [`Style`].
9///
10/// The building block of styled terminal output. A [`Line`] is composed of
11/// one or more `Span`s, each with its own style.
12///
13/// # Examples
14///
15/// ```
16/// use retroglyph::text::Span;
17/// use retroglyph::style::Style;
18/// use retroglyph::color::Color;
19///
20/// let plain = Span::raw("hello");
21/// let colored = Span::styled("world", Style::new().fg(Color::GREEN));
22/// ```
23#[derive(Debug, Clone, PartialEq, Eq, Hash)]
24pub struct Span {
25    /// The text content.
26    pub content: String,
27    /// The style applied to this span.
28    pub style: Style,
29}
30
31impl Span {
32    /// Creates a span with the given content and no styling.
33    #[must_use]
34    pub fn raw(content: impl Into<String>) -> Self {
35        Self {
36            content: content.into(),
37            style: Style::default(),
38        }
39    }
40
41    /// Creates a span with the given content and style.
42    #[must_use]
43    pub fn styled(content: impl Into<String>, style: Style) -> Self {
44        Self {
45            content: content.into(),
46            style,
47        }
48    }
49
50    /// Returns the display width of this span in terminal columns.
51    #[must_use]
52    pub fn width(&self) -> usize {
53        self.content.as_str().width()
54    }
55}
56
57impl<S: Into<String>> From<S> for Span {
58    fn from(s: S) -> Self {
59        Self::raw(s)
60    }
61}
62
63/// A horizontal sequence of [`Span`]s rendered as a single line.
64///
65/// # Examples
66///
67/// ```
68/// use retroglyph::text::{Line, Span};
69/// use retroglyph::style::Style;
70/// use retroglyph::color::Color;
71///
72/// let line = Line::from(vec![
73///     Span::raw("HP: "),
74///     Span::styled("100", Style::new().fg(Color::GREEN)),
75/// ]);
76/// assert_eq!(line.width(), 7);
77/// ```
78#[derive(Debug, Clone, PartialEq, Eq, Default)]
79pub struct Line {
80    /// The spans that make up this line.
81    pub spans: Vec<Span>,
82}
83
84impl Line {
85    /// Creates an empty line.
86    #[must_use]
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Creates a line from a single unstyled string.
92    #[must_use]
93    pub fn raw(content: impl Into<String>) -> Self {
94        Self {
95            spans: alloc::vec![Span::raw(content)],
96        }
97    }
98
99    /// Returns the total display width of this line in terminal columns.
100    ///
101    /// Accounts for wide characters (CJK, emoji) that occupy two columns.
102    #[must_use]
103    pub fn width(&self) -> usize {
104        self.spans.iter().map(Span::width).sum()
105    }
106}
107
108impl From<&str> for Line {
109    fn from(s: &str) -> Self {
110        Self::raw(s)
111    }
112}
113
114impl From<String> for Line {
115    fn from(s: String) -> Self {
116        Self::raw(s)
117    }
118}
119
120impl From<Span> for Line {
121    fn from(span: Span) -> Self {
122        Self {
123            spans: alloc::vec![span],
124        }
125    }
126}
127
128impl From<Vec<Span>> for Line {
129    fn from(spans: Vec<Span>) -> Self {
130        Self { spans }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::color::Color;
138
139    #[test]
140    fn test_span_raw() {
141        let s = Span::raw("hello");
142        assert_eq!(s.content, "hello");
143        assert_eq!(s.style, Style::default());
144        assert_eq!(s.width(), 5);
145    }
146
147    #[test]
148    fn test_span_styled() {
149        let style = Style::new().fg(Color::RED);
150        let s = Span::styled("hi", style);
151        assert_eq!(s.content, "hi");
152        assert_eq!(s.style, style);
153    }
154
155    #[test]
156    fn test_span_width_wide_chars() {
157        let s = Span::raw("中文"); // each CJK char is 2 columns
158        assert_eq!(s.width(), 4);
159    }
160
161    #[test]
162    fn test_line_from_str() {
163        let line = Line::from("hello");
164        assert_eq!(line.spans.len(), 1);
165        assert_eq!(line.width(), 5);
166    }
167
168    #[test]
169    fn test_line_from_spans() {
170        let line = Line::from(vec![
171            Span::raw("HP: "),
172            Span::styled("100", Style::new().fg(Color::GREEN)),
173        ]);
174        assert_eq!(line.width(), 7);
175    }
176
177    #[test]
178    fn test_line_width_wide_chars() {
179        let line = Line::from(vec![Span::raw("中"), Span::raw("x")]);
180        assert_eq!(line.width(), 3); // 2 + 1
181    }
182
183    #[test]
184    fn test_line_empty() {
185        let line = Line::new();
186        assert_eq!(line.width(), 0);
187    }
188}