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 regex::Regex;
9use unicode_width::UnicodeWidthStr;
10
11use crate::align::AlignMethod;
12use crate::style::Style;
13
14// ---------------------------------------------------------------------------
15// Span
16// ---------------------------------------------------------------------------
17
18/// A marked-up region in some text.
19#[derive(Debug, Clone, PartialEq)]
20pub struct Span {
21    pub start: usize,
22    pub end: usize,
23    pub style: Style,
24}
25
26impl Span {
27    /// Create a new `Span` covering byte range `[start, end)` with a given style.
28    pub fn new(start: usize, end: usize, style: Style) -> Self {
29        Self { start, end, style }
30    }
31
32    /// Check if this span is non-empty.
33    pub fn is_empty(&self) -> bool {
34        self.end <= self.start
35    }
36
37    /// Split a span at the given offset, producing two spans.
38    /// The first covers [start, offset), the second [offset, end).
39    pub fn split(&self, offset: usize) -> (Self, Option<Self>) {
40        if offset <= self.start || offset >= self.end {
41            return (self.clone(), None);
42        }
43        let span1 = Self::new(self.start, self.end.min(offset), self.style.clone());
44        let span2 = Self::new(span1.end, self.end, self.style.clone());
45        (span1, Some(span2))
46    }
47
48    /// Move start and end by a given offset.
49    pub fn move_by(&self, offset: isize) -> Self {
50        let start = (self.start as isize + offset).max(0) as usize;
51        let end = (self.end as isize + offset).max(0) as usize;
52        Self::new(start, end, self.style.clone())
53    }
54
55    /// Crop the span end to not exceed `offset`.
56    pub fn right_crop(&self, offset: usize) -> Self {
57        if offset >= self.end {
58            self.clone()
59        } else {
60            Self::new(self.start, self.end.min(offset), self.style.clone())
61        }
62    }
63}
64
65// ---------------------------------------------------------------------------
66// Text
67// ---------------------------------------------------------------------------
68
69/// A renderable piece of text with optional style spans.
70#[derive(Debug, Clone)]
71pub struct Text {
72    /// The plain text content.
73    pub plain: String,
74    /// Style spans applied over the text.
75    pub spans: Vec<Span>,
76    /// Default style for the entire text.
77    pub style: Style,
78    /// Justification method.
79    pub justify: JustifyMethod,
80    /// End string (appended during rendering).
81    pub end: String,
82    /// Overflow method.
83    pub overflow: OverflowMethod,
84    /// If true, don't wrap.
85    pub no_wrap: bool,
86    /// Tab width for expand_tabs (default 8).
87    pub tab_size: usize,
88    /// Whether to show indent guide lines.
89    pub indent_guides: bool,
90}
91
92pub type JustifyMethod = crate::align::AlignMethod;
93pub type OverflowMethod = crate::console::OverflowMethod;
94
95impl Text {
96    /// Create a new Text with the given plain content.
97    pub fn new(plain: impl Into<String>) -> Self {
98        Self {
99            plain: plain.into(),
100            spans: Vec::new(),
101            style: Style::new(),
102            justify: JustifyMethod::Left,
103            end: "\n".to_string(),
104            overflow: OverflowMethod::Fold,
105            no_wrap: false,
106            tab_size: 8,
107            indent_guides: false,
108        }
109    }
110
111    /// Create a styled Text with a default style but no content.
112    pub fn styled(style: Style) -> Self {
113        Self {
114            plain: String::new(),
115            spans: Vec::new(),
116            style,
117            justify: JustifyMethod::Left,
118            end: "\n".to_string(),
119            overflow: OverflowMethod::Fold,
120            no_wrap: false,
121            tab_size: 8,
122            indent_guides: false,
123        }
124    }
125
126    /// Parse an ANSI-escaped string into a styled Text.
127    ///
128    /// Extracts SGR (Select Graphic Rendition) codes as style spans and strips
129    /// all other ANSI escape sequences.
130    pub fn from_ansi(text: &str) -> Self {
131        let re = Regex::new(r"\x1b\[([\d;]*)([a-zA-Z])").unwrap();
132        let mut plain = String::new();
133        let mut spans: Vec<Span> = Vec::new();
134        let mut current_style = Style::new();
135        let mut last_end = 0usize;
136
137        for cap in re.captures_iter(text) {
138            let m = cap.get(0).unwrap();
139            let match_start = m.start();
140            let match_end = m.end();
141            let cmd = cap.get(2).map_or("", |m| m.as_str());
142
143            // Text before this escape sequence
144            if match_start > last_end {
145                let segment = &text[last_end..match_start];
146                let start = plain.len();
147                plain.push_str(segment);
148                let end = plain.len();
149                if !current_style.is_plain() && start < end {
150                    spans.push(Span::new(start, end, current_style.clone()));
151                }
152            }
153
154            // Only parse SGR (m command), strip everything else
155            if cmd == "m" {
156                let params_str = cap.get(1).map_or("", |m| m.as_str());
157                apply_sgr(&mut current_style, params_str);
158            }
159
160            last_end = match_end;
161        }
162
163        // Remaining text after last escape
164        if last_end < text.len() {
165            let segment = &text[last_end..];
166            let start = plain.len();
167            plain.push_str(segment);
168            let end = plain.len();
169            if !current_style.is_plain() && start < end {
170                spans.push(Span::new(start, end, current_style.clone()));
171            }
172        }
173
174        Self {
175            plain,
176            spans,
177            style: Style::new(),
178            justify: JustifyMethod::Left,
179            end: "\n".to_string(),
180            overflow: OverflowMethod::Fold,
181            no_wrap: false,
182            tab_size: 8,
183            indent_guides: false,
184        }
185    }
186
187    /// Parse a BBCode-like markup string into a styled Text.
188    /// Delegates to [`crate::markup::render()`].
189    pub fn from_markup(markup: &str) -> Self {
190        crate::markup::render(markup)
191    }
192
193    /// Get a reference to the default style.
194    pub fn get_style(&self) -> &Style {
195        &self.style
196    }
197
198    /// Get a mutable reference to the default style.
199    pub fn get_style_mut(&mut self) -> &mut Style {
200        &mut self.style
201    }
202
203    /// Get a reference to the spans slice.
204    pub fn spans(&self) -> &[Span] {
205        &self.spans
206    }
207
208    /// Builder: set the default style.
209    pub fn style(mut self, style: Style) -> Self {
210        self.style = style;
211        self
212    }
213
214    /// Builder: set the justification.
215    pub fn justify(mut self, justify: JustifyMethod) -> Self {
216        self.justify = justify;
217        self
218    }
219
220    /// Builder: set the end string.
221    pub fn end(mut self, end: impl Into<String>) -> Self {
222        self.end = end.into();
223        self
224    }
225
226    /// Builder: set overflow method.
227    pub fn overflow(mut self, overflow: OverflowMethod) -> Self {
228        self.overflow = overflow;
229        self
230    }
231
232    /// Builder: set no_wrap flag.
233    pub fn no_wrap(mut self, value: bool) -> Self {
234        self.no_wrap = value;
235        self
236    }
237
238    /// Builder: set tab size for expand_tabs.
239    pub fn tab_size(mut self, size: usize) -> Self {
240        self.tab_size = size;
241        self
242    }
243
244    /// Builder: show indent guides.
245    pub fn with_indent_guides(mut self, show: bool) -> Self {
246        self.indent_guides = show;
247        self
248    }
249
250    /// Append another Text or string to this one, with an optional style.
251    pub fn append(&mut self, text: impl Into<Text>, style: Option<Style>) {
252        let text: Text = text.into();
253        let offset = self.plain.len();
254        self.plain.push_str(&text.plain);
255
256        // Shift and add spans from the appended text
257        for span in &text.spans {
258            let mut s = span.clone();
259            s.start += offset;
260            s.end += offset;
261            self.spans.push(s);
262        }
263
264        // If a style is given, add a span for the appended text
265        if let Some(st) = style {
266            self.spans.push(Span::new(
267                offset,
268                offset + text.plain.len(),
269                st,
270            ));
271        }
272    }
273
274    /// Append a sequence of (text, style) tokens.
275    pub fn append_tokens(&mut self, tokens: Vec<(String, Style)>) {
276        for (text, style) in tokens {
277            self.append_styled(text, style);
278        }
279    }
280
281    /// Append a plain string with a style.
282    pub fn append_styled(&mut self, text: impl Into<String>, style: Style) {
283        let text = text.into();
284        let offset = self.plain.len();
285        self.plain.push_str(&text);
286        self.spans.push(Span::new(offset, offset + text.len(), style));
287    }
288
289    /// Get the cell length of the plain text.
290    pub fn cell_len(&self) -> usize {
291        UnicodeWidthStr::width(self.plain.as_str())
292    }
293
294    /// Get the style at a given position, combining spans.
295    pub fn style_at(&self, position: usize) -> Style {
296        let mut style = self.style.clone();
297        for span in &self.spans {
298            if position >= span.start && position < span.end {
299                style = style.combine(&span.style);
300            }
301        }
302        style
303    }
304
305    /// Get the combined style at a specific byte offset.
306    /// Iterates through spans that cover the offset and combines them.
307    pub fn get_style_at_offset(&self, offset: usize) -> Style {
308        self.style_at(offset)
309    }
310
311    /// Truncate the text to the given maximum width.
312    pub fn truncate(&mut self, max_width: usize, overflow: OverflowMethod) {
313        let w = self.cell_len();
314        if w <= max_width {
315            return;
316        }
317
318        match overflow {
319            OverflowMethod::Ellipsis => {
320                let ellipsis = "\u{2026}";
321                let ellip_w = UnicodeWidthStr::width(ellipsis);
322                if max_width <= ellip_w {
323                    self.plain = ellipsis[..max_width].to_string();
324                    self.spans.clear();
325                    return;
326                }
327                // Walk chars from the left until we reach max_width - ellipsis width
328                let target = max_width - ellip_w;
329                let mut byte_pos = 0usize;
330                let mut w_count = 0usize;
331                for (i, ch) in self.plain.char_indices() {
332                    let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
333                    if w_count + cw > target {
334                        break;
335                    }
336                    w_count += cw;
337                    byte_pos = i + ch.len_utf8();
338                }
339                self.plain.truncate(byte_pos);
340                self.plain.push_str(ellipsis);
341                // Crop spans
342                let crop_at = byte_pos;
343                self.spans.retain(|s| s.start < crop_at);
344                for s in &mut self.spans {
345                    if s.end > crop_at {
346                        s.end = crop_at;
347                    }
348                }
349            }
350            OverflowMethod::Crop => {
351                // Walk chars up to max_width
352                let mut w_count = 0usize;
353                let mut byte_pos = 0usize;
354                for (i, ch) in self.plain.char_indices() {
355                    let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
356                    if w_count + cw > max_width {
357                        break;
358                    }
359                    w_count += cw;
360                    byte_pos = i + ch.len_utf8();
361                }
362                self.plain.truncate(byte_pos);
363                let crop_at = byte_pos;
364                self.spans.retain(|s| s.start < crop_at);
365                for s in &mut self.spans {
366                    if s.end > crop_at {
367                        s.end = crop_at;
368                    }
369                }
370            }
371            _ => {} // Fold / Ignore: don't truncate
372        }
373    }
374
375    /// Expand tab characters to spaces.
376    pub fn expand_tabs(&mut self) {
377        let tab_width = self.tab_size;
378        let mut result = String::new();
379        let mut col = 0usize;
380        for ch in self.plain.chars() {
381            if ch == '\t' {
382                let spaces = tab_width - (col % tab_width);
383                result.push_str(&" ".repeat(spaces));
384                col += spaces;
385            } else {
386                result.push(ch);
387                col += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
388            }
389        }
390        self.plain = result;
391    }
392
393    /// Split the text into lines.
394    pub fn split_lines(&self) -> Vec<Text> {
395        self.plain
396            .split('\n')
397            .map(|line| Text::new(line.to_string()))
398            .collect()
399    }
400
401    /// Apply a style to a range of text.
402    /// Equivalent to Python's `Text.stylize()`.
403    pub fn stylize(&mut self, style: Style, start: usize, end: Option<usize>) {
404        let end = end.unwrap_or(self.plain.len());
405        if start < end && start < self.plain.len() {
406            self.spans.push(Span::new(start, end, style));
407        }
408    }
409
410    /// Like [`stylize`] but inserts the span at the beginning of the spans
411    /// list, giving it lower priority than existing spans.
412    pub fn stylize_before(&mut self, style: Style, start: usize, end: Option<usize>) {
413        let end = end.unwrap_or(self.plain.len());
414        if start < end && start < self.plain.len() {
415            self.spans.insert(0, Span::new(start, end, style));
416        }
417    }
418
419    /// Apply metadata to the text at the given spans.
420    /// Sets the style.meta field on overlapping spans.
421    pub fn apply_meta(&mut self, meta: Vec<u8>, spans: &[Span]) {
422        for span in spans {
423            let start = span.start;
424            let end = span.end;
425            // Apply meta to existing spans that overlap
426            for existing in &mut self.spans {
427                if existing.start < end && existing.end > start {
428                    existing.style.meta = Some(meta.clone());
429                }
430            }
431            // Add a span for this region with the meta
432            self.spans.push(Span::new(start, end, {
433                let mut s = Style::new();
434                s.meta = Some(meta.clone());
435                s
436            }));
437        }
438    }
439
440    /// Highlight all regex matches with the given style. Returns count of
441    /// matches. Equivalent to Python's `Text.highlight_regex()`.
442    pub fn highlight_regex(&mut self, pattern: &str, style: Style) -> usize {
443        let re = regex::Regex::new(pattern);
444        let re = match re {
445            Ok(r) => r,
446            Err(_) => return 0,
447        };
448
449        let mut count = 0usize;
450        // Find all matches in the plain text
451        let matches: Vec<(usize, usize)> = re
452            .find_iter(&self.plain)
453            .map(|m| (m.start(), m.end()))
454            .collect();
455
456        for (start, end) in matches {
457            self.spans.push(Span::new(start, end, style.clone()));
458            count += 1;
459        }
460        count
461    }
462
463    /// Return a new Text with the same style settings but empty content.
464    pub fn blank_copy(&self) -> Self {
465        Self {
466            plain: String::new(),
467            spans: Vec::new(),
468            style: self.style.clone(),
469            justify: self.justify,
470            end: self.end.clone(),
471            overflow: self.overflow,
472            no_wrap: self.no_wrap,
473            tab_size: self.tab_size,
474            indent_guides: self.indent_guides,
475        }
476    }
477
478    /// Return a copy with only the plain text (no spans, default style).
479    pub fn copy_styles(&self) -> Self {
480        Self::new(self.plain.clone())
481    }
482
483    /// Detect the leading whitespace indentation.
484    /// Returns (indent_string, indent_count) where indent_count is the number
485    /// of occurrences of the first indent character.
486    pub fn detect_indentation(&self) -> (String, usize) {
487        let trimmed_start = self.plain.len() - self.plain.trim_start().len();
488        if trimmed_start == 0 {
489            return (String::new(), 0);
490        }
491        let indent_str = self.plain[..trimmed_start].to_string();
492        let first_char = indent_str.chars().next().unwrap_or(' ');
493        let indent_count = indent_str.chars().filter(|&c| c == first_char).count();
494        (indent_str, indent_count)
495    }
496
497    /// Split the text at the given byte offsets into multiple pieces,
498    /// preserving style spans.
499    pub fn divide(&self, offsets: &[usize]) -> Vec<Text> {
500        let mut result: Vec<Text> = Vec::new();
501        let mut prev = 0usize;
502        for &offset in offsets {
503            if offset <= prev || offset > self.plain.len() {
504                continue;
505            }
506            result.push(self.slice(prev, offset));
507            prev = offset;
508        }
509        if prev < self.plain.len() {
510            result.push(self.slice(prev, self.plain.len()));
511        }
512        result
513    }
514
515    /// Extract a sub-text covering byte range [start, end), preserving spans.
516    fn slice(&self, start: usize, end: usize) -> Text {
517        let piece = self.plain[start..end].to_string();
518        let mut text = Text::new(piece);
519        text.style = self.style.clone();
520        for span in &self.spans {
521            if span.start < end && span.end > start {
522                let s_start = if span.start > start { span.start - start } else { 0 };
523                let s_end = if span.end < end { span.end - start } else { end - start };
524                if s_start < s_end {
525                    text.spans.push(Span::new(s_start, s_end, span.style.clone()));
526                }
527            }
528        }
529        text
530    }
531
532    /// Apply a style to the entire text by adding a full-length span.
533    pub fn extend_style(&mut self, style: Style) {
534        if !self.plain.is_empty() {
535            self.spans.push(Span::new(0, self.plain.len(), style));
536        }
537    }
538
539    /// Truncate or pad to exactly fit `width` cells.
540    /// Uses existing truncate + align methods.
541    pub fn fit(&self, width: usize) -> Text {
542        let mut copy = self.clone();
543        let cell_len = copy.cell_len();
544        if cell_len > width {
545            copy.truncate(width, OverflowMethod::Crop);
546        } else if cell_len < width {
547            copy.align(AlignMethod::Left, width);
548        }
549        copy
550    }
551
552    /// Pad with spaces or truncate to exact `length` cells.
553    pub fn set_length(&mut self, length: usize) {
554        let cell_len = self.cell_len();
555        if cell_len > length {
556            self.truncate(length, OverflowMethod::Crop);
557        } else if cell_len < length {
558            self.pad_right(length - cell_len, ' ');
559        }
560    }
561
562    /// Remove a suffix from the text, returning true if it was present.
563    pub fn remove_suffix(&mut self, suffix: &str) -> bool {
564        if self.plain.ends_with(suffix) {
565            let end = self.plain.len() - suffix.len();
566            self.plain.truncate(end);
567            self.spans.retain(|s| s.start < end);
568            for s in &mut self.spans {
569                if s.end > end {
570                    s.end = end;
571                }
572            }
573            true
574        } else {
575            false
576        }
577    }
578
579    /// Remove a trailing end string, returning true if it was present.
580    pub fn rstrip_end(&mut self, end: &str) -> bool {
581        self.remove_suffix(end)
582    }
583
584    /// Crop all text beyond `offset`. Returns the cropped (removed) portion.
585    pub fn right_crop(&mut self, offset: usize) -> Text {
586        if offset >= self.plain.len() {
587            // Nothing to crop
588            return Text::new("");
589        }
590
591        let cropped_text = self.plain[offset..].to_string();
592        let mut cropped = Text::new(&*cropped_text);
593        cropped.style = self.style.clone();
594
595        self.plain.truncate(offset);
596
597        // Split spans at the boundary
598        let mut kept_spans: Vec<Span> = Vec::new();
599        let mut cropped_spans: Vec<Span> = Vec::new();
600        for span in &self.spans {
601            if span.start < offset {
602                let mut s = span.clone();
603                if s.end > offset {
604                    // Span crosses the boundary
605                    cropped_spans.push(Span::new(0, s.end - offset, span.style.clone()));
606                    s.end = offset;
607                }
608                kept_spans.push(s);
609            } else {
610                // Entirely in the cropped portion
611                cropped_spans.push(Span::new(
612                    span.start - offset,
613                    span.end - offset,
614                    span.style.clone(),
615                ));
616            }
617        }
618        self.spans = kept_spans;
619        cropped.spans = cropped_spans;
620        cropped
621    }
622
623    /// Remove trailing whitespace.
624    pub fn rstrip(&mut self) -> &mut Self {
625        let trimmed_end = self.plain.len() - self.plain.trim_end().len();
626        if trimmed_end > 0 {
627            let new_len = self.plain.len() - trimmed_end;
628            self.plain.truncate(new_len);
629            self.spans.retain(|s| s.start < new_len);
630            for s in &mut self.spans {
631                if s.end > new_len {
632                    s.end = new_len;
633                }
634            }
635        }
636        self
637    }
638
639    /// Split at every character boundary, producing per-character Text pieces.
640    pub fn split(&self) -> Vec<Text> {
641        let mut result: Vec<Text> = Vec::new();
642        let mut byte_pos = 0usize;
643        for ch in self.plain.chars() {
644            let ch_len = ch.len_utf8();
645            let ch_str = &self.plain[byte_pos..byte_pos + ch_len];
646            let mut text = Text::new(ch_str.to_string());
647            text.style = self.style_at(byte_pos);
648            result.push(text);
649            byte_pos += ch_len;
650        }
651        result
652    }
653
654    /// Simple word-wrap: split text into lines, each not exceeding `width`
655    /// cells. Returns a `Vec<Text>`, one per wrapped line.
656    /// Equivalent to Python's `Text.wrap()`.
657    pub fn wrap(&self, width: usize) -> Vec<Text> {
658        let mut lines: Vec<Text> = Vec::new();
659        let mut current = Text::new("");
660
661        for word in self.plain.split_whitespace() {
662            let word_w = unicode_width::UnicodeWidthStr::width(word);
663            let cur_w = current.cell_len();
664
665            if cur_w == 0 {
666                // First word
667                current = Text::new(word);
668            } else if cur_w + 1 + word_w <= width {
669                // Fits on current line
670                current.plain.push(' ');
671                current.plain.push_str(word);
672            } else {
673                // Start new line
674                if !current.plain.is_empty() {
675                    lines.push(current);
676                }
677                current = Text::new(word);
678            }
679        }
680
681        if !current.plain.is_empty() {
682            lines.push(current);
683        }
684
685        lines
686    }
687
688    /// Render the text, applying styles and returning a styled string.
689    pub fn render(&self) -> String {
690        // Simple: apply spans as ANSI codes
691        if self.spans.is_empty() && self.style.is_plain() {
692            return self.plain.clone();
693        }
694
695        let mut out = String::new();
696        let chars: Vec<(usize, char)> = self.plain.char_indices().collect();
697        let default_ansi = self.style.to_ansi();
698        let reset = if default_ansi.is_empty() { "" } else { "\x1b[0m" };
699
700        if !default_ansi.is_empty() {
701            out.push_str(&default_ansi);
702        }
703
704        for (byte_pos, ch) in &chars {
705            // Apply any spans that start at this position
706            let mut applied = String::new();
707            for span in &self.spans {
708                if span.start == *byte_pos {
709                    applied.push_str(&span.style.to_ansi());
710                }
711            }
712            out.push_str(&applied);
713
714            // Output the character
715            out.push(*ch);
716
717            // End any spans that finish after this character
718            let char_end = byte_pos + ch.len_utf8();
719            let mut ended = false;
720            for span in &self.spans {
721                if span.end == char_end {
722                    out.push_str("\x1b[0m");
723                    ended = true;
724                }
725            }
726            // If we ended spans but the default style needs re-applying
727            if ended && !default_ansi.is_empty() {
728                out.push_str(&default_ansi);
729            }
730        }
731
732        if !reset.is_empty() {
733            out.push_str(reset);
734        }
735
736        out
737    }
738
739    /// Serialize the text to a BBCode-like markup string.
740    /// Each span is wrapped in `[style]...[/]` tags.
741    pub fn markup(&self) -> String {
742        if self.spans.is_empty() {
743            return crate::markup::escape(&self.plain);
744        }
745
746        let mut sorted: Vec<&Span> = self.spans.iter().collect();
747        sorted.sort_by_key(|s| (s.start, s.end));
748
749        let mut result = String::new();
750        let mut pos = 0usize;
751
752        for span in &sorted {
753            if span.start > pos {
754                result.push_str(&crate::markup::escape(&self.plain[pos..span.start]));
755            }
756            if span.start < span.end {
757                let style_str = span.style.to_string();
758                if style_str != "none" && !style_str.is_empty() {
759                    result.push_str(&format!("[{}]", style_str));
760                    result.push_str(&crate::markup::escape(&self.plain[span.start..span.end]));
761                    result.push_str("[/]");
762                } else {
763                    result.push_str(&crate::markup::escape(&self.plain[span.start..span.end]));
764                }
765            }
766            pos = pos.max(span.end);
767        }
768
769        if pos < self.plain.len() {
770            result.push_str(&crate::markup::escape(&self.plain[pos..]));
771        }
772
773        result
774    }
775
776    /// Pad the text on both sides with `count` copies of `character`.
777    pub fn pad(&mut self, count: usize, character: char) {
778        self.plain = format!(
779            "{}{}{}",
780            character.to_string().repeat(count),
781            self.plain,
782            character.to_string().repeat(count)
783        );
784        // Shift spans by `count`
785        for span in &mut self.spans {
786            span.start += count;
787            span.end += count;
788        }
789    }
790
791    /// Pad the left side only.
792    pub fn pad_left(&mut self, count: usize, character: char) {
793        self.plain = format!("{}{}", character.to_string().repeat(count), self.plain);
794        for span in &mut self.spans {
795            span.start += count;
796            span.end += count;
797        }
798    }
799
800    /// Pad the right side only.
801    pub fn pad_right(&mut self, count: usize, character: char) {
802        self.plain = format!("{}{}", self.plain, character.to_string().repeat(count));
803    }
804
805    /// Align the text within a given width using the specified method.
806    pub fn align(&mut self, method: AlignMethod, width: usize) {
807        let current = self.cell_len();
808        if current >= width {
809            return;
810        }
811        let padding = width - current;
812        match method {
813            AlignMethod::Left => self.pad_right(padding, ' '),
814            AlignMethod::Right => self.pad_left(padding, ' '),
815            AlignMethod::Center => {
816                let left = padding / 2;
817                self.pad_left(left, ' ');
818                self.pad_right(padding - left, ' ');
819            }
820            AlignMethod::Full => {} // not applicable
821        }
822    }
823}
824
825impl Default for Text {
826    fn default() -> Self {
827        Self::new("")
828    }
829}
830
831impl fmt::Display for Text {
832    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
833        write!(f, "{}", self.render())
834    }
835}
836
837impl From<&str> for Text {
838    fn from(s: &str) -> Self {
839        Self::new(s)
840    }
841}
842
843impl From<String> for Text {
844    fn from(s: String) -> Self {
845        Self::new(s)
846    }
847}
848
849// ---------------------------------------------------------------------------
850// TextType — either a &str or a Text
851// ---------------------------------------------------------------------------
852
853/// Represents something that can be used as text: a plain `&str` or a `Text`.
854#[derive(Debug, Clone)]
855pub enum TextType {
856    Plain(String),
857    Rich(Text),
858}
859
860impl TextType {
861    pub fn render(&self) -> String {
862        match self {
863            Self::Plain(s) => s.clone(),
864            Self::Rich(t) => t.render(),
865        }
866    }
867}
868
869impl From<&str> for TextType {
870    fn from(s: &str) -> Self {
871        Self::Plain(s.to_string())
872    }
873}
874
875impl From<String> for TextType {
876    fn from(s: String) -> Self {
877        Self::Plain(s)
878    }
879}
880
881impl From<Text> for TextType {
882    fn from(t: Text) -> Self {
883        Self::Rich(t)
884    }
885}
886
887impl fmt::Display for TextType {
888    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
889        match self {
890            Self::Plain(s) => write!(f, "{s}"),
891            Self::Rich(t) => write!(f, "{t}"),
892        }
893    }
894}
895
896// ---------------------------------------------------------------------------
897// Internal: parse an SGR parameter string and apply to a Style
898// ---------------------------------------------------------------------------
899
900/// Apply ANSI SGR parameters to a style in-place.
901/// `params` is the semicolon-separated string after `\x1b[` and before `m`.
902fn apply_sgr(style: &mut Style, params: &str) {
903    if params.is_empty() || params == "0" {
904        // Full reset
905        *style = Style::new();
906        return;
907    }
908
909    let parts: Vec<&str> = params.split(';').collect();
910    let mut i = 0usize;
911    while i < parts.len() {
912        match parts[i] {
913            "1" => { *style = style.clone().bold(true); }
914            "2" => { *style = style.clone().dim(true); }
915            "3" => { *style = style.clone().italic(true); }
916            "4" => { *style = style.clone().underline(true); }
917            "5" => { *style = style.clone().blink(true); }
918            "6" => { *style = style.clone().blink2(true); }
919            "7" => { *style = style.clone().reverse(true); }
920            "8" => { *style = style.clone().conceal(true); }
921            "9" => { *style = style.clone().strike(true); }
922            "21" => { *style = style.clone().underline2(true); }
923            "22" => { *style = style.clone().bold(false).dim(false); }
924            "23" => { *style = style.clone().italic(false); }
925            "24" => { *style = style.clone().underline(false); }
926            "25" => { *style = style.clone().blink(false).blink2(false); }
927            "27" => { *style = style.clone().reverse(false); }
928            "28" => { *style = style.clone().conceal(false); }
929            "29" => { *style = style.clone().strike(false); }
930            "51" => { *style = style.clone().frame(true); }
931            "52" => { *style = style.clone().encircle(true); }
932            "53" => { *style = style.clone().overline(true); }
933            "54" => { *style = style.clone().frame(false).encircle(false); }
934            "55" => { *style = style.clone().overline(false); }
935            "38" => {
936                // Extended foreground color
937                if i + 1 < parts.len() {
938                    match parts[i + 1] {
939                        "5" => {
940                            if i + 2 < parts.len() {
941                                if let Ok(n) = parts[i + 2].parse::<u8>() {
942                                    let c = crate::color::Color::from_8bit(n);
943                                    *style = style.clone().color(c);
944                                }
945                                i += 2;
946                            }
947                        }
948                        "2" => {
949                            if i + 4 < parts.len() {
950                                let r = parts[i + 2].parse::<u8>().unwrap_or(0);
951                                let g = parts[i + 3].parse::<u8>().unwrap_or(0);
952                                let b = parts[i + 4].parse::<u8>().unwrap_or(0);
953                                *style = style.clone().color(crate::color::Color::from_rgb(r, g, b));
954                                i += 4;
955                            }
956                        }
957                        _ => {}
958                    }
959                }
960            }
961            "48" => {
962                // Extended background color
963                if i + 1 < parts.len() {
964                    match parts[i + 1] {
965                        "5" => {
966                            if i + 2 < parts.len() {
967                                if let Ok(n) = parts[i + 2].parse::<u8>() {
968                                    let c = crate::color::Color::from_8bit(n);
969                                    *style = style.clone().bgcolor(c);
970                                }
971                                i += 2;
972                            }
973                        }
974                        "2" => {
975                            if i + 4 < parts.len() {
976                                let r = parts[i + 2].parse::<u8>().unwrap_or(0);
977                                let g = parts[i + 3].parse::<u8>().unwrap_or(0);
978                                let b = parts[i + 4].parse::<u8>().unwrap_or(0);
979                                *style = style.clone().bgcolor(crate::color::Color::from_rgb(r, g, b));
980                                i += 4;
981                            }
982                        }
983                        _ => {}
984                    }
985                }
986            }
987            "39" => { style.color = None; }
988            "49" => { style.bgcolor = None; }
989            n => {
990                // Standard colors (30-37, 40-47, 90-97, 100-107)
991                if let Ok(num) = n.parse::<u8>() {
992                    match num {
993                        30..=37 => {
994                            let c = crate::color::Color::from_8bit(num - 30);
995                            *style = style.clone().color(c);
996                        }
997                        40..=47 => {
998                            let c = crate::color::Color::from_8bit(num - 40);
999                            *style = style.clone().bgcolor(c);
1000                        }
1001                        90..=97 => {
1002                            let c = crate::color::Color::from_8bit(num - 82);
1003                            *style = style.clone().color(c);
1004                        }
1005                        100..=107 => {
1006                            let c = crate::color::Color::from_8bit(num - 92);
1007                            *style = style.clone().bgcolor(c);
1008                        }
1009                        _ => {}
1010                    }
1011                }
1012            }
1013        }
1014        i += 1;
1015    }
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::*;
1021    use crate::color::Color;
1022
1023    #[test]
1024    fn test_text_append() {
1025        let mut t = Text::new("Hello");
1026        t.append_styled(" World", Style::new().bold(true));
1027        assert_eq!(t.plain, "Hello World");
1028        assert_eq!(t.spans.len(), 1);
1029        assert_eq!(t.spans[0].start, 5);
1030        assert_eq!(t.spans[0].end, 11);
1031    }
1032
1033    #[test]
1034    fn test_text_truncate() {
1035        let mut t = Text::new("Hello World");
1036        t.truncate(5, OverflowMethod::Ellipsis);
1037        assert!(t.plain.contains('\u{2026}'));
1038    }
1039
1040    #[test]
1041    fn test_styled_constructor() {
1042        let style = Style::new().bold(true).color(Color::parse("red").unwrap());
1043        let t = Text::styled(style.clone());
1044        assert_eq!(t.plain, "");
1045        assert_eq!(t.style, style);
1046        assert!(t.spans.is_empty());
1047    }
1048
1049    #[test]
1050    fn test_from_ansi_empty() {
1051        let t = Text::from_ansi("");
1052        assert_eq!(t.plain, "");
1053        assert!(t.spans.is_empty());
1054    }
1055
1056    #[test]
1057    fn test_from_ansi_no_escapes() {
1058        let t = Text::from_ansi("hello world");
1059        assert_eq!(t.plain, "hello world");
1060        assert!(t.spans.is_empty());
1061    }
1062
1063    #[test]
1064    fn test_from_ansi_bold() {
1065        let t = Text::from_ansi("\x1b[1mbold\x1b[0m");
1066        assert_eq!(t.plain, "bold");
1067        assert!(!t.spans.is_empty());
1068    }
1069
1070    #[test]
1071    fn test_style_getter() {
1072        let style = Style::new().bold(true);
1073        let t = Text::styled(style.clone());
1074        assert_eq!(t.get_style(), &style);
1075    }
1076
1077    #[test]
1078    fn test_style_mut_getter() {
1079        let mut t = Text::new("hello");
1080        t.style = Style::new().bold(true);
1081        assert_eq!(t.get_style().get_bold(), Some(true));
1082    }
1083
1084    #[test]
1085    fn test_spans_getter() {
1086        let mut t = Text::new("hello");
1087        t.stylize(Style::new().bold(true), 0, Some(3));
1088        assert_eq!(t.spans().len(), 1);
1089    }
1090
1091    #[test]
1092    fn test_from_markup() {
1093        let t = Text::from_markup("[bold]hello[/bold]");
1094        assert_eq!(t.plain, "hello");
1095        assert!(!t.spans.is_empty());
1096    }
1097
1098    #[test]
1099    fn test_append_tokens() {
1100        let mut t = Text::new("");
1101        let tokens = vec![
1102            ("Hello ".to_string(), Style::new().bold(true)),
1103            ("World".to_string(), Style::new().italic(true)),
1104        ];
1105        t.append_tokens(tokens);
1106        assert_eq!(t.plain, "Hello World");
1107        assert_eq!(t.spans.len(), 2);
1108    }
1109
1110    #[test]
1111    fn test_stylize_before() {
1112        let mut t = Text::new("hello");
1113        t.stylize(Style::new().bold(true), 0, Some(5));
1114        t.stylize_before(Style::new().italic(true), 0, Some(5));
1115        // stylize_before inserts at the beginning (index 0)
1116        assert_eq!(t.spans.len(), 2);
1117        assert_eq!(t.spans[0].style.get_italic(), Some(true));
1118    }
1119
1120    #[test]
1121    fn test_blank_copy() {
1122        let mut t = Text::new("hello");
1123        t.stylize(Style::new().bold(true), 0, Some(3));
1124        t.justify = JustifyMethod::Center;
1125        let blank = t.blank_copy();
1126        assert_eq!(blank.plain, "");
1127        assert!(blank.spans.is_empty());
1128        assert_eq!(blank.justify, JustifyMethod::Center);
1129    }
1130
1131    #[test]
1132    fn test_copy_styles() {
1133        let mut t = Text::new("hello");
1134        t.stylize(Style::new().bold(true), 0, Some(3));
1135        let copy = t.copy_styles();
1136        assert_eq!(copy.plain, "hello");
1137        assert!(copy.spans.is_empty());
1138    }
1139
1140    #[test]
1141    fn test_detect_indentation() {
1142        let t = Text::new("  hello");
1143        let (indent, count) = t.detect_indentation();
1144        assert_eq!(indent, "  ");
1145        assert_eq!(count, 2);
1146    }
1147
1148    #[test]
1149    fn test_detect_indentation_none() {
1150        let t = Text::new("hello");
1151        let (indent, count) = t.detect_indentation();
1152        assert_eq!(indent, "");
1153        assert_eq!(count, 0);
1154    }
1155
1156    #[test]
1157    fn test_get_style_at_offset() {
1158        let mut t = Text::new("hello world");
1159        t.stylize(Style::new().bold(true), 0, Some(5));
1160        let s0 = t.get_style_at_offset(0);
1161        assert_eq!(s0.get_bold(), Some(true));
1162        let s6 = t.get_style_at_offset(6);
1163        assert_eq!(s6.get_bold(), None); // not in span, default style has no bold
1164    }
1165
1166    #[test]
1167    fn test_divide() {
1168        let mut t = Text::new("abcdef");
1169        t.stylize(Style::new().bold(true), 0, Some(3));
1170        let parts = t.divide(&[2, 4]);
1171        assert_eq!(parts.len(), 3);
1172        assert_eq!(parts[0].plain, "ab");
1173        assert_eq!(parts[1].plain, "cd");
1174        assert_eq!(parts[2].plain, "ef");
1175    }
1176
1177    #[test]
1178    fn test_extend_style() {
1179        let mut t = Text::new("hello");
1180        t.extend_style(Style::new().bold(true));
1181        assert_eq!(t.spans.len(), 1);
1182        assert_eq!(t.spans[0].start, 0);
1183        assert_eq!(t.spans[0].end, 5);
1184    }
1185
1186    #[test]
1187    fn test_fit_truncate() {
1188        let t = Text::new("hello world");
1189        let fitted = t.fit(5);
1190        assert_eq!(fitted.cell_len(), 5);
1191    }
1192
1193    #[test]
1194    fn test_fit_pad() {
1195        let t = Text::new("hi");
1196        let fitted = t.fit(10);
1197        assert_eq!(fitted.cell_len(), 10);
1198    }
1199
1200    #[test]
1201    fn test_set_length_truncate() {
1202        let mut t = Text::new("hello world");
1203        t.set_length(5);
1204        assert_eq!(t.cell_len(), 5);
1205    }
1206
1207    #[test]
1208    fn test_set_length_pad() {
1209        let mut t = Text::new("hi");
1210        t.set_length(10);
1211        assert_eq!(t.cell_len(), 10);
1212    }
1213
1214    #[test]
1215    fn test_remove_suffix() {
1216        let mut t = Text::new("hello.txt");
1217        assert!(t.remove_suffix(".txt"));
1218        assert_eq!(t.plain, "hello");
1219    }
1220
1221    #[test]
1222    fn test_remove_suffix_not_found() {
1223        let mut t = Text::new("hello");
1224        assert!(!t.remove_suffix(".txt"));
1225        assert_eq!(t.plain, "hello");
1226    }
1227
1228    #[test]
1229    fn test_right_crop() {
1230        let mut t = Text::new("hello world");
1231        let cropped = t.right_crop(5);
1232        assert_eq!(t.plain, "hello");
1233        assert_eq!(cropped.plain, " world");
1234    }
1235
1236    #[test]
1237    fn test_rstrip() {
1238        let mut t = Text::new("hello   ");
1239        t.rstrip();
1240        assert_eq!(t.plain, "hello");
1241    }
1242
1243    #[test]
1244    fn test_rstrip_end() {
1245        let mut t = Text::new("hello\n");
1246        assert!(t.rstrip_end("\n"));
1247        assert_eq!(t.plain, "hello");
1248    }
1249
1250    #[test]
1251    fn test_split_chars() {
1252        let t = Text::new("abc");
1253        let chars = t.split();
1254        assert_eq!(chars.len(), 3);
1255        assert_eq!(chars[0].plain, "a");
1256        assert_eq!(chars[1].plain, "b");
1257        assert_eq!(chars[2].plain, "c");
1258    }
1259
1260    #[test]
1261    fn test_markup() {
1262        let mut t = Text::new("hello world");
1263        t.stylize(Style::new().bold(true), 0, Some(5));
1264        let markup = t.markup();
1265        assert!(markup.contains("[bold]"));
1266        assert!(markup.contains("[/]"));
1267        assert!(markup.contains("hello"));
1268        assert!(markup.contains(" world"));
1269    }
1270
1271    #[test]
1272    fn test_markup_no_spans() {
1273        let t = Text::new("hello");
1274        assert_eq!(t.markup(), "hello");
1275    }
1276
1277    #[test]
1278    fn test_no_wrap_builder() {
1279        let t = Text::new("hello").no_wrap(true);
1280        assert!(t.no_wrap);
1281    }
1282
1283    #[test]
1284    fn test_tab_size_builder() {
1285        let t = Text::new("hello").tab_size(4);
1286        assert_eq!(t.tab_size, 4);
1287    }
1288
1289    #[test]
1290    fn test_with_indent_guides_builder() {
1291        let t = Text::new("hello").with_indent_guides(true);
1292        assert!(t.indent_guides);
1293    }
1294
1295    #[test]
1296    fn test_expand_tabs_with_custom_size() {
1297        let mut t = Text::new("\thello").tab_size(4);
1298        t.expand_tabs();
1299        assert_eq!(t.plain, "    hello");
1300    }
1301
1302    #[test]
1303    fn test_apply_meta() {
1304        let mut t = Text::new("hello world");
1305        let meta = vec![1u8, 2u8, 3u8];
1306        let spans = vec![Span::new(0, 5, Style::new())];
1307        t.apply_meta(meta, &spans);
1308        // Should have added a span with meta
1309        assert!(t.spans.iter().any(|s| s.style.meta.is_some()));
1310    }
1311
1312    #[test]
1313    fn test_default_impl() {
1314        let t: Text = Default::default();
1315        assert_eq!(t.plain, "");
1316        assert_eq!(t.tab_size, 8);
1317    }
1318}