Skip to main content

fast_rich/
text.rs

1//! Text and Span types for styled text with wrapping and alignment.
2//!
3//! This module provides `Text` - a container for styled text that supports
4//! word wrapping, alignment, and combining multiple styled spans.
5
6use crate::style::Style;
7use std::borrow::Cow;
8use unicode_segmentation::UnicodeSegmentation;
9use unicode_width::UnicodeWidthStr;
10
11/// A styled region of text.
12#[derive(Debug, Clone, PartialEq)]
13pub struct Span {
14    /// The text content
15    pub text: Cow<'static, str>,
16    /// The style applied to this span
17    pub style: Style,
18    /// Optional hyperlink URL (OSC 8 terminal links)
19    pub link: Option<String>,
20}
21
22impl Span {
23    /// Create a new span with no style.
24    pub fn raw<S: Into<Cow<'static, str>>>(text: S) -> Self {
25        Span {
26            text: text.into(),
27            style: Style::new(),
28            link: None,
29        }
30    }
31
32    /// Create a new span with a style.
33    pub fn styled<S: Into<Cow<'static, str>>>(text: S, style: Style) -> Self {
34        Span {
35            text: text.into(),
36            style,
37            link: None,
38        }
39    }
40
41    /// Create a new span with a style and hyperlink.
42    pub fn linked<S: Into<Cow<'static, str>>>(text: S, style: Style, url: String) -> Self {
43        Span {
44            text: text.into(),
45            style,
46            link: Some(url),
47        }
48    }
49
50    /// Get the display width of this span.
51    pub fn width(&self) -> usize {
52        UnicodeWidthStr::width(self.text.as_ref())
53    }
54
55    /// Check if the span is empty.
56    pub fn is_empty(&self) -> bool {
57        self.text.is_empty()
58    }
59}
60
61impl<S: Into<Cow<'static, str>>> From<S> for Span {
62    fn from(text: S) -> Self {
63        Span::raw(text)
64    }
65}
66
67/// Text alignment options.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum Alignment {
70    /// Left-aligned (default)
71    #[default]
72    Left,
73    /// Center-aligned
74    Center,
75    /// Right-aligned
76    Right,
77}
78
79/// Overflow behavior when text exceeds available width.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum Overflow {
82    /// Wrap to next line (default)
83    #[default]
84    Wrap,
85    /// Truncate with ellipsis
86    Ellipsis,
87    /// Hard truncate without indicator
88    Truncate,
89    /// Allow overflow
90    Visible,
91}
92
93/// A text container with multiple styled spans.
94#[derive(Debug, Clone, Default)]
95pub struct Text {
96    /// The spans that make up this text
97    pub spans: Vec<Span>,
98    /// Text alignment
99    pub alignment: Alignment,
100    /// Overflow behavior
101    pub overflow: Overflow,
102    /// Optional style applied to the whole text
103    pub style: Style,
104}
105
106impl Text {
107    /// Create a new empty text.
108    pub fn new() -> Self {
109        Text::default()
110    }
111
112    /// Create text from a plain string.
113    pub fn plain<S: Into<Cow<'static, str>>>(text: S) -> Self {
114        Text {
115            spans: vec![Span::raw(text)],
116            ..Default::default()
117        }
118    }
119
120    /// Create text from a styled string.
121    pub fn styled<S: Into<Cow<'static, str>>>(text: S, style: Style) -> Self {
122        Text {
123            spans: vec![Span::styled(text, style)],
124            style,
125            ..Default::default()
126        }
127    }
128
129    /// Create text from multiple spans.
130    pub fn from_spans<I: IntoIterator<Item = Span>>(spans: I) -> Self {
131        Text {
132            spans: spans.into_iter().collect(),
133            ..Default::default()
134        }
135    }
136
137    /// Add a span to the text.
138    pub fn push_span(&mut self, span: Span) {
139        self.spans.push(span);
140    }
141
142    /// Append plain text.
143    pub fn push<S: Into<Cow<'static, str>>>(&mut self, text: S) {
144        self.spans.push(Span::raw(text));
145    }
146
147    /// Append styled text.
148    pub fn push_styled<S: Into<Cow<'static, str>>>(&mut self, text: S, style: Style) {
149        self.spans.push(Span::styled(text, style));
150    }
151
152    /// Set the alignment.
153    pub fn alignment(mut self, alignment: Alignment) -> Self {
154        self.alignment = alignment;
155        self
156    }
157
158    /// Set the overflow behavior.
159    pub fn overflow(mut self, overflow: Overflow) -> Self {
160        self.overflow = overflow;
161        self
162    }
163
164    /// Disable word wrapping (equivalent to `overflow(Overflow::Visible)`).
165    ///
166    /// When set, text will extend beyond the available width instead of wrapping.
167    pub fn no_wrap(self) -> Self {
168        self.overflow(Overflow::Visible)
169    }
170
171    /// Set the overall style.
172    pub fn style(mut self, style: Style) -> Self {
173        self.style = style;
174        self
175    }
176
177    /// Get the total display width (without line breaks).
178    pub fn width(&self) -> usize {
179        self.spans.iter().map(|s| s.width()).sum()
180    }
181
182    /// Get the plain text content without styling.
183    pub fn plain_text(&self) -> String {
184        self.spans.iter().map(|s| s.text.as_ref()).collect()
185    }
186
187    /// Check if the text is empty.
188    pub fn is_empty(&self) -> bool {
189        self.spans.is_empty() || self.spans.iter().all(|s| s.is_empty())
190    }
191
192    /// Split the text into lines, wrapping at the given width.
193    pub fn wrap(&self, width: usize) -> Vec<Vec<Span>> {
194        if width == 0 {
195            return vec![];
196        }
197
198        match self.overflow {
199            Overflow::Visible => vec![self.spans.clone()],
200            Overflow::Truncate | Overflow::Ellipsis => {
201                vec![self.truncate_spans(width, self.overflow == Overflow::Ellipsis)]
202            }
203            Overflow::Wrap => self.wrap_spans(width),
204        }
205    }
206
207    fn truncate_spans(&self, width: usize, ellipsis: bool) -> Vec<Span> {
208        let mut result = Vec::new();
209        let mut remaining_width = if ellipsis {
210            width.saturating_sub(1)
211        } else {
212            width
213        };
214
215        for span in &self.spans {
216            if remaining_width == 0 {
217                break;
218            }
219
220            let span_width = span.width();
221            if span_width <= remaining_width {
222                result.push(span.clone());
223                remaining_width -= span_width;
224            } else {
225                // Truncate this span
226                let truncated = truncate_str(&span.text, remaining_width);
227                result.push(Span::styled(truncated.to_string(), span.style));
228                remaining_width = 0;
229            }
230        }
231
232        if ellipsis && self.width() > width {
233            result.push(Span::raw("…"));
234        }
235
236        result
237    }
238
239    fn wrap_spans(&self, max_width: usize) -> Vec<Vec<Span>> {
240        let mut lines: Vec<Vec<Span>> = Vec::new();
241        let mut current_line: Vec<Span> = Vec::new();
242        let mut current_width = 0;
243
244        for span in &self.spans {
245            let words = split_into_words(&span.text);
246
247            for (word, trailing_space) in words {
248                let word_width = UnicodeWidthStr::width(word);
249                let space_width = if trailing_space { 1 } else { 0 };
250                let total_width = word_width + space_width;
251
252                // If word fits on current line
253                if current_width + word_width <= max_width {
254                    let text = if trailing_space {
255                        format!("{word} ")
256                    } else {
257                        word.to_string()
258                    };
259                    current_line.push(Span::styled(text, span.style));
260                    current_width += total_width;
261                } else if word_width > max_width {
262                    // Word is too long, need to break it
263                    if !current_line.is_empty() {
264                        lines.push(std::mem::take(&mut current_line));
265                        current_width = 0;
266                    }
267
268                    // Break the word across lines
269                    let broken = break_word(word, max_width);
270                    for (i, part) in broken.iter().enumerate() {
271                        if i > 0 {
272                            lines.push(std::mem::take(&mut current_line));
273                        }
274                        current_line.push(Span::styled(part.to_string(), span.style));
275                        current_width = UnicodeWidthStr::width(part.as_str());
276                    }
277
278                    if trailing_space && current_width < max_width {
279                        current_line.push(Span::styled(" ", span.style));
280                        current_width += 1;
281                    }
282                } else {
283                    // Start new line
284                    if !current_line.is_empty() {
285                        lines.push(std::mem::take(&mut current_line));
286                    }
287                    let text = if trailing_space {
288                        format!("{word} ")
289                    } else {
290                        word.to_string()
291                    };
292                    current_line.push(Span::styled(text, span.style));
293                    current_width = total_width;
294                }
295            }
296        }
297
298        if !current_line.is_empty() {
299            lines.push(current_line);
300        }
301
302        if lines.is_empty() {
303            lines.push(Vec::new());
304        }
305
306        lines
307    }
308
309    /// Apply alignment to a line, returning padded spans.
310    pub fn align_line(&self, line: Vec<Span>, width: usize) -> Vec<Span> {
311        let line_width: usize = line.iter().map(|s| s.width()).sum();
312
313        if line_width >= width {
314            return line;
315        }
316
317        let padding = width - line_width;
318
319        match self.alignment {
320            Alignment::Left => {
321                // Don't add padding for left alignment (professional behavior)
322                // This prevents visual artifacts and matches Python rich
323                line
324            }
325            Alignment::Right => {
326                let mut result = vec![Span::raw(" ".repeat(padding))];
327                result.extend(line);
328                result
329            }
330            Alignment::Center => {
331                let left_pad = padding / 2;
332                let right_pad = padding - left_pad;
333                let mut result = vec![Span::raw(" ".repeat(left_pad))];
334                result.extend(line);
335                result.push(Span::raw(" ".repeat(right_pad)));
336                result
337            }
338        }
339    }
340}
341
342impl<S: Into<Cow<'static, str>>> From<S> for Text {
343    fn from(text: S) -> Self {
344        Text::plain(text)
345    }
346}
347
348/// Truncate a string to a given display width.
349fn truncate_str(s: &str, max_width: usize) -> &str {
350    let mut width = 0;
351    let mut end = 0;
352
353    for grapheme in s.graphemes(true) {
354        let grapheme_width = UnicodeWidthStr::width(grapheme);
355        if width + grapheme_width > max_width {
356            break;
357        }
358        width += grapheme_width;
359        end += grapheme.len();
360    }
361
362    &s[..end]
363}
364
365/// Split text into words, preserving trailing spaces.
366fn split_into_words(s: &str) -> Vec<(&str, bool)> {
367    let mut words = Vec::new();
368    let mut word_start = None;
369    let mut leading_spaces = 0;
370
371    // Count leading spaces and add them as prefix
372    for (i, c) in s.char_indices() {
373        if c.is_whitespace() {
374            leading_spaces = i + c.len_utf8();
375        } else {
376            break;
377        }
378    }
379
380    // If there are leading spaces, add empty prefix with trailing space
381    // or just add the spaces to the first word
382    let chars_to_process = if leading_spaces > 0 {
383        &s[leading_spaces..]
384    } else {
385        s
386    };
387
388    for (i, c) in chars_to_process.char_indices() {
389        if c.is_whitespace() {
390            if let Some(start) = word_start {
391                let word = &chars_to_process[start..i];
392                // Add leading spaces to first word
393                let final_word = if start == 0 && leading_spaces > 0 {
394                    // This is handled differently - we'll prepend spaces
395                    word
396                } else {
397                    word
398                };
399                words.push((final_word, true));
400                word_start = None;
401            }
402        } else if word_start.is_none() {
403            word_start = Some(i);
404        }
405    }
406
407    if let Some(start) = word_start {
408        words.push((&chars_to_process[start..], false));
409    }
410
411    // If we had leading spaces and at least one word, prepend spaces to first word
412    // OR if the entire string was spaces, return empty
413    if leading_spaces > 0 && !words.is_empty() {
414        // We need to handle leading spaces by prepending to first word
415        // But since we're returning slices, we can't easily modify
416        // Instead, return leading space as separate "word" with trailing_space=true
417        // Actually, best approach: add leading space indicator
418        // For simplicity in this context, we return (" ", true) as first entry
419        let mut result = vec![(&s[..leading_spaces], false)];
420        result.extend(words);
421        return result;
422    } else if leading_spaces > 0 && words.is_empty() {
423        // String was only spaces
424        return vec![(s, false)];
425    }
426
427    words
428}
429
430/// Break a long word into parts that fit within max_width.
431fn break_word(word: &str, max_width: usize) -> Vec<String> {
432    let mut parts = Vec::new();
433    let mut current = String::new();
434    let mut current_width = 0;
435
436    for grapheme in word.graphemes(true) {
437        let grapheme_width = UnicodeWidthStr::width(grapheme);
438
439        if current_width + grapheme_width > max_width && !current.is_empty() {
440            parts.push(std::mem::take(&mut current));
441            current_width = 0;
442        }
443
444        current.push_str(grapheme);
445        current_width += grapheme_width;
446    }
447
448    if !current.is_empty() {
449        parts.push(current);
450    }
451
452    parts
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn test_span_width() {
461        assert_eq!(Span::raw("hello").width(), 5);
462        assert_eq!(Span::raw("你好").width(), 4); // Chinese characters are double-width
463        assert_eq!(Span::raw("").width(), 0);
464    }
465
466    #[test]
467    fn test_text_plain() {
468        let text = Text::plain("Hello, World!");
469        assert_eq!(text.plain_text(), "Hello, World!");
470        assert_eq!(text.width(), 13);
471    }
472
473    #[test]
474    fn test_text_wrap_simple() {
475        let text = Text::plain("hello world");
476        let lines = text.wrap(6);
477        assert_eq!(lines.len(), 2);
478        assert_eq!(lines[0][0].text, "hello ");
479        assert_eq!(lines[1][0].text, "world");
480    }
481
482    #[test]
483    fn test_text_wrap_long_word() {
484        let text = Text::plain("supercalifragilistic");
485        let lines = text.wrap(10);
486        assert!(lines.len() > 1);
487    }
488
489    #[test]
490    fn test_truncate_ellipsis() {
491        let text = Text::plain("Hello, World!").overflow(Overflow::Ellipsis);
492        let lines = text.wrap(8);
493        let plain: String = lines[0].iter().map(|s| s.text.as_ref()).collect();
494        assert!(plain.ends_with('…'));
495        // Check display width, not byte length (ellipsis is 3 bytes but 1 char width)
496        assert!(UnicodeWidthStr::width(plain.as_str()) <= 8);
497    }
498
499    #[test]
500    fn test_alignment_left() {
501        let text = Text::plain("hi").alignment(Alignment::Left);
502        let lines = text.wrap(10);
503        let aligned = text.align_line(lines[0].clone(), 10);
504        let plain: String = aligned.iter().map(|s| s.text.as_ref()).collect();
505        // Left alignment no longer adds padding (professional behavior)
506        assert_eq!(plain, "hi");
507    }
508
509    #[test]
510    fn test_alignment_right() {
511        let text = Text::plain("hi").alignment(Alignment::Right);
512        let lines = text.wrap(10);
513        let aligned = text.align_line(lines[0].clone(), 10);
514        let plain: String = aligned.iter().map(|s| s.text.as_ref()).collect();
515        assert_eq!(plain, "        hi");
516    }
517
518    #[test]
519    fn test_alignment_center() {
520        let text = Text::plain("hi").alignment(Alignment::Center);
521        let lines = text.wrap(10);
522        let aligned = text.align_line(lines[0].clone(), 10);
523        let plain: String = aligned.iter().map(|s| s.text.as_ref()).collect();
524        assert_eq!(plain, "    hi    ");
525    }
526}