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