Skip to main content

rich_rs/
text.rs

1//! Text: rich text with styled spans.
2//!
3//! Text is the primary way to work with styled content in Rich.
4//! It stores plain text with a list of spans that define styled regions.
5
6use std::cmp::Ordering;
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10use regex::Regex;
11
12use crate::Renderable;
13use crate::cells::cell_len;
14use crate::console::{Console, ConsoleOptions, JustifyMethod};
15use crate::control::strip_control_codes;
16use crate::error::Result;
17use crate::markup;
18use crate::measure::Measurement;
19use crate::segment::{Segment, Segments};
20use crate::style::{Style, StyleMeta};
21
22/// A span of styled text within a Text object.
23///
24/// Spans define a region of text (by character index) and the style to apply.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Span {
27    /// Start index (character offset, inclusive).
28    pub start: usize,
29    /// End index (character offset, exclusive).
30    pub end: usize,
31    /// Style to apply.
32    pub style: Style,
33    /// Optional style metadata (hyperlinks, Textual handlers, etc.).
34    pub meta: Option<StyleMeta>,
35}
36
37impl Span {
38    /// Create a new span.
39    pub fn new(start: usize, end: usize, style: Style) -> Self {
40        Span {
41            start,
42            end,
43            style,
44            meta: None,
45        }
46    }
47
48    /// Create a new span with optional metadata.
49    pub fn new_with_meta(start: usize, end: usize, style: Style, meta: Option<StyleMeta>) -> Self {
50        Span {
51            start,
52            end,
53            style,
54            meta: meta.and_then(|m| if m.is_empty() { None } else { Some(m) }),
55        }
56    }
57
58    /// Check if the span has any content (end > start).
59    pub fn is_empty(&self) -> bool {
60        self.end <= self.start
61    }
62
63    /// Split a span into two at a given offset.
64    ///
65    /// If the offset is outside the span, returns `(self, None)`.
66    ///
67    /// # Arguments
68    ///
69    /// * `offset` - The character offset at which to split.
70    ///
71    /// # Returns
72    ///
73    /// A tuple of (first_span, optional_second_span).
74    ///
75    /// # Example
76    ///
77    /// ```
78    /// use rich_rs::text::Span;
79    /// use rich_rs::Style;
80    ///
81    /// let span = Span::new(0, 10, Style::new().with_bold(true));
82    /// let (first, second) = span.split(5);
83    /// assert_eq!(first.start, 0);
84    /// assert_eq!(first.end, 5);
85    /// assert!(second.is_some());
86    /// let second = second.unwrap();
87    /// assert_eq!(second.start, 5);
88    /// assert_eq!(second.end, 10);
89    /// ```
90    pub fn split(&self, offset: usize) -> (Span, Option<Span>) {
91        if offset < self.start {
92            return (self.clone(), None);
93        }
94        if offset >= self.end {
95            return (self.clone(), None);
96        }
97
98        let span1 = Span::new_with_meta(
99            self.start,
100            offset.min(self.end),
101            self.style,
102            self.meta.clone(),
103        );
104        let span2 = Span::new_with_meta(span1.end, self.end, self.style, self.meta.clone());
105        (span1, Some(span2))
106    }
107
108    /// Move the span by a given offset.
109    ///
110    /// Both start and end are adjusted by adding the offset.
111    ///
112    /// # Arguments
113    ///
114    /// * `offset` - The amount to add to start and end (can be negative via wrapping).
115    ///
116    /// # Returns
117    ///
118    /// A new Span with adjusted positions.
119    pub fn move_by(&self, offset: isize) -> Span {
120        let new_start = (self.start as isize + offset).max(0) as usize;
121        let new_end = (self.end as isize + offset).max(0) as usize;
122        Span::new_with_meta(new_start, new_end, self.style, self.meta.clone())
123    }
124
125    /// Crop the span at a given offset.
126    ///
127    /// If offset is at or beyond the end, returns self unchanged.
128    /// Otherwise, returns a span ending at offset.
129    ///
130    /// # Arguments
131    ///
132    /// * `offset` - The offset at which to crop.
133    ///
134    /// # Returns
135    ///
136    /// A new (possibly smaller) span.
137    pub fn right_crop(&self, offset: usize) -> Span {
138        if offset >= self.end {
139            return self.clone();
140        }
141        Span::new_with_meta(
142            self.start,
143            offset.min(self.end),
144            self.style,
145            self.meta.clone(),
146        )
147    }
148
149    /// Extend the span by a given number of cells.
150    ///
151    /// # Arguments
152    ///
153    /// * `cells` - The number of cells to add to the end.
154    ///
155    /// # Returns
156    ///
157    /// A new span with extended end position.
158    pub fn extend(&self, cells: usize) -> Span {
159        if cells == 0 {
160            return self.clone();
161        }
162        Span::new_with_meta(self.start, self.end + cells, self.style, self.meta.clone())
163    }
164}
165
166/// A part that can be assembled into Text.
167///
168/// Used by `Text::assemble()` to accept various input types.
169#[derive(Debug, Clone)]
170pub enum TextPart {
171    /// Plain text without styling.
172    Plain(String),
173    /// Text with a style.
174    Styled(String, Style),
175    /// Another Text object.
176    Text(Text),
177}
178
179impl From<&str> for TextPart {
180    fn from(s: &str) -> Self {
181        TextPart::Plain(s.to_string())
182    }
183}
184
185impl From<String> for TextPart {
186    fn from(s: String) -> Self {
187        TextPart::Plain(s)
188    }
189}
190
191impl From<Text> for TextPart {
192    fn from(t: Text) -> Self {
193        TextPart::Text(t)
194    }
195}
196
197impl From<(&str, Style)> for TextPart {
198    fn from((s, style): (&str, Style)) -> Self {
199        TextPart::Styled(s.to_string(), style)
200    }
201}
202
203impl From<(String, Style)> for TextPart {
204    fn from((s, style): (String, Style)) -> Self {
205        TextPart::Styled(s, style)
206    }
207}
208
209/// Rich text with styled spans.
210///
211/// Text is the primary way to work with styled content in Rich.
212/// It stores plain text with a list of spans that define styled regions.
213///
214/// # Example
215///
216/// ```
217/// use rich_rs::Text;
218/// use rich_rs::Style;
219///
220/// let mut text = Text::plain("Hello, World!");
221/// text.stylize(0, 5, Style::new().with_bold(true));
222/// text.stylize(7, 12, Style::new().with_italic(true));
223/// ```
224#[derive(Debug, Clone, Default)]
225pub struct Text {
226    /// The plain text content.
227    text: String,
228    /// Styled spans applied to the text.
229    spans: Vec<Span>,
230    /// Base style for the entire text.
231    style: Option<Style>,
232    /// Base metadata for the entire text.
233    meta: Option<StyleMeta>,
234}
235
236impl Text {
237    /// Create new empty text.
238    pub fn new() -> Self {
239        Text::default()
240    }
241
242    /// Create text from a plain string.
243    pub fn plain(text: impl Into<String>) -> Self {
244        Text {
245            text: text.into(),
246            spans: Vec::new(),
247            style: None,
248            meta: None,
249        }
250    }
251
252    /// Create text with a style applied to the entire content.
253    ///
254    /// Note: Unlike creating Text::plain and then calling stylize(),
255    /// this sets the base style which affects the entire text including
256    /// any padding added later. The base style is applied as a background
257    /// to all spans during rendering.
258    pub fn styled(text: impl Into<String>, style: Style) -> Self {
259        let text = text.into();
260        Text {
261            text,
262            spans: Vec::new(),
263            style: Some(style),
264            meta: None,
265        }
266    }
267
268    /// Create text with a base style and base metadata applied to the entire content.
269    pub fn styled_with_meta(text: impl Into<String>, style: Style, meta: StyleMeta) -> Self {
270        let text = text.into();
271        Text {
272            text,
273            spans: Vec::new(),
274            style: Some(style),
275            meta: if meta.is_empty() { None } else { Some(meta) },
276        }
277    }
278
279    /// Create text from markup string.
280    ///
281    /// Parses BBCode-like markup (e.g., `[bold red]text[/]`) into styled Text.
282    ///
283    /// # Arguments
284    ///
285    /// * `markup` - The markup string to parse.
286    /// * `emoji` - Whether to replace emoji codes (`:smile:` -> actual emoji).
287    ///
288    /// # Returns
289    ///
290    /// A `Text` object with styled spans, or an error if the markup is invalid.
291    ///
292    /// # Example
293    ///
294    /// ```
295    /// use rich_rs::Text;
296    ///
297    /// let text = Text::from_markup("[bold]Hello[/] World", true).unwrap();
298    /// assert_eq!(text.plain_text(), "Hello World");
299    /// ```
300    pub fn from_markup(markup: &str, emoji: bool) -> Result<Text> {
301        crate::markup::render(markup, emoji)
302    }
303
304    /// Create a Text object from a string containing ANSI escape codes.
305    ///
306    /// This is a port of Python Rich's `Text.from_ansi`, backed by `AnsiDecoder`.
307    /// The decoder is lenient and will ignore unknown / malformed escape sequences.
308    ///
309    /// Style state may persist across lines, matching Rich behavior.
310    pub fn from_ansi(ansi_text: &str) -> Text {
311        let mut decoder = crate::ansi::AnsiDecoder::new();
312        // Match Python Rich: `Text.from_ansi` constructs a joiner Text with an explicit (possibly empty)
313        // base style. In rich-rs, using `Some(NULL_STYLE)` preserves that API-visible base-style
314        // without affecting rendering (null styles are ignored when generating spans).
315        let joiner = Text::styled("\n", Style::new());
316        joiner.join(decoder.decode(ansi_text))
317    }
318
319    /// Assemble text from multiple parts.
320    ///
321    /// Each part can be:
322    /// - A plain string (`&str` or `String`)
323    /// - A `Text` object
324    /// - A tuple of `(text, Style)`
325    ///
326    /// # Example
327    ///
328    /// ```
329    /// use rich_rs::{Text, TextPart, Style};
330    ///
331    /// let bold = Style::new().with_bold(true);
332    /// let text = Text::assemble([
333    ///     TextPart::from("Hello, "),
334    ///     TextPart::from(("World", bold)),
335    ///     TextPart::from("!"),
336    /// ]);
337    /// assert_eq!(text.plain_text(), "Hello, World!");
338    /// ```
339    pub fn assemble<I, P>(parts: I) -> Self
340    where
341        I: IntoIterator<Item = P>,
342        P: Into<TextPart>,
343    {
344        let mut result = Text::new();
345
346        for part in parts {
347            match part.into() {
348                TextPart::Plain(s) => {
349                    result.append(&s, None);
350                }
351                TextPart::Styled(s, style) => {
352                    result.append(&s, Some(style));
353                }
354                TextPart::Text(t) => {
355                    result.append_text(&t);
356                }
357            }
358        }
359
360        result
361    }
362
363    /// Get the plain text content.
364    pub fn plain_text(&self) -> &str {
365        &self.text
366    }
367
368    /// Get the cell width of the text.
369    pub fn cell_len(&self) -> usize {
370        cell_len(&self.text)
371    }
372
373    /// Get the character count.
374    pub fn len(&self) -> usize {
375        self.text.chars().count()
376    }
377
378    /// Check if the text is empty.
379    pub fn is_empty(&self) -> bool {
380        self.text.is_empty()
381    }
382
383    /// Append text with an optional style.
384    pub fn append(&mut self, text: impl Into<String>, style: Option<Style>) {
385        let text = text.into();
386        let start = self.len();
387        let end = start + text.chars().count();
388
389        self.text.push_str(&text);
390
391        if let Some(s) = style {
392            self.spans.push(Span::new(start, end, s));
393        }
394    }
395
396    /// Append another Text object, preserving its spans and base style.
397    pub fn append_text(&mut self, other: &Text) {
398        let offset = self.len();
399        let other_len = other.len();
400        self.text.push_str(&other.text);
401
402        // If the other text has a base style/meta, add a span for it.
403        // This preserves region-specific base attributes when merging Text objects.
404        let other_base_style = other.style.unwrap_or_default();
405        let other_base_meta = other.meta.clone().unwrap_or_default();
406        if !other_base_style.is_null() || !other_base_meta.is_empty() {
407            self.spans.push(Span::new_with_meta(
408                offset,
409                offset + other_len,
410                other_base_style,
411                Some(other_base_meta),
412            ));
413        }
414
415        // Copy and offset spans from the other text
416        for span in &other.spans {
417            self.spans.push(Span::new_with_meta(
418                span.start + offset,
419                span.end + offset,
420                span.style,
421                span.meta.clone(),
422            ));
423        }
424    }
425
426    /// Apply a style to a range of the text (legacy API, kept for compatibility).
427    ///
428    /// Spans are clamped to the text bounds. Out-of-bounds or empty spans are ignored.
429    /// For the enhanced version with negative index support, use `stylize_range`.
430    pub fn stylize(&mut self, start: usize, end: usize, style: Style) {
431        let length = self.len();
432        if start >= length || end <= start {
433            return;
434        }
435        let clamped_end = end.min(length);
436        self.spans.push(Span::new(start, clamped_end, style));
437    }
438
439    /// Apply a style to a range of the text with negative index support.
440    ///
441    /// Negative indices count from the end of the text (-1 is the last character).
442    /// If `end` is `None`, styles to the end of the text.
443    ///
444    /// # Arguments
445    ///
446    /// * `style` - The style to apply.
447    /// * `start` - Start offset (negative indexing supported). Defaults to 0.
448    /// * `end` - End offset (negative indexing supported), or `None` for end of text.
449    ///
450    /// # Example
451    ///
452    /// ```
453    /// use rich_rs::{Text, Style};
454    ///
455    /// let mut text = Text::plain("Hello World");
456    /// // Style the last 5 characters
457    /// text.stylize_range(Style::new().with_bold(true), -5, None);
458    /// ```
459    pub fn stylize_range(&mut self, style: Style, start: isize, end: Option<isize>) {
460        if style.is_null() {
461            return;
462        }
463
464        let length = self.len() as isize;
465
466        // Handle negative indices
467        let start = if start < 0 {
468            (length + start).max(0) as usize
469        } else {
470            start as usize
471        };
472
473        let end = match end {
474            None => self.len(),
475            Some(e) if e < 0 => (length + e).max(0) as usize,
476            Some(e) => e as usize,
477        };
478
479        // Validate range
480        if start >= self.len() || end <= start {
481            return;
482        }
483
484        self.spans
485            .push(Span::new(start, end.min(self.len()), style));
486    }
487
488    /// Apply a style to the text, inserting at the beginning of the spans list.
489    ///
490    /// Styles applied with `stylize_before` have lower priority than existing styles.
491    /// This is useful for adding a base style that existing styles can override.
492    ///
493    /// # Arguments
494    ///
495    /// * `style` - The style to apply.
496    /// * `start` - Start offset (negative indexing supported). Defaults to 0.
497    /// * `end` - End offset (negative indexing supported), or `None` for end of text.
498    pub fn stylize_before(&mut self, style: Style, start: isize, end: Option<isize>) {
499        if style.is_null() {
500            return;
501        }
502
503        let length = self.len() as isize;
504
505        // Handle negative indices
506        let start = if start < 0 {
507            (length + start).max(0) as usize
508        } else {
509            start as usize
510        };
511
512        let end = match end {
513            None => self.len(),
514            Some(e) if e < 0 => (length + e).max(0) as usize,
515            Some(e) => e as usize,
516        };
517
518        // Validate range
519        if start >= self.len() || end <= start {
520            return;
521        }
522
523        // Insert at the beginning for lower priority
524        self.spans
525            .insert(0, Span::new(start, end.min(self.len()), style));
526    }
527
528    /// Apply metadata to the text (or a range), using a metadata-only span.
529    ///
530    /// Negative indices count from the end of the text (-1 is the last character).
531    /// If `end` is `None`, metadata is applied to the end of the text.
532    pub fn apply_meta(
533        &mut self,
534        meta: BTreeMap<String, crate::style::MetaValue>,
535        start: isize,
536        end: Option<isize>,
537    ) {
538        if meta.is_empty() {
539            return;
540        }
541
542        let length = self.len() as isize;
543
544        let start = if start < 0 {
545            (length + start).max(0) as usize
546        } else {
547            start as usize
548        };
549
550        let end = match end {
551            None => self.len(),
552            Some(e) if e < 0 => (length + e).max(0) as usize,
553            Some(e) => e as usize,
554        };
555
556        if start >= self.len() || end <= start {
557            return;
558        }
559
560        let meta = StyleMeta {
561            link: None,
562            link_id: None,
563            meta: Some(Arc::new(meta)),
564        };
565
566        self.spans.push(Span::new_with_meta(
567            start,
568            end.min(self.len()),
569            Style::new(),
570            Some(meta),
571        ));
572    }
573
574    /// Highlight text matching a regular expression.
575    ///
576    /// # Arguments
577    ///
578    /// * `pattern` - A regular expression pattern.
579    /// * `style` - The style to apply to matches.
580    ///
581    /// # Returns
582    ///
583    /// The number of matches found.
584    ///
585    /// # Example
586    ///
587    /// ```
588    /// use rich_rs::{Text, Style};
589    ///
590    /// let mut text = Text::plain("foo bar foo baz");
591    /// let count = text.highlight_regex(r"foo", Style::new().with_bold(true));
592    /// assert_eq!(count, 2);
593    /// ```
594    pub fn highlight_regex(&mut self, pattern: &str, style: Style) -> usize {
595        let re = match Regex::new(pattern) {
596            Ok(r) => r,
597            Err(_) => return 0,
598        };
599
600        let mut count = 0;
601        let plain = self.plain_text().to_string();
602
603        for mat in re.find_iter(&plain) {
604            // Convert byte offsets to character offsets
605            let start_char = plain[..mat.start()].chars().count();
606            let end_char = start_char + plain[mat.start()..mat.end()].chars().count();
607
608            if end_char > start_char {
609                self.spans.push(Span::new(start_char, end_char, style));
610                count += 1;
611            }
612        }
613
614        count
615    }
616
617    /// Highlight occurrences of specific words.
618    ///
619    /// # Arguments
620    ///
621    /// * `words` - Words to highlight.
622    /// * `style` - The style to apply.
623    /// * `case_sensitive` - Whether matching should be case-sensitive.
624    ///
625    /// # Returns
626    ///
627    /// The number of words highlighted.
628    ///
629    /// # Example
630    ///
631    /// ```
632    /// use rich_rs::{Text, Style};
633    ///
634    /// let mut text = Text::plain("Hello World Hello");
635    /// let count = text.highlight_words(&["Hello"], Style::new().with_bold(true), true);
636    /// assert_eq!(count, 2);
637    /// ```
638    pub fn highlight_words(&mut self, words: &[&str], style: Style, case_sensitive: bool) -> usize {
639        if words.is_empty() {
640            return 0;
641        }
642
643        // Build regex pattern from words
644        let pattern = words
645            .iter()
646            .map(|w| regex::escape(w))
647            .collect::<Vec<_>>()
648            .join("|");
649
650        let pattern = if case_sensitive {
651            pattern
652        } else {
653            format!("(?i){}", pattern)
654        };
655
656        let re = match Regex::new(&pattern) {
657            Ok(r) => r,
658            Err(_) => return 0,
659        };
660
661        let mut count = 0;
662        let plain = self.plain_text().to_string();
663
664        for mat in re.find_iter(&plain) {
665            // Convert byte offsets to character offsets
666            let start_char = plain[..mat.start()].chars().count();
667            let end_char = start_char + plain[mat.start()..mat.end()].chars().count();
668
669            if end_char > start_char {
670                self.spans.push(Span::new(start_char, end_char, style));
671                count += 1;
672            }
673        }
674
675        count
676    }
677
678    /// Divide text at multiple offsets.
679    ///
680    /// This is a critical algorithm for text wrapping. It splits the text at
681    /// the given character offsets and correctly distributes spans across
682    /// the resulting Text objects.
683    ///
684    /// # Arguments
685    ///
686    /// * `offsets` - Character offsets at which to divide the text.
687    ///
688    /// # Returns
689    ///
690    /// A vector of Text objects, one for each division.
691    ///
692    /// # Example
693    ///
694    /// ```
695    /// use rich_rs::Text;
696    ///
697    /// let text = Text::plain("Hello World!");
698    /// let divided = text.divide([5, 6]);
699    /// assert_eq!(divided.len(), 3);
700    /// assert_eq!(divided[0].plain_text(), "Hello");
701    /// assert_eq!(divided[1].plain_text(), " ");
702    /// assert_eq!(divided[2].plain_text(), "World!");
703    /// ```
704    pub fn divide(&self, offsets: impl IntoIterator<Item = usize>) -> Vec<Text> {
705        let plain = self.plain_text();
706        let text_length = self.len();
707
708        // Collect, sort, clamp, and deduplicate offsets
709        let mut offsets: Vec<usize> = offsets
710            .into_iter()
711            .map(|o| o.min(text_length)) // Clamp to text length
712            .collect();
713        offsets.sort_unstable();
714        offsets.dedup();
715
716        // Filter out 0 and text_length since we add them below
717        let offsets: Vec<usize> = offsets
718            .into_iter()
719            .filter(|&o| o > 0 && o < text_length)
720            .collect();
721
722        if offsets.is_empty() {
723            return vec![self.clone()];
724        }
725
726        // Build line ranges: [0..offset[0]], [offset[0]..offset[1]], ..., [last_offset..len]
727        let mut divide_offsets = vec![0];
728        divide_offsets.extend(offsets.iter().copied());
729        divide_offsets.push(text_length);
730
731        // Create ranges from consecutive offset pairs
732        let line_ranges: Vec<(usize, usize)> = divide_offsets
733            .windows(2)
734            .map(|w| (w[0], w[1]))
735            .filter(|(start, end)| start < end) // Skip empty ranges
736            .collect();
737
738        if line_ranges.is_empty() {
739            return vec![self.clone()];
740        }
741
742        // Extract substrings for each range (character-based slicing)
743        let chars: Vec<char> = plain.chars().collect();
744        let new_lines: Vec<Text> = line_ranges
745            .iter()
746            .map(|&(start, end)| {
747                let clamped_end = end.min(chars.len());
748                let clamped_start = start.min(clamped_end);
749                let substring: String = chars[clamped_start..clamped_end].iter().collect();
750                Text {
751                    text: substring,
752                    spans: Vec::new(),
753                    style: self.style,
754                    meta: self.meta.clone(),
755                }
756            })
757            .collect();
758
759        // If no spans, we're done
760        if self.spans.is_empty() {
761            return new_lines;
762        }
763
764        // Distribute spans to the appropriate lines
765        let mut result = new_lines;
766        let line_count = line_ranges.len();
767
768        for span in &self.spans {
769            // Skip invalid or out-of-bounds spans
770            if span.start >= span.end || span.start >= text_length {
771                continue;
772            }
773            let span_start = span.start;
774            let span_end = span.end.min(text_length);
775
776            // Binary search to find the starting line for this span
777            let mut lower_bound = 0;
778            let mut upper_bound = line_count;
779            let mut start_line_no = (lower_bound + upper_bound) / 2;
780
781            loop {
782                if start_line_no >= line_count {
783                    break;
784                }
785                let (line_start, line_end) = line_ranges[start_line_no];
786                if span_start < line_start {
787                    if start_line_no == 0 {
788                        break;
789                    }
790                    upper_bound = start_line_no - 1;
791                } else if span_start > line_end {
792                    lower_bound = start_line_no + 1;
793                } else {
794                    break;
795                }
796                start_line_no = (lower_bound + upper_bound) / 2;
797            }
798
799            // Find the ending line for this span
800            let end_line_no = if span_end < line_ranges[start_line_no].1 {
801                start_line_no
802            } else {
803                lower_bound = start_line_no;
804                upper_bound = line_count;
805                let mut end_line_no = (lower_bound + upper_bound) / 2;
806
807                loop {
808                    if end_line_no >= line_count {
809                        end_line_no = line_count - 1;
810                        break;
811                    }
812                    let (line_start, line_end) = line_ranges[end_line_no];
813                    if span_end < line_start {
814                        if end_line_no == 0 {
815                            break;
816                        }
817                        upper_bound = end_line_no - 1;
818                    } else if span_end > line_end {
819                        lower_bound = end_line_no + 1;
820                    } else {
821                        break;
822                    }
823                    end_line_no = (lower_bound + upper_bound) / 2;
824                }
825                end_line_no
826            };
827
828            // Add span to all lines it covers
829            for line_no in start_line_no..=end_line_no.min(line_count - 1) {
830                let (line_start, line_end) = line_ranges[line_no];
831                let new_start = span_start.saturating_sub(line_start);
832                let new_end = span_end
833                    .saturating_sub(line_start)
834                    .min(line_end - line_start);
835
836                if new_end > new_start {
837                    result[line_no].spans.push(Span::new_with_meta(
838                        new_start,
839                        new_end,
840                        span.style,
841                        span.meta.clone(),
842                    ));
843                }
844            }
845        }
846
847        result
848    }
849
850    /// Get the spans.
851    pub fn spans(&self) -> &[Span] {
852        &self.spans
853    }
854
855    /// Get mutable access to spans.
856    pub fn spans_mut(&mut self) -> &mut Vec<Span> {
857        &mut self.spans
858    }
859
860    /// Get the base style.
861    pub fn base_style(&self) -> Option<Style> {
862        self.style
863    }
864
865    /// Set the base style.
866    pub fn set_base_style(&mut self, style: Option<Style>) {
867        self.style = style;
868    }
869
870    /// Create a copy of this text.
871    pub fn copy(&self) -> Text {
872        self.clone()
873    }
874
875    /// Create a blank copy with same metadata but no content.
876    pub fn blank_copy(&self, plain: &str) -> Text {
877        Text {
878            text: plain.to_string(),
879            spans: Vec::new(),
880            style: self.style,
881            meta: self.meta.clone(),
882        }
883    }
884
885    /// Join multiple Text objects with this text as separator.
886    pub fn join<I>(&self, texts: I) -> Text
887    where
888        I: IntoIterator<Item = Text>,
889    {
890        let mut result = self.blank_copy("");
891        let mut first = true;
892
893        for text in texts {
894            if !first && !self.is_empty() {
895                result.append_text(self);
896            }
897            result.append_text(&text);
898            first = false;
899        }
900
901        result
902    }
903
904    // ========================================================================
905    // Padding and alignment methods
906    // ========================================================================
907
908    /// Pad text on the right to reach target cell width.
909    ///
910    /// Returns a new Text with spaces appended to reach the target width.
911    /// If the text is already wider than width, returns a clone unchanged.
912    ///
913    /// # Example
914    ///
915    /// ```
916    /// use rich_rs::Text;
917    ///
918    /// let text = Text::plain("hello");
919    /// let padded = text.pad_right(10);
920    /// assert_eq!(padded.plain_text(), "hello     ");
921    /// assert_eq!(padded.cell_len(), 10);
922    /// ```
923    pub fn pad_right(&self, width: usize) -> Text {
924        let current_width = self.cell_len();
925        if current_width >= width {
926            return self.clone();
927        }
928
929        let mut result = self.clone();
930        let spaces = " ".repeat(width - current_width);
931        result.text.push_str(&spaces);
932        result
933    }
934
935    /// Pad text on the left to reach target cell width.
936    ///
937    /// Returns a new Text with spaces prepended to reach the target width.
938    /// Existing spans are shifted by the padding amount.
939    ///
940    /// # Example
941    ///
942    /// ```
943    /// use rich_rs::Text;
944    ///
945    /// let text = Text::plain("hello");
946    /// let padded = text.pad_left(10);
947    /// assert_eq!(padded.plain_text(), "     hello");
948    /// assert_eq!(padded.cell_len(), 10);
949    /// ```
950    pub fn pad_left(&self, width: usize) -> Text {
951        let current_width = self.cell_len();
952        if current_width >= width {
953            return self.clone();
954        }
955
956        let pad_count = width - current_width;
957        let spaces = " ".repeat(pad_count);
958
959        // Shift all spans by the padding amount
960        let shifted_spans: Vec<Span> = self
961            .spans
962            .iter()
963            .map(|span| {
964                Span::new_with_meta(
965                    span.start + pad_count,
966                    span.end + pad_count,
967                    span.style,
968                    span.meta.clone(),
969                )
970            })
971            .collect();
972
973        Text {
974            text: format!("{}{}", spaces, self.text),
975            spans: shifted_spans,
976            style: self.style,
977            meta: self.meta.clone(),
978        }
979    }
980
981    /// Center text within a given cell width.
982    ///
983    /// Returns a new Text padded on both sides to center within the width.
984    /// Left padding is (width - cell_len) / 2, right padding fills the rest.
985    ///
986    /// # Example
987    ///
988    /// ```
989    /// use rich_rs::Text;
990    ///
991    /// let text = Text::plain("hi");
992    /// let centered = text.center(6);
993    /// assert_eq!(centered.plain_text(), "  hi  ");
994    /// assert_eq!(centered.cell_len(), 6);
995    /// ```
996    pub fn center(&self, width: usize) -> Text {
997        let current_width = self.cell_len();
998        if current_width >= width {
999            return self.clone();
1000        }
1001
1002        let total_pad = width - current_width;
1003        let left_pad = total_pad / 2;
1004        let right_pad = total_pad - left_pad;
1005
1006        let left_spaces = " ".repeat(left_pad);
1007        let right_spaces = " ".repeat(right_pad);
1008
1009        // Shift all spans by the left padding amount
1010        let shifted_spans: Vec<Span> = self
1011            .spans
1012            .iter()
1013            .map(|span| {
1014                Span::new_with_meta(
1015                    span.start + left_pad,
1016                    span.end + left_pad,
1017                    span.style,
1018                    span.meta.clone(),
1019                )
1020            })
1021            .collect();
1022
1023        Text {
1024            text: format!("{}{}{}", left_spaces, self.text, right_spaces),
1025            spans: shifted_spans,
1026            style: self.style,
1027            meta: self.meta.clone(),
1028        }
1029    }
1030
1031    /// Expand tabs to spaces.
1032    ///
1033    /// Returns a new Text with tabs replaced by spaces, aligning to tab stops.
1034    ///
1035    /// # Arguments
1036    ///
1037    /// * `tab_size` - The tab stop width (default 8).
1038    ///
1039    /// # Example
1040    ///
1041    /// ```
1042    /// use rich_rs::Text;
1043    ///
1044    /// let text = Text::plain("a\tb");
1045    /// let expanded = text.expand_tabs(4);
1046    /// assert_eq!(expanded.plain_text(), "a   b");
1047    /// ```
1048    pub fn expand_tabs(&self, tab_size: usize) -> Text {
1049        if !self.text.contains('\t') {
1050            return self.clone();
1051        }
1052
1053        let tab_size = if tab_size == 0 { 8 } else { tab_size };
1054
1055        let mut result_text = String::new();
1056        let mut result_spans: Vec<Span> = Vec::new();
1057        let mut cell_position: usize = 0;
1058
1059        let chars: Vec<char> = self.text.chars().collect();
1060
1061        for &c in &chars {
1062            if c == '\t' {
1063                // Calculate spaces needed to reach next tab stop
1064                let tab_remainder = cell_position % tab_size;
1065                let spaces = if tab_remainder == 0 {
1066                    tab_size
1067                } else {
1068                    tab_size - tab_remainder
1069                };
1070
1071                result_text.push_str(&" ".repeat(spaces));
1072                cell_position += spaces;
1073            } else if c == '\n' {
1074                result_text.push(c);
1075                cell_position = 0; // Reset on newline
1076            } else {
1077                result_text.push(c);
1078                cell_position += crate::cells::char_width(c);
1079            }
1080        }
1081
1082        // Rebuild spans with adjusted positions
1083        // We need to map old char offsets to new char offsets
1084        let mut old_to_new: Vec<usize> = Vec::with_capacity(chars.len() + 1);
1085        old_to_new.push(0);
1086
1087        let mut new_pos: usize = 0;
1088        cell_position = 0;
1089
1090        for &c in &chars {
1091            if c == '\t' {
1092                let tab_remainder = cell_position % tab_size;
1093                let spaces = if tab_remainder == 0 {
1094                    tab_size
1095                } else {
1096                    tab_size - tab_remainder
1097                };
1098                new_pos += spaces;
1099                cell_position += spaces;
1100            } else if c == '\n' {
1101                new_pos += 1;
1102                cell_position = 0;
1103            } else {
1104                new_pos += 1;
1105                cell_position += crate::cells::char_width(c);
1106            }
1107            old_to_new.push(new_pos);
1108        }
1109
1110        for span in &self.spans {
1111            let new_start = if span.start < old_to_new.len() {
1112                old_to_new[span.start]
1113            } else {
1114                old_to_new.last().copied().unwrap_or(0)
1115            };
1116            let new_end = if span.end < old_to_new.len() {
1117                old_to_new[span.end]
1118            } else {
1119                old_to_new.last().copied().unwrap_or(0)
1120            };
1121
1122            if new_end > new_start {
1123                result_spans.push(Span::new_with_meta(
1124                    new_start,
1125                    new_end,
1126                    span.style,
1127                    span.meta.clone(),
1128                ));
1129            }
1130        }
1131
1132        Text {
1133            text: result_text,
1134            spans: result_spans,
1135            style: self.style,
1136            meta: self.meta.clone(),
1137        }
1138    }
1139
1140    /// Add indentation guides to the text.
1141    ///
1142    /// This adds visual indentation guides (like vertical lines) to show
1143    /// the indentation level of each line.
1144    ///
1145    /// # Arguments
1146    ///
1147    /// * `indent_size` - The number of spaces per indentation level.
1148    /// * `style` - Optional style for the guide characters.
1149    ///
1150    /// # Returns
1151    ///
1152    /// A new Text with indentation guides added.
1153    pub fn with_indent_guides(self, indent_size: usize, style: Option<crate::Style>) -> Text {
1154        let guide_style = style.unwrap_or_else(|| {
1155            Style::new()
1156                .with_dim(true)
1157                .with_color(crate::color::SimpleColor::Standard(2))
1158        });
1159        self.with_indent_guides_full(Some(indent_size), "│", guide_style)
1160    }
1161
1162    /// Strip trailing whitespace from the text.
1163    ///
1164    /// Returns a new Text with trailing whitespace removed.
1165    /// Spans are adjusted to fit within the new text bounds.
1166    pub fn rstrip(&self) -> Text {
1167        let trimmed = self.text.trim_end();
1168        let new_len = trimmed.chars().count();
1169
1170        let adjusted_spans: Vec<Span> = self
1171            .spans
1172            .iter()
1173            .filter_map(|span| {
1174                if span.start >= new_len {
1175                    None
1176                } else {
1177                    Some(Span::new_with_meta(
1178                        span.start,
1179                        span.end.min(new_len),
1180                        span.style,
1181                        span.meta.clone(),
1182                    ))
1183                }
1184            })
1185            .filter(|span| !span.is_empty())
1186            .collect();
1187
1188        Text {
1189            text: trimmed.to_string(),
1190            spans: adjusted_spans,
1191            style: self.style,
1192            meta: self.meta.clone(),
1193        }
1194    }
1195
1196    /// Remove trailing whitespace beyond a certain width.
1197    ///
1198    /// Only removes whitespace characters that extend beyond the target size.
1199    /// This is used after wrapping to clean up trailing spaces on lines.
1200    ///
1201    /// # Arguments
1202    ///
1203    /// * `size` - The desired cell width target.
1204    pub fn rstrip_end(&self, size: usize) -> Text {
1205        let text_width = self.cell_len();
1206        if text_width <= size {
1207            return self.clone();
1208        }
1209
1210        let excess = text_width - size;
1211
1212        // Find how much trailing whitespace we have (in cell width)
1213        let mut trailing_ws_width = 0;
1214        for c in self.text.chars().rev() {
1215            if c.is_whitespace() {
1216                trailing_ws_width += crate::cells::char_width(c);
1217            } else {
1218                break;
1219            }
1220        }
1221
1222        if trailing_ws_width == 0 {
1223            return self.clone();
1224        }
1225
1226        // Remove trailing whitespace until we've removed min(trailing_ws_width, excess) cells
1227        let cells_to_remove = trailing_ws_width.min(excess);
1228
1229        // Build new text by removing trailing whitespace
1230        let mut chars: Vec<char> = self.text.chars().collect();
1231        let mut removed = 0;
1232        while !chars.is_empty() && removed < cells_to_remove {
1233            if let Some(&c) = chars.last() {
1234                if c.is_whitespace() {
1235                    removed += crate::cells::char_width(c);
1236                    chars.pop();
1237                } else {
1238                    break;
1239                }
1240            } else {
1241                break;
1242            }
1243        }
1244
1245        let new_text: String = chars.iter().collect();
1246        let new_len = chars.len();
1247
1248        let adjusted_spans: Vec<Span> = self
1249            .spans
1250            .iter()
1251            .filter_map(|span| {
1252                if span.start >= new_len {
1253                    None
1254                } else {
1255                    Some(Span::new_with_meta(
1256                        span.start,
1257                        span.end.min(new_len),
1258                        span.style,
1259                        span.meta.clone(),
1260                    ))
1261                }
1262            })
1263            .filter(|span| !span.is_empty())
1264            .collect();
1265
1266        Text {
1267            text: new_text,
1268            spans: adjusted_spans,
1269            style: self.style,
1270            meta: self.meta.clone(),
1271        }
1272    }
1273
1274    /// Truncate text to fit within a cell width.
1275    ///
1276    /// # Arguments
1277    ///
1278    /// * `max_width` - Maximum cell width.
1279    /// * `overflow` - How to handle overflow (Fold, Crop, Ellipsis).
1280    /// * `pad` - If true, pad with spaces if text is shorter than max_width.
1281    pub fn truncate(
1282        &self,
1283        max_width: usize,
1284        overflow: crate::console::OverflowMethod,
1285        pad: bool,
1286    ) -> Text {
1287        use crate::cells::set_cell_size;
1288        use crate::console::OverflowMethod;
1289
1290        if overflow == OverflowMethod::Ignore {
1291            if pad && self.cell_len() < max_width {
1292                return self.pad_right(max_width);
1293            }
1294            return self.clone();
1295        }
1296
1297        let current_width = self.cell_len();
1298
1299        if current_width <= max_width {
1300            if pad {
1301                return self.pad_right(max_width);
1302            }
1303            return self.clone();
1304        }
1305
1306        // Truncate the text
1307        let new_plain = if overflow == OverflowMethod::Ellipsis && max_width > 0 {
1308            let truncated = set_cell_size(&self.text, max_width.saturating_sub(1));
1309            format!("{}…", truncated)
1310        } else {
1311            set_cell_size(&self.text, max_width)
1312        };
1313
1314        let new_char_len = new_plain.chars().count();
1315
1316        // Adjust spans
1317        let adjusted_spans: Vec<Span> = self
1318            .spans
1319            .iter()
1320            .filter_map(|span| {
1321                if span.start >= new_char_len {
1322                    None
1323                } else {
1324                    Some(Span::new_with_meta(
1325                        span.start,
1326                        span.end.min(new_char_len),
1327                        span.style,
1328                        span.meta.clone(),
1329                    ))
1330                }
1331            })
1332            .filter(|span| !span.is_empty())
1333            .collect();
1334
1335        Text {
1336            text: new_plain,
1337            spans: adjusted_spans,
1338            style: self.style,
1339            meta: self.meta.clone(),
1340        }
1341    }
1342
1343    /// Split text on a separator into a list of Text objects.
1344    ///
1345    /// # Arguments
1346    ///
1347    /// * `separator` - The string to split on.
1348    /// * `include_separator` - If true, include the separator at the end of each line.
1349    /// * `allow_blank` - If true, include a blank line if text ends with separator.
1350    ///
1351    /// # Returns
1352    ///
1353    /// A vector of Text objects, one per split segment.
1354    pub fn split(&self, separator: &str, include_separator: bool, allow_blank: bool) -> Vec<Text> {
1355        if separator.is_empty() {
1356            return vec![self.clone()];
1357        }
1358
1359        if !self.text.contains(separator) {
1360            return vec![self.clone()];
1361        }
1362
1363        // Find all separator positions (ranges)
1364        let chars: Vec<char> = self.text.chars().collect();
1365        let sep_chars: Vec<char> = separator.chars().collect();
1366        let sep_len = sep_chars.len();
1367        let text_len = chars.len();
1368
1369        // Collect separator ranges (start, end)
1370        let mut sep_ranges: Vec<(usize, usize)> = Vec::new();
1371        let mut i = 0;
1372        while i + sep_len <= text_len {
1373            if &chars[i..i + sep_len] == sep_chars.as_slice() {
1374                sep_ranges.push((i, i + sep_len));
1375                i += sep_len;
1376            } else {
1377                i += 1;
1378            }
1379        }
1380
1381        if sep_ranges.is_empty() {
1382            return vec![self.clone()];
1383        }
1384
1385        // Build segments by extracting text between separators
1386        let mut result: Vec<Text> = Vec::new();
1387        let mut pos = 0;
1388
1389        for (sep_start, sep_end) in &sep_ranges {
1390            // Extract segment before separator
1391            if include_separator {
1392                // Include everything from pos up to and including separator
1393                if *sep_end > pos {
1394                    let segment_text: String = chars[pos..*sep_end].iter().collect();
1395                    result.push(self.slice_at_offsets(pos, *sep_end, &segment_text));
1396                }
1397            } else {
1398                // Only include the part before the separator
1399                let segment_text: String = chars[pos..*sep_start].iter().collect();
1400                if allow_blank || !segment_text.is_empty() {
1401                    result.push(self.slice_at_offsets(pos, *sep_start, &segment_text));
1402                }
1403            }
1404            pos = *sep_end;
1405        }
1406
1407        // Handle the trailing segment after the last separator
1408        if pos < text_len {
1409            let segment_text: String = chars[pos..].iter().collect();
1410            if allow_blank || !segment_text.is_empty() {
1411                result.push(self.slice_at_offsets(pos, text_len, &segment_text));
1412            }
1413        } else if include_separator {
1414            // Text ends with separator and include_separator is true
1415            // Add trailing empty segment only if allow_blank
1416            if allow_blank {
1417                result.push(self.blank_copy(""));
1418            }
1419        } else {
1420            // Text ends with separator and include_separator is false
1421            // Add trailing empty segment only if allow_blank
1422            if allow_blank {
1423                result.push(self.blank_copy(""));
1424            }
1425        }
1426
1427        result
1428    }
1429
1430    /// Helper to create a slice with adjusted spans.
1431    fn slice_at_offsets(&self, start: usize, end: usize, text: &str) -> Text {
1432        let adjusted_spans: Vec<Span> = self
1433            .spans
1434            .iter()
1435            .filter_map(|span| {
1436                if span.end <= start || span.start >= end {
1437                    None
1438                } else {
1439                    let new_start = span.start.saturating_sub(start);
1440                    let new_end = span.end.min(end).saturating_sub(start);
1441                    if new_start < new_end {
1442                        Some(Span::new_with_meta(
1443                            new_start,
1444                            new_end,
1445                            span.style,
1446                            span.meta.clone(),
1447                        ))
1448                    } else {
1449                        None
1450                    }
1451                }
1452            })
1453            .collect();
1454
1455        Text {
1456            text: text.to_string(),
1457            spans: adjusted_spans,
1458            style: self.style,
1459            meta: self.meta.clone(),
1460        }
1461    }
1462
1463    // ========================================================================
1464    // Full justification helper
1465    // ========================================================================
1466
1467    /// Justify text to fill width by expanding spaces between words.
1468    ///
1469    /// Used for "full" justification. This expands spaces between words
1470    /// to make the text fill the entire width.
1471    fn justify_full(&self, width: usize) -> Text {
1472        let current_width = self.cell_len();
1473        if current_width >= width {
1474            return self.clone();
1475        }
1476
1477        // Split into words on spaces.
1478        // Note: Python Rich uses `split(" ")`, which preserves empty tokens for
1479        // consecutive spaces. Our split currently drops empty segments unless
1480        // `allow_blank` is true; this is sufficient for the demo content which
1481        // uses single spaces between words.
1482        let words = self.split(" ", false, false);
1483        if words.len() <= 1 {
1484            // Single word or empty - can't justify, just pad right
1485            return self.pad_right(width);
1486        }
1487
1488        // Calculate total word width and number of gaps
1489        let words_width: usize = words.iter().map(|w| w.cell_len()).sum();
1490        let num_gaps = words.len().saturating_sub(1);
1491        if num_gaps == 0 {
1492            return self.pad_right(width);
1493        }
1494
1495        // Distribute spaces to match Python Rich:
1496        // start with 1 space per gap, then add extra spaces from right-to-left.
1497        let mut spaces: Vec<usize> = vec![1; num_gaps];
1498        let mut num_spaces = num_gaps;
1499        let mut index = 0usize;
1500        while words_width + num_spaces < width {
1501            let pos = num_gaps.saturating_sub(index).saturating_sub(1);
1502            spaces[pos] += 1;
1503            num_spaces += 1;
1504            index = (index + 1) % num_gaps;
1505        }
1506
1507        let mut result = Text::new();
1508        result.style = self.style;
1509
1510        for (i, word) in words.iter().enumerate() {
1511            result.append_text(word);
1512
1513            if i < num_gaps {
1514                // Add spaces between words
1515                result.append(" ".repeat(spaces[i]), None);
1516            }
1517        }
1518
1519        result
1520    }
1521
1522    // ========================================================================
1523    // Wrap method
1524    // ========================================================================
1525
1526    /// Wrap text to fit within a given width.
1527    ///
1528    /// This method word-wraps the text to fit within the specified cell width,
1529    /// applying justification and handling overflow as specified.
1530    ///
1531    /// # Arguments
1532    ///
1533    /// * `width` - Maximum width in cells.
1534    /// * `justify` - Text justification (None for no justification).
1535    /// * `overflow` - How to handle words longer than width.
1536    /// * `tab_size` - Tab stop width (default 8).
1537    /// * `no_wrap` - If true, don't wrap (just return self).
1538    ///
1539    /// # Returns
1540    ///
1541    /// A vector of Text objects, one per wrapped line.
1542    ///
1543    /// # Example
1544    ///
1545    /// ```
1546    /// use rich_rs::{Text, OverflowMethod};
1547    ///
1548    /// let text = Text::plain("hello world this is a test");
1549    /// let lines = text.wrap(10, None, Some(OverflowMethod::Fold), 8, false);
1550    /// assert!(lines.len() >= 3);
1551    /// ```
1552    pub fn wrap(
1553        &self,
1554        width: usize,
1555        justify: Option<crate::console::JustifyMethod>,
1556        overflow: Option<crate::console::OverflowMethod>,
1557        tab_size: usize,
1558        no_wrap: bool,
1559    ) -> Vec<Text> {
1560        use crate::console::{JustifyMethod, OverflowMethod};
1561        use crate::wrap::divide_line;
1562
1563        let wrap_justify = justify.unwrap_or(JustifyMethod::Default);
1564        let wrap_overflow = overflow.unwrap_or(OverflowMethod::Fold);
1565
1566        // If overflow is Ignore, treat as no_wrap
1567        let no_wrap = no_wrap || wrap_overflow == OverflowMethod::Ignore;
1568
1569        let mut all_lines: Vec<Text> = Vec::new();
1570
1571        // Split on existing newlines first
1572        let source_lines = self.split("\n", false, true);
1573
1574        for line in source_lines {
1575            // Expand tabs
1576            let line = if line.plain_text().contains('\t') {
1577                line.expand_tabs(tab_size)
1578            } else {
1579                line
1580            };
1581
1582            let wrapped_lines = if no_wrap {
1583                vec![line.clone()]
1584            } else {
1585                // Get break positions using divide_line
1586                let fold = wrap_overflow == OverflowMethod::Fold;
1587                let offsets = divide_line(line.plain_text(), width, fold);
1588
1589                if offsets.is_empty() {
1590                    vec![line.clone()]
1591                } else {
1592                    // Convert byte offsets to character offsets
1593                    let char_offsets: Vec<usize> = offsets
1594                        .iter()
1595                        .map(|&byte_offset| line.plain_text()[..byte_offset].chars().count())
1596                        .collect();
1597                    line.divide(char_offsets)
1598                }
1599            };
1600
1601            // Process each wrapped line
1602            for wrapped_line in wrapped_lines {
1603                all_lines.push(wrapped_line);
1604            }
1605        }
1606
1607        // Apply post-processing: rstrip_end, justification, truncation
1608        let num_lines = all_lines.len();
1609        for (i, wrapped_line) in all_lines.iter_mut().enumerate() {
1610            let is_last_line = i == num_lines - 1;
1611
1612            // Strip trailing whitespace beyond width (only if wrapping)
1613            if !no_wrap {
1614                *wrapped_line = wrapped_line.rstrip_end(width);
1615            }
1616
1617            // Apply justification
1618            *wrapped_line = match wrap_justify {
1619                JustifyMethod::Left => wrapped_line.pad_right(width),
1620                JustifyMethod::Right => {
1621                    let stripped = wrapped_line.rstrip();
1622                    stripped.pad_left(width)
1623                }
1624                JustifyMethod::Center => {
1625                    let stripped = wrapped_line.rstrip();
1626                    stripped.center(width)
1627                }
1628                JustifyMethod::Full => {
1629                    // Full justification - last line should be left-aligned
1630                    if is_last_line {
1631                        wrapped_line.rstrip().pad_right(width)
1632                    } else {
1633                        wrapped_line.justify_full(width)
1634                    }
1635                }
1636                JustifyMethod::Default => wrapped_line.clone(),
1637            };
1638
1639            // Truncate if needed (but not for no_wrap/ignore)
1640            if !no_wrap {
1641                *wrapped_line = wrapped_line.truncate(width, wrap_overflow, false);
1642            }
1643        }
1644
1645        all_lines
1646    }
1647}
1648
1649// ========================================================================
1650// Additional parity methods
1651// ========================================================================
1652
1653impl Text {
1654    /// Return a sub-Text from character offsets `start..end` with correctly adjusted spans.
1655    ///
1656    /// This is the equivalent of Python's `text[start:end]`. Spans that partially overlap
1657    /// the range are clipped. Spans fully outside are dropped.
1658    pub fn slice(&self, start: usize, end: usize) -> Text {
1659        let text_len = self.len();
1660        let start = start.min(text_len);
1661        let end = end.min(text_len);
1662        if start >= end {
1663            return self.blank_copy("");
1664        }
1665        let lines = self.divide([start, end]);
1666        // divide([start, end]) returns up to 3 segments: [0..start], [start..end], [end..len]
1667        // We want the middle one (index 1), or the first one if start==0.
1668        if start == 0 {
1669            lines
1670                .into_iter()
1671                .next()
1672                .unwrap_or_else(|| self.blank_copy(""))
1673        } else if lines.len() > 1 {
1674            lines
1675                .into_iter()
1676                .nth(1)
1677                .unwrap_or_else(|| self.blank_copy(""))
1678        } else {
1679            self.blank_copy("")
1680        }
1681    }
1682
1683    /// Align text within width: left (pad right), center (pad both sides), right (pad left),
1684    /// full (distribute spaces between words).
1685    pub fn align(&mut self, align: JustifyMethod, width: usize) {
1686        let current_width = self.cell_len();
1687        if current_width >= width {
1688            *self = self.truncate(width, crate::console::OverflowMethod::Crop, false);
1689            return;
1690        }
1691        let excess_space = width - current_width;
1692        if excess_space == 0 {
1693            return;
1694        }
1695        match align {
1696            JustifyMethod::Left | JustifyMethod::Default => {
1697                *self = self.pad_right(width);
1698            }
1699            JustifyMethod::Center => {
1700                *self = self.center(width);
1701            }
1702            JustifyMethod::Right => {
1703                *self = self.pad_left(width);
1704            }
1705            JustifyMethod::Full => {
1706                *self = self.justify_full(width);
1707            }
1708        }
1709    }
1710
1711    /// Check if the plain text contains a substring.
1712    pub fn contains(&self, text: &str) -> bool {
1713        self.text.contains(text)
1714    }
1715
1716    /// Reconstruct a markup string from the styled text.
1717    ///
1718    /// For each span, wraps the text range in `[style]...[/style]` tags.
1719    pub fn to_markup(&self) -> String {
1720        let plain = &self.text;
1721        if self.spans.is_empty() && self.style.is_none_or(|s| s.is_null()) {
1722            return markup::escape(plain);
1723        }
1724
1725        // Build events: (offset, is_closing, style_string)
1726        let mut events: Vec<(usize, bool, String)> = Vec::new();
1727
1728        // Base style
1729        if let Some(style) = self.style {
1730            if !style.is_null() {
1731                let style_str = style.to_markup_string();
1732                if !style_str.is_empty() {
1733                    events.push((0, false, style_str.clone()));
1734                    events.push((self.len(), true, style_str));
1735                }
1736            }
1737        }
1738
1739        // Span styles
1740        for span in &self.spans {
1741            let style_str = span.style.to_markup_string();
1742            if !style_str.is_empty() {
1743                events.push((span.start, false, style_str.clone()));
1744                events.push((span.end, true, style_str));
1745            }
1746        }
1747
1748        // Sort by offset, then closings before openings at same position
1749        events.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
1750
1751        let mut output = String::new();
1752        let chars: Vec<char> = plain.chars().collect();
1753        let mut position = 0;
1754
1755        for (offset, closing, style_str) in &events {
1756            let offset = (*offset).min(chars.len());
1757            if offset > position {
1758                let text_slice: String = chars[position..offset].iter().collect();
1759                output.push_str(&markup::escape(&text_slice));
1760                position = offset;
1761            }
1762            if *closing {
1763                output.push_str(&format!("[/{}]", style_str));
1764            } else {
1765                output.push_str(&format!("[{}]", style_str));
1766            }
1767        }
1768
1769        // Remaining text
1770        if position < chars.len() {
1771            let text_slice: String = chars[position..].iter().collect();
1772            output.push_str(&markup::escape(&text_slice));
1773        }
1774
1775        output
1776    }
1777
1778    /// Get the combined style at a specific character offset by combining all overlapping spans.
1779    pub fn get_style_at_offset(&self, offset: usize) -> Style {
1780        let mut style = self.style.unwrap_or_default();
1781        for span in &self.spans {
1782            if span.start <= offset && offset < span.end {
1783                style = style.combine(&span.style);
1784            }
1785        }
1786        style
1787    }
1788
1789    /// Truncate or pad (with spaces) to exact character length.
1790    pub fn set_length(&mut self, length: usize) {
1791        let current = self.len();
1792        match current.cmp(&length) {
1793            Ordering::Less => {
1794                // Pad right
1795                *self = self.pad_right(length);
1796            }
1797            Ordering::Greater => {
1798                // Right crop
1799                self.right_crop(current - length);
1800            }
1801            Ordering::Equal => {}
1802        }
1803    }
1804
1805    /// Remove `amount` characters from the right side.
1806    pub fn right_crop(&mut self, amount: usize) {
1807        if amount == 0 {
1808            return;
1809        }
1810        let chars: Vec<char> = self.text.chars().collect();
1811        if amount >= chars.len() {
1812            self.text.clear();
1813            self.spans.clear();
1814            return;
1815        }
1816        let new_len = chars.len() - amount;
1817        self.text = chars[..new_len].iter().collect();
1818        self.spans = self
1819            .spans
1820            .iter()
1821            .filter(|span| span.start < new_len)
1822            .map(|span| {
1823                if span.end <= new_len {
1824                    span.clone()
1825                } else {
1826                    Span::new_with_meta(span.start, new_len, span.style, span.meta.clone())
1827                }
1828            })
1829            .collect();
1830    }
1831
1832    /// Fit text to width by splitting on newlines, then setting each line to exact width.
1833    pub fn fit(&self, width: usize) -> Vec<Text> {
1834        let mut lines = Vec::new();
1835        for mut line in self.split("\n", false, true) {
1836            line.set_length(width);
1837            lines.push(line);
1838        }
1839        lines
1840    }
1841
1842    /// Remove suffix from the text if present.
1843    pub fn remove_suffix(&mut self, suffix: &str) {
1844        if self.text.ends_with(suffix) {
1845            let suffix_chars = suffix.chars().count();
1846            self.right_crop(suffix_chars);
1847        }
1848    }
1849
1850    /// Extend the last span's style to cover `count` more characters.
1851    pub fn extend_style(&mut self, count: usize) {
1852        if count == 0 {
1853            return;
1854        }
1855        let end_offset = self.len();
1856        if !self.spans.is_empty() {
1857            for span in &mut self.spans {
1858                if span.end >= end_offset {
1859                    span.end += count;
1860                }
1861            }
1862        }
1863        self.text.push_str(&" ".repeat(count));
1864    }
1865
1866    /// Detect the common indentation level.
1867    pub fn detect_indentation(&self) -> usize {
1868        let re = Regex::new(r"(?m)^( *)(.*)$").unwrap_or_else(|_| Regex::new("$^").unwrap());
1869        let mut indentations: Vec<usize> = Vec::new();
1870        for cap in re.captures_iter(&self.text) {
1871            let indent_len = cap.get(1).map_or(0, |m| m.as_str().len());
1872            let content = cap.get(2).map_or("", |m| m.as_str());
1873            if !content.is_empty() && indent_len > 0 {
1874                indentations.push(indent_len);
1875            }
1876        }
1877
1878        if indentations.is_empty() {
1879            return 1;
1880        }
1881
1882        // Filter to even indentations, then compute GCD
1883        let even_indents: Vec<usize> = indentations.into_iter().filter(|i| i % 2 == 0).collect();
1884        if even_indents.is_empty() {
1885            return 1;
1886        }
1887
1888        even_indents.iter().copied().reduce(gcd).unwrap_or(1).max(1)
1889    }
1890
1891    /// Copy spans from another Text onto this one (direct copy, no offset adjustment).
1892    pub fn copy_styles(&mut self, other: &Text) {
1893        self.spans.extend(other.spans.iter().cloned());
1894    }
1895
1896    /// Append an iterable of `(content, style)` tokens.
1897    ///
1898    /// Control codes are stripped from each token before appending, matching append behavior.
1899    pub fn append_tokens<I, S>(&mut self, tokens: I)
1900    where
1901        I: IntoIterator<Item = (S, Option<Style>)>,
1902        S: AsRef<str>,
1903    {
1904        for (content, style) in tokens {
1905            let sanitized = strip_control_codes(content.as_ref());
1906            self.append(sanitized, style);
1907        }
1908    }
1909
1910    /// Implement real indent guide rendering.
1911    ///
1912    /// Scans for leading whitespace and replaces indent positions with the guide
1913    /// character in the given style.
1914    pub fn with_indent_guides_full(
1915        &self,
1916        indent_size: Option<usize>,
1917        character: &str,
1918        style: Style,
1919    ) -> Text {
1920        let indent_size = indent_size.unwrap_or_else(|| self.detect_indentation());
1921        if indent_size == 0 {
1922            return self.clone();
1923        }
1924
1925        let expanded = self.expand_tabs(indent_size);
1926        let indent_line = format!("{}{}", character, " ".repeat(indent_size.saturating_sub(1)));
1927        let re_indent = Regex::new(r"^( *)(.*)$").unwrap();
1928
1929        let mut new_lines: Vec<Text> = Vec::new();
1930        let mut blank_lines: usize = 0;
1931
1932        for line in expanded.split("\n", false, true) {
1933            let plain = line.plain_text().to_string();
1934            if let Some(caps) = re_indent.captures(&plain) {
1935                let indent = caps.get(1).map_or("", |m| m.as_str());
1936                let content = caps.get(2).map_or("", |m| m.as_str());
1937
1938                if content.is_empty() {
1939                    blank_lines += 1;
1940                    continue;
1941                }
1942
1943                let indent_len = indent.len();
1944                let full_indents = indent_len / indent_size;
1945                let remaining_space = indent_len % indent_size;
1946                let new_indent = format!(
1947                    "{}{}",
1948                    indent_line.repeat(full_indents),
1949                    " ".repeat(remaining_space)
1950                );
1951
1952                let mut result_line = line.clone();
1953                // Replace the leading whitespace with guide characters
1954                let new_indent_len = new_indent.chars().count();
1955                let chars: Vec<char> = result_line.text.chars().collect();
1956                let replace_len = indent_len.min(new_indent_len).min(chars.len());
1957                let mut new_text = new_indent.clone();
1958                if replace_len < chars.len() {
1959                    let rest: String = chars[replace_len..].iter().collect();
1960                    new_text.push_str(&rest);
1961                }
1962                result_line.text = new_text;
1963                result_line.stylize(0, new_indent_len.min(indent_len), style);
1964
1965                // Add blank lines with indent guides
1966                if blank_lines > 0 {
1967                    for _ in 0..blank_lines {
1968                        let blank_guide = Text::styled(new_indent.clone(), style);
1969                        new_lines.push(blank_guide);
1970                    }
1971                    blank_lines = 0;
1972                }
1973
1974                new_lines.push(result_line);
1975            } else {
1976                blank_lines += 1;
1977            }
1978        }
1979
1980        // Handle trailing blank lines
1981        for _ in 0..blank_lines {
1982            new_lines.push(Text::new());
1983        }
1984
1985        let joiner = expanded.blank_copy("\n");
1986        joiner.join(new_lines)
1987    }
1988}
1989
1990impl std::ops::Add for Text {
1991    type Output = Text;
1992
1993    fn add(self, rhs: Text) -> Text {
1994        let mut result = self.clone();
1995        result.append_text(&rhs);
1996        result
1997    }
1998}
1999
2000impl std::ops::Add<&str> for Text {
2001    type Output = Text;
2002
2003    fn add(self, rhs: &str) -> Text {
2004        let mut result = self.clone();
2005        result.append(rhs, None);
2006        result
2007    }
2008}
2009
2010/// Compute the greatest common divisor.
2011fn gcd(a: usize, b: usize) -> usize {
2012    if b == 0 { a } else { gcd(b, a % b) }
2013}
2014
2015impl From<&str> for Text {
2016    fn from(s: &str) -> Self {
2017        Text::plain(s)
2018    }
2019}
2020
2021impl From<String> for Text {
2022    fn from(s: String) -> Self {
2023        Text::plain(s)
2024    }
2025}
2026
2027/// Implement Renderable for Text.
2028///
2029/// This converts Text to Segments for rendering to the terminal.
2030impl Renderable for Text {
2031    fn render(&self, _console: &Console, options: &ConsoleOptions) -> Segments {
2032        let text = self.plain_text();
2033        let width = options.max_width;
2034
2035        // Even when `no_wrap` is enabled, we still need to run through `wrap()` when
2036        // justification or overflow is requested, so that padding/truncation can be applied.
2037        let needs_processing = width > 0
2038            && (options.justify.is_some()
2039                || options.overflow.is_some()
2040                || text.lines().any(|line| cell_len(line) > width));
2041
2042        if !needs_processing {
2043            return self.render_unwrapped();
2044        }
2045
2046        let lines = self.wrap(
2047            width,
2048            options.justify,
2049            options.overflow,
2050            options.tab_size,
2051            options.no_wrap,
2052        );
2053
2054        if lines.len() == 1 {
2055            return lines[0].render_unwrapped();
2056        }
2057
2058        // Render each already-wrapped line without re-running wrap/justify/overflow.
2059        // Re-processing would strip trailing padding and re-center again, which can
2060        // shift multiline centered text to the right line-by-line (demo parity issue).
2061        let mut segments = Segments::new();
2062        for (i, line) in lines.iter().enumerate() {
2063            segments.extend(line.render_unwrapped());
2064            if i + 1 < lines.len() {
2065                segments.push(Segment::line());
2066            }
2067        }
2068
2069        segments
2070    }
2071
2072    fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
2073        let text = self.plain_text();
2074        let lines: Vec<&str> = text.lines().collect();
2075
2076        let mut max_width = lines.iter().map(|line| cell_len(line)).max().unwrap_or(0);
2077
2078        let words: Vec<&str> = text.split_whitespace().collect();
2079        let mut min_width = words
2080            .iter()
2081            .map(|word| cell_len(word))
2082            .max()
2083            .unwrap_or(max_width);
2084
2085        if options.max_width > 0 {
2086            if max_width > options.max_width {
2087                max_width = options.max_width;
2088            }
2089            if min_width > options.max_width {
2090                min_width = options.max_width;
2091            }
2092        }
2093
2094        Measurement::new(min_width, max_width)
2095    }
2096}
2097
2098impl Text {
2099    fn render_unwrapped(&self) -> Segments {
2100        let text = self.plain_text();
2101
2102        // Fast path: no spans - still apply base style if present
2103        if self.spans.is_empty() {
2104            let base_style = self.style.unwrap_or_default();
2105            let base_meta = self.meta.clone().unwrap_or_default();
2106            let segment = match (base_style.is_null(), base_meta.is_empty()) {
2107                (true, true) => Segment::new(text.to_string()),
2108                (true, false) => Segment::new_with_meta(text.to_string(), base_meta),
2109                (false, true) => Segment::styled(text.to_string(), base_style),
2110                (false, false) => {
2111                    Segment::styled_with_meta(text.to_string(), base_style, base_meta)
2112                }
2113            };
2114            return Segments::from(segment);
2115        }
2116
2117        // Build a list of events: (offset, is_end, span_index)
2118        // span_index 0 is reserved for the base style
2119        let enumerated_spans: Vec<(usize, &Span)> = self
2120            .spans
2121            .iter()
2122            .enumerate()
2123            .map(|(i, s)| (i + 1, s))
2124            .collect();
2125
2126        // Build style map: index -> style
2127        let mut style_map: std::collections::HashMap<usize, Style> =
2128            std::collections::HashMap::new();
2129        style_map.insert(0, self.style.unwrap_or_default());
2130        for (index, span) in &enumerated_spans {
2131            style_map.insert(*index, span.style);
2132        }
2133
2134        // Build meta map: index -> meta
2135        let mut meta_map: std::collections::HashMap<usize, StyleMeta> =
2136            std::collections::HashMap::new();
2137        meta_map.insert(0, self.meta.clone().unwrap_or_default());
2138        for (index, span) in &enumerated_spans {
2139            meta_map.insert(*index, span.meta.clone().unwrap_or_default());
2140        }
2141
2142        // Build events
2143        let mut events: Vec<(usize, bool, usize)> = Vec::new();
2144        events.push((0, false, 0)); // Base style starts at 0
2145        for (index, span) in &enumerated_spans {
2146            events.push((span.start, false, *index)); // Start event
2147            events.push((span.end, true, *index)); // End event
2148        }
2149        events.push((self.len(), true, 0)); // Base style ends at text end
2150
2151        // Sort by offset, then by is_end (starts before ends at same position)
2152        events.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
2153
2154        // Process events and generate segments
2155        let mut segments = Segments::new();
2156        let mut stack: Vec<usize> = Vec::new();
2157
2158        let chars: Vec<char> = text.chars().collect();
2159
2160        for i in 0..events.len() - 1 {
2161            let (offset, leaving, style_id) = events[i];
2162            let (next_offset, _, _) = events[i + 1];
2163
2164            if leaving {
2165                // Remove style from stack
2166                if let Some(pos) = stack.iter().position(|&x| x == style_id) {
2167                    stack.remove(pos);
2168                }
2169            } else {
2170                // Add style to stack
2171                stack.push(style_id);
2172            }
2173
2174            // Generate segment for this region
2175            if next_offset > offset && offset < chars.len() {
2176                let end = next_offset.min(chars.len());
2177                let segment_text: String = chars[offset..end].iter().collect();
2178
2179                // Combine styles from stack (later styles override earlier ones)
2180                let mut combined_style = Style::new();
2181                let mut combined_meta = StyleMeta::new();
2182                let mut sorted_stack = stack.clone();
2183                sorted_stack.sort();
2184                for &style_id in &sorted_stack {
2185                    if let Some(&style) = style_map.get(&style_id) {
2186                        combined_style = combined_style.combine(&style);
2187                    }
2188                    if let Some(meta) = meta_map.get(&style_id) {
2189                        combined_meta = combined_meta.combine(meta);
2190                    }
2191                }
2192
2193                match (combined_style.is_null(), combined_meta.is_empty()) {
2194                    (true, true) => segments.push(Segment::new(segment_text)),
2195                    (true, false) => {
2196                        segments.push(Segment::new_with_meta(segment_text, combined_meta))
2197                    }
2198                    (false, true) => segments.push(Segment::styled(segment_text, combined_style)),
2199                    (false, false) => segments.push(Segment::styled_with_meta(
2200                        segment_text,
2201                        combined_style,
2202                        combined_meta,
2203                    )),
2204                }
2205            }
2206        }
2207
2208        segments
2209    }
2210}
2211
2212#[cfg(test)]
2213mod tests {
2214    use super::*;
2215
2216    // ==================== Span tests ====================
2217
2218    #[test]
2219    fn test_span_new() {
2220        let style = Style::new().with_bold(true);
2221        let span = Span::new(0, 10, style);
2222        assert_eq!(span.start, 0);
2223        assert_eq!(span.end, 10);
2224        assert_eq!(span.style, style);
2225    }
2226
2227    #[test]
2228    fn test_span_is_empty() {
2229        let style = Style::new();
2230        assert!(Span::new(5, 5, style).is_empty());
2231        assert!(Span::new(10, 5, style).is_empty());
2232        assert!(!Span::new(0, 5, style).is_empty());
2233    }
2234
2235    #[test]
2236    fn test_span_split_middle() {
2237        let style = Style::new().with_bold(true);
2238        let span = Span::new(0, 10, style);
2239        let (first, second) = span.split(5);
2240
2241        assert_eq!(first.start, 0);
2242        assert_eq!(first.end, 5);
2243        assert!(second.is_some());
2244        let second = second.unwrap();
2245        assert_eq!(second.start, 5);
2246        assert_eq!(second.end, 10);
2247    }
2248
2249    #[test]
2250    fn test_span_split_before_start() {
2251        let style = Style::new();
2252        let span = Span::new(5, 10, style);
2253        let (first, second) = span.split(3);
2254
2255        assert_eq!(first.start, 5);
2256        assert_eq!(first.end, 10);
2257        assert!(second.is_none());
2258    }
2259
2260    #[test]
2261    fn test_span_split_after_end() {
2262        let style = Style::new();
2263        let span = Span::new(0, 5, style);
2264        let (first, second) = span.split(10);
2265
2266        assert_eq!(first.start, 0);
2267        assert_eq!(first.end, 5);
2268        assert!(second.is_none());
2269    }
2270
2271    #[test]
2272    fn test_span_split_at_end() {
2273        let style = Style::new();
2274        let span = Span::new(0, 5, style);
2275        let (first, second) = span.split(5);
2276
2277        assert_eq!(first.start, 0);
2278        assert_eq!(first.end, 5);
2279        assert!(second.is_none());
2280    }
2281
2282    #[test]
2283    fn test_span_move_positive() {
2284        let style = Style::new();
2285        let span = Span::new(5, 10, style);
2286        let moved = span.move_by(3);
2287
2288        assert_eq!(moved.start, 8);
2289        assert_eq!(moved.end, 13);
2290    }
2291
2292    #[test]
2293    fn test_span_move_negative() {
2294        let style = Style::new();
2295        let span = Span::new(5, 10, style);
2296        let moved = span.move_by(-3);
2297
2298        assert_eq!(moved.start, 2);
2299        assert_eq!(moved.end, 7);
2300    }
2301
2302    #[test]
2303    fn test_span_move_negative_clamp() {
2304        let style = Style::new();
2305        let span = Span::new(2, 5, style);
2306        let moved = span.move_by(-10);
2307
2308        assert_eq!(moved.start, 0);
2309        assert_eq!(moved.end, 0);
2310    }
2311
2312    #[test]
2313    fn test_span_right_crop() {
2314        let style = Style::new();
2315        let span = Span::new(0, 10, style);
2316
2317        let cropped = span.right_crop(5);
2318        assert_eq!(cropped.start, 0);
2319        assert_eq!(cropped.end, 5);
2320
2321        let uncropped = span.right_crop(15);
2322        assert_eq!(uncropped.start, 0);
2323        assert_eq!(uncropped.end, 10);
2324    }
2325
2326    #[test]
2327    fn test_span_extend() {
2328        let style = Style::new();
2329        let span = Span::new(0, 5, style);
2330
2331        let extended = span.extend(3);
2332        assert_eq!(extended.start, 0);
2333        assert_eq!(extended.end, 8);
2334
2335        let unchanged = span.extend(0);
2336        assert_eq!(unchanged.start, 0);
2337        assert_eq!(unchanged.end, 5);
2338    }
2339
2340    // ==================== Text basic tests ====================
2341
2342    #[test]
2343    fn test_text_plain() {
2344        let text = Text::plain("hello");
2345        assert_eq!(text.plain_text(), "hello");
2346        assert_eq!(text.len(), 5);
2347    }
2348
2349    #[test]
2350    fn test_text_styled() {
2351        let style = Style::new().with_bold(true);
2352        let text = Text::styled("hello", style);
2353        // Text::styled sets base style, not a span (matching Python behavior)
2354        assert_eq!(text.spans().len(), 0);
2355        assert_eq!(text.base_style(), Some(style));
2356    }
2357
2358    #[test]
2359    fn test_text_append() {
2360        let mut text = Text::new();
2361        text.append("hello ", None);
2362        text.append("world", Some(Style::new().with_bold(true)));
2363        assert_eq!(text.plain_text(), "hello world");
2364        assert_eq!(text.spans().len(), 1);
2365    }
2366
2367    #[test]
2368    fn test_text_append_text() {
2369        let mut text = Text::plain("Hello ");
2370        let other = Text::styled("World", Style::new().with_bold(true));
2371        text.append_text(&other);
2372
2373        assert_eq!(text.plain_text(), "Hello World");
2374        assert_eq!(text.spans().len(), 1);
2375        assert_eq!(text.spans()[0].start, 6);
2376        assert_eq!(text.spans()[0].end, 11);
2377    }
2378
2379    // ==================== Text::assemble tests ====================
2380
2381    #[test]
2382    fn test_text_assemble() {
2383        let bold = Style::new().with_bold(true);
2384        let text = Text::assemble([
2385            TextPart::Plain("Hello, ".to_string()),
2386            TextPart::Styled("World".to_string(), bold),
2387            TextPart::Plain("!".to_string()),
2388        ]);
2389
2390        assert_eq!(text.plain_text(), "Hello, World!");
2391        assert_eq!(text.spans().len(), 1);
2392        assert_eq!(text.spans()[0].start, 7);
2393        assert_eq!(text.spans()[0].end, 12);
2394    }
2395
2396    #[test]
2397    fn test_text_assemble_with_text() {
2398        let inner = Text::styled("styled", Style::new().with_italic(true));
2399        let text = Text::assemble([
2400            TextPart::Plain("prefix ".to_string()),
2401            TextPart::Text(inner),
2402            TextPart::Plain(" suffix".to_string()),
2403        ]);
2404
2405        assert_eq!(text.plain_text(), "prefix styled suffix");
2406    }
2407
2408    // ==================== stylize_range tests ====================
2409
2410    #[test]
2411    fn test_stylize_range_basic() {
2412        let mut text = Text::plain("Hello World");
2413        text.stylize_range(Style::new().with_bold(true), 0, Some(5));
2414
2415        assert_eq!(text.spans().len(), 1);
2416        assert_eq!(text.spans()[0].start, 0);
2417        assert_eq!(text.spans()[0].end, 5);
2418    }
2419
2420    #[test]
2421    fn test_stylize_range_negative_start() {
2422        let mut text = Text::plain("Hello World");
2423        text.stylize_range(Style::new().with_bold(true), -5, None);
2424
2425        assert_eq!(text.spans().len(), 1);
2426        assert_eq!(text.spans()[0].start, 6); // 11 - 5 = 6
2427        assert_eq!(text.spans()[0].end, 11);
2428    }
2429
2430    #[test]
2431    fn test_stylize_range_negative_end() {
2432        let mut text = Text::plain("Hello World");
2433        text.stylize_range(Style::new().with_bold(true), 0, Some(-6));
2434
2435        assert_eq!(text.spans().len(), 1);
2436        assert_eq!(text.spans()[0].start, 0);
2437        assert_eq!(text.spans()[0].end, 5); // 11 - 6 = 5
2438    }
2439
2440    #[test]
2441    fn test_stylize_range_none_end() {
2442        let mut text = Text::plain("Hello World");
2443        text.stylize_range(Style::new().with_bold(true), 6, None);
2444
2445        assert_eq!(text.spans().len(), 1);
2446        assert_eq!(text.spans()[0].start, 6);
2447        assert_eq!(text.spans()[0].end, 11);
2448    }
2449
2450    #[test]
2451    fn test_stylize_range_invalid() {
2452        let mut text = Text::plain("Hello");
2453        // Start after end
2454        text.stylize_range(Style::new().with_bold(true), 10, Some(5));
2455        assert!(text.spans().is_empty());
2456
2457        // Start >= length
2458        text.stylize_range(Style::new().with_bold(true), 10, None);
2459        assert!(text.spans().is_empty());
2460    }
2461
2462    // ==================== stylize_before tests ====================
2463
2464    #[test]
2465    fn test_stylize_before() {
2466        let mut text = Text::plain("Hello World");
2467        text.stylize_range(Style::new().with_bold(true), 0, None);
2468        text.stylize_before(Style::new().with_italic(true), 0, None);
2469
2470        // stylize_before should insert at beginning
2471        assert_eq!(text.spans().len(), 2);
2472        assert_eq!(text.spans()[0].style.italic, Some(true));
2473        assert_eq!(text.spans()[1].style.bold, Some(true));
2474    }
2475
2476    // ==================== highlight_regex tests ====================
2477
2478    #[test]
2479    fn test_highlight_regex_basic() {
2480        let mut text = Text::plain("foo bar foo baz");
2481        let count = text.highlight_regex(r"foo", Style::new().with_bold(true));
2482
2483        assert_eq!(count, 2);
2484        assert_eq!(text.spans().len(), 2);
2485    }
2486
2487    #[test]
2488    fn test_highlight_regex_no_match() {
2489        let mut text = Text::plain("hello world");
2490        let count = text.highlight_regex(r"xyz", Style::new().with_bold(true));
2491
2492        assert_eq!(count, 0);
2493        assert!(text.spans().is_empty());
2494    }
2495
2496    #[test]
2497    fn test_highlight_regex_invalid() {
2498        let mut text = Text::plain("hello world");
2499        let count = text.highlight_regex(r"[invalid", Style::new().with_bold(true));
2500
2501        assert_eq!(count, 0);
2502    }
2503
2504    // ==================== highlight_words tests ====================
2505
2506    #[test]
2507    fn test_highlight_words_basic() {
2508        let mut text = Text::plain("Hello World Hello");
2509        let count = text.highlight_words(&["Hello"], Style::new().with_bold(true), true);
2510
2511        assert_eq!(count, 2);
2512        assert_eq!(text.spans().len(), 2);
2513    }
2514
2515    #[test]
2516    fn test_highlight_words_case_insensitive() {
2517        let mut text = Text::plain("Hello HELLO hello");
2518        let count = text.highlight_words(&["hello"], Style::new().with_bold(true), false);
2519
2520        assert_eq!(count, 3);
2521    }
2522
2523    #[test]
2524    fn test_highlight_words_case_sensitive() {
2525        let mut text = Text::plain("Hello HELLO hello");
2526        let count = text.highlight_words(&["Hello"], Style::new().with_bold(true), true);
2527
2528        assert_eq!(count, 1);
2529    }
2530
2531    #[test]
2532    fn test_highlight_words_multiple() {
2533        let mut text = Text::plain("foo bar baz foo");
2534        let count = text.highlight_words(&["foo", "bar"], Style::new().with_bold(true), true);
2535
2536        assert_eq!(count, 3); // foo, bar, foo
2537    }
2538
2539    #[test]
2540    fn test_highlight_words_empty() {
2541        let mut text = Text::plain("hello");
2542        let count = text.highlight_words(&[], Style::new().with_bold(true), true);
2543
2544        assert_eq!(count, 0);
2545    }
2546
2547    // ==================== divide tests ====================
2548
2549    #[test]
2550    fn test_divide_empty_offsets() {
2551        let text = Text::plain("Hello World");
2552        let divided = text.divide([]);
2553
2554        assert_eq!(divided.len(), 1);
2555        assert_eq!(divided[0].plain_text(), "Hello World");
2556    }
2557
2558    #[test]
2559    fn test_divide_single_offset() {
2560        let text = Text::plain("Hello World");
2561        let divided = text.divide([5]);
2562
2563        assert_eq!(divided.len(), 2);
2564        assert_eq!(divided[0].plain_text(), "Hello");
2565        assert_eq!(divided[1].plain_text(), " World");
2566    }
2567
2568    #[test]
2569    fn test_divide_multiple_offsets() {
2570        let text = Text::plain("Hello World!");
2571        let divided = text.divide([5, 6]);
2572
2573        assert_eq!(divided.len(), 3);
2574        assert_eq!(divided[0].plain_text(), "Hello");
2575        assert_eq!(divided[1].plain_text(), " ");
2576        assert_eq!(divided[2].plain_text(), "World!");
2577    }
2578
2579    #[test]
2580    fn test_divide_with_spans() {
2581        let mut text = Text::plain("Hello World");
2582        text.stylize(0, 5, Style::new().with_bold(true));
2583
2584        let divided = text.divide([5]);
2585
2586        assert_eq!(divided.len(), 2);
2587        assert_eq!(divided[0].plain_text(), "Hello");
2588        assert_eq!(divided[0].spans().len(), 1);
2589        assert_eq!(divided[0].spans()[0].start, 0);
2590        assert_eq!(divided[0].spans()[0].end, 5);
2591
2592        assert_eq!(divided[1].plain_text(), " World");
2593        assert!(divided[1].spans().is_empty());
2594    }
2595
2596    #[test]
2597    fn test_divide_span_crosses_boundary() {
2598        let mut text = Text::plain("Hello World");
2599        // Span covers "llo Wo" (3-9)
2600        text.stylize(3, 9, Style::new().with_bold(true));
2601
2602        let divided = text.divide([5]);
2603
2604        // First part: "Hello" with span 3-5
2605        assert_eq!(divided[0].plain_text(), "Hello");
2606        assert_eq!(divided[0].spans().len(), 1);
2607        assert_eq!(divided[0].spans()[0].start, 3);
2608        assert_eq!(divided[0].spans()[0].end, 5);
2609
2610        // Second part: " World" with span 0-4 (was 5-9, offset by -5)
2611        assert_eq!(divided[1].plain_text(), " World");
2612        assert_eq!(divided[1].spans().len(), 1);
2613        assert_eq!(divided[1].spans()[0].start, 0);
2614        assert_eq!(divided[1].spans()[0].end, 4);
2615    }
2616
2617    // ==================== Renderable tests ====================
2618
2619    #[test]
2620    fn test_text_render_plain() {
2621        let text = Text::plain("Hello World");
2622        let console = Console::new();
2623        let options = ConsoleOptions::default();
2624
2625        let segments = text.render(&console, &options);
2626        assert_eq!(segments.len(), 1);
2627        assert_eq!(&*segments.iter().next().unwrap().text, "Hello World");
2628    }
2629
2630    #[test]
2631    fn test_text_render_styled() {
2632        let mut text = Text::plain("Hello World");
2633        text.stylize(0, 5, Style::new().with_bold(true));
2634
2635        let console = Console::new();
2636        let options = ConsoleOptions::default();
2637
2638        let segments = text.render(&console, &options);
2639        assert!(segments.len() >= 2);
2640    }
2641
2642    #[test]
2643    fn test_text_measure() {
2644        let text = Text::plain("Hello\nWorld!");
2645        let console = Console::new();
2646        let options = ConsoleOptions::default();
2647
2648        let measurement = text.measure(&console, &options);
2649        assert_eq!(measurement.maximum, 6); // "World!" is longest
2650        assert_eq!(measurement.minimum, 6); // "World!" is longest word
2651    }
2652
2653    // ==================== from_markup tests ====================
2654
2655    #[test]
2656    fn test_from_markup() {
2657        let text = Text::from_markup("[bold]Hello[/] World", false).unwrap();
2658        assert_eq!(text.plain_text(), "Hello World");
2659        assert!(!text.spans().is_empty());
2660    }
2661
2662    // ==================== join tests ====================
2663
2664    #[test]
2665    fn test_text_join() {
2666        let separator = Text::plain(", ");
2667        let texts = vec![Text::plain("a"), Text::plain("b"), Text::plain("c")];
2668
2669        let joined = separator.join(texts);
2670        assert_eq!(joined.plain_text(), "a, b, c");
2671    }
2672
2673    #[test]
2674    fn test_text_join_empty_separator() {
2675        let separator = Text::plain("");
2676        let texts = vec![Text::plain("a"), Text::plain("b")];
2677
2678        let joined = separator.join(texts);
2679        assert_eq!(joined.plain_text(), "ab");
2680    }
2681
2682    // ==================== Unicode tests ====================
2683
2684    #[test]
2685    fn test_text_unicode_len() {
2686        let text = Text::plain("你好");
2687        assert_eq!(text.len(), 2); // 2 characters
2688        assert_eq!(text.cell_len(), 4); // 4 cells (each CJK char is 2 wide)
2689    }
2690
2691    #[test]
2692    fn test_divide_unicode() {
2693        let text = Text::plain("你好世界");
2694        let divided = text.divide([2]);
2695
2696        assert_eq!(divided.len(), 2);
2697        assert_eq!(divided[0].plain_text(), "你好");
2698        assert_eq!(divided[1].plain_text(), "世界");
2699    }
2700
2701    // ==================== pad_right tests ====================
2702
2703    #[test]
2704    fn test_pad_right_basic() {
2705        let text = Text::plain("hello");
2706        let padded = text.pad_right(10);
2707        assert_eq!(padded.plain_text(), "hello     ");
2708        assert_eq!(padded.cell_len(), 10);
2709    }
2710
2711    #[test]
2712    fn test_pad_right_already_wide() {
2713        let text = Text::plain("hello world");
2714        let padded = text.pad_right(5);
2715        assert_eq!(padded.plain_text(), "hello world");
2716    }
2717
2718    #[test]
2719    fn test_pad_right_preserves_spans() {
2720        let mut text = Text::plain("hello");
2721        text.stylize(0, 5, Style::new().with_bold(true));
2722        let padded = text.pad_right(10);
2723        assert_eq!(padded.spans().len(), 1);
2724        assert_eq!(padded.spans()[0].start, 0);
2725        assert_eq!(padded.spans()[0].end, 5);
2726    }
2727
2728    // ==================== pad_left tests ====================
2729
2730    #[test]
2731    fn test_pad_left_basic() {
2732        let text = Text::plain("hello");
2733        let padded = text.pad_left(10);
2734        assert_eq!(padded.plain_text(), "     hello");
2735        assert_eq!(padded.cell_len(), 10);
2736    }
2737
2738    #[test]
2739    fn test_pad_left_shifts_spans() {
2740        let mut text = Text::plain("hello");
2741        text.stylize(0, 5, Style::new().with_bold(true));
2742        let padded = text.pad_left(10);
2743        assert_eq!(padded.spans().len(), 1);
2744        assert_eq!(padded.spans()[0].start, 5); // Shifted by padding
2745        assert_eq!(padded.spans()[0].end, 10);
2746    }
2747
2748    // ==================== center tests ====================
2749
2750    #[test]
2751    fn test_center_basic() {
2752        let text = Text::plain("hi");
2753        let centered = text.center(6);
2754        assert_eq!(centered.plain_text(), "  hi  ");
2755        assert_eq!(centered.cell_len(), 6);
2756    }
2757
2758    #[test]
2759    fn test_center_odd_padding() {
2760        let text = Text::plain("hi");
2761        let centered = text.center(7);
2762        // 5 total padding, 2 left, 3 right
2763        assert_eq!(centered.plain_text(), "  hi   ");
2764    }
2765
2766    #[test]
2767    fn test_center_shifts_spans() {
2768        let mut text = Text::plain("hi");
2769        text.stylize(0, 2, Style::new().with_bold(true));
2770        let centered = text.center(6);
2771        assert_eq!(centered.spans().len(), 1);
2772        assert_eq!(centered.spans()[0].start, 2);
2773        assert_eq!(centered.spans()[0].end, 4);
2774    }
2775
2776    // ==================== expand_tabs tests ====================
2777
2778    #[test]
2779    fn test_expand_tabs_basic() {
2780        let text = Text::plain("a\tb");
2781        let expanded = text.expand_tabs(4);
2782        assert_eq!(expanded.plain_text(), "a   b");
2783    }
2784
2785    #[test]
2786    fn test_expand_tabs_multiple() {
2787        let text = Text::plain("a\tbc\td");
2788        let expanded = text.expand_tabs(4);
2789        // "a" at pos 0, tab expands to 3 spaces (to reach pos 4)
2790        // "bc" at pos 4-5, tab expands to 2 spaces (to reach pos 8)
2791        // "d" at pos 8
2792        assert_eq!(expanded.plain_text(), "a   bc  d");
2793    }
2794
2795    #[test]
2796    fn test_expand_tabs_no_tabs() {
2797        let text = Text::plain("hello");
2798        let expanded = text.expand_tabs(4);
2799        assert_eq!(expanded.plain_text(), "hello");
2800    }
2801
2802    #[test]
2803    fn test_expand_tabs_preserves_spans() {
2804        let mut text = Text::plain("a\tb");
2805        text.stylize(0, 1, Style::new().with_bold(true)); // Style "a"
2806        text.stylize(2, 3, Style::new().with_italic(true)); // Style "b"
2807        let expanded = text.expand_tabs(4);
2808
2809        // "a" should still be styled at position 0
2810        // "b" should now be at position 4 (after "a   ")
2811        assert_eq!(expanded.spans().len(), 2);
2812        assert_eq!(expanded.spans()[0].start, 0);
2813        assert_eq!(expanded.spans()[0].end, 1);
2814        assert_eq!(expanded.spans()[1].start, 4);
2815        assert_eq!(expanded.spans()[1].end, 5);
2816    }
2817
2818    // ==================== rstrip tests ====================
2819
2820    #[test]
2821    fn test_rstrip_basic() {
2822        let text = Text::plain("hello   ");
2823        let stripped = text.rstrip();
2824        assert_eq!(stripped.plain_text(), "hello");
2825    }
2826
2827    #[test]
2828    fn test_rstrip_no_whitespace() {
2829        let text = Text::plain("hello");
2830        let stripped = text.rstrip();
2831        assert_eq!(stripped.plain_text(), "hello");
2832    }
2833
2834    #[test]
2835    fn test_rstrip_adjusts_spans() {
2836        let mut text = Text::plain("hello   ");
2837        text.stylize(0, 8, Style::new().with_bold(true));
2838        let stripped = text.rstrip();
2839        assert_eq!(stripped.spans().len(), 1);
2840        assert_eq!(stripped.spans()[0].end, 5); // Clamped to new length
2841    }
2842
2843    // ==================== rstrip_end tests ====================
2844
2845    #[test]
2846    fn test_rstrip_end_basic() {
2847        let text = Text::plain("hello   ");
2848        let stripped = text.rstrip_end(5);
2849        assert_eq!(stripped.plain_text(), "hello");
2850    }
2851
2852    #[test]
2853    fn test_rstrip_end_partial() {
2854        let text = Text::plain("hello   ");
2855        let stripped = text.rstrip_end(7);
2856        // Only removes 1 trailing space (8 - 7 = 1)
2857        assert_eq!(stripped.plain_text(), "hello  ");
2858    }
2859
2860    #[test]
2861    fn test_rstrip_end_already_short() {
2862        let text = Text::plain("hello");
2863        let stripped = text.rstrip_end(10);
2864        assert_eq!(stripped.plain_text(), "hello");
2865    }
2866
2867    // ==================== truncate tests ====================
2868
2869    #[test]
2870    fn test_truncate_crop() {
2871        use crate::console::OverflowMethod;
2872        let text = Text::plain("hello world");
2873        let truncated = text.truncate(5, OverflowMethod::Crop, false);
2874        assert_eq!(truncated.plain_text(), "hello");
2875    }
2876
2877    #[test]
2878    fn test_truncate_ellipsis() {
2879        use crate::console::OverflowMethod;
2880        let text = Text::plain("hello world");
2881        let truncated = text.truncate(6, OverflowMethod::Ellipsis, false);
2882        assert_eq!(truncated.plain_text(), "hello…");
2883    }
2884
2885    #[test]
2886    fn test_truncate_with_pad() {
2887        use crate::console::OverflowMethod;
2888        let text = Text::plain("hi");
2889        let truncated = text.truncate(5, OverflowMethod::Crop, true);
2890        assert_eq!(truncated.plain_text(), "hi   ");
2891    }
2892
2893    // ==================== split tests ====================
2894
2895    #[test]
2896    fn test_split_newlines() {
2897        let text = Text::plain("hello\nworld");
2898        let lines = text.split("\n", false, false);
2899        assert_eq!(lines.len(), 2);
2900        assert_eq!(lines[0].plain_text(), "hello");
2901        assert_eq!(lines[1].plain_text(), "world");
2902    }
2903
2904    #[test]
2905    fn test_split_include_separator() {
2906        let text = Text::plain("hello\nworld");
2907        let lines = text.split("\n", true, false);
2908        assert_eq!(lines.len(), 2);
2909        assert_eq!(lines[0].plain_text(), "hello\n");
2910        assert_eq!(lines[1].plain_text(), "world");
2911    }
2912
2913    #[test]
2914    fn test_split_allow_blank() {
2915        let text = Text::plain("hello\n");
2916        let lines = text.split("\n", false, true);
2917        assert_eq!(lines.len(), 2);
2918        assert_eq!(lines[0].plain_text(), "hello");
2919        assert_eq!(lines[1].plain_text(), "");
2920    }
2921
2922    // ==================== wrap tests ====================
2923
2924    #[test]
2925    fn test_wrap_basic() {
2926        let text = Text::plain("hello world test");
2927        let lines = text.wrap(6, None, None, 8, false);
2928        assert_eq!(lines.len(), 3);
2929        assert_eq!(lines[0].plain_text(), "hello ");
2930        assert_eq!(lines[1].plain_text(), "world ");
2931        assert_eq!(lines[2].plain_text(), "test");
2932    }
2933
2934    #[test]
2935    fn test_wrap_existing_newlines() {
2936        let text = Text::plain("hello\nworld");
2937        let lines = text.wrap(20, None, None, 8, false);
2938        assert_eq!(lines.len(), 2);
2939        assert_eq!(lines[0].plain_text(), "hello");
2940        assert_eq!(lines[1].plain_text(), "world");
2941    }
2942
2943    #[test]
2944    fn test_wrap_left_justify() {
2945        use crate::console::JustifyMethod;
2946        let text = Text::plain("hi");
2947        let lines = text.wrap(5, Some(JustifyMethod::Left), None, 8, false);
2948        assert_eq!(lines.len(), 1);
2949        assert_eq!(lines[0].plain_text(), "hi   ");
2950    }
2951
2952    #[test]
2953    fn test_wrap_right_justify() {
2954        use crate::console::JustifyMethod;
2955        let text = Text::plain("hi");
2956        let lines = text.wrap(5, Some(JustifyMethod::Right), None, 8, false);
2957        assert_eq!(lines.len(), 1);
2958        assert_eq!(lines[0].plain_text(), "   hi");
2959    }
2960
2961    #[test]
2962    fn test_wrap_center_justify() {
2963        use crate::console::JustifyMethod;
2964        let text = Text::plain("hi");
2965        let lines = text.wrap(6, Some(JustifyMethod::Center), None, 8, false);
2966        assert_eq!(lines.len(), 1);
2967        assert_eq!(lines[0].plain_text(), "  hi  ");
2968    }
2969
2970    #[test]
2971    fn test_wrap_fold_long_word() {
2972        use crate::console::OverflowMethod;
2973        let text = Text::plain("abcdefghij");
2974        let lines = text.wrap(4, None, Some(OverflowMethod::Fold), 8, false);
2975        assert_eq!(lines.len(), 3);
2976        assert_eq!(lines[0].plain_text(), "abcd");
2977        assert_eq!(lines[1].plain_text(), "efgh");
2978        assert_eq!(lines[2].plain_text(), "ij");
2979    }
2980
2981    #[test]
2982    fn test_wrap_no_wrap() {
2983        let text = Text::plain("hello world");
2984        let lines = text.wrap(5, None, None, 8, true);
2985        assert_eq!(lines.len(), 1);
2986        assert_eq!(lines[0].plain_text(), "hello world");
2987    }
2988
2989    #[test]
2990    fn test_render_no_wrap_still_applies_justify() {
2991        use crate::Console;
2992        use crate::console::{ConsoleOptions, JustifyMethod};
2993
2994        let console = Console::new();
2995        let options = ConsoleOptions {
2996            max_width: 6,
2997            justify: Some(JustifyMethod::Center),
2998            no_wrap: true,
2999            ..Default::default()
3000        };
3001
3002        let text = Text::plain("hi");
3003        let segments = text.render(&console, &options);
3004        let rendered: String = segments.iter().map(|s| s.text.as_ref()).collect();
3005        assert_eq!(rendered, "  hi  ");
3006    }
3007
3008    #[test]
3009    fn test_justify_full_distributes_extra_spaces_right_to_left() {
3010        // Words are "a", "b", "c" => 3 chars.
3011        // Width 8 means we need 5 spaces total between words.
3012        // Python Rich distributes extra spaces from right-to-left.
3013        // With 2 gaps, that yields left gap 2 spaces, right gap 3 spaces.
3014        let text = Text::plain("a b c");
3015        let justified = text.justify_full(8);
3016        assert_eq!(justified.plain_text(), "a  b   c");
3017    }
3018
3019    #[test]
3020    fn test_wrap_with_tabs() {
3021        let text = Text::plain("a\tb");
3022        let lines = text.wrap(20, None, None, 4, false);
3023        assert_eq!(lines.len(), 1);
3024        assert_eq!(lines[0].plain_text(), "a   b");
3025    }
3026
3027    #[test]
3028    fn test_wrap_preserves_spans() {
3029        let mut text = Text::plain("hello world");
3030        text.stylize(0, 5, Style::new().with_bold(true));
3031        let lines = text.wrap(6, None, None, 8, false);
3032
3033        assert_eq!(lines.len(), 2);
3034        // First line "hello " should have the bold span
3035        assert!(!lines[0].spans().is_empty());
3036        assert_eq!(lines[0].spans()[0].style.bold, Some(true));
3037    }
3038
3039    #[test]
3040    fn test_wrap_cjk() {
3041        let text = Text::plain("你好世界");
3042        // Each CJK char is 2 cells, so with width 5, we can fit 2 chars (4 cells)
3043        let lines = text.wrap(5, None, None, 8, false);
3044        assert_eq!(lines.len(), 2);
3045        assert_eq!(lines[0].plain_text(), "你好");
3046        assert_eq!(lines[1].plain_text(), "世界");
3047    }
3048
3049    #[test]
3050    fn test_wrap_full_justify() {
3051        use crate::console::JustifyMethod;
3052        let text = Text::plain("a b c");
3053        let lines = text.wrap(7, Some(JustifyMethod::Full), None, 8, false);
3054        assert_eq!(lines.len(), 1);
3055        // Last line should be left-aligned, not full justified
3056        assert_eq!(lines[0].plain_text(), "a b c  ");
3057    }
3058
3059    #[test]
3060    fn test_wrap_full_justify_multiline() {
3061        use crate::console::JustifyMethod;
3062        let text = Text::plain("a b c d e");
3063        let lines = text.wrap(5, Some(JustifyMethod::Full), None, 8, false);
3064        // Should have multiple lines, with full justification on non-last lines
3065        assert!(lines.len() >= 2);
3066    }
3067
3068    // ==================== slice tests ====================
3069
3070    #[test]
3071    fn test_slice_basic() {
3072        let text = Text::plain("Hello World");
3073        let sliced = text.slice(0, 5);
3074        assert_eq!(sliced.plain_text(), "Hello");
3075    }
3076
3077    #[test]
3078    fn test_slice_middle() {
3079        let text = Text::plain("Hello World");
3080        let sliced = text.slice(6, 11);
3081        assert_eq!(sliced.plain_text(), "World");
3082    }
3083
3084    #[test]
3085    fn test_slice_preserves_spans() {
3086        let mut text = Text::plain("Hello World");
3087        text.stylize(0, 5, Style::new().with_bold(true));
3088
3089        let sliced = text.slice(0, 5);
3090        assert_eq!(sliced.plain_text(), "Hello");
3091        assert!(!sliced.spans().is_empty());
3092        assert_eq!(sliced.spans()[0].start, 0);
3093        assert_eq!(sliced.spans()[0].end, 5);
3094    }
3095
3096    #[test]
3097    fn test_slice_clips_span() {
3098        let mut text = Text::plain("Hello World");
3099        text.stylize(3, 8, Style::new().with_bold(true));
3100
3101        let sliced = text.slice(0, 5);
3102        assert_eq!(sliced.plain_text(), "Hello");
3103        assert!(!sliced.spans().is_empty());
3104        assert_eq!(sliced.spans()[0].start, 3);
3105        assert_eq!(sliced.spans()[0].end, 5);
3106    }
3107
3108    #[test]
3109    fn test_slice_empty_range() {
3110        let text = Text::plain("Hello World");
3111        let sliced = text.slice(5, 5);
3112        assert_eq!(sliced.plain_text(), "");
3113    }
3114
3115    // ==================== align tests ====================
3116
3117    #[test]
3118    fn test_align_left() {
3119        use crate::console::JustifyMethod;
3120        let mut text = Text::plain("hi");
3121        text.align(JustifyMethod::Left, 6);
3122        assert_eq!(text.plain_text(), "hi    ");
3123    }
3124
3125    #[test]
3126    fn test_align_right() {
3127        use crate::console::JustifyMethod;
3128        let mut text = Text::plain("hi");
3129        text.align(JustifyMethod::Right, 6);
3130        assert_eq!(text.plain_text(), "    hi");
3131    }
3132
3133    #[test]
3134    fn test_align_center() {
3135        use crate::console::JustifyMethod;
3136        let mut text = Text::plain("hi");
3137        text.align(JustifyMethod::Center, 6);
3138        assert_eq!(text.plain_text(), "  hi  ");
3139    }
3140
3141    // ==================== contains tests ====================
3142
3143    #[test]
3144    fn test_contains_true() {
3145        let text = Text::plain("Hello World");
3146        assert!(text.contains("World"));
3147    }
3148
3149    #[test]
3150    fn test_contains_false() {
3151        let text = Text::plain("Hello World");
3152        assert!(!text.contains("xyz"));
3153    }
3154
3155    // ==================== get_style_at_offset tests ====================
3156
3157    #[test]
3158    fn test_get_style_at_offset() {
3159        let mut text = Text::plain("Hello World");
3160        let bold = Style::new().with_bold(true);
3161        text.stylize(0, 5, bold);
3162
3163        let style_at_0 = text.get_style_at_offset(0);
3164        assert_eq!(style_at_0.bold, Some(true));
3165
3166        let style_at_6 = text.get_style_at_offset(6);
3167        assert_ne!(style_at_6.bold, Some(true));
3168    }
3169
3170    // ==================== set_length tests ====================
3171
3172    #[test]
3173    fn test_set_length_pad() {
3174        let mut text = Text::plain("hi");
3175        text.set_length(5);
3176        assert_eq!(text.cell_len(), 5);
3177        assert!(text.plain_text().starts_with("hi"));
3178    }
3179
3180    #[test]
3181    fn test_set_length_crop() {
3182        let mut text = Text::plain("hello world");
3183        text.set_length(5);
3184        assert_eq!(text.plain_text(), "hello");
3185    }
3186
3187    // ==================== right_crop tests ====================
3188
3189    #[test]
3190    fn test_text_right_crop() {
3191        let mut text = Text::plain("Hello World");
3192        text.right_crop(6);
3193        assert_eq!(text.plain_text(), "Hello");
3194    }
3195
3196    #[test]
3197    fn test_text_right_crop_adjusts_spans() {
3198        let mut text = Text::plain("Hello World");
3199        text.stylize(0, 11, Style::new().with_bold(true));
3200        text.right_crop(6);
3201        assert_eq!(text.spans()[0].end, 5);
3202    }
3203
3204    // ==================== fit tests ====================
3205
3206    #[test]
3207    fn test_fit_basic() {
3208        let text = Text::plain("Hello\nWorld");
3209        let lines = text.fit(10);
3210        assert_eq!(lines.len(), 2);
3211        assert_eq!(lines[0].cell_len(), 10);
3212        assert_eq!(lines[1].cell_len(), 10);
3213    }
3214
3215    // ==================== remove_suffix tests ====================
3216
3217    #[test]
3218    fn test_remove_suffix_found() {
3219        let mut text = Text::plain("Hello World");
3220        text.remove_suffix(" World");
3221        assert_eq!(text.plain_text(), "Hello");
3222    }
3223
3224    #[test]
3225    fn test_remove_suffix_not_found() {
3226        let mut text = Text::plain("Hello World");
3227        text.remove_suffix("xyz");
3228        assert_eq!(text.plain_text(), "Hello World");
3229    }
3230
3231    // ==================== extend_style tests ====================
3232
3233    #[test]
3234    fn test_extend_style() {
3235        let mut text = Text::plain("hello");
3236        text.stylize(0, 5, Style::new().with_bold(true));
3237        text.extend_style(3);
3238        assert_eq!(text.len(), 8);
3239        assert_eq!(text.spans()[0].end, 8);
3240    }
3241
3242    // ==================== detect_indentation tests ====================
3243
3244    #[test]
3245    fn test_detect_indentation() {
3246        let text = Text::plain("  foo\n    bar\n      baz");
3247        let indent = text.detect_indentation();
3248        assert_eq!(indent, 2);
3249    }
3250
3251    // ==================== copy_styles tests ====================
3252
3253    #[test]
3254    fn test_copy_styles() {
3255        let mut text = Text::plain("Hello World");
3256        let mut other = Text::plain("Hello World");
3257        other.stylize(0, 5, Style::new().with_bold(true));
3258
3259        text.copy_styles(&other);
3260        assert_eq!(text.spans().len(), 1);
3261    }
3262
3263    // ==================== apply_meta tests ====================
3264
3265    #[test]
3266    fn test_apply_meta_adds_metadata_span() {
3267        let mut text = Text::plain("Hello World");
3268        let mut meta = BTreeMap::new();
3269        meta.insert("foo".to_string(), crate::style::MetaValue::str("bar"));
3270
3271        text.apply_meta(meta, 0, Some(5));
3272
3273        assert_eq!(text.spans().len(), 1);
3274        let span = &text.spans()[0];
3275        assert_eq!(span.start, 0);
3276        assert_eq!(span.end, 5);
3277        assert!(span.style.is_null());
3278        let span_meta = span.meta.as_ref().and_then(|m| m.meta.as_ref());
3279        assert!(span_meta.is_some());
3280        assert_eq!(
3281            span_meta.and_then(|m| m.get("foo")),
3282            Some(&crate::style::MetaValue::str("bar"))
3283        );
3284    }
3285
3286    #[test]
3287    fn test_apply_meta_supports_negative_offsets() {
3288        let mut text = Text::plain("abcdef");
3289        let mut meta = BTreeMap::new();
3290        meta.insert(
3291            "@click".to_string(),
3292            crate::style::MetaValue::str("handler"),
3293        );
3294
3295        text.apply_meta(meta, -3, None);
3296
3297        assert_eq!(text.spans().len(), 1);
3298        assert_eq!(text.spans()[0].start, 3);
3299        assert_eq!(text.spans()[0].end, 6);
3300    }
3301
3302    // ==================== append_tokens tests ====================
3303
3304    #[test]
3305    fn test_append_tokens_appends_content_and_styles() {
3306        let mut text = Text::plain("A");
3307        let bold = Style::new().with_bold(true);
3308        let italic = Style::new().with_italic(true);
3309
3310        text.append_tokens(vec![("B", Some(bold)), ("C", None), ("D", Some(italic))]);
3311
3312        assert_eq!(text.plain_text(), "ABCD");
3313        assert_eq!(text.spans().len(), 2);
3314        assert_eq!(text.spans()[0].start, 1);
3315        assert_eq!(text.spans()[0].end, 2);
3316        assert_eq!(text.spans()[0].style.bold, Some(true));
3317        assert_eq!(text.spans()[1].start, 3);
3318        assert_eq!(text.spans()[1].end, 4);
3319        assert_eq!(text.spans()[1].style.italic, Some(true));
3320    }
3321
3322    #[test]
3323    fn test_append_tokens_strips_control_codes() {
3324        let mut text = Text::new();
3325        let bold = Style::new().with_bold(true);
3326
3327        text.append_tokens(vec![("a\x07b", Some(bold))]);
3328
3329        assert_eq!(text.plain_text(), "ab");
3330        assert_eq!(text.spans().len(), 1);
3331        assert_eq!(text.spans()[0].start, 0);
3332        assert_eq!(text.spans()[0].end, 2);
3333    }
3334
3335    // ==================== Add trait tests ====================
3336
3337    #[test]
3338    fn test_add_text() {
3339        let a = Text::plain("Hello ");
3340        let b = Text::plain("World");
3341        let result = a + b;
3342        assert_eq!(result.plain_text(), "Hello World");
3343    }
3344
3345    #[test]
3346    fn test_add_str() {
3347        let a = Text::plain("Hello ");
3348        let result = a + "World";
3349        assert_eq!(result.plain_text(), "Hello World");
3350    }
3351
3352    // ==================== to_markup tests ====================
3353
3354    #[test]
3355    fn test_to_markup_plain() {
3356        let text = Text::plain("Hello World");
3357        assert_eq!(text.to_markup(), "Hello World");
3358    }
3359
3360    #[test]
3361    fn test_to_markup_with_style() {
3362        let mut text = Text::plain("Hello");
3363        text.stylize(0, 5, Style::new().with_bold(true));
3364        let markup = text.to_markup();
3365        assert!(markup.contains("[bold]"));
3366        assert!(markup.contains("[/bold]"));
3367        assert!(markup.contains("Hello"));
3368    }
3369}