tuxtui_core/
text.rs

1//! Rich text primitives for styled terminal content.
2
3use crate::geometry::Alignment;
4use crate::style::{Style, Stylize};
5use alloc::borrow::Cow;
6use alloc::string::{String, ToString};
7use alloc::vec::Vec;
8use core::fmt;
9use unicode_width::UnicodeWidthStr;
10
11#[cfg(feature = "serde")]
12use serde::{Deserialize, Serialize};
13
14/// A styled span of text.
15///
16/// The most basic text primitive representing a single string with a style.
17///
18/// # Example
19///
20/// ```
21/// use tuxtui_core::text::Span;
22/// use tuxtui_core::style::{Color, Style};
23///
24/// let span = Span::styled("Hello", Style::default().fg(Color::Blue));
25/// ```
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
28pub struct Span<'a> {
29    /// The text content
30    pub content: Cow<'a, str>,
31    /// The style for this span
32    pub style: Style,
33}
34
35impl<'a> Span<'a> {
36    /// Create a new span with the given content and default style.
37    #[must_use]
38    pub fn raw<T: Into<Cow<'a, str>>>(content: T) -> Self {
39        Self {
40            content: content.into(),
41            style: Style::default(),
42        }
43    }
44
45    /// Create a new span with the given content and style.
46    #[must_use]
47    pub fn styled<T: Into<Cow<'a, str>>>(content: T, style: Style) -> Self {
48        Self {
49            content: content.into(),
50            style,
51        }
52    }
53
54    /// Get the display width of this span.
55    #[must_use]
56    pub fn width(&self) -> usize {
57        self.content.width()
58    }
59
60    /// Convert this span to an owned version.
61    #[must_use]
62    pub fn into_owned(self) -> Span<'static> {
63        Span {
64            content: Cow::Owned(self.content.into_owned()),
65            style: self.style,
66        }
67    }
68
69    /// Patch the style of this span.
70    #[must_use]
71    pub fn patch_style(mut self, style: Style) -> Self {
72        self.style = self.style.patch(style);
73        self
74    }
75}
76
77impl<'a> From<&'a str> for Span<'a> {
78    fn from(s: &'a str) -> Self {
79        Self::raw(s)
80    }
81}
82
83impl From<String> for Span<'static> {
84    fn from(s: String) -> Self {
85        Self::raw(s)
86    }
87}
88
89impl<'a> Stylize for Span<'a> {
90    fn style(mut self, style: Style) -> Self {
91        self.style = self.style.patch(style);
92        self
93    }
94}
95
96impl fmt::Display for Span<'_> {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        write!(f, "{}", self.content)
99    }
100}
101
102/// A line of text composed of multiple styled spans.
103///
104/// A line represents a single row of text that can have multiple different styles.
105///
106/// # Example
107///
108/// ```
109/// use tuxtui_core::text::{Line, Span};
110/// use tuxtui_core::style::{Color, Style};
111///
112/// let line = Line::from(vec![
113///     Span::styled("Hello ", Style::default().fg(Color::Blue)),
114///     Span::styled("World!", Style::default().fg(Color::Red)),
115/// ]);
116/// ```
117#[derive(Debug, Clone, PartialEq, Eq, Hash)]
118#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
119pub struct Line<'a> {
120    /// The spans that make up this line
121    pub spans: Vec<Span<'a>>,
122    /// Alignment for this line
123    pub alignment: Alignment,
124    /// Line style applied to all spans
125    pub style: Style,
126}
127
128impl<'a> Line<'a> {
129    /// Create a new empty line.
130    #[must_use]
131    pub fn new() -> Self {
132        Self {
133            spans: Vec::new(),
134            alignment: Alignment::Start,
135            style: Style::default(),
136        }
137    }
138
139    /// Create a new line from spans.
140    #[must_use]
141    pub fn from_spans(spans: Vec<Span<'a>>) -> Self {
142        Self {
143            spans,
144            alignment: Alignment::Start,
145            style: Style::default(),
146        }
147    }
148
149    /// Create a styled line from a string.
150    #[must_use]
151    pub fn styled<T: Into<Cow<'a, str>>>(content: T, style: Style) -> Self {
152        Self {
153            spans: alloc::vec![Span::styled(content, style)],
154            alignment: Alignment::Start,
155            style: Style::default(),
156        }
157    }
158
159    /// Set the alignment for this line.
160    #[must_use]
161    pub fn alignment(mut self, alignment: Alignment) -> Self {
162        self.alignment = alignment;
163        self
164    }
165
166    /// Get the display width of this line.
167    #[must_use]
168    pub fn width(&self) -> usize {
169        self.spans.iter().map(Span::width).sum()
170    }
171
172    /// Truncate the line to fit within the given width, optionally adding an ellipsis.
173    ///
174    /// # Example
175    ///
176    /// ```
177    /// use tuxtui_core::text::Line;
178    ///
179    /// let line = Line::from("This is a very long line");
180    /// let truncated = line.truncate(10, Some("..."));
181    /// assert!(truncated.width() <= 10);
182    /// ```
183    #[must_use]
184    pub fn truncate(self, max_width: usize, ellipsis: Option<&str>) -> Line<'a> {
185        use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
186
187        let current_width = self.width();
188        if current_width <= max_width {
189            return self;
190        }
191
192        let ellipsis_str = ellipsis.unwrap_or("...");
193        let ellipsis_width = ellipsis_str.width();
194
195        if ellipsis_width >= max_width {
196            return Line::from(ellipsis_str.to_string());
197        }
198
199        let target_width = max_width.saturating_sub(ellipsis_width);
200        let mut new_spans = alloc::vec::Vec::new();
201        let mut accumulated_width = 0;
202
203        for span in self.spans {
204            let span_width = span.width();
205
206            if accumulated_width + span_width <= target_width {
207                accumulated_width += span_width;
208                new_spans.push(span);
209            } else {
210                let remaining = target_width.saturating_sub(accumulated_width);
211                if remaining > 0 {
212                    let mut truncated = alloc::string::String::new();
213                    let mut w = 0;
214                    for c in span.content.chars() {
215                        let cw = c.width().unwrap_or(0);
216                        if w + cw <= remaining {
217                            truncated.push(c);
218                            w += cw;
219                        } else {
220                            break;
221                        }
222                    }
223                    if !truncated.is_empty() {
224                        new_spans.push(Span::styled(truncated, span.style));
225                    }
226                }
227                break;
228            }
229        }
230
231        new_spans.push(Span::raw(ellipsis_str.to_string()));
232
233        Line {
234            spans: new_spans,
235            alignment: self.alignment,
236            style: self.style,
237        }
238    }
239
240    /// Convert this line to an owned version.
241    #[must_use]
242    pub fn into_owned(self) -> Line<'static> {
243        Line {
244            spans: self.spans.into_iter().map(Span::into_owned).collect(),
245            alignment: self.alignment,
246            style: self.style,
247        }
248    }
249
250    /// Patch the style of this line (affects all spans).
251    #[must_use]
252    pub fn patch_style(mut self, style: Style) -> Self {
253        self.style = self.style.patch(style);
254        self
255    }
256
257    /// Push a span to this line.
258    pub fn push_span(&mut self, span: Span<'a>) {
259        self.spans.push(span);
260    }
261}
262
263impl<'a> Default for Line<'a> {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269impl<'a> From<&'a str> for Line<'a> {
270    fn from(s: &'a str) -> Self {
271        Self::from_spans(alloc::vec![Span::raw(s)])
272    }
273}
274
275impl From<String> for Line<'static> {
276    fn from(s: String) -> Self {
277        Self::from_spans(alloc::vec![Span::raw(s)])
278    }
279}
280
281impl<'a> From<Span<'a>> for Line<'a> {
282    fn from(span: Span<'a>) -> Self {
283        Self::from_spans(alloc::vec![span])
284    }
285}
286
287impl<'a> From<Vec<Span<'a>>> for Line<'a> {
288    fn from(spans: Vec<Span<'a>>) -> Self {
289        Self::from_spans(spans)
290    }
291}
292
293impl<'a> Stylize for Line<'a> {
294    fn style(self, style: Style) -> Self {
295        self.patch_style(style)
296    }
297}
298
299impl fmt::Display for Line<'_> {
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        for span in &self.spans {
302            write!(f, "{span}")?;
303        }
304        Ok(())
305    }
306}
307
308/// Multi-line rich text.
309///
310/// Text can contain multiple lines, each with its own spans and alignment.
311///
312/// # Example
313///
314/// ```
315/// use tuxtui_core::text::{Text, Line};
316/// use tuxtui_core::style::{Color, Style};
317///
318/// let text = Text::from(vec![
319///     Line::from("First line"),
320///     Line::styled("Second line", Style::default().fg(Color::Blue)),
321/// ]);
322/// ```
323#[derive(Debug, Clone, PartialEq, Eq, Hash)]
324#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
325pub struct Text<'a> {
326    /// The lines that make up this text
327    pub lines: Vec<Line<'a>>,
328    /// Overall style applied to all lines
329    pub style: Style,
330}
331
332impl<'a> Text<'a> {
333    /// Create new empty text.
334    #[must_use]
335    pub fn new() -> Self {
336        Self {
337            lines: Vec::new(),
338            style: Style::default(),
339        }
340    }
341
342    /// Create text from lines.
343    #[must_use]
344    pub fn from_lines(lines: Vec<Line<'a>>) -> Self {
345        Self {
346            lines,
347            style: Style::default(),
348        }
349    }
350
351    /// Create styled text from a string.
352    #[must_use]
353    pub fn styled<T: Into<Cow<'a, str>>>(content: T, style: Style) -> Self {
354        let content = content.into();
355        let lines: Vec<Line<'a>> = match content {
356            Cow::Borrowed(s) => s.lines().map(|line| Line::styled(line, style)).collect(),
357            Cow::Owned(s) => s
358                .lines()
359                .map(|line| Line::styled(line.to_string(), style))
360                .collect(),
361        };
362        Self { lines, style }
363    }
364
365    /// Get the width of the widest line.
366    #[must_use]
367    pub fn width(&self) -> usize {
368        self.lines.iter().map(Line::width).max().unwrap_or(0)
369    }
370
371    /// Get the number of lines.
372    #[must_use]
373    pub fn height(&self) -> usize {
374        self.lines.len()
375    }
376
377    /// Convert this text to an owned version.
378    #[must_use]
379    pub fn into_owned(self) -> Text<'static> {
380        Text {
381            lines: self.lines.into_iter().map(Line::into_owned).collect(),
382            style: self.style,
383        }
384    }
385
386    /// Patch the style of this text (affects all lines).
387    #[must_use]
388    pub fn patch_style(mut self, style: Style) -> Self {
389        self.style = self.style.patch(style);
390        self
391    }
392
393    /// Push a line to this text.
394    pub fn push_line(&mut self, line: Line<'a>) {
395        self.lines.push(line);
396    }
397
398    /// Extend this text with more lines.
399    pub fn extend_lines(&mut self, lines: impl IntoIterator<Item = Line<'a>>) {
400        self.lines.extend(lines);
401    }
402}
403
404impl<'a> Default for Text<'a> {
405    fn default() -> Self {
406        Self::new()
407    }
408}
409
410impl<'a> From<&'a str> for Text<'a> {
411    fn from(s: &'a str) -> Self {
412        let lines = s.lines().map(Line::from).collect();
413        Self {
414            lines,
415            style: Style::default(),
416        }
417    }
418}
419
420impl From<String> for Text<'static> {
421    fn from(s: String) -> Self {
422        let lines: Vec<Line<'static>> =
423            s.lines().map(|line| Line::from(line.to_string())).collect();
424        Self {
425            lines,
426            style: Style::default(),
427        }
428    }
429}
430
431impl<'a> From<Line<'a>> for Text<'a> {
432    fn from(line: Line<'a>) -> Self {
433        Self::from_lines(alloc::vec![line])
434    }
435}
436
437impl<'a> From<Vec<Line<'a>>> for Text<'a> {
438    fn from(lines: Vec<Line<'a>>) -> Self {
439        Self::from_lines(lines)
440    }
441}
442
443impl<'a> Stylize for Text<'a> {
444    fn style(self, style: Style) -> Self {
445        self.patch_style(style)
446    }
447}
448
449impl fmt::Display for Text<'_> {
450    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
451        for (i, line) in self.lines.iter().enumerate() {
452            if i > 0 {
453                writeln!(f)?;
454            }
455            write!(f, "{line}")?;
456        }
457        Ok(())
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::style::Color;
465
466    #[test]
467    fn test_span_width() {
468        let span = Span::raw("Hello");
469        assert_eq!(span.width(), 5);
470    }
471
472    #[test]
473    fn test_line_width() {
474        let line = Line::from(vec![Span::raw("Hello "), Span::raw("World")]);
475        assert_eq!(line.width(), 11);
476    }
477
478    #[test]
479    fn test_text_dimensions() {
480        let text = Text::from("Hello\nWorld\n!");
481        assert_eq!(text.height(), 3);
482        assert_eq!(text.width(), 5);
483    }
484
485    #[test]
486    fn test_stylize() {
487        use crate::style::Stylize;
488        let span = Span::raw("test").red().bold();
489        assert_eq!(span.style.fg, Some(Color::Red));
490    }
491}