Skip to main content

lv_tui/
text.rs

1use crate::buffer::Buffer;
2use crate::geom::{Pos, Rect, Size};
3use crate::style::Style;
4#[cfg(test)]
5use crate::style::Color;
6
7/// A styled text span — text content with a [`Style`].
8///
9/// Produced by the [`Stylize`](crate::stylize::Stylize) trait: `"hello".red().bold()`.
10#[derive(Debug, Clone)]
11pub struct Span {
12    pub text: String,
13    pub style: Style,
14}
15
16impl Span {
17    /// Creates a plain span with default style.
18    pub fn new(text: impl Into<String>) -> Self {
19        Self { text: text.into(), style: Style::default() }
20    }
21
22    /// Creates a span with the given style.
23    pub fn styled(text: impl Into<String>, style: Style) -> Self {
24        Self { text: text.into(), style }
25    }
26
27    /// Unicode display width of this span.
28    pub fn width(&self) -> u16 {
29        self.text.chars()
30            .map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16)
31            .sum()
32    }
33}
34
35impl From<String> for Span {
36    fn from(s: String) -> Self { Self { text: s, style: Style::default() } }
37}
38// No From<&str> for Span — would conflict with From<&str> for Text in widget APIs
39
40/// A single line of styled text — a sequence of [`Span`]s.
41#[derive(Debug, Clone)]
42pub struct Line {
43    pub spans: Vec<Span>,
44}
45
46impl Line {
47    pub fn new(spans: impl IntoIterator<Item = Span>) -> Self {
48        Self { spans: spans.into_iter().collect() }
49    }
50
51    /// Total Unicode display width of all spans.
52    pub fn width(&self) -> u16 {
53        self.spans.iter().map(Span::width).sum()
54    }
55
56    /// Write this line to the buffer at the given position.
57    /// Returns the width written.
58    pub fn render(&self, buf: &mut Buffer, pos: Pos, clip: Rect) -> u16 {
59        let mut x = pos.x;
60        for span in &self.spans {
61            if span.text.is_empty() { continue; }
62            buf.write_text(Pos { x, y: pos.y }, clip, &span.text, &span.style);
63            x = x.saturating_add(span.width());
64        }
65        x.saturating_sub(pos.x)
66    }
67}
68
69impl From<Span> for Line {
70    fn from(span: Span) -> Self { Self { spans: vec![span] } }
71}
72// No From<&str> for Line — use Line::from(Span::new(...)) instead
73
74impl From<Vec<Span>> for Line {
75    fn from(spans: Vec<Span>) -> Self { Self { spans } }
76}
77
78/// Multi-line styled text.
79#[derive(Debug, Clone)]
80pub struct Text {
81    pub lines: Vec<Line>,
82}
83
84impl Text {
85    pub fn new(lines: impl IntoIterator<Item = Line>) -> Self {
86        Self { lines: lines.into_iter().collect() }
87    }
88
89    /// Number of lines.
90    pub fn height(&self) -> usize { self.lines.len() }
91
92    /// Maximum width among all lines.
93    pub fn max_width(&self) -> u16 {
94        self.lines.iter().map(Line::width).max().unwrap_or(0)
95    }
96
97    /// The text of the first span of the first line, or "" if empty.
98    pub fn first_text(&self) -> &str {
99        self.lines.first().and_then(|l| l.spans.first()).map(|s| s.text.as_str()).unwrap_or("")
100    }
101
102    /// Write all lines to the buffer within the given rect.
103    /// Returns the actual size used.
104    pub fn render(&self, buf: &mut Buffer, rect: Rect) -> Size {
105        for (i, line) in self.lines.iter().enumerate() {
106            let y = rect.y.saturating_add(i as u16);
107            if y >= rect.y.saturating_add(rect.height) { break; }
108            line.render(buf, Pos { x: rect.x, y }, rect);
109        }
110        Size { width: self.max_width(), height: self.lines.len() as u16 }
111    }
112}
113
114// From impls — make widget APIs accept &str/String/Line/Span directly
115
116impl From<&str> for Text {
117    fn from(s: &str) -> Self {
118        if s.is_empty() { return Self { lines: Vec::new() }; }
119        let lines: Vec<Line> = s.split('\n').map(|seg| Line::from(Span::new(seg))).collect();
120        Self { lines }
121    }
122}
123
124impl From<String> for Text {
125    fn from(s: String) -> Self { Text::from(s.as_str()) }
126}
127
128impl From<Line> for Text {
129    fn from(l: Line) -> Self { Self { lines: vec![l] } }
130}
131
132impl From<Vec<Line>> for Text {
133    fn from(lines: Vec<Line>) -> Self { Self { lines } }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::stylize::Stylize;
140
141    // ── Span ──
142
143    #[test]
144    fn test_span_new() {
145        let s = Span::new("hello");
146        assert_eq!(s.text, "hello");
147    }
148
149    #[test]
150    fn test_span_width() {
151        assert_eq!(Span::new("abc").width(), 3);
152    }
153
154    // ── Line ──
155
156    #[test]
157    fn test_line_from_span() {
158        let l = Line::from(Span::new("hello"));
159        assert_eq!(l.spans.len(), 1);
160        assert_eq!(l.spans[0].text, "hello");
161    }
162
163    #[test]
164    fn test_line_from_spans() {
165        let l = Line::from(vec![
166            Span::styled("A", Style::default().fg(Color::Red)),
167            Span::new("B"),
168        ]);
169        assert_eq!(l.spans.len(), 2);
170        assert_eq!(l.width(), 2);
171    }
172
173    #[test]
174    fn test_line_render() {
175        let mut buf = Buffer::new(Size { width: 10, height: 1 });
176        let line = Line::from(Span::new("hi"));
177        let w = line.render(&mut buf, Pos::default(), Rect { x: 0, y: 0, width: 10, height: 1 });
178        assert_eq!(w, 2);
179        assert_eq!(buf.cells[0].symbol, "h");
180    }
181
182    // ── Text ──
183
184    #[test]
185    fn test_text_from_str() {
186        let t = Text::from("hello");
187        assert_eq!(t.lines.len(), 1);
188    }
189
190    #[test]
191    fn test_text_from_line() {
192        let t = Text::from(Line::from(Span::new("hello")));
193        assert_eq!(t.lines.len(), 1);
194    }
195
196    #[test]
197    fn test_text_from_vec() {
198        let t = Text::from(vec![
199            Line::from(Span::new("line1")),
200            Line::from(Span::new("line2")),
201        ]);
202        assert_eq!(t.height(), 2);
203    }
204
205    #[test]
206    fn test_text_with_stylize() {
207        let t = Text::from(Line::from(vec![
208            "Error: ".red().bold(),
209            Span::new("not found"),
210        ]));
211        assert_eq!(t.lines[0].spans[0].style.fg, Some(crate::style::Color::Red));
212        assert!(t.lines[0].spans[0].style.bold);
213    }
214
215    #[test]
216    fn test_text_render() {
217        let mut buf = Buffer::new(Size { width: 20, height: 2 });
218        let t = Text::from(vec![
219            Line::from(Span::new("hello")),
220            Line::from(Span::new("world")),
221        ]);
222        t.render(&mut buf, Rect { x: 0, y: 0, width: 20, height: 2 });
223        assert_eq!(buf.cells[0].symbol, "h");
224        assert_eq!(&buf.cells[20].symbol, "w"); // second line at index 20
225    }
226
227    #[test]
228    fn test_line_width_multi_style() {
229        let line = Line::from(vec![
230            Span::styled("AB", Style::default().fg(Color::Red)),
231            Span::new("CD"),
232        ]);
233        assert_eq!(line.width(), 4);
234    }
235}