Skip to main content

smelt_term/
line.rs

1//! Styled text primitives. `Span` is a single attribute-uniform run;
2//! `Line` is a sequence of spans for one visual row. Both are data-only;
3//! rendering happens via [`crate::grid::GridSlice::put_line`].
4
5use crate::grid::{display_width, Style};
6use std::borrow::Cow;
7
8/// A styled run of text. `Cow` text keeps borrowed literals zero-copy.
9#[derive(Clone, Debug, PartialEq, Eq, Default)]
10pub struct Span<'a> {
11    pub text: Cow<'a, str>,
12    pub style: Style,
13}
14
15impl<'a> Span<'a> {
16    pub fn raw(text: impl Into<Cow<'a, str>>) -> Self {
17        Self {
18            text: text.into(),
19            style: Style::default(),
20        }
21    }
22
23    pub fn styled(text: impl Into<Cow<'a, str>>, style: Style) -> Self {
24        Self {
25            text: text.into(),
26            style,
27        }
28    }
29
30    /// Display width of the span's text in terminal cells.
31    pub fn width(&self) -> u16 {
32        display_width(self.text.as_ref())
33    }
34}
35
36impl<'a> From<&'a str> for Span<'a> {
37    fn from(s: &'a str) -> Self {
38        Self::raw(s)
39    }
40}
41
42impl From<String> for Span<'_> {
43    fn from(s: String) -> Self {
44        Self::raw(Cow::Owned(s))
45    }
46}
47
48/// One row of styled text. Spans paint left-to-right with no implicit
49/// gaps; callers add padding spans where they want them.
50#[derive(Clone, Debug, PartialEq, Eq, Default)]
51pub struct Line<'a> {
52    pub spans: Vec<Span<'a>>,
53}
54
55impl<'a> Line<'a> {
56    pub fn new() -> Self {
57        Self { spans: Vec::new() }
58    }
59
60    pub fn from_spans<I: IntoIterator<Item = Span<'a>>>(spans: I) -> Self {
61        Self {
62            spans: spans.into_iter().collect(),
63        }
64    }
65
66    pub fn raw(text: impl Into<Cow<'a, str>>) -> Self {
67        Self::from_spans([Span::raw(text)])
68    }
69
70    /// Append a span; returns `self` for chaining.
71    pub fn push<S: Into<Span<'a>>>(mut self, span: S) -> Self {
72        self.spans.push(span.into());
73        self
74    }
75
76    /// Total display width across all spans.
77    pub fn width(&self) -> u16 {
78        self.spans
79            .iter()
80            .fold(0u16, |width, span| width.saturating_add(span.width()))
81    }
82}
83
84impl<'a> From<&'a str> for Line<'a> {
85    fn from(s: &'a str) -> Self {
86        Self::raw(s)
87    }
88}
89
90impl From<String> for Line<'_> {
91    fn from(s: String) -> Self {
92        Self::raw(Cow::Owned(s))
93    }
94}
95
96impl<'a> From<Span<'a>> for Line<'a> {
97    fn from(span: Span<'a>) -> Self {
98        Self::from_spans([span])
99    }
100}
101
102/// Construct a [`Line`] from a list of `Into<Span>` values.
103/// `line!["foo", " ", Span::styled("bar", red)]`
104#[macro_export]
105macro_rules! line {
106    () => { $crate::line::Line::new() };
107    ($($span:expr),+ $(,)?) => {{
108        $crate::line::Line::from_spans([$(::core::convert::Into::<$crate::line::Span<'_>>::into($span)),+])
109    }};
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::grid::Color;
116
117    #[test]
118    fn span_from_str_is_default_style() {
119        let s: Span = "hi".into();
120        assert_eq!(s.text, "hi");
121        assert_eq!(s.style, Style::default());
122        assert_eq!(s.width(), 2);
123    }
124
125    #[test]
126    fn line_width_sums_spans() {
127        let l = Line::from_spans([
128            Span::raw("ab"),
129            Span::styled("cde", Style::new().fg(Color::Red)),
130        ]);
131        assert_eq!(l.width(), 5);
132    }
133
134    #[test]
135    fn line_macro_mixes_strs_and_spans() {
136        let l = line!["foo", " ", Span::styled("bar", Style::new().fg(Color::Red))];
137        assert_eq!(l.spans.len(), 3);
138        assert_eq!(l.spans[0].text, "foo");
139        assert_eq!(l.spans[1].text, " ");
140        assert_eq!(l.spans[2].text, "bar");
141        assert_eq!(l.spans[2].style.fg, Some(Color::Red));
142    }
143
144    #[test]
145    fn span_width_counts_two_columns_per_wide_char() {
146        // CJK chars and most emoji render 2 cells wide.
147        assert_eq!(Span::raw("漢字").width(), 4);
148        assert_eq!(Span::raw("a漢b").width(), 4);
149    }
150
151    #[test]
152    fn span_width_is_zero_for_empty_text() {
153        assert_eq!(Span::raw("").width(), 0);
154    }
155
156    #[test]
157    fn line_width_zero_for_empty_and_for_all_empty_spans() {
158        assert_eq!(Line::new().width(), 0);
159        let l = Line::from_spans([Span::raw(""), Span::raw("")]);
160        assert_eq!(l.width(), 0);
161    }
162
163    #[test]
164    fn line_push_appends_a_span() {
165        let l = Line::new()
166            .push("a")
167            .push(Span::styled("b", Style::new().fg(Color::Red)));
168        assert_eq!(l.spans.len(), 2);
169        assert_eq!(l.spans[0].text, "a");
170        assert_eq!(l.spans[1].style.fg, Some(Color::Red));
171    }
172
173    #[test]
174    fn line_raw_wraps_text_in_a_single_default_span() {
175        let l = Line::raw("hello");
176        assert_eq!(l.spans.len(), 1);
177        assert_eq!(l.spans[0].text, "hello");
178        assert_eq!(l.spans[0].style, Style::default());
179    }
180}