Skip to main content

retroglyph_core/
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_core::text::Span;
17/// use retroglyph_core::style::Style;
18/// use retroglyph_core::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_core::text::{Line, Span};
69/// use retroglyph_core::style::Style;
70/// use retroglyph_core::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/// Build a [`Line`] from a list of `(Style, text)` pairs.
135///
136/// Each pair becomes a [`Span`] with the given style. The resulting `Line`
137/// is equivalent to calling `Line::from(vec![Span::styled(t, s), ...])` but
138/// with less boilerplate for multi-segment event-log text.
139///
140/// # Examples
141///
142/// ```
143/// # extern crate alloc;
144/// use retroglyph_core::spans;
145/// use retroglyph_core::style::Style;
146/// use retroglyph_core::color::Color;
147///
148/// let line = spans![
149///     (Style::new().fg(Color::CYAN), "snowtroop "),
150///     (Style::default(), "→ 1 hit"),
151/// ];
152/// assert_eq!(line.width(), 17);
153/// ```
154#[macro_export]
155macro_rules! spans {
156    ($(($style:expr, $text:expr)),* $(,)?) => {
157        $crate::text::Line::from(alloc::vec![
158            $($crate::text::Span::styled($text, $style)),*
159        ])
160    };
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::color::Color;
167
168    #[test]
169    fn test_span_raw() {
170        let s = Span::raw("hello");
171        assert_eq!(s.content, "hello");
172        assert_eq!(s.style, Style::default());
173        assert_eq!(s.width(), 5);
174    }
175
176    #[test]
177    fn test_span_styled() {
178        let style = Style::new().fg(Color::RED);
179        let s = Span::styled("hi", style);
180        assert_eq!(s.content, "hi");
181        assert_eq!(s.style, style);
182    }
183
184    #[test]
185    fn test_span_width_wide_chars() {
186        let s = Span::raw("中文"); // each CJK char is 2 columns
187        assert_eq!(s.width(), 4);
188    }
189
190    #[test]
191    fn test_line_from_str() {
192        let line = Line::from("hello");
193        assert_eq!(line.spans.len(), 1);
194        assert_eq!(line.width(), 5);
195    }
196
197    #[test]
198    fn test_line_from_spans() {
199        let line = Line::from(vec![
200            Span::raw("HP: "),
201            Span::styled("100", Style::new().fg(Color::GREEN)),
202        ]);
203        assert_eq!(line.width(), 7);
204    }
205
206    #[test]
207    fn test_line_width_wide_chars() {
208        let line = Line::from(vec![Span::raw("中"), Span::raw("x")]);
209        assert_eq!(line.width(), 3); // 2 + 1
210    }
211
212    #[test]
213    fn test_line_empty() {
214        let line = Line::new();
215        assert_eq!(line.width(), 0);
216    }
217}