Skip to main content

rusty_rich/
text.rs

1//! Text with spans — equivalent to Rich's `text.py`.
2//!
3//! `Text` is a styled string: plain text plus a collection of `Span`s that
4//! mark regions with specific styles.
5
6use std::fmt;
7
8use unicode_width::UnicodeWidthStr;
9
10use crate::align::AlignMethod;
11use crate::style::Style;
12
13// ---------------------------------------------------------------------------
14// Span
15// ---------------------------------------------------------------------------
16
17/// A marked-up region in some text.
18#[derive(Debug, Clone, PartialEq)]
19pub struct Span {
20    pub start: usize,
21    pub end: usize,
22    pub style: Style,
23}
24
25impl Span {
26    pub fn new(start: usize, end: usize, style: Style) -> Self {
27        Self { start, end, style }
28    }
29
30    /// Check if this span is non-empty.
31    pub fn is_empty(&self) -> bool {
32        self.end <= self.start
33    }
34
35    /// Split a span at the given offset, producing two spans.
36    /// The first covers [start, offset), the second [offset, end).
37    pub fn split(&self, offset: usize) -> (Self, Option<Self>) {
38        if offset <= self.start || offset >= self.end {
39            return (self.clone(), None);
40        }
41        let span1 = Self::new(self.start, self.end.min(offset), self.style.clone());
42        let span2 = Self::new(span1.end, self.end, self.style.clone());
43        (span1, Some(span2))
44    }
45
46    /// Move start and end by a given offset.
47    pub fn move_by(&self, offset: isize) -> Self {
48        let start = (self.start as isize + offset).max(0) as usize;
49        let end = (self.end as isize + offset).max(0) as usize;
50        Self::new(start, end, self.style.clone())
51    }
52
53    /// Crop the span end to not exceed `offset`.
54    pub fn right_crop(&self, offset: usize) -> Self {
55        if offset >= self.end {
56            self.clone()
57        } else {
58            Self::new(self.start, self.end.min(offset), self.style.clone())
59        }
60    }
61}
62
63// ---------------------------------------------------------------------------
64// Text
65// ---------------------------------------------------------------------------
66
67/// A renderable piece of text with optional style spans.
68#[derive(Debug, Clone)]
69pub struct Text {
70    /// The plain text content.
71    pub plain: String,
72    /// Style spans applied over the text.
73    pub spans: Vec<Span>,
74    /// Default style for the entire text.
75    pub style: Style,
76    /// Justification method.
77    pub justify: JustifyMethod,
78    /// End string (appended during rendering).
79    pub end: String,
80    /// Overflow method.
81    pub overflow: OverflowMethod,
82    /// If true, don't wrap.
83    pub no_wrap: bool,
84}
85
86pub type JustifyMethod = crate::align::AlignMethod;
87pub type OverflowMethod = crate::console::OverflowMethod;
88
89impl Text {
90    /// Create a new Text with the given plain content.
91    pub fn new(plain: impl Into<String>) -> Self {
92        Self {
93            plain: plain.into(),
94            spans: Vec::new(),
95            style: Style::new(),
96            justify: JustifyMethod::Left,
97            end: "\n".to_string(),
98            overflow: OverflowMethod::Fold,
99            no_wrap: false,
100        }
101    }
102
103    /// Create a styled Text.
104    pub fn styled(plain: impl Into<String>, style: Style) -> Self {
105        Self {
106            plain: plain.into(),
107            spans: Vec::new(),
108            style,
109            justify: JustifyMethod::Left,
110            end: "\n".to_string(),
111            overflow: OverflowMethod::Fold,
112            no_wrap: false,
113        }
114    }
115
116    /// Builder: set the default style.
117    pub fn style(mut self, style: Style) -> Self {
118        self.style = style;
119        self
120    }
121
122    /// Builder: set the justification.
123    pub fn justify(mut self, justify: JustifyMethod) -> Self {
124        self.justify = justify;
125        self
126    }
127
128    /// Builder: set the end string.
129    pub fn end(mut self, end: impl Into<String>) -> Self {
130        self.end = end.into();
131        self
132    }
133
134    /// Builder: set overflow method.
135    pub fn overflow(mut self, overflow: OverflowMethod) -> Self {
136        self.overflow = overflow;
137        self
138    }
139
140    /// Append another Text or string to this one, with an optional style.
141    pub fn append(&mut self, text: impl Into<Text>, style: Option<Style>) {
142        let text: Text = text.into();
143        let offset = self.plain.len();
144        self.plain.push_str(&text.plain);
145
146        // Shift and add spans from the appended text
147        for span in &text.spans {
148            let mut s = span.clone();
149            s.start += offset;
150            s.end += offset;
151            self.spans.push(s);
152        }
153
154        // If a style is given, add a span for the appended text
155        if let Some(st) = style {
156            self.spans.push(Span::new(
157                offset,
158                offset + text.plain.len(),
159                st,
160            ));
161        }
162    }
163
164    /// Append a plain string with a style.
165    pub fn append_styled(&mut self, text: impl Into<String>, style: Style) {
166        let text = text.into();
167        let offset = self.plain.len();
168        self.plain.push_str(&text);
169        self.spans.push(Span::new(offset, offset + text.len(), style));
170    }
171
172    /// Get the cell length of the plain text.
173    pub fn cell_len(&self) -> usize {
174        UnicodeWidthStr::width(self.plain.as_str())
175    }
176
177    /// Get the style at a given position, combining spans.
178    pub fn style_at(&self, position: usize) -> Style {
179        let mut style = self.style.clone();
180        for span in &self.spans {
181            if position >= span.start && position < span.end {
182                style = style.combine(&span.style);
183            }
184        }
185        style
186    }
187
188    /// Truncate the text to the given maximum width.
189    pub fn truncate(&mut self, max_width: usize, overflow: OverflowMethod) {
190        let w = self.cell_len();
191        if w <= max_width {
192            return;
193        }
194
195        match overflow {
196            OverflowMethod::Ellipsis => {
197                let ellipsis = "…";
198                let ellip_w = UnicodeWidthStr::width(ellipsis);
199                if max_width <= ellip_w {
200                    self.plain = ellipsis[..max_width].to_string();
201                    self.spans.clear();
202                    return;
203                }
204                // Walk chars from the left until we reach max_width - ellipsis width
205                let target = max_width - ellip_w;
206                let _char_count = 0usize;
207                let mut byte_pos = 0usize;
208                let mut w_count = 0usize;
209                for (i, ch) in self.plain.char_indices() {
210                    let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
211                    if w_count + cw > target {
212                        break;
213                    }
214                    w_count += cw;
215                    byte_pos = i + ch.len_utf8();
216                }
217                self.plain.truncate(byte_pos);
218                self.plain.push_str(ellipsis);
219                // Crop spans
220                let crop_at = byte_pos;
221                self.spans.retain(|s| s.start < crop_at);
222                for s in &mut self.spans {
223                    if s.end > crop_at {
224                        s.end = crop_at;
225                    }
226                }
227            }
228            OverflowMethod::Crop => {
229                // Walk chars up to max_width
230                let mut w_count = 0usize;
231                let mut byte_pos = 0usize;
232                for (i, ch) in self.plain.char_indices() {
233                    let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
234                    if w_count + cw > max_width {
235                        break;
236                    }
237                    w_count += cw;
238                    byte_pos = i + ch.len_utf8();
239                }
240                self.plain.truncate(byte_pos);
241                let crop_at = byte_pos;
242                self.spans.retain(|s| s.start < crop_at);
243                for s in &mut self.spans {
244                    if s.end > crop_at {
245                        s.end = crop_at;
246                    }
247                }
248            }
249            _ => {} // Fold / Ignore: don't truncate
250        }
251    }
252
253    /// Expand tab characters to spaces.
254    pub fn expand_tabs(&mut self) {
255        let tab_width = 8;
256        let mut result = String::new();
257        let mut col = 0usize;
258        for ch in self.plain.chars() {
259            if ch == '\t' {
260                let spaces = tab_width - (col % tab_width);
261                result.push_str(&" ".repeat(spaces));
262                col += spaces;
263            } else {
264                result.push(ch);
265                col += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
266            }
267        }
268        self.plain = result;
269    }
270
271    /// Split the text into lines.
272    pub fn split_lines(&self) -> Vec<Text> {
273        self.plain
274            .split('\n')
275            .map(|line| Text::new(line.to_string()))
276            .collect()
277    }
278
279    /// Render the text, applying styles and returning a styled string.
280    pub fn render(&self) -> String {
281        // Simple: apply spans as ANSI codes
282        if self.spans.is_empty() && self.style.is_plain() {
283            return self.plain.clone();
284        }
285
286        let mut out = String::new();
287        let chars: Vec<(usize, char)> = self.plain.char_indices().collect();
288        let default_ansi = self.style.to_ansi();
289        let reset = if default_ansi.is_empty() { "" } else { "\x1b[0m" };
290
291        if !default_ansi.is_empty() {
292            out.push_str(&default_ansi);
293        }
294
295        for (byte_pos, ch) in &chars {
296            // Apply any spans that start at this position
297            let mut applied = String::new();
298            for span in &self.spans {
299                if span.start == *byte_pos {
300                    applied.push_str(&span.style.to_ansi());
301                }
302            }
303            out.push_str(&applied);
304
305            // Output the character
306            out.push(*ch);
307
308            // End any spans that finish after this character
309            let char_end = byte_pos + ch.len_utf8();
310            let mut ended = false;
311            for span in &self.spans {
312                if span.end == char_end {
313                    out.push_str("\x1b[0m");
314                    ended = true;
315                }
316            }
317            // If we ended spans but the default style needs re-applying
318            if ended && !default_ansi.is_empty() {
319                out.push_str(&default_ansi);
320            }
321        }
322
323        if !reset.is_empty() {
324            out.push_str(reset);
325        }
326
327        out
328    }
329
330    /// Pad the text on both sides with `count` copies of `character`.
331    pub fn pad(&mut self, count: usize, character: char) {
332        self.plain = format!(
333            "{}{}{}",
334            character.to_string().repeat(count),
335            self.plain,
336            character.to_string().repeat(count)
337        );
338        // Shift spans by `count`
339        for span in &mut self.spans {
340            span.start += count;
341            span.end += count;
342        }
343    }
344
345    /// Pad the left side only.
346    pub fn pad_left(&mut self, count: usize, character: char) {
347        self.plain = format!("{}{}", character.to_string().repeat(count), self.plain);
348        for span in &mut self.spans {
349            span.start += count;
350            span.end += count;
351        }
352    }
353
354    /// Pad the right side only.
355    pub fn pad_right(&mut self, count: usize, character: char) {
356        self.plain = format!("{}{}", self.plain, character.to_string().repeat(count));
357    }
358
359    /// Align the text within a given width using the specified method.
360    pub fn align(&mut self, method: AlignMethod, width: usize) {
361        let current = self.cell_len();
362        if current >= width {
363            return;
364        }
365        let padding = width - current;
366        match method {
367            AlignMethod::Left => self.pad_right(padding, ' '),
368            AlignMethod::Right => self.pad_left(padding, ' '),
369            AlignMethod::Center => {
370                let left = padding / 2;
371                self.pad_left(left, ' ');
372                self.pad_right(padding - left, ' ');
373            }
374            AlignMethod::Full => {} // not applicable
375        }
376    }
377
378    /// Apply a style to a range of text.
379    /// Equivalent to Python's `Text.stylize()`.
380    pub fn stylize(&mut self, style: Style, start: usize, end: Option<usize>) {
381        let end = end.unwrap_or(self.plain.len());
382        if start < end && start < self.plain.len() {
383            self.spans.push(Span::new(start, end, style));
384        }
385    }
386
387    /// Highlight all regex matches with the given style. Returns count of
388    /// matches. Equivalent to Python's `Text.highlight_regex()`.
389    pub fn highlight_regex(&mut self, pattern: &str, style: Style) -> usize {
390        let re = regex::Regex::new(pattern);
391        let re = match re {
392            Ok(r) => r,
393            Err(_) => return 0,
394        };
395
396        let mut count = 0usize;
397        // Find all matches in the plain text
398        let matches: Vec<(usize, usize)> = re
399            .find_iter(&self.plain)
400            .map(|m| (m.start(), m.end()))
401            .collect();
402
403        for (start, end) in matches {
404            self.spans.push(Span::new(start, end, style.clone()));
405            count += 1;
406        }
407        count
408    }
409
410    /// Simple word-wrap: split text into lines, each not exceeding `width`
411    /// cells. Returns a `Vec<Text>`, one per wrapped line.
412    /// Equivalent to Python's `Text.wrap()`.
413    pub fn wrap(&self, width: usize) -> Vec<Text> {
414        let mut lines: Vec<Text> = Vec::new();
415        let mut current = Text::new("");
416
417        for word in self.plain.split_whitespace() {
418            let word_w = unicode_width::UnicodeWidthStr::width(word);
419            let cur_w = current.cell_len();
420
421            if cur_w == 0 {
422                // First word
423                current = Text::new(word);
424            } else if cur_w + 1 + word_w <= width {
425                // Fits on current line
426                current.plain.push(' ');
427                current.plain.push_str(word);
428            } else {
429                // Start new line
430                if !current.plain.is_empty() {
431                    lines.push(current);
432                }
433                current = Text::new(word);
434            }
435        }
436
437        if !current.plain.is_empty() {
438            lines.push(current);
439        }
440
441        lines
442    }
443}
444
445impl fmt::Display for Text {
446    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
447        write!(f, "{}", self.render())
448    }
449}
450
451impl From<&str> for Text {
452    fn from(s: &str) -> Self {
453        Self::new(s)
454    }
455}
456
457impl From<String> for Text {
458    fn from(s: String) -> Self {
459        Self::new(s)
460    }
461}
462
463// ---------------------------------------------------------------------------
464// TextType — either a &str or a Text
465// ---------------------------------------------------------------------------
466
467/// Represents something that can be used as text: a plain `&str` or a `Text`.
468#[derive(Debug, Clone)]
469pub enum TextType {
470    Plain(String),
471    Rich(Text),
472}
473
474impl TextType {
475    pub fn render(&self) -> String {
476        match self {
477            Self::Plain(s) => s.clone(),
478            Self::Rich(t) => t.render(),
479        }
480    }
481}
482
483impl From<&str> for TextType {
484    fn from(s: &str) -> Self {
485        Self::Plain(s.to_string())
486    }
487}
488
489impl From<String> for TextType {
490    fn from(s: String) -> Self {
491        Self::Plain(s)
492    }
493}
494
495impl From<Text> for TextType {
496    fn from(t: Text) -> Self {
497        Self::Rich(t)
498    }
499}
500
501impl fmt::Display for TextType {
502    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
503        match self {
504            Self::Plain(s) => write!(f, "{s}"),
505            Self::Rich(t) => write!(f, "{t}"),
506        }
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn test_text_append() {
516        let mut t = Text::new("Hello");
517        t.append_styled(" World", Style::new().bold(true));
518        assert_eq!(t.plain, "Hello World");
519        assert_eq!(t.spans.len(), 1);
520        assert_eq!(t.spans[0].start, 5);
521        assert_eq!(t.spans[0].end, 11);
522    }
523
524    #[test]
525    fn test_text_truncate() {
526        let mut t = Text::new("Hello World");
527        t.truncate(5, OverflowMethod::Ellipsis);
528        assert!(t.plain.contains('…'));
529    }
530}