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