Skip to main content

rich/
text.rs

1//! Styled text with spans.
2//!
3//! Port of upstream `rich/text.py` (core subset). [`Text`] is a plain string
4//! plus a list of [`Span`]s, each applying a [`Style`] to a byte range. Spans
5//! may overlap and nest; [`Text::render`] flattens them into non-overlapping
6//! [`Segment`]s by combining every span covering each run.
7
8use crate::cells::{cell_len, set_cell_size};
9use crate::console::{Justify, Overflow};
10use crate::errors::Result;
11use crate::markup;
12use crate::segment::Segment;
13use crate::style::{Style, StyleType};
14use crate::theme::Theme;
15
16/// The control codes upstream drops in `Text.__init__` (`strip_control_codes`):
17/// BEL, backspace, vertical tab, form feed and carriage return. Tab and newline
18/// are layout, not control, and are kept.
19///
20/// Crate-visible because **every** producer of a `Text` plus its spans must agree
21/// on this set. `markup::render` computes span byte-offsets as it builds the
22/// plain string; if it kept a code that `Text::new` later removed, the content
23/// would shift left while the offsets stayed put, and a boundary landing inside
24/// a multi-byte character panics on slicing.
25pub(crate) fn is_control_code(c: char) -> bool {
26    matches!(c, '\u{7}' | '\u{8}' | '\u{b}' | '\u{c}' | '\r')
27}
28
29/// Cell width of a tab stop: upstream's `Console.tab_size` default, and the
30/// fallback when neither a text nor its console sets one (`tab_size or 8`).
31pub const DEFAULT_TAB_SIZE: usize = 8;
32
33/// A style applied to a byte range `[start, end)` of a [`Text`]'s plain string.
34/// Mirrors `rich.text.Span`.
35///
36/// The style may be a *name* rather than a resolved [`Style`]; see [`StyleType`].
37/// Names are resolved when the text is rendered, against the theme of whichever
38/// console renders it.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Span {
41    pub start: usize,
42    pub end: usize,
43    pub style: StyleType,
44}
45
46/// Styled text. Mirrors `rich.text.Text`.
47#[derive(Debug, Clone, Default)]
48pub struct Text {
49    plain: String,
50    spans: Vec<Span>,
51    /// A base style applied to the whole text. May be an unresolved name.
52    style: StyleType,
53    /// How lines are justified within the render width. `None` defers to
54    /// the console options (upstream's `justify=None`); `Some(Default)` is an
55    /// explicit `justify="default"`, which a container's justify does not
56    /// override.
57    justify: Option<Justify>,
58    /// What to do with lines wider than the render width. `None` defers to the
59    /// console options, then to [`Overflow::Fold`].
60    overflow: Option<Overflow>,
61    /// Whether to skip wrapping. `None` defers to the console options, then to
62    /// `false`.
63    no_wrap: Option<bool>,
64    /// Tab stop width. `None` defers to the console's `tab_size` when
65    /// rendered, then to [`DEFAULT_TAB_SIZE`]. Upstream's `Text.tab_size`.
66    tab_size: Option<usize>,
67}
68
69impl Text {
70    /// Strip the control codes upstream removes in `Text.__init__`
71    /// (`strip_control_codes`): BEL, backspace, vertical tab, form feed and
72    /// carriage return. Tab and newline are deliberately kept — they are layout,
73    /// not control.
74    fn strip_control_codes(text: &str) -> String {
75        if text.chars().any(is_control_code) {
76            text.chars().filter(|c| !is_control_code(*c)).collect()
77        } else {
78            text.to_string()
79        }
80    }
81
82    /// Plain, unstyled text.
83    pub fn new(plain: impl Into<String>) -> Self {
84        Text {
85            plain: Text::strip_control_codes(&plain.into()),
86            spans: Vec::new(),
87            style: StyleType::default(),
88            justify: None,
89            overflow: None,
90            no_wrap: None,
91            tab_size: None,
92        }
93    }
94
95    /// Text with a base style, which may be a style *name* resolved at render
96    /// time (`Text::styled("hi", "repr.number")`) or a resolved [`Style`].
97    pub fn styled(plain: impl Into<String>, style: impl Into<StyleType>) -> Self {
98        Text {
99            // Strips too: upstream's `Text.__init__` does this regardless of
100            // style, and a constructor that skipped it would reintroduce the
101            // offset divergence the moment a caller added spans.
102            plain: Text::strip_control_codes(&plain.into()),
103            spans: Vec::new(),
104            style: style.into(),
105            justify: None,
106            overflow: None,
107            no_wrap: None,
108            tab_size: None,
109        }
110    }
111
112    /// Set how lines are justified within the render width (builder form).
113    pub fn justify(mut self, justify: Justify) -> Self {
114        self.set_justify(justify);
115        self
116    }
117
118    /// Set how lines are justified within the render width.
119    ///
120    /// [`Justify::Default`] means *unset* here (upstream's `justify=None`),
121    /// deferring to the console options; use
122    /// [`set_justify_option`](Self::set_justify_option) for an explicit
123    /// `justify="default"`.
124    pub fn set_justify(&mut self, justify: Justify) {
125        self.justify = (justify != Justify::Default).then_some(justify);
126    }
127
128    /// This text's own justify method ([`Justify::Default`] when unset).
129    pub fn get_justify(&self) -> Justify {
130        self.justify.unwrap_or_default()
131    }
132
133    /// Set this text's justify, distinguishing an explicit
134    /// `Some(Justify::Default)` (upstream's `justify="default"`, which a
135    /// table column's or the print call's justify does not override) from
136    /// `None` (upstream's `justify=None`, which defers to them).
137    pub fn set_justify_option(&mut self, justify: Option<Justify>) {
138        self.justify = justify;
139    }
140
141    /// This text's own justify, `None` when unset. See
142    /// [`set_justify_option`](Self::set_justify_option).
143    pub fn get_justify_option(&self) -> Option<Justify> {
144        self.justify
145    }
146
147    /// Set what happens to lines wider than the render width (builder form).
148    pub fn overflow(mut self, overflow: Overflow) -> Self {
149        self.overflow = Some(overflow);
150        self
151    }
152
153    /// Set what happens to lines wider than the render width. Pass `None` to
154    /// defer to the console options.
155    pub fn set_overflow(&mut self, overflow: Option<Overflow>) {
156        self.overflow = overflow;
157    }
158
159    /// This text's own overflow method, if it set one.
160    pub fn get_overflow(&self) -> Option<Overflow> {
161        self.overflow
162    }
163
164    /// Disable (or re-enable) wrapping for this text (builder form).
165    pub fn no_wrap(mut self, no_wrap: bool) -> Self {
166        self.no_wrap = Some(no_wrap);
167        self
168    }
169
170    /// Disable (or re-enable) wrapping. Pass `None` to defer to the console
171    /// options.
172    pub fn set_no_wrap(&mut self, no_wrap: Option<bool>) {
173        self.no_wrap = no_wrap;
174    }
175
176    /// This text's own no-wrap setting, if it set one.
177    pub fn get_no_wrap(&self) -> Option<bool> {
178        self.no_wrap
179    }
180
181    /// Set the tab stop width (builder form). Port of `Text(tab_size=…)`.
182    pub fn tab_size(mut self, tab_size: usize) -> Self {
183        self.tab_size = Some(tab_size);
184        self
185    }
186
187    /// Set the tab stop width. Pass `None` to defer to the console's
188    /// `tab_size`. Upstream's `Text.tab_size` attribute.
189    pub fn set_tab_size(&mut self, tab_size: Option<usize>) {
190        self.tab_size = tab_size;
191    }
192
193    /// This text's own tab stop width, if it set one.
194    pub fn get_tab_size(&self) -> Option<usize> {
195        self.tab_size
196    }
197
198    /// Shorten this text to at most `max_width` cells, optionally padding it out
199    /// to exactly `max_width` when it is shorter. Port of `Text.truncate`.
200    ///
201    /// `overflow` defaults to this text's own method, then to [`Overflow::Fold`];
202    /// [`Overflow::Ignore`] leaves the text alone entirely. Note that `Fold` and
203    /// `Crop` behave identically here — folding is a property of *wrapping*, and
204    /// a line that has already been wrapped can only be cut.
205    pub fn truncate(&mut self, max_width: usize, overflow: Option<Overflow>, pad: bool) {
206        let overflow = overflow.or(self.overflow).unwrap_or(Overflow::Fold);
207        if overflow == Overflow::Ignore {
208            return;
209        }
210        let length = cell_len(&self.plain);
211        if length > max_width {
212            let plain = if overflow == Overflow::Ellipsis {
213                // `…` is one cell wide, so cut one short and add it back.
214                format!(
215                    "{}…",
216                    set_cell_size(&self.plain, max_width.saturating_sub(1))
217                )
218            } else {
219                set_cell_size(&self.plain, max_width)
220            };
221            self.set_plain(plain);
222        } else if pad {
223            let plain = set_cell_size(&self.plain, max_width);
224            self.set_plain(plain);
225        }
226    }
227
228    /// Replace the plain string, clamping every span into the new length so no
229    /// span can dangle past the end. Upstream's `Text.plain` setter does the
230    /// same via `_trim_spans`.
231    fn set_plain(&mut self, plain: String) {
232        // Upstream spans use character offsets. A truncation may replace a
233        // wide glyph with padding or ASCII with a multibyte ellipsis, so byte
234        // offsets alone cannot be clamped into the replacement string.
235        if !self.spans.is_empty()
236            && !self.plain.starts_with(&plain)
237            && !plain.starts_with(&self.plain)
238        {
239            let old_offsets: Vec<usize> = self
240                .plain
241                .char_indices()
242                .map(|(i, _)| i)
243                .chain(std::iter::once(self.plain.len()))
244                .collect();
245            let new_offsets: Vec<usize> = plain
246                .char_indices()
247                .map(|(i, _)| i)
248                .chain(std::iter::once(plain.len()))
249                .collect();
250            let map_offset = |offset: usize| {
251                let character = old_offsets.partition_point(|old| *old < offset);
252                new_offsets[character.min(new_offsets.len() - 1)]
253            };
254            for span in &mut self.spans {
255                span.start = map_offset(span.start);
256                span.end = map_offset(span.end);
257            }
258        }
259        let length = plain.len();
260        self.plain = plain;
261        self.spans.retain(|span| span.start < length);
262        for span in &mut self.spans {
263            span.end = span.end.min(length);
264        }
265    }
266
267    /// An empty `Text` carrying this one's style, justify, overflow and no-wrap.
268    /// Port of `Text.blank_copy`.
269    pub fn blank_copy(&self) -> Text {
270        Text {
271            plain: String::new(),
272            spans: Vec::new(),
273            style: self.style.clone(),
274            justify: self.justify,
275            overflow: self.overflow,
276            no_wrap: self.no_wrap,
277            tab_size: self.tab_size,
278        }
279    }
280
281    /// Cut this text at each byte offset in `offsets`, returning the pieces.
282    /// Port of `Text.divide`.
283    ///
284    /// Every piece inherits the base style, justify, overflow and no-wrap, and
285    /// each span is re-based onto the pieces it covers. Spans that would come out
286    /// empty are dropped, matching upstream's `new_end > new_start`.
287    ///
288    /// Offsets are **byte** offsets (as everywhere else in this port's span
289    /// arithmetic) and must fall on `char` boundaries.
290    pub fn divide(&self, offsets: &[usize]) -> Vec<Text> {
291        if offsets.is_empty() {
292            return vec![self.clone()];
293        }
294        let mut bounds = Vec::with_capacity(offsets.len() + 2);
295        bounds.push(0);
296        bounds.extend(offsets.iter().copied());
297        bounds.push(self.plain.len());
298
299        let mut lines: Vec<Text> = bounds
300            .windows(2)
301            .map(|w| {
302                let (start, end) = (w[0].min(self.plain.len()), w[1].min(self.plain.len()));
303                let mut line = self.blank_copy();
304                if start < end {
305                    line.plain = self.plain[start..end].to_string();
306                }
307                line
308            })
309            .collect();
310
311        // Upstream binary-searches for the first and last line a span touches
312        // rather than visiting every line for every span, which made
313        // splitting a highlighted file quadratic in its line count. Offsets
314        // from `split` are sorted; for anything else keep the full scan, whose
315        // result the search would not reproduce.
316        let sorted = bounds.windows(2).all(|w| w[0] <= w[1]);
317        for span in &self.spans {
318            let lines_touched = if sorted {
319                // Lines ending at or before the span starts cannot hold it,
320                // nor can lines starting at or after it ends.
321                let first = bounds[1..].partition_point(|&end| end <= span.start);
322                let last = bounds[..bounds.len() - 1].partition_point(|&start| start < span.end);
323                first..last.max(first)
324            } else {
325                0..bounds.len() - 1
326            };
327            for index in lines_touched {
328                let (line_start, line_end) = (bounds[index], bounds[index + 1]);
329                let new_start = span.start.max(line_start) - line_start;
330                let new_end = span.end.min(line_end).saturating_sub(line_start);
331                if new_end > new_start {
332                    lines[index].spans.push(Span {
333                        start: new_start,
334                        end: new_end,
335                        style: span.style.clone(),
336                    });
337                }
338            }
339        }
340        lines
341    }
342
343    /// Split on `separator`. Port of `Text.split`.
344    ///
345    /// `include_separator` keeps the separator at the end of each piece.
346    /// `allow_blank` keeps the trailing empty piece that a text ending in the
347    /// separator would otherwise produce.
348    ///
349    /// # Panics
350    /// If `separator` is empty, which upstream asserts against.
351    pub fn split(&self, separator: &str, include_separator: bool, allow_blank: bool) -> Vec<Text> {
352        assert!(!separator.is_empty(), "separator must not be empty");
353        if !self.plain.contains(separator) {
354            return vec![self.clone()];
355        }
356        let matches: Vec<usize> = self
357            .plain
358            .match_indices(separator)
359            .map(|(i, _)| i)
360            .collect();
361        let mut lines = if include_separator {
362            let offsets: Vec<usize> = matches.iter().map(|s| s + separator.len()).collect();
363            self.divide(&offsets)
364        } else {
365            // Cut on both sides of every separator, then drop the separators.
366            let mut offsets = Vec::with_capacity(matches.len() * 2);
367            for start in &matches {
368                offsets.push(*start);
369                offsets.push(start + separator.len());
370            }
371            self.divide(&offsets)
372                .into_iter()
373                .filter(|line| line.plain != separator)
374                .collect()
375        };
376        if !allow_blank && self.plain.ends_with(separator) {
377            lines.pop();
378        }
379        lines
380    }
381
382    /// Pad both sides with `count` copies of `character`. Port of `Text.pad`.
383    pub fn pad(&mut self, count: usize, character: char) {
384        self.pad_left(count, character);
385        self.pad_right(count, character);
386    }
387
388    /// Pad the left with `count` copies of `character`, shifting every span to
389    /// follow the text. Port of `Text.pad_left`.
390    pub fn pad_left(&mut self, count: usize, character: char) {
391        if count == 0 {
392            return;
393        }
394        let padding: String = std::iter::repeat_n(character, count).collect();
395        let offset = padding.len();
396        self.plain.insert_str(0, &padding);
397        for span in &mut self.spans {
398            span.start += offset;
399            span.end += offset;
400        }
401    }
402
403    /// Pad the right with `count` copies of `character`. Port of
404    /// `Text.pad_right`. Spans are untouched, so the padding is unstyled.
405    pub fn pad_right(&mut self, count: usize, character: char) {
406        if count == 0 {
407            return;
408        }
409        self.plain.extend(std::iter::repeat_n(character, count));
410    }
411
412    /// Drop the last `amount` bytes, clipping any span that reached into them.
413    /// Port of `Text.right_crop`.
414    pub fn right_crop(&mut self, amount: usize) {
415        if amount == 0 {
416            return;
417        }
418        let max_offset = self.plain.len().saturating_sub(amount);
419        let plain = self.plain[..max_offset].to_string();
420        self.set_plain(plain);
421    }
422
423    /// Remove trailing whitespace. Port of `Text.rstrip`.
424    pub fn rstrip(&mut self) {
425        // Python's `str.rstrip()` whitespace, which includes U+001C..U+001F.
426        let plain = self.plain.trim_end_matches(is_python_space).to_string();
427        self.set_plain(plain);
428    }
429
430    /// Remove *only as much* trailing whitespace as it takes to get down to
431    /// `size` characters, leaving the rest. Port of `Text.rstrip_end`.
432    ///
433    /// This is what lets a wrapped line keep the space that ended it while a
434    /// line that overshot the width gives its padding back. As upstream, the
435    /// length is `len(self)` — characters, not cells.
436    pub fn rstrip_end(&mut self, size: usize) {
437        let length = self.plain.chars().count();
438        if length <= size {
439            return;
440        }
441        let excess = length - size;
442        let trimmed = self.plain.trim_end_matches(is_python_space);
443        let whitespace = self.plain[trimmed.len()..].chars().count();
444        if whitespace > 0 {
445            let keep = length - whitespace.min(excess);
446            let bytes = self.plain.len() - char_to_byte(&self.plain, keep);
447            self.right_crop(bytes);
448        }
449    }
450
451    /// Replace tabs with spaces up to the next `tab_size` stop. Port of
452    /// `Text.expand_tabs`.
453    ///
454    /// Styles extend over the inserted spaces, so a styled tab pads in its own
455    /// style rather than punching an unstyled hole (upstream reaches the same
456    /// result via `extend_style`).
457    /// Append `count` spaces, extending any span that reached the end so the
458    /// padding takes its style. Port of `Text.extend_style`.
459    fn extend_style(&mut self, count: usize) {
460        if count == 0 {
461            return;
462        }
463        let length = self.plain.len();
464        self.plain.extend(std::iter::repeat_n(' ', count));
465        for span in &mut self.spans {
466            if span.end >= length {
467                span.end += count;
468            }
469        }
470    }
471
472    pub fn expand_tabs(&mut self, tab_size: usize) {
473        if !self.plain.contains('\t') || tab_size == 0 {
474            return;
475        }
476        // Rebuilt part-by-part rather than by remapping offsets, because the
477        // *split* is observable: upstream turns each tab-terminated run into its
478        // own piece, so a span crossing several tabs comes back as several spans
479        // and renders as several segments. Remapping offsets keeps one span and
480        // emits one segment — same colours, different bytes.
481        let mut result = Text::new("");
482        for line in self.split("\n", true, false) {
483            if !line.plain.contains('\t') {
484                result = result.append_text(&line);
485                continue;
486            }
487            let mut cell_position = 0usize;
488            for mut part in line.split("\t", true, false) {
489                if part.plain.ends_with('\t') {
490                    // The tab becomes one space, then the run is padded out to
491                    // the next stop — so a tab always advances at least one cell.
492                    part.plain.pop();
493                    part.plain.push(' ');
494                    cell_position += part.cell_len();
495                    let remainder = cell_position % tab_size;
496                    if remainder != 0 {
497                        let spaces = tab_size - remainder;
498                        part.extend_style(spaces);
499                        cell_position += spaces;
500                    }
501                } else {
502                    cell_position += part.cell_len();
503                }
504                result = result.append_text(&part);
505            }
506        }
507        self.plain = result.plain;
508        self.spans = result.spans;
509    }
510
511    /// Join `lines` with this text as the separator, carrying each piece's base
512    /// style across as a covering span. Port of `Text.join`.
513    pub fn join(&self, lines: &[Text]) -> Text {
514        let mut joined = self.blank_copy();
515        let last = lines.len().saturating_sub(1);
516        for (index, line) in lines.iter().enumerate() {
517            joined = joined.append_text(line);
518            if !self.plain.is_empty() && index != last {
519                joined = joined.append_text(self);
520            }
521        }
522        joined
523    }
524
525    /// A `Text` from a string containing ANSI escape codes, one decoded line
526    /// per line joined by newlines, with base `style`. Port of
527    /// `Text.from_ansi` (its `tab_size` defaults to 8; set `justify`,
528    /// `overflow` and `no_wrap` with the builders).
529    pub fn from_ansi(text: &str, style: impl Into<StyleType>) -> Text {
530        let mut joiner = Text::styled("\n", style);
531        joiner.tab_size = Some(DEFAULT_TAB_SIZE);
532        let lines = crate::ansi::AnsiDecoder::new().decode_split_newlines(text);
533        joiner.join(&lines)
534    }
535
536    /// The indentation unit of the text: the greatest common divisor of its
537    /// even line indents, or 1. Port of `Text.detect_indentation`.
538    pub fn detect_indentation(&self) -> usize {
539        fn gcd(a: usize, b: usize) -> usize {
540            if b == 0 {
541                a
542            } else {
543                gcd(b, a % b)
544            }
545        }
546        let mut indents: Vec<usize> = self
547            .plain
548            .split('\n')
549            .map(|line| line.len() - line.trim_start_matches(' ').len())
550            .filter(|indent| indent % 2 == 0)
551            .collect();
552        indents.sort_unstable();
553        indents.dedup();
554        match indents.into_iter().reduce(gcd) {
555            None | Some(0) => 1,
556            Some(indentation) => indentation,
557        }
558    }
559
560    /// A copy with indent guides drawn in each line's leading spaces: every
561    /// `indent_size` spaces (auto-detected for `None`) become `character`
562    /// then padding, styled `style`. Port of `Text.with_indent_guides`
563    /// (upstream's defaults are `"│"` and `"dim green"`).
564    pub fn with_indent_guides(
565        &self,
566        indent_size: Option<usize>,
567        character: &str,
568        style: impl Into<StyleType>,
569    ) -> Text {
570        let style = style.into();
571        let indent_size = indent_size.unwrap_or_else(|| self.detect_indentation());
572        let mut text = self.clone();
573        text.expand_tabs(match self.tab_size {
574            None => DEFAULT_TAB_SIZE,
575            Some(tab_size) => tab_size,
576        });
577        let indent_line = format!("{character}{}", " ".repeat(indent_size.saturating_sub(1)));
578        let mut new_lines: Vec<Text> = Vec::new();
579        let mut blank_lines = 0usize;
580        for mut line in text.split("\n", false, true) {
581            let indent = line.plain.len() - line.plain.trim_start_matches(' ').len();
582            // `re.match(r"^( *)(.*)$")`: `.` stops at a line break, so a
583            // line with nothing after its indent (or a `\r`) counts as blank.
584            let rest = &line.plain[indent..];
585            if rest.is_empty() || rest.starts_with('\r') {
586                blank_lines += 1;
587                continue;
588            }
589            let (full_indents, remaining_space) = (
590                indent.checked_div(indent_size).unwrap_or(0),
591                indent.checked_rem(indent_size).unwrap_or(indent),
592            );
593            let new_indent = format!(
594                "{}{}",
595                indent_line.repeat(full_indents),
596                " ".repeat(remaining_space)
597            );
598            // `line.plain = new_indent + line.plain[len(new_indent):]`: the
599            // same number of characters, so upstream's (character) spans stay
600            // put; here the byte offsets past the indent shift.
601            let old_bytes = char_to_byte(&line.plain, new_indent.chars().count());
602            let plain = format!("{new_indent}{}", &line.plain[old_bytes..]);
603            let remap = |position: usize| {
604                if position >= old_bytes {
605                    position - old_bytes + new_indent.len()
606                } else {
607                    char_to_byte(&new_indent, position)
608                }
609            };
610            for span in &mut line.spans {
611                span.start = remap(span.start);
612                span.end = remap(span.end);
613            }
614            line.plain = plain;
615            line.stylize(style.clone(), 0, new_indent.len());
616            for _ in 0..std::mem::take(&mut blank_lines) {
617                new_lines.push(Text::styled(new_indent.clone(), style.clone()));
618            }
619            new_lines.push(line);
620        }
621        for _ in 0..blank_lines {
622            new_lines.push(Text::styled("", style.clone()));
623        }
624        let mut separator = text.blank_copy();
625        separator.plain.push('\n');
626        separator.join(&new_lines)
627    }
628
629    /// Style every occurrence of any of `words`. Port of `Text.highlight_words`,
630    /// returning the number of matches.
631    pub fn highlight_words(
632        &mut self,
633        words: &[&str],
634        style: impl Into<StyleType>,
635        case_sensitive: bool,
636    ) -> Result<usize> {
637        let alternation = words
638            .iter()
639            .map(|word| fancy_regex::escape(word).into_owned())
640            .collect::<Vec<_>>()
641            .join("|");
642        if alternation.is_empty() {
643            return Ok(0);
644        }
645        let pattern = if case_sensitive {
646            alternation
647        } else {
648            format!("(?i){alternation}")
649        };
650        self.highlight_regex(&pattern, Some(style.into()), "")
651    }
652
653    /// Style every match of `pattern`, returning the number of matches. Full port
654    /// of `Text.highlight_regex`.
655    ///
656    /// `style`, when given, styles the whole match. Each **named group** is then
657    /// styled with `{style_prefix}{name}` as a style *name*, left for the theme
658    /// to resolve at render time — which is how a highlighter colours its groups
659    /// without ever seeing a console.
660    ///
661    /// Groups that did not participate in the match, and zero-width ones, are
662    /// skipped.
663    pub fn highlight_regex(
664        &mut self,
665        pattern: &str,
666        style: Option<StyleType>,
667        style_prefix: &str,
668    ) -> Result<usize> {
669        let regex = fancy_regex::Regex::new(pattern)
670            .map_err(|e| crate::errors::RichError::Regex(format!("invalid pattern: {e}")))?;
671        Ok(self.highlight_with_regex(&regex, style, style_prefix))
672    }
673
674    /// As [`highlight_regex`](Self::highlight_regex) with an already-compiled
675    /// pattern, for callers that apply the same patterns repeatedly.
676    ///
677    /// A match that errors mid-scan (a `fancy-regex` backtrack-limit hit) stops
678    /// the scan and keeps the spans found so far, rather than discarding them.
679    pub(crate) fn highlight_with_regex(
680        &mut self,
681        regex: &fancy_regex::Regex,
682        style: Option<StyleType>,
683        style_prefix: &str,
684    ) -> usize {
685        // Capture-definition order, matching upstream's `match.groupdict()`.
686        let names: Vec<(usize, String)> = regex
687            .capture_names()
688            .enumerate()
689            .filter_map(|(index, name)| name.map(|name| (index, name.to_string())))
690            .collect();
691
692        // Scanning borrows the plain string while the spans are pushed, so move
693        // it out and put it back — no copy, and no fighting the borrow checker.
694        let plain = std::mem::take(&mut self.plain);
695        let mut count = 0;
696        for captures in regex.captures_iter(&plain) {
697            let Ok(captures) = captures else { break };
698            if let (Some(style), Some(whole)) = (style.as_ref(), captures.get(0)) {
699                if whole.end() > whole.start() {
700                    self.spans.push(Span {
701                        start: whole.start(),
702                        end: whole.end(),
703                        style: style.clone(),
704                    });
705                }
706            }
707            count += 1;
708            for (index, name) in &names {
709                if let Some(group) = captures.get(*index) {
710                    if group.end() > group.start() {
711                        self.spans.push(Span {
712                            start: group.start(),
713                            end: group.end(),
714                            style: StyleType::Name(format!("{style_prefix}{name}")),
715                        });
716                    }
717                }
718            }
719        }
720        self.plain = plain;
721        count
722    }
723
724    /// Build styled text from console markup. Port of `Text.from_markup`.
725    ///
726    /// Tag names are stored on the spans and resolved when the text is rendered,
727    /// so no theme is needed here.
728    pub fn from_markup(markup_text: &str) -> Result<Text> {
729        markup::render(markup_text)
730    }
731
732    /// The unstyled string content.
733    pub fn plain(&self) -> &str {
734        &self.plain
735    }
736
737    /// The spans currently applied.
738    pub fn spans(&self) -> &[Span] {
739        &self.spans
740    }
741
742    /// Length in terminal cells.
743    pub fn cell_len(&self) -> usize {
744        cell_len(&self.plain)
745    }
746
747    /// True when there is no content.
748    pub fn is_empty(&self) -> bool {
749        self.plain.is_empty()
750    }
751
752    /// Append more text, optionally under `style` (a resolved [`Style`] or a
753    /// style name).
754    pub fn append(&mut self, text: &str, style: Option<StyleType>) {
755        let start = self.plain.len();
756        // Strip here as well as in `new`: upstream's `Text.append` runs the same
757        // `strip_control_codes`, and skipping it let BEL, backspace, vertical
758        // tab and form feed reach the terminal through every path that builds
759        // text incrementally — Markdown, Syntax and plain files. A backspace run
760        // is a spoofing tool: `FAILED\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}PASSED`
761        // displays as `PASSED`.
762        self.plain.push_str(&Text::strip_control_codes(text));
763        let end = self.plain.len();
764        // `if style:` — a null style or an empty name adds no span, so it
765        // leaves no segment boundary behind either.
766        if let Some(style) = style.filter(|style| !is_falsy_style(style)) {
767            self.spans.push(Span { start, end, style });
768        }
769    }
770
771    /// Append another `Text`, carrying over its base style (as a covering span)
772    /// and all of its spans, shifted to their new offsets. Port of
773    /// `Text.append_text`. Consumes `self` and returns it for chaining.
774    pub fn append_text(mut self, other: &Text) -> Text {
775        let offset = self.plain.len();
776        self.plain.push_str(&other.plain);
777        let end = self.plain.len();
778        if !other.style.is_null_style() {
779            self.spans.push(Span {
780                start: offset,
781                end,
782                style: other.style.clone(),
783            });
784        }
785        for span in &other.spans {
786            self.spans.push(Span {
787                start: span.start + offset,
788                end: span.end + offset,
789                style: span.style.clone(),
790            });
791        }
792        self
793    }
794
795    /// Apply `style` to the byte range `[start, end)`. Port of `Text.stylize`,
796    /// including its argument order.
797    ///
798    /// `style` may be a resolved [`Style`] or a name (`"repr.number"`) left for
799    /// the renderer to look up. Byte offsets, not char offsets; ASCII-only
800    /// callers such as highlighters are unaffected by the distinction.
801    ///
802    /// A range that is empty or inverted is ignored, which is what gives us
803    /// upstream's `end > start` skip for non-participating regex groups.
804    pub fn stylize(&mut self, style: impl Into<StyleType>, start: usize, end: usize) {
805        let style = style.into();
806        let end = end.min(self.plain.len());
807        // `if style:` — a falsy style adds no span.
808        if start >= end || is_falsy_style(&style) {
809            return;
810        }
811        self.spans.push(Span { start, end, style });
812    }
813
814    /// As [`stylize`](Self::stylize), but the span goes *first*, beneath every
815    /// existing span. Port of `Text.stylize_before`.
816    pub fn stylize_before(&mut self, style: impl Into<StyleType>, start: usize, end: usize) {
817        let style = style.into();
818        let end = end.min(self.plain.len());
819        if start >= end || is_falsy_style(&style) {
820            return;
821        }
822        self.spans.insert(0, Span { start, end, style });
823    }
824
825    /// Apply metadata to the byte range `start..end` (the rest of the text
826    /// when `end` is `None`). Port of `Text.apply_meta`: a span of
827    /// `Style.from_meta(meta)`, which an empty map (a null style) skips.
828    pub fn apply_meta(&mut self, meta: crate::style::Meta, start: usize, end: Option<usize>) {
829        let end = end.unwrap_or(self.plain.len());
830        self.stylize(Style::from_meta(meta), start, end);
831    }
832
833    /// Apply event-handler metadata to the whole text. Port of `Text.on`:
834    /// handlers are stored as `"@name"` keys over `meta`.
835    pub fn on(
836        &mut self,
837        meta: Option<crate::style::Meta>,
838        handlers: &[(&str, crate::style::MetaValue)],
839    ) -> &mut Self {
840        let len = self.plain.len();
841        self.stylize(Style::on(meta, handlers), 0, len);
842        self
843    }
844
845    /// Append a raw span, as upstream's `text.spans.append(span)` does: no
846    /// clamping and no falsy-style check.
847    ///
848    /// Offsets are **byte** offsets into [`plain`](Self::plain) and must fall
849    /// on `char` boundaries (upstream's are character offsets; convert first).
850    /// A span may extend past the end of the text, as upstream allows.
851    pub fn push_span(&mut self, span: Span) {
852        self.spans.push(span);
853    }
854
855    /// The spans, mutably. Upstream's `Text.spans` is a public, mutable list;
856    /// the same byte-offset rules as [`push_span`](Self::push_span) apply.
857    pub fn spans_mut(&mut self) -> &mut Vec<Span> {
858        &mut self.spans
859    }
860
861    /// Replace every span. Port of the `Text.spans` setter.
862    pub fn set_spans(&mut self, spans: Vec<Span>) {
863        self.spans = spans;
864    }
865
866    /// Drop this text's own `justify`, `overflow` and `no_wrap`, so they defer to
867    /// the console options as upstream's `Text.join` result does.
868    pub(crate) fn clear_layout_options(&mut self) {
869        self.justify = None;
870        self.overflow = None;
871        self.no_wrap = None;
872        self.tab_size = None;
873    }
874
875    /// Move the base style into a span over the whole text, ahead of the
876    /// existing spans — what `Text.join` does to each joined text. A falsy
877    /// style (null, or an empty name) adds no span, as `if text.style:` skips
878    /// it upstream.
879    pub(crate) fn base_style_to_span(&mut self) {
880        let style = std::mem::take(&mut self.style);
881        let falsy =
882            style.is_null_style() || matches!(&style, StyleType::Name(name) if name.is_empty());
883        if !falsy {
884            self.spans.insert(
885                0,
886                Span {
887                    start: 0,
888                    end: self.plain.len(),
889                    style,
890                },
891            );
892        }
893    }
894
895    /// The whole-text base style, resolved or named. Port of the `Text.style`
896    /// attribute.
897    pub fn base_style(&self) -> &StyleType {
898        &self.style
899    }
900
901    /// Set the whole-text base style, resolved or named.
902    pub fn set_base_style(&mut self, style: impl Into<StyleType>) {
903        self.style = style.into();
904    }
905
906    /// Flatten into non-overlapping segments (newlines become [`Segment::line`]),
907    /// combining `base_style`, this text's base style, and every covering span.
908    /// Does **not** wrap. Port of the core of `Text.render`.
909    ///
910    /// Named span styles are resolved against `theme`.
911    pub fn render(&self, theme: &Theme, base_style: &Style) -> Vec<Segment> {
912        self.render_joined(theme, base_style, None)
913    }
914
915    /// The `(minimum, maximum)` cell width of this text: `maximum` is the widest
916    /// hard line, `minimum` the widest word. Port of `Text.__rich_measure__`.
917    pub fn measurement(&self) -> (usize, usize) {
918        // Measured against the raw string, as upstream does: `cell_len` counts
919        // a tab as zero cells, and tabs expand only at render time. A printed
920        // `Text` renders at the full width (see `Renderable::printed_text`), so
921        // this measurement never becomes its own render width (#447).
922        measure_plain(&self.plain)
923    }
924
925    /// Render into visual lines, wrapping each hard line to `width` cells when
926    /// `Some`, and justifying per this text's own justify.
927    pub fn render_lines(
928        &self,
929        theme: &Theme,
930        base_style: &Style,
931        width: Option<usize>,
932    ) -> Vec<Vec<Segment>> {
933        self.render_lines_justified(theme, base_style, width, self.get_justify())
934    }
935
936    /// Like [`render_lines`](Self::render_lines) but with an explicit `justify`
937    /// (used by the console to apply `options.justify`).
938    pub fn render_lines_justified(
939        &self,
940        theme: &Theme,
941        base_style: &Style,
942        width: Option<usize>,
943        justify: Justify,
944    ) -> Vec<Vec<Segment>> {
945        self.render_lines_wrapped(
946            theme,
947            base_style,
948            width,
949            justify,
950            self.overflow.unwrap_or(Overflow::Fold),
951            self.no_wrap.unwrap_or(false),
952        )
953    }
954
955    /// The full wrap-justify-truncate pipeline, with every knob resolved by the
956    /// caller. Port of `Text.wrap`.
957    ///
958    /// Lines are split on `\n`, wrapped to `width` (folding over-long words only
959    /// when `overflow` is [`Overflow::Fold`]), justified, and finally truncated
960    /// to `width`. [`Overflow::Ignore`] skips wrapping and truncation both, so
961    /// lines may come back wider than `width`.
962    ///
963    /// Tabs expand to this text's own [`tab_size`](Self::get_tab_size), else
964    /// [`DEFAULT_TAB_SIZE`]; [`render_lines_wrapped_tabs`](Self::render_lines_wrapped_tabs)
965    /// takes the width explicitly (upstream's `wrap(tab_size=…)`).
966    pub fn render_lines_wrapped(
967        &self,
968        theme: &Theme,
969        base_style: &Style,
970        width: Option<usize>,
971        justify: Justify,
972        overflow: Overflow,
973        no_wrap: bool,
974    ) -> Vec<Vec<Segment>> {
975        self.render_lines_wrapped_tabs(
976            theme,
977            base_style,
978            width,
979            justify,
980            overflow,
981            no_wrap,
982            match self.tab_size {
983                None | Some(0) => DEFAULT_TAB_SIZE,
984                Some(tab_size) => tab_size,
985            },
986        )
987    }
988
989    /// The tab stop width `Text.__rich_console__` wraps with on `console`:
990    /// this text's own, else the console's, and `8` for zero
991    /// (`tab_size or 8`).
992    pub fn console_tab_size(&self, console: &crate::console::Console) -> usize {
993        match self.tab_size.unwrap_or_else(|| console.tab_size()) {
994            0 => DEFAULT_TAB_SIZE,
995            tab_size => tab_size,
996        }
997    }
998
999    /// [`render_lines_wrapped`](Self::render_lines_wrapped) with an explicit
1000    /// tab stop width. Port of `Text.wrap(…, tab_size=…)`.
1001    #[allow(clippy::too_many_arguments)]
1002    pub fn render_lines_wrapped_tabs(
1003        &self,
1004        theme: &Theme,
1005        base_style: &Style,
1006        width: Option<usize>,
1007        justify: Justify,
1008        overflow: Overflow,
1009        no_wrap: bool,
1010        tab_size: usize,
1011    ) -> Vec<Vec<Segment>> {
1012        // Tabs are expanded before anything measures or wraps the text, as
1013        // upstream's `Text.wrap` does per line. Without this a tab occupies one
1014        // cell everywhere in the layout and then eight on the terminal, so every
1015        // width calculation downstream is wrong.
1016        if self.plain.contains('\t') {
1017            let mut expanded = self.clone();
1018            expanded.expand_tabs(tab_size);
1019            return expanded.render_lines_wrapped_tabs(
1020                theme, base_style, width, justify, overflow, no_wrap, tab_size,
1021            );
1022        }
1023
1024        // Resolve every span's style once, up front, into a vector parallel to
1025        // `self.spans` — upstream's `style_map`. Resolving inside the per-line
1026        // loop would re-parse the same names for every visual line.
1027        let resolved: Vec<Style> = self
1028            .spans
1029            .iter()
1030            .map(|span| theme.get_style_or_null(&span.style))
1031            .collect();
1032        let effective_base = base_style.combine(&theme.get_style_or_null(&self.style));
1033        // Upstream folds `overflow == "ignore"` into no_wrap before splitting.
1034        let no_wrap = no_wrap || overflow == Overflow::Ignore;
1035        let groups = self.wrapped_ranges(width, overflow, no_wrap);
1036        // Which spans touch which visual line, found once by binary search as
1037        // upstream's `divide` does. Scanning every span for every line (and
1038        // every cut within it) made printing a long highlighted repr quadratic.
1039        let line_spans = self.spans_by_line(&groups);
1040        let mut line_spans = line_spans.iter();
1041        let Some(width) = width else {
1042            return groups
1043                .into_iter()
1044                .flatten()
1045                .map(|(start, end)| {
1046                    let ids = line_spans.next().map(Vec::as_slice).unwrap_or(&[]);
1047                    self.line_segments(&resolved, start, end, &effective_base, ids)
1048                })
1049                .collect();
1050        };
1051
1052        let mut lines: Vec<Vec<Segment>> = Vec::new();
1053        // One hard line at a time, as upstream's `for line in self.split(...)`
1054        // does — the paragraph boundary is what full justification treats as
1055        // ragged, so the groups cannot be flattened first.
1056        for group in groups {
1057            let group_spans: Vec<&[usize]> = group
1058                .iter()
1059                .map(|_| line_spans.next().map(Vec::as_slice).unwrap_or(&[]))
1060                .collect();
1061            let mut new_lines: Vec<Vec<Segment>> = group
1062                .iter()
1063                .zip(&group_spans)
1064                .map(|(&(start, end), ids)| {
1065                    self.line_segments(&resolved, start, end, &effective_base, ids)
1066                })
1067                .collect();
1068
1069            // `overflow == "ignore"` is a hard stop upstream: the line is
1070            // appended verbatim and the loop `continue`s, so it is neither
1071            // justified nor truncated. Padding it out to the width here was
1072            // adding trailing spaces to text upstream returns untouched.
1073            if overflow == Overflow::Ignore {
1074                lines.append(&mut new_lines);
1075                continue;
1076            }
1077
1078            // Give each wrapped line back the padding it overshot by, exactly
1079            // where upstream's `Text.wrap` does it — after dividing, before
1080            // justifying. `divide_line` counts a word *including* its trailing
1081            // space, so a line whose last word ends flush with the width comes
1082            // back one cell too long; without this the ellipsis overflow then
1083            // chops a real character to make room for a `…` that upstream never
1084            // emits ("abcdefghij more" at width 10 became "abcdefghi…", not
1085            // "abcdefghij").
1086            //
1087            // Only in the wrapping branch: upstream's `rstrip_end` loop sits
1088            // inside `Text.wrap`'s `else`, which `no_wrap` skips entirely.
1089            if !no_wrap {
1090                for line in &mut new_lines {
1091                    rstrip_end_line(line, width);
1092                }
1093            }
1094            if justify != Justify::Default {
1095                let last = new_lines.len().saturating_sub(1);
1096                for (index, line) in new_lines.iter_mut().enumerate() {
1097                    // Full justification leaves the final line of the paragraph
1098                    // ragged, so it needs to know where it is in the group.
1099                    let (start, end) = group[index];
1100                    *line = justify_line(
1101                        line,
1102                        width,
1103                        justify,
1104                        overflow,
1105                        &effective_base,
1106                        index == last,
1107                        &|char_index| self.covered_at(start, end, char_index, group_spans[index]),
1108                    );
1109                }
1110            }
1111            for line in &mut new_lines {
1112                *line = truncate_line(line, width, overflow);
1113            }
1114            lines.append(&mut new_lines);
1115        }
1116        lines
1117    }
1118
1119    /// Whether any span covers the `char_index`-th character of the line
1120    /// `[start, end)` — i.e. whether upstream's `Text.render` has a span
1121    /// boundary at that character's edge of the line. Justify padding joins
1122    /// the neighbouring run only where it does not.
1123    ///
1124    /// `ids` are the spans touching that line (see [`spans_by_line`](Self::spans_by_line)).
1125    fn covered_at(&self, start: usize, end: usize, char_index: usize, ids: &[usize]) -> bool {
1126        let Some((offset, _)) = self.plain[start..end].char_indices().nth(char_index) else {
1127            return false;
1128        };
1129        let position = start + offset;
1130        ids.iter().any(|&id| {
1131            let span = &self.spans[id];
1132            span.start <= position && position < span.end
1133        })
1134    }
1135
1136    /// For each visual line of `groups` (flattened, in order), the indices of
1137    /// the spans that overlap it, in span order. Lines are ordered and
1138    /// disjoint, so each span binary-searches its first and last line, as
1139    /// upstream's `Text.divide` does; the cost is the number of (span, line)
1140    /// pairs that actually overlap rather than spans × lines.
1141    fn spans_by_line(&self, groups: &[Vec<(usize, usize)>]) -> Vec<Vec<usize>> {
1142        let lines: Vec<(usize, usize)> = groups.iter().flatten().copied().collect();
1143        let mut by_line: Vec<Vec<usize>> = vec![Vec::new(); lines.len()];
1144        // Upstream only divides a text that has a newline or a wrap cut, and
1145        // `divide` drops spans that come out empty. An undivided text keeps
1146        // its empty spans, whose start/end events still split the run
1147        // (`[b]a[i][/i]b[/b]` renders `a` and `b` as two segments).
1148        let undivided = lines.len() == 1;
1149        for (id, span) in self.spans.iter().enumerate() {
1150            if span.start >= span.end {
1151                if undivided && span.start == span.end {
1152                    by_line[0].push(id);
1153                }
1154                continue;
1155            }
1156            let first = lines.partition_point(|&(_, end)| end <= span.start);
1157            let last = lines.partition_point(|&(start, _)| start < span.end);
1158            for index in first..last.max(first) {
1159                let (start, end) = lines[index];
1160                if span.start.max(start) < span.end.min(end) {
1161                    by_line[index].push(id);
1162                }
1163            }
1164        }
1165        by_line
1166    }
1167
1168    /// As [`render_lines_wrapped`](Self::render_lines_wrapped), flattened into a
1169    /// single segment stream with [`Segment::line`] between visual lines.
1170    pub fn render_joined_wrapped(
1171        &self,
1172        theme: &Theme,
1173        base_style: &Style,
1174        width: usize,
1175        justify: Justify,
1176        overflow: Overflow,
1177        no_wrap: bool,
1178    ) -> Vec<Segment> {
1179        self.render_joined_wrapped_tabs(
1180            theme,
1181            base_style,
1182            width,
1183            justify,
1184            overflow,
1185            no_wrap,
1186            match self.tab_size {
1187                None | Some(0) => DEFAULT_TAB_SIZE,
1188                Some(tab_size) => tab_size,
1189            },
1190        )
1191    }
1192
1193    /// [`render_joined_wrapped`](Self::render_joined_wrapped) with an explicit
1194    /// tab stop width (see [`console_tab_size`](Self::console_tab_size)).
1195    #[allow(clippy::too_many_arguments)]
1196    pub fn render_joined_wrapped_tabs(
1197        &self,
1198        theme: &Theme,
1199        base_style: &Style,
1200        width: usize,
1201        justify: Justify,
1202        overflow: Overflow,
1203        no_wrap: bool,
1204        tab_size: usize,
1205    ) -> Vec<Segment> {
1206        let lines = self.render_lines_wrapped_tabs(
1207            theme,
1208            base_style,
1209            Some(width),
1210            justify,
1211            overflow,
1212            no_wrap,
1213            tab_size,
1214        );
1215        let mut segments = Vec::new();
1216        let last = lines.len().saturating_sub(1);
1217        for (index, line) in lines.into_iter().enumerate() {
1218            // A final empty line (a trailing newline, or an empty text left
1219            // unpadded, as with `overflow="ignore"`) is still a line upstream:
1220            // `Text.render` yields `Segment("")` before the `end`.
1221            if index == last && line.is_empty() {
1222                segments.push(Segment::new("", None));
1223            }
1224            segments.extend(line);
1225            if index != last {
1226                segments.push(Segment::line());
1227            }
1228        }
1229        segments
1230    }
1231
1232    /// Render into a flat segment stream with [`Segment::line`] between visual
1233    /// lines (wrapping when `width` is `Some`), using this text's own justify.
1234    fn render_joined(
1235        &self,
1236        theme: &Theme,
1237        base_style: &Style,
1238        width: Option<usize>,
1239    ) -> Vec<Segment> {
1240        let lines = self.render_lines(theme, base_style, width);
1241        let mut segments = Vec::new();
1242        let last = lines.len().saturating_sub(1);
1243        for (index, line) in lines.into_iter().enumerate() {
1244            segments.extend(line);
1245            if index != last {
1246                segments.push(Segment::line());
1247            }
1248        }
1249        segments
1250    }
1251
1252    /// The `(start_byte, end_byte)` range of each visual line, **grouped by the
1253    /// hard line it came from**: hard lines split on `\n`, then each wrapped to
1254    /// `width` cells when `Some`.
1255    ///
1256    /// The grouping is not cosmetic. Upstream wraps and justifies one hard line
1257    /// at a time (`for line in self.split(...)`), so full justification leaves
1258    /// the last visual line of *each paragraph* ragged. Flattening first makes
1259    /// every paragraph but the final one get stretched, which turned
1260    /// `"line here"` into `"line  here"`.
1261    fn wrapped_ranges(
1262        &self,
1263        width: Option<usize>,
1264        overflow: Overflow,
1265        no_wrap: bool,
1266    ) -> Vec<Vec<(usize, usize)>> {
1267        let mut hard: Vec<(usize, usize)> = Vec::new();
1268        let mut start = 0;
1269        for (i, byte) in self.plain.bytes().enumerate() {
1270            if byte == b'\n' {
1271                hard.push((start, i));
1272                start = i + 1;
1273            }
1274        }
1275        hard.push((start, self.plain.len()));
1276
1277        let Some(width) = width else {
1278            return hard.into_iter().map(|range| vec![range]).collect();
1279        };
1280        if no_wrap {
1281            return hard.into_iter().map(|range| vec![range]).collect();
1282        }
1283
1284        let mut groups: Vec<Vec<(usize, usize)>> = Vec::with_capacity(hard.len());
1285        for (a, b) in hard {
1286            let sub = &self.plain[a..b];
1287            // Only `fold` breaks a word that is wider than the whole line; the
1288            // cropping methods leave it long and let truncation cut it.
1289            let breaks = crate::wrap::divide_line(sub, width, overflow == Overflow::Fold);
1290            let mut cuts = vec![a];
1291            let mut previous_char = 0;
1292            let mut previous_byte = 0;
1293            for char_offset in breaks {
1294                // Breaks are ordered char offsets. Scan only the next slice;
1295                // rescanning the prefix for every line is quadratic.
1296                previous_byte += char_to_byte(&sub[previous_byte..], char_offset - previous_char);
1297                previous_char = char_offset;
1298                cuts.push(a + previous_byte);
1299            }
1300            cuts.push(b);
1301            groups.push(cuts.windows(2).map(|w| (w[0], w[1])).collect());
1302        }
1303        groups
1304    }
1305
1306    /// Combine `effective_base` with every span covering `[start, end)`,
1307    /// producing non-overlapping segments for that byte range. Port of the
1308    /// sweep in upstream's `Text.render`: span start/end events are sorted
1309    /// once and walked with a stack of active spans.
1310    ///
1311    /// `resolved` is the per-render style map, index-parallel to `self.spans`;
1312    /// `ids` are the spans overlapping this line, in span order. Active spans
1313    /// are combined in span order (upstream's `sorted(stack)`), and spans that
1314    /// resolved to nothing are **not** skipped — they still contribute a
1315    /// boundary. The highlighter fixtures depend on it: an ISO-8601 date emits
1316    /// separate segments per sub-field even where the field styles are
1317    /// identical.
1318    fn line_segments(
1319        &self,
1320        resolved: &[Style],
1321        start: usize,
1322        end: usize,
1323        effective_base: &Style,
1324        ids: &[usize],
1325    ) -> Vec<Segment> {
1326        if start >= end {
1327            return Vec::new();
1328        }
1329        // (offset, leaving, span id); entering sorts before leaving.
1330        let mut events: Vec<(usize, bool, usize)> = Vec::with_capacity(ids.len() * 2 + 1);
1331        for &id in ids {
1332            let span = &self.spans[id];
1333            let (span_start, span_end) = (span.start.max(start), span.end.min(end));
1334            // An empty span (kept only on an undivided line) is a boundary.
1335            let empty = span.start == span.end && start < span.start && span.end < end;
1336            if span_start < span_end || empty {
1337                events.push((span_start, false, id));
1338                events.push((span_end, true, id));
1339            }
1340        }
1341        events.sort_unstable();
1342
1343        let mut segments = Vec::new();
1344        let mut stack: Vec<usize> = Vec::new();
1345        let mut offset = start;
1346        let mut events = events.into_iter().peekable();
1347        loop {
1348            // Apply every event at `offset`.
1349            while let Some(&(position, leaving, id)) = events.peek() {
1350                if position > offset {
1351                    break;
1352                }
1353                events.next();
1354                match stack.binary_search(&id) {
1355                    Ok(index) if leaving => {
1356                        stack.remove(index);
1357                    }
1358                    Err(index) if !leaving => stack.insert(index, id),
1359                    _ => {}
1360                }
1361            }
1362            let next = events
1363                .peek()
1364                .map_or(end, |&(position, _, _)| position.min(end));
1365            if next <= offset {
1366                break;
1367            }
1368            let mut style = effective_base.clone();
1369            for &id in &stack {
1370                style = style.combine(&resolved[id]);
1371            }
1372            segments.push(Segment::new(&self.plain[offset..next], Some(style)));
1373            offset = next;
1374        }
1375        segments
1376    }
1377}
1378
1379/// Python truthiness of a style argument: a null `Style` and an empty style
1380/// name are falsy, and upstream's `append`/`stylize` skip them.
1381fn is_falsy_style(style: &StyleType) -> bool {
1382    style.is_null_style() || matches!(style, StyleType::Name(name) if name.is_empty())
1383}
1384
1385/// Byte offset of the `char_idx`-th char in `text` (clamped to `text.len()`).
1386fn char_to_byte(text: &str, char_idx: usize) -> usize {
1387    text.char_indices()
1388        .nth(char_idx)
1389        .map(|(byte, _)| byte)
1390        .unwrap_or(text.len())
1391}
1392
1393/// Cut a rendered line down to `width` cells, applying `overflow`. Segment-level
1394/// counterpart of [`Text::truncate`], used once per line at the end of the wrap
1395/// pipeline.
1396///
1397/// [`Overflow::Fold`] and [`Overflow::Crop`] both plain-cut: by this point the
1398/// line has already been wrapped, so anything still over-long is an unbreakable
1399/// run that folding cannot help with.
1400///
1401/// [`Overflow::Ellipsis`] cuts one cell short and appends `…`. The marker takes
1402/// the style of the first segment the cut did *not* keep whole — upstream writes
1403/// the ellipsis into the plain string and lets span-trimming decide, which works
1404/// out to the same rule, including when the cut lands exactly on a boundary.
1405fn truncate_line(line: &[Segment], width: usize, overflow: Overflow) -> Vec<Segment> {
1406    if overflow == Overflow::Ignore {
1407        return line.to_vec();
1408    }
1409    // Measured and cut over the WHOLE line, exactly as upstream's `Text.truncate`
1410    // works on `self.plain`. Summing the segments instead is wrong wherever a
1411    // grapheme spans a segment boundary — a zero-width joiner at the end of one
1412    // segment swallows the first character of the next, so the per-segment sum
1413    // reads one cell too wide and cuts text upstream keeps.
1414    let plain: String = line.iter().map(|segment| segment.text.as_str()).collect();
1415    if cell_len(&plain) <= width {
1416        return line.to_vec();
1417    }
1418    let ellipsis = overflow == Overflow::Ellipsis;
1419    // `…` occupies one cell, so the kept text must stop one cell early.
1420    let keep = if ellipsis {
1421        width.saturating_sub(1)
1422    } else {
1423        width
1424    };
1425    // Never longer than `plain`, and only ever differs from a byte prefix of it
1426    // in its final byte (a wide grapheme straddling the cut becomes a space), so
1427    // slicing it at the original segment boundaries stays on char boundaries.
1428    let kept = set_cell_size(&plain, keep);
1429
1430    let mut result: Vec<Segment> = Vec::new();
1431    // Style the ellipsis inherits: that of the first segment the cut did not
1432    // keep whole, falling back to the last segment's when the cut lands exactly
1433    // on the end of the line's bytes.
1434    let mut cut_style: Option<Style> = line.last().and_then(|segment| segment.style.clone());
1435    let mut offset = 0usize;
1436    for segment in line {
1437        if offset >= kept.len() {
1438            cut_style = segment.style.clone();
1439            break;
1440        }
1441        let end = (offset + segment.text.len()).min(kept.len());
1442        if end > offset {
1443            result.push(Segment::new(&kept[offset..end], segment.style.clone()));
1444        }
1445        if offset + segment.text.len() > kept.len() {
1446            cut_style = segment.style.clone();
1447            break;
1448        }
1449        offset = end;
1450    }
1451    if ellipsis {
1452        // Upstream appends the marker to the plain string and re-renders, so it
1453        // lands inside the preceding run rather than beside it. Merging keeps
1454        // the byte stream identical — a separate segment would re-emit the style.
1455        match result.last_mut() {
1456            Some(last) if !last.control && last.style == cut_style => last.text.push('…'),
1457            _ => result.push(Segment::new("…", cut_style)),
1458        }
1459    }
1460    result
1461}
1462
1463/// Split a rendered line into whitespace-separated words, each word keeping its
1464/// own styled segments. Separator spaces are dropped — [`full_justify`] decides
1465/// the new gaps. Port of the `line.split(" ")` in upstream's `full` branch.
1466fn split_words(line: &[Segment]) -> Vec<Vec<Segment>> {
1467    let mut words: Vec<Vec<Segment>> = Vec::new();
1468    let mut current: Vec<Segment> = Vec::new();
1469    for segment in line {
1470        // A segment can straddle a space, so split within it and keep the style.
1471        for (index, piece) in segment.text.split(' ').enumerate() {
1472            if index > 0 {
1473                words.push(std::mem::take(&mut current));
1474            }
1475            if !piece.is_empty() {
1476                current.push(Segment::new(piece, segment.style.clone()));
1477            }
1478        }
1479    }
1480    words.push(current);
1481    // Wrapping leaves a trailing space on every line but the last, so the naive
1482    // split ends with an empty word. Upstream's `Text.split` drops it, and the
1483    // count matters: it decides how many gaps share the slack.
1484    if words.last().is_some_and(|w| w.is_empty()) {
1485        words.pop();
1486    }
1487    words
1488}
1489
1490/// Distribute `width` across `line`'s words by widening the gaps between them.
1491/// Direct port of the `justify == "full"` branch of upstream's `Lines.justify`:
1492/// every gap starts at one space, and the extra columns are handed out from the
1493/// rightmost gap backwards, cycling.
1494fn full_justify(line: &[Segment], width: usize, style: &Style) -> Vec<Segment> {
1495    let words = split_words(line);
1496    let words_size: usize = words
1497        .iter()
1498        .map(|word| word.iter().map(Segment::cell_length).sum::<usize>())
1499        .sum();
1500    let mut num_spaces = words.len().saturating_sub(1);
1501    let mut spaces = vec![1usize; num_spaces];
1502    if !spaces.is_empty() {
1503        let mut index = 0;
1504        while words_size + num_spaces < width {
1505            let slot = spaces.len() - index - 1;
1506            spaces[slot] += 1;
1507            num_spaces += 1;
1508            index = (index + 1) % spaces.len();
1509        }
1510    }
1511
1512    let mut out: Vec<Segment> = Vec::new();
1513    for (index, word) in words.iter().enumerate() {
1514        out.extend(word.iter().cloned());
1515        if let Some(&gap) = spaces.get(index) {
1516            // Upstream styles the gap with the surrounding style when the two
1517            // neighbours agree, else with the line's base style.
1518            let before = word.last().and_then(|s| s.style.clone());
1519            let after = words
1520                .get(index + 1)
1521                .and_then(|w| w.first())
1522                .and_then(|s| s.style.clone());
1523            let gap_style = if before == after {
1524                before.unwrap_or_else(|| style.clone())
1525            } else {
1526                style.clone()
1527            };
1528            out.push(Segment::new(" ".repeat(gap), Some(gap_style)));
1529        }
1530    }
1531    out
1532}
1533
1534/// Pad `line` to `width` cells according to `justify`, using `style` for the
1535/// pad (so e.g. a styled table cell fills with its own style).
1536///
1537/// `is_last` marks the final line of the paragraph, which full justification
1538/// leaves ragged rather than stretching.
1539///
1540/// `covered(i)` says whether a span covers the line's `i`-th character.
1541/// Upstream pads the plain string, so its padding joins the adjacent run
1542/// unless a span ends (or starts) at the text's edge; a separate segment
1543/// would re-emit the style as a second SGR run.
1544fn justify_line(
1545    line: &[Segment],
1546    width: usize,
1547    justify: Justify,
1548    overflow: Overflow,
1549    style: &Style,
1550    is_last: bool,
1551    covered: &dyn Fn(usize) -> bool,
1552) -> Vec<Segment> {
1553    // Full justification rewrites the interior gaps instead of padding an edge.
1554    if justify == Justify::Full {
1555        // Upstream `break`s before the final line, so it is left exactly as
1556        // wrapped — not even padded out to the width, unlike every other mode.
1557        return if is_last {
1558            line.to_vec()
1559        } else {
1560            full_justify(line, width, style)
1561        };
1562    }
1563    let mut content = line.to_vec();
1564    // Upstream's `Lines.justify` calls `line.rstrip()` in its `center` and
1565    // `right` branches — and only there — so the space wrapping left at the end
1566    // of a line is *not* content to be positioned. Keeping it shifts the visible
1567    // text half a space left when centring (`" abcd efgh ijklmnop "` became
1568    // `"abcd efgh ijklmnop  "`) and a whole column left when right-aligning.
1569    // `left`/`full` deliberately keep it: upstream pads them without stripping.
1570    if matches!(justify, Justify::Center | Justify::Right) {
1571        rstrip_line(&mut content);
1572        // …and then TRUNCATES, before it pads. The order is load-bearing: cell
1573        // width is not additive across a cut, so a line whose over-long tail is
1574        // chopped can measure *less* than the width afterwards and still want
1575        // padding. A leading zero-width joiner is the clearest case — it eats the
1576        // character after it, so cutting the line hands one of its cells back —
1577        // and padding first computes the gap from the pre-cut measurement, which
1578        // is zero, and leaves the line short.
1579        content = truncate_line(&content, width, overflow);
1580    }
1581
1582    // Whether padding may join the content's first / last run: no span
1583    // covers the character at that edge, so upstream's run there is the base
1584    // style alone and the padding extends it.
1585    let content_chars: usize = content.iter().map(|s| s.text.chars().count()).sum();
1586    // With no content at all, the two pads of a centred line are one run.
1587    let join_left = content_chars > 0 && !covered(0);
1588    let join_right = content_chars == 0 || !covered(content_chars - 1);
1589
1590    let mut out = Vec::with_capacity(content.len() + 2);
1591    match justify {
1592        Justify::Right => {
1593            // `line.pad_left(width - cell_len(line.plain))`.
1594            let excess = width.saturating_sub(line_cell_len(&content));
1595            if excess > 0 {
1596                out.push(Segment::new(" ".repeat(excess), Some(style.clone())));
1597            }
1598            append_joined(&mut out, content, join_left);
1599        }
1600        Justify::Center => {
1601            // `pad_left((width - cell_len) // 2)` and then `pad_right(width -
1602            // cell_len)` — the second `cell_len` is re-measured *after* the left
1603            // pad, so the two halves are not simply `excess / 2` and the rest.
1604            let left = width.saturating_sub(line_cell_len(&content)) / 2;
1605            if left > 0 {
1606                out.push(Segment::new(" ".repeat(left), Some(style.clone())));
1607            }
1608            append_joined(&mut out, content, join_left);
1609            let right = width.saturating_sub(line_cell_len(&out));
1610            if right > 0 {
1611                push_joined(
1612                    &mut out,
1613                    Segment::new(" ".repeat(right), Some(style.clone())),
1614                    join_right,
1615                );
1616            }
1617        }
1618        // Left, Default, and full justification's ragged last line pad right.
1619        // Upstream reaches this through `truncate(width, pad=True)`, whose pad
1620        // is driven by the *pre*-truncate length — so padding and truncating are
1621        // mutually exclusive here and the order does not matter.
1622        Justify::Left | Justify::Full | Justify::Default => {
1623            let excess = width.saturating_sub(line_cell_len(&content));
1624            out.append(&mut content);
1625            if excess > 0 {
1626                push_joined(
1627                    &mut out,
1628                    Segment::new(" ".repeat(excess), Some(style.clone())),
1629                    join_right,
1630                );
1631            }
1632        }
1633    }
1634    out
1635}
1636
1637/// Append `segment`, extending the last segment instead when `join` allows it
1638/// and the two share a style.
1639fn push_joined(out: &mut Vec<Segment>, segment: Segment, join: bool) {
1640    match out.last_mut() {
1641        Some(last) if join && !last.control && last.style == segment.style => {
1642            last.text.push_str(&segment.text);
1643        }
1644        _ => out.push(segment),
1645    }
1646}
1647
1648/// Append `content`, joining its first segment onto the last of `out` when
1649/// `join` allows it and the two share a style.
1650fn append_joined(out: &mut Vec<Segment>, content: Vec<Segment>, join: bool) {
1651    let mut content = content.into_iter();
1652    if let Some(first) = content.next() {
1653        push_joined(out, first, join);
1654    }
1655    out.extend(content);
1656}
1657
1658/// The cell width of a rendered line.
1659fn line_cell_len(line: &[Segment]) -> usize {
1660    line.iter().map(Segment::cell_length).sum()
1661}
1662
1663/// The number of trailing whitespace *characters* on a rendered line.
1664///
1665/// Segment-level, because by the time the wrap pipeline justifies a line the
1666/// spans have already been flattened into [`Segment`]s and there is no `Text`
1667/// left to call `rstrip` on.
1668fn trailing_whitespace(line: &[Segment]) -> usize {
1669    let mut count = 0usize;
1670    for segment in line.iter().rev() {
1671        let trimmed = segment.text.trim_end_matches(is_python_space);
1672        count += segment.text[trimmed.len()..].chars().count();
1673        if !trimmed.is_empty() {
1674            break;
1675        }
1676    }
1677    count
1678}
1679
1680/// Drop the last `count` characters, discarding segments that empty out.
1681/// Segment-level counterpart of `Text.right_crop`.
1682fn right_crop_line(line: &mut Vec<Segment>, count: usize) {
1683    let mut remaining = count;
1684    while remaining > 0 {
1685        let Some(last) = line.last_mut() else { break };
1686        let length = last.text.chars().count();
1687        if length <= remaining {
1688            remaining -= length;
1689            line.pop();
1690        } else {
1691            let keep = char_to_byte(&last.text, length - remaining);
1692            last.text.truncate(keep);
1693            remaining = 0;
1694        }
1695    }
1696}
1697
1698/// Remove all trailing whitespace. Segment-level counterpart of `Text.rstrip`.
1699fn rstrip_line(line: &mut Vec<Segment>) {
1700    right_crop_line(line, trailing_whitespace(line));
1701}
1702
1703/// Remove *only as much* trailing whitespace as it takes to get the line down to
1704/// `size`, leaving the rest. Segment-level counterpart of `Text.rstrip_end`.
1705///
1706/// The length compared against `size` is a **character** count, not a cell
1707/// count: upstream's `Text.rstrip_end` uses `len(self)`, which is
1708/// `len(self.plain)`. The two only diverge on wide characters, and copying the
1709/// quirk is cheaper than explaining a one-column difference later.
1710fn rstrip_end_line(line: &mut Vec<Segment>, size: usize) {
1711    let length: usize = line.iter().map(|s| s.text.chars().count()).sum();
1712    let Some(excess) = length.checked_sub(size).filter(|excess| *excess > 0) else {
1713        return;
1714    };
1715    let whitespace = trailing_whitespace(line);
1716    if whitespace > 0 {
1717        right_crop_line(line, whitespace.min(excess));
1718    }
1719}
1720
1721/// `Text.__rich_measure__` on a plain string: `(widest word, widest line)`.
1722pub(crate) fn measure_plain(plain: &str) -> (usize, usize) {
1723    // `text.splitlines()` and `text.split()`: Python's line boundaries
1724    // (U+2028, U+0085, …) and whitespace (including U+001C..U+001F), not
1725    // just `\n` and Rust's `White_Space`.
1726    let max_line = python_splitlines(plain)
1727        .into_iter()
1728        .map(cell_len)
1729        .max()
1730        .unwrap_or(0);
1731    let min_word = plain
1732        .split(is_python_space)
1733        .filter(|word| !word.is_empty())
1734        .map(cell_len)
1735        .max()
1736        .unwrap_or(max_line);
1737    (min_word, max_line)
1738}
1739
1740/// Python's `str.isspace()`: Rust's `White_Space` plus the four ASCII
1741/// information separators U+001C..=U+001F.
1742pub(crate) fn is_python_space(c: char) -> bool {
1743    c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c)
1744}
1745
1746/// Port of Python's `str.splitlines()`: every Unicode line boundary ends a
1747/// line, `\r\n` counts once, and a trailing boundary adds no empty line.
1748pub(crate) fn python_splitlines(text: &str) -> Vec<&str> {
1749    let mut lines = Vec::new();
1750    let mut start = 0;
1751    let mut chars = text.char_indices().peekable();
1752    while let Some((i, c)) = chars.next() {
1753        if matches!(
1754            c,
1755            '\n' | '\r'
1756                | '\x0b'
1757                | '\x0c'
1758                | '\x1c'
1759                | '\x1d'
1760                | '\x1e'
1761                | '\u{85}'
1762                | '\u{2028}'
1763                | '\u{2029}'
1764        ) {
1765            lines.push(&text[start..i]);
1766            start = i + c.len_utf8();
1767            if c == '\r' && chars.peek().map(|&(_, n)| n) == Some('\n') {
1768                chars.next();
1769                start += 1;
1770            }
1771        }
1772    }
1773    if start < text.len() {
1774        lines.push(&text[start..]);
1775    }
1776    lines
1777}
1778
1779#[cfg(test)]
1780mod tests {
1781    use super::*;
1782
1783    /// Each divided line's text and its spans as (start, end, style).
1784    type Divided = Vec<(String, Vec<(usize, usize, String)>)>;
1785
1786    /// `divide` as it was before the per-span line search: every line for
1787    /// every span. The search must give the same lines, spans and span order.
1788    fn divide_by_scanning(text: &Text, offsets: &[usize]) -> Divided {
1789        if offsets.is_empty() {
1790            let spans = text
1791                .spans
1792                .iter()
1793                .map(|s| (s.start, s.end, format!("{:?}", s.style)))
1794                .collect();
1795            return vec![(text.plain.clone(), spans)];
1796        }
1797        let mut bounds = vec![0];
1798        bounds.extend(offsets.iter().copied());
1799        bounds.push(text.plain.len());
1800        let mut lines: Divided = bounds
1801            .windows(2)
1802            .map(|w| {
1803                let (start, end) = (w[0].min(text.plain.len()), w[1].min(text.plain.len()));
1804                let plain = if start < end {
1805                    text.plain[start..end].to_string()
1806                } else {
1807                    String::new()
1808                };
1809                (plain, Vec::new())
1810            })
1811            .collect();
1812        for span in &text.spans {
1813            for (index, window) in bounds.windows(2).enumerate() {
1814                let (line_start, line_end) = (window[0], window[1]);
1815                let new_start = span.start.max(line_start) - line_start;
1816                let new_end = span.end.min(line_end).saturating_sub(line_start);
1817                if new_end > new_start {
1818                    lines[index]
1819                        .1
1820                        .push((new_start, new_end, format!("{:?}", span.style)));
1821                }
1822            }
1823        }
1824        lines
1825    }
1826
1827    #[test]
1828    fn divide_matches_the_full_scan_for_sorted_offsets() {
1829        let mut state = 0x5eed_u64;
1830        let mut next = |bound: usize| {
1831            state = state
1832                .wrapping_mul(6364136223846793005)
1833                .wrapping_add(1442695040888963407);
1834            (state >> 33) as usize % bound.max(1)
1835        };
1836        for case in 0..500 {
1837            let len = next(40);
1838            let plain: String = (0..len)
1839                .map(|i| if i % 7 == 3 { '\n' } else { 'a' })
1840                .collect();
1841            let mut text = Text::new(plain.clone());
1842            for i in 0..next(12) {
1843                // Empty, overlapping and past-the-end spans included.
1844                let start = next(len + 3);
1845                let end = start + next(len + 3);
1846                text.spans.push(Span {
1847                    start,
1848                    end,
1849                    style: format!("s{i}").as_str().into(),
1850                });
1851            }
1852            let mut offsets: Vec<usize> = (0..next(10)).map(|_| next(len + 4)).collect();
1853            offsets.sort_unstable();
1854            let divided: Divided = text
1855                .divide(&offsets)
1856                .into_iter()
1857                .map(|line| {
1858                    let spans = line
1859                        .spans
1860                        .iter()
1861                        .map(|s| (s.start, s.end, format!("{:?}", s.style)))
1862                        .collect();
1863                    (line.plain, spans)
1864                })
1865                .collect();
1866            assert_eq!(
1867                divided,
1868                divide_by_scanning(&text, &offsets),
1869                "case {case}: {plain:?} {offsets:?}"
1870            );
1871        }
1872    }
1873
1874    /// An unbroken run of VS16 emoji must fold at the width like anything else.
1875    /// Measured per code point it did not: the heart reads one cell and the
1876    /// variation selector zero, so twenty hearts "fit" in thirty cells and came
1877    /// back as a single forty-cell row — wide enough to punch through the panel
1878    /// or table border drawn around it.
1879    ///
1880    /// Real rich 15.0.0, `[cell_len(l.plain) for l in Text("❤️"*20).wrap(c, 30)]`
1881    /// is `[30, 10]`.
1882    #[test]
1883    fn an_emoji_run_folds_at_the_width_instead_of_overflowing() {
1884        let hearts = "\u{2764}\u{fe0f}".repeat(20);
1885        let widths: Vec<usize> = wrapped_plain(&Text::new(&hearts), 30)
1886            .iter()
1887            .map(|line| cell_len(line))
1888            .collect();
1889        assert_eq!(widths, vec![30, 10]);
1890    }
1891
1892    /// Upstream wraps and justifies **one hard line at a time**, so the line
1893    /// full justification leaves ragged is the last of each paragraph — not just
1894    /// the last of the whole text. Flattening first stretched every paragraph
1895    /// but the final one.
1896    ///
1897    /// Real rich 15.0.0, `Text(case, justify="full").wrap(console, width)`:
1898    ///
1899    /// ```text
1900    /// width 10 -> ['word', '  indented', 'line here', 'last']
1901    /// width 30 -> ['word', '  indented line here', 'last']   (no_wrap)
1902    /// ```
1903    ///
1904    /// `line here` is the giveaway: it ends its paragraph, so upstream leaves
1905    /// the single gap alone where we widened it to `line  here`.
1906    #[test]
1907    fn full_justify_leaves_each_paragraphs_last_line_ragged() {
1908        let text = Text::new("word\n  indented line here\nlast").justify(Justify::Full);
1909        assert_eq!(
1910            wrapped_plain(&text, 10),
1911            vec!["word", "  indented", "line here", "last"]
1912        );
1913        let no_wrap = Text::new("word\n  indented line here\nlast")
1914            .justify(Justify::Full)
1915            .no_wrap(true);
1916        assert_eq!(
1917            wrapped_plain(&no_wrap, 30),
1918            vec!["word", "  indented line here", "last"]
1919        );
1920    }
1921
1922    /// `overflow="ignore"` is a hard stop in upstream's `Text.wrap`: the line is
1923    /// appended verbatim and the loop `continue`s, so it is neither justified nor
1924    /// truncated. Padding it out to the width added trailing spaces to content
1925    /// upstream returns byte-for-byte.
1926    ///
1927    /// Real rich 15.0.0, `Text(case, justify=…, overflow="ignore").wrap(c, w)`:
1928    ///
1929    /// ```text
1930    /// left   'hello'         @12 -> ['hello']
1931    /// center 'hello'         @12 -> ['hello']
1932    /// right  'trailing   '   @3  -> ['trailing   ']
1933    /// ```
1934    #[test]
1935    fn overflow_ignore_is_neither_justified_nor_truncated() {
1936        for justify in [Justify::Left, Justify::Center, Justify::Right] {
1937            let text = Text::new("hello")
1938                .justify(justify)
1939                .overflow(Overflow::Ignore);
1940            assert_eq!(wrapped_plain(&text, 12), vec!["hello"], "{justify:?}");
1941        }
1942        let text = Text::new("trailing   ")
1943            .justify(Justify::Right)
1944            .overflow(Overflow::Ignore);
1945        assert_eq!(wrapped_plain(&text, 3), vec!["trailing   "]);
1946    }
1947
1948    /// Upstream's `Lines.justify` truncates *inside* its center and right
1949    /// branches, before it pads. The order matters because cell width is not
1950    /// additive across a cut: a leading zero-width joiner eats the character
1951    /// after it, so chopping the line's tail hands a cell back and the line then
1952    /// wants padding it did not want before. Padding first measures the un-cut
1953    /// line, finds no slack, and leaves the line a column short.
1954    ///
1955    /// Real rich 15.0.0:
1956    ///
1957    /// ```text
1958    /// right    '‍┬┴⠁├╰⠃⡁⠃╯⠆┴' @9, ellipsis -> [' ‍┬┴⠁├╰⠃⡁⠃…']
1959    /// right    '‍⠃╯⠆┴'         @2, crop, no_wrap -> [' ‍⠃╯']
1960    /// center   's ‍8-o🧠e'      @4, ellipsis -> [' s  ', '‍8-o… ']
1961    /// ```
1962    #[test]
1963    fn center_and_right_truncate_before_they_pad() {
1964        let text = Text::new("\u{200d}┬┴⠁├╰⠃⡁⠃╯⠆┴")
1965            .justify(Justify::Right)
1966            .overflow(Overflow::Ellipsis);
1967        assert_eq!(wrapped_plain(&text, 9), vec![" \u{200d}┬┴⠁├╰⠃⡁⠃…"]);
1968
1969        let cropped = Text::new("\u{200d}⠃╯⠆┴")
1970            .justify(Justify::Right)
1971            .overflow(Overflow::Crop)
1972            .no_wrap(true);
1973        assert_eq!(wrapped_plain(&cropped, 2), vec![" \u{200d}⠃╯"]);
1974
1975        let centered = Text::new("s \u{200d}8-o\u{1f9e0}e")
1976            .justify(Justify::Center)
1977            .overflow(Overflow::Ellipsis);
1978        assert_eq!(wrapped_plain(&centered, 4), vec![" s  ", "\u{200d}8-o… "]);
1979    }
1980
1981    /// A line is measured and cut as one string, the way upstream's
1982    /// `Text.truncate` works on `self.plain` — not segment by segment. Cell
1983    /// width is not additive across a segment boundary: full justification
1984    /// splits the line into one segment per word, which strands the zero-width
1985    /// joiner at the end of `π‍` away from the space it swallows, so the
1986    /// per-segment sum reads eight cells for a seven-cell line and an ellipsis
1987    /// eats a character upstream keeps.
1988    ///
1989    /// Real rich 15.0.0,
1990    /// `Text("⚠1️;& π‍  ψ\u{a0}τ\u{a0}γ ⡀", justify="full", overflow="ellipsis").wrap(c, 7)`:
1991    ///
1992    /// ```text
1993    /// ['⚠1️;& ', 'π‍  ψ τ γ', '⡀']   with cell widths [7, 7, 1]
1994    /// ```
1995    #[test]
1996    fn a_line_is_measured_whole_not_segment_by_segment() {
1997        let text = Text::new("\u{26a0}1\u{fe0f};&\u{3000}\u{3c0}\u{200d}  \u{3c8}\u{a0}\u{3c4}\u{a0}\u{3b3} \u{2840}")
1998            .justify(Justify::Full)
1999            .overflow(Overflow::Ellipsis);
2000        assert_eq!(
2001            wrapped_plain(&text, 7),
2002            vec![
2003                "\u{26a0}1\u{fe0f};&\u{3000}",
2004                "\u{3c0}\u{200d}  \u{3c8}\u{a0}\u{3c4}\u{a0}\u{3b3}",
2005                "\u{2840}"
2006            ]
2007        );
2008    }
2009
2010    /// Full justification widens the gaps between words so every line but the
2011    /// last fills the width exactly.
2012    ///
2013    /// Captured verbatim from real rich 15.0.0 —
2014    /// `Lines.justify(console, 20, justify="full")` on
2015    /// `"aaa bbb ccc ddddddddddddddddddd ee ff"` yields:
2016    ///
2017    /// ```text
2018    /// 'aaa     bbb      ccc'   <- stretched to exactly 20
2019    /// 'ddddddddddddddddddd'    <- one word: nothing to widen, and the
2020    ///                             trailing space wrapping left is dropped
2021    /// 'ee ff'                  <- final line untouched: NOT padded to width
2022    /// ```
2023    ///
2024    /// Two details worth pinning: the slack is handed out from the rightmost
2025    /// gap backwards (so the gaps are 5 then 6, not 6 then 5), and the last
2026    /// line is the one case where a justified line is left short of the width.
2027    #[test]
2028    fn full_justify_matches_upstream() {
2029        let text = Text::new("aaa bbb ccc ddddddddddddddddddd ee ff").justify(Justify::Full);
2030        let plain: Vec<String> = text
2031            .render_lines(&Theme::default_theme(), &Style::new(), Some(20))
2032            .iter()
2033            .map(|line| line.iter().map(|s| s.text.as_str()).collect())
2034            .collect();
2035        assert_eq!(
2036            plain,
2037            vec!["aaa     bbb      ccc", "ddddddddddddddddddd", "ee ff"]
2038        );
2039        assert_eq!(plain[0].chars().count(), 20);
2040    }
2041
2042    fn wrapped_plain(text: &Text, width: usize) -> Vec<String> {
2043        text.render_lines(&Theme::default_theme(), &Style::new(), Some(width))
2044            .iter()
2045            .map(|line| line.iter().map(|s| s.text.as_str()).collect())
2046            .collect()
2047    }
2048
2049    /// Wrapping hands each line the space that ended it, and upstream's
2050    /// `Lines.justify` throws that space away (`line.rstrip()`) before centring
2051    /// or right-aligning — but *not* before left-aligning or full-justifying.
2052    ///
2053    /// Captured verbatim from real rich 15.0.0,
2054    /// `Text(case, justify=…).wrap(console, 20)`:
2055    ///
2056    /// ```text
2057    /// center 'abcd efgh ijklmnop qrst'  -> ' abcd efgh ijklmnop '
2058    /// right  'abcd efgh ijklmnop qrst'  -> '  abcd efgh ijklmnop'
2059    /// right  'aaaa bbbb cccc dddd eeee' -> ' aaaa bbbb cccc dddd'
2060    /// left   'abcd efgh ijklmnop qrst'  -> 'abcd efgh ijklmnop  '
2061    /// ```
2062    ///
2063    /// Counting the wrap space as content puts the centred line one column too
2064    /// far left and the right-aligned line a whole column short of the edge.
2065    #[test]
2066    fn center_and_right_rstrip_the_wrap_space() {
2067        let wrapped = "abcd efgh ijklmnop qrst";
2068        assert_eq!(
2069            wrapped_plain(&Text::new(wrapped).justify(Justify::Center), 20)[0],
2070            " abcd efgh ijklmnop "
2071        );
2072        assert_eq!(
2073            wrapped_plain(&Text::new(wrapped).justify(Justify::Right), 20)[0],
2074            "  abcd efgh ijklmnop"
2075        );
2076        assert_eq!(
2077            wrapped_plain(
2078                &Text::new("aaaa bbbb cccc dddd eeee").justify(Justify::Right),
2079                20
2080            )[0],
2081            " aaaa bbbb cccc dddd"
2082        );
2083        // Left is the control: upstream pads it without stripping, so the
2084        // trailing space stays part of the line and nothing shifts.
2085        assert_eq!(
2086            wrapped_plain(&Text::new(wrapped).justify(Justify::Left), 20)[0],
2087            "abcd efgh ijklmnop  "
2088        );
2089    }
2090
2091    /// `divide_line` measures a word *with* its trailing space, so a line whose
2092    /// last word ends flush with the width comes back one character too long.
2093    /// Upstream's `Text.wrap` calls `rstrip_end(width)` on every divided line to
2094    /// hand that back before overflow is applied.
2095    ///
2096    /// Real rich 15.0.0, `Text(case, overflow="ellipsis").wrap(console, 10)`:
2097    ///
2098    /// ```text
2099    /// 'abcdefghij more'   -> ['abcdefghij', 'more']   <- no ellipsis
2100    /// 'abcdefghijkl more' -> ['abcdefghi…', 'more']   <- genuinely too long
2101    /// ```
2102    ///
2103    /// Skipping the rstrip makes the first case measure 11 cells, so the
2104    /// ellipsis fires and eats the `j` that upstream keeps.
2105    #[test]
2106    fn rstrip_end_stops_the_wrap_space_from_triggering_an_ellipsis() {
2107        assert_eq!(
2108            wrapped_plain(
2109                &Text::new("abcdefghij more").overflow(Overflow::Ellipsis),
2110                10
2111            ),
2112            vec!["abcdefghij", "more"]
2113        );
2114        assert_eq!(
2115            wrapped_plain(
2116                &Text::new("abcdefghijkl more").overflow(Overflow::Ellipsis),
2117                10
2118            ),
2119            vec!["abcdefghi…", "more"]
2120        );
2121    }
2122
2123    /// `Text.apply_meta` / `Text.on`, as rich 15.0.0 records them.
2124    #[test]
2125    fn apply_meta_and_on_add_meta_spans() {
2126        use crate::style::{Meta, MetaValue};
2127        let mut text = Text::new("hello");
2128        text.apply_meta([("a", MetaValue::Int(1))].into_iter().collect(), 1, Some(3));
2129        text.on(None, &[("click", MetaValue::Str("x".into()))]);
2130        text.apply_meta(Meta::new(), 0, None);
2131        let spans = text.spans();
2132        assert_eq!(spans.len(), 2);
2133        assert_eq!((spans[0].start, spans[0].end), (1, 3));
2134        let meta = |span: &Span| match &span.style {
2135            StyleType::Style(style) => style.meta(),
2136            StyleType::Name(_) => Meta::new(),
2137        };
2138        assert_eq!(
2139            meta(&spans[0]),
2140            [("a", MetaValue::Int(1))].into_iter().collect()
2141        );
2142        assert_eq!(
2143            meta(&spans[1]),
2144            [("@click", MetaValue::Str("x".into()))]
2145                .into_iter()
2146                .collect()
2147        );
2148    }
2149
2150    #[test]
2151    fn append_creates_spans() {
2152        let mut text = Text::new("");
2153        text.append("hello", Some(Style::parse("bold").unwrap().into()));
2154        text.append(" world", None);
2155        assert_eq!(text.plain(), "hello world");
2156        assert_eq!(text.spans().len(), 1);
2157    }
2158
2159    #[test]
2160    fn render_flattens_overlapping_spans() {
2161        let mut text = Text::new("abcdef");
2162        text.stylize(Style::parse("bold").unwrap(), 0, 4);
2163        text.stylize(Style::parse("red").unwrap(), 2, 6);
2164        let segments = text.render(&Theme::default_theme(), &Style::new());
2165        // Boundaries at 0,2,4,6 -> "ab"(bold) "cd"(bold+red) "ef"(red)
2166        let rendered: Vec<_> = segments.iter().map(|s| s.text.clone()).collect();
2167        assert_eq!(rendered, vec!["ab", "cd", "ef"]);
2168    }
2169
2170    /// `Text::truncate` on its own, against real rich 15.0.0. `fold` and `crop`
2171    /// deliberately agree: folding is a wrapping behaviour, and truncation has
2172    /// no line to fold onto.
2173    #[test]
2174    fn truncate_matches_upstream() {
2175        for (overflow, expected) in [
2176            (Overflow::Fold, "hello"),
2177            (Overflow::Crop, "hello"),
2178            (Overflow::Ellipsis, "hell…"),
2179            (Overflow::Ignore, "hello world"),
2180        ] {
2181            let mut text = Text::new("hello world");
2182            text.truncate(5, Some(overflow), false);
2183            assert_eq!(text.plain(), expected, "overflow {overflow:?}");
2184        }
2185    }
2186
2187    /// `pad` fills out to the width, but only when the text is short — a text
2188    /// that is already too long is cut, never padded.
2189    #[test]
2190    fn truncate_pads_only_when_short() {
2191        let mut short = Text::new("hi");
2192        short.truncate(6, Some(Overflow::Crop), true);
2193        assert_eq!(short.plain(), "hi    ");
2194
2195        let mut exact = Text::new("hi");
2196        exact.truncate(2, Some(Overflow::Crop), true);
2197        assert_eq!(exact.plain(), "hi");
2198    }
2199
2200    /// Truncating must not leave a span pointing past the end of the string.
2201    #[test]
2202    fn truncate_trims_dangling_spans() {
2203        let mut text = Text::new("hello world");
2204        text.stylize(Style::parse("bold").unwrap(), 6, 11);
2205        text.stylize(Style::parse("red").unwrap(), 0, 5);
2206        text.truncate(3, Some(Overflow::Crop), false);
2207        assert_eq!(text.plain(), "hel");
2208        // The "world" span starts past the new end and is dropped entirely; the
2209        // "hello" span survives, clamped.
2210        assert_eq!(text.spans().len(), 1);
2211        assert!(text.spans().iter().all(|s| s.end <= text.plain().len()));
2212    }
2213
2214    use crate::protocol::Renderable;
2215
2216    /// The overflow method may come from the text or from the console options,
2217    /// and the text's own setting wins — mirroring upstream's
2218    /// `self.overflow or options.overflow or DEFAULT_OVERFLOW`.
2219    #[test]
2220    fn text_overflow_beats_console_options() {
2221        let console = crate::Console::builder().width(8).build();
2222        let mut options = console.options();
2223        options.overflow = Some(Overflow::Ellipsis);
2224        options.no_wrap = Some(true);
2225
2226        // Nothing set on the text: the options decide.
2227        let from_options = Text::new("the quick brown fox");
2228        assert_eq!(
2229            plain_of(&from_options.rich_render(&console, &options)),
2230            "the qui…"
2231        );
2232
2233        // Set on the text: the text decides, and the options are ignored.
2234        let from_text = Text::new("the quick brown fox").overflow(Overflow::Crop);
2235        assert_eq!(
2236            plain_of(&from_text.rich_render(&console, &options)),
2237            "the quic"
2238        );
2239    }
2240
2241    /// With no overflow anywhere, upstream's default applies: fold.
2242    #[test]
2243    fn overflow_defaults_to_fold() {
2244        let console = crate::Console::builder().width(8).build();
2245        let text = Text::new("supercalifragilistic");
2246        let rendered = plain_of(&text.rich_render(&console, &console.options()));
2247        assert_eq!(rendered, "supercal\nifragili\nstic");
2248    }
2249
2250    /// Concatenate the visible text of a segment stream, for assertions that
2251    /// care about layout rather than styling.
2252    fn plain_of(segments: &[Segment]) -> String {
2253        segments
2254            .iter()
2255            .filter(|s| !s.control)
2256            .map(|s| s.text.as_str())
2257            .collect()
2258    }
2259
2260    /// `Text::new` stripped control codes but `append` did not, so every path
2261    /// that builds text incrementally — Markdown, Syntax, plain files — leaked
2262    /// them to the terminal. A backspace run is a spoofing tool: the reader sees
2263    /// the overwritten text, not what the file says.
2264    #[test]
2265    fn append_strips_control_codes_like_new() {
2266        let mut text = Text::new("");
2267        text.append("FAILED\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}PASSED", None);
2268        assert_eq!(text.plain(), "FAILEDPASSED");
2269
2270        for code in ['\u{7}', '\u{8}', '\u{b}', '\u{c}', '\u{d}'] {
2271            let mut text = Text::new("");
2272            text.append(&format!("a{code}b"), None);
2273            assert_eq!(text.plain(), "ab", "control code {code:?} survived append");
2274        }
2275    }
2276
2277    /// Upstream's `strip_control_codes` keeps NUL and ESC; only BEL, backspace,
2278    /// vertical tab, form feed and carriage return go.
2279    #[test]
2280    fn append_keeps_the_codes_upstream_keeps() {
2281        let mut text = Text::new("");
2282        text.append("a\u{0}b\u{1b}c", None);
2283        assert_eq!(text.plain(), "a\u{0}b\u{1b}c");
2284    }
2285}