Skip to main content

rich/
text.rs

1//! Styled text with spans.
2//!
3//! Port of upstream `rich/text.py` (core subset). [`Text`] is a plain string
4//! plus a list of [`Span`]s, each applying a [`Style`] to a byte range. Spans
5//! may overlap and nest; [`Text::render`] flattens them into non-overlapping
6//! [`Segment`]s by combining every span covering each run.
7
8use crate::cells::{cell_len, set_cell_size};
9use crate::console::{Justify, Overflow};
10use crate::errors::Result;
11use crate::markup;
12use crate::segment::Segment;
13use crate::style::{Style, StyleType};
14use crate::theme::Theme;
15
16/// The control codes upstream drops in `Text.__init__` (`strip_control_codes`):
17/// BEL, backspace, vertical tab, form feed and carriage return. Tab and newline
18/// are layout, not control, and are kept.
19///
20/// Crate-visible because **every** producer of a `Text` plus its spans must agree
21/// on this set. `markup::render` computes span byte-offsets as it builds the
22/// plain string; if it kept a code that `Text::new` later removed, the content
23/// would shift left while the offsets stayed put, and a boundary landing inside
24/// a multi-byte character panics on slicing.
25pub(crate) fn is_control_code(c: char) -> bool {
26    matches!(c, '\u{7}' | '\u{8}' | '\u{b}' | '\u{c}' | '\r')
27}
28
29/// Cell width of a tab stop. Upstream's `Console.tab_size` default; a per-console
30/// override is not ported yet (see `docs/DIVERGENCES.md`).
31pub const DEFAULT_TAB_SIZE: usize = 8;
32
33/// A style applied to a byte range `[start, end)` of a [`Text`]'s plain string.
34/// Mirrors `rich.text.Span`.
35///
36/// The style may be a *name* rather than a resolved [`Style`]; see [`StyleType`].
37/// Names are resolved when the text is rendered, against the theme of whichever
38/// console renders it.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Span {
41    pub start: usize,
42    pub end: usize,
43    pub style: StyleType,
44}
45
46/// Styled text. Mirrors `rich.text.Text`.
47#[derive(Debug, Clone, Default)]
48pub struct Text {
49    plain: String,
50    spans: Vec<Span>,
51    /// A base style applied to the whole text. May be an unresolved name.
52    style: StyleType,
53    /// How lines are justified within the render width.
54    justify: Justify,
55    /// What to do with lines wider than the render width. `None` defers to the
56    /// console options, then to [`Overflow::Fold`].
57    overflow: Option<Overflow>,
58    /// Whether to skip wrapping. `None` defers to the console options, then to
59    /// `false`.
60    no_wrap: Option<bool>,
61}
62
63impl Text {
64    /// Strip the control codes upstream removes in `Text.__init__`
65    /// (`strip_control_codes`): BEL, backspace, vertical tab, form feed and
66    /// carriage return. Tab and newline are deliberately kept — they are layout,
67    /// not control.
68    fn strip_control_codes(text: &str) -> String {
69        if text.chars().any(is_control_code) {
70            text.chars().filter(|c| !is_control_code(*c)).collect()
71        } else {
72            text.to_string()
73        }
74    }
75
76    /// Plain, unstyled text.
77    pub fn new(plain: impl Into<String>) -> Self {
78        Text {
79            plain: Text::strip_control_codes(&plain.into()),
80            spans: Vec::new(),
81            style: StyleType::default(),
82            justify: Justify::Default,
83            overflow: None,
84            no_wrap: None,
85        }
86    }
87
88    /// Text with a base style, which may be a style *name* resolved at render
89    /// time (`Text::styled("hi", "repr.number")`) or a resolved [`Style`].
90    pub fn styled(plain: impl Into<String>, style: impl Into<StyleType>) -> Self {
91        Text {
92            // Strips too: upstream's `Text.__init__` does this regardless of
93            // style, and a constructor that skipped it would reintroduce the
94            // offset divergence the moment a caller added spans.
95            plain: Text::strip_control_codes(&plain.into()),
96            spans: Vec::new(),
97            style: style.into(),
98            justify: Justify::Default,
99            overflow: None,
100            no_wrap: None,
101        }
102    }
103
104    /// Set how lines are justified within the render width (builder form).
105    pub fn justify(mut self, justify: Justify) -> Self {
106        self.justify = justify;
107        self
108    }
109
110    /// Set how lines are justified within the render width.
111    pub fn set_justify(&mut self, justify: Justify) {
112        self.justify = justify;
113    }
114
115    /// This text's own justify method.
116    pub fn get_justify(&self) -> Justify {
117        self.justify
118    }
119
120    /// Set what happens to lines wider than the render width (builder form).
121    pub fn overflow(mut self, overflow: Overflow) -> Self {
122        self.overflow = Some(overflow);
123        self
124    }
125
126    /// Set what happens to lines wider than the render width. Pass `None` to
127    /// defer to the console options.
128    pub fn set_overflow(&mut self, overflow: Option<Overflow>) {
129        self.overflow = overflow;
130    }
131
132    /// This text's own overflow method, if it set one.
133    pub fn get_overflow(&self) -> Option<Overflow> {
134        self.overflow
135    }
136
137    /// Disable (or re-enable) wrapping for this text (builder form).
138    pub fn no_wrap(mut self, no_wrap: bool) -> Self {
139        self.no_wrap = Some(no_wrap);
140        self
141    }
142
143    /// Disable (or re-enable) wrapping. Pass `None` to defer to the console
144    /// options.
145    pub fn set_no_wrap(&mut self, no_wrap: Option<bool>) {
146        self.no_wrap = no_wrap;
147    }
148
149    /// This text's own no-wrap setting, if it set one.
150    pub fn get_no_wrap(&self) -> Option<bool> {
151        self.no_wrap
152    }
153
154    /// Shorten this text to at most `max_width` cells, optionally padding it out
155    /// to exactly `max_width` when it is shorter. Port of `Text.truncate`.
156    ///
157    /// `overflow` defaults to this text's own method, then to [`Overflow::Fold`];
158    /// [`Overflow::Ignore`] leaves the text alone entirely. Note that `Fold` and
159    /// `Crop` behave identically here — folding is a property of *wrapping*, and
160    /// a line that has already been wrapped can only be cut.
161    pub fn truncate(&mut self, max_width: usize, overflow: Option<Overflow>, pad: bool) {
162        let overflow = overflow.or(self.overflow).unwrap_or(Overflow::Fold);
163        if overflow == Overflow::Ignore {
164            return;
165        }
166        let length = cell_len(&self.plain);
167        if length > max_width {
168            let plain = if overflow == Overflow::Ellipsis {
169                // `…` is one cell wide, so cut one short and add it back.
170                format!(
171                    "{}…",
172                    set_cell_size(&self.plain, max_width.saturating_sub(1))
173                )
174            } else {
175                set_cell_size(&self.plain, max_width)
176            };
177            self.set_plain(plain);
178        } else if pad {
179            let plain = set_cell_size(&self.plain, max_width);
180            self.set_plain(plain);
181        }
182    }
183
184    /// Replace the plain string, clamping every span into the new length so no
185    /// span can dangle past the end. Upstream's `Text.plain` setter does the
186    /// same via `_trim_spans`.
187    fn set_plain(&mut self, plain: String) {
188        let length = plain.len();
189        self.plain = plain;
190        self.spans.retain(|span| span.start < length);
191        for span in &mut self.spans {
192            span.end = span.end.min(length);
193        }
194    }
195
196    /// An empty `Text` carrying this one's style, justify, overflow and no-wrap.
197    /// Port of `Text.blank_copy`.
198    pub fn blank_copy(&self) -> Text {
199        Text {
200            plain: String::new(),
201            spans: Vec::new(),
202            style: self.style.clone(),
203            justify: self.justify,
204            overflow: self.overflow,
205            no_wrap: self.no_wrap,
206        }
207    }
208
209    /// Cut this text at each byte offset in `offsets`, returning the pieces.
210    /// Port of `Text.divide`.
211    ///
212    /// Every piece inherits the base style, justify, overflow and no-wrap, and
213    /// each span is re-based onto the pieces it covers. Spans that would come out
214    /// empty are dropped, matching upstream's `new_end > new_start`.
215    ///
216    /// Offsets are **byte** offsets (as everywhere else in this port's span
217    /// arithmetic) and must fall on `char` boundaries.
218    pub fn divide(&self, offsets: &[usize]) -> Vec<Text> {
219        if offsets.is_empty() {
220            return vec![self.clone()];
221        }
222        let mut bounds = Vec::with_capacity(offsets.len() + 2);
223        bounds.push(0);
224        bounds.extend(offsets.iter().copied());
225        bounds.push(self.plain.len());
226
227        let mut lines: Vec<Text> = bounds
228            .windows(2)
229            .map(|w| {
230                let (start, end) = (w[0].min(self.plain.len()), w[1].min(self.plain.len()));
231                let mut line = self.blank_copy();
232                if start < end {
233                    line.plain = self.plain[start..end].to_string();
234                }
235                line
236            })
237            .collect();
238
239        for span in &self.spans {
240            for (index, window) in bounds.windows(2).enumerate() {
241                let (line_start, line_end) = (window[0], window[1]);
242                let new_start = span.start.max(line_start) - line_start;
243                let new_end = span.end.min(line_end).saturating_sub(line_start);
244                if new_end > new_start {
245                    lines[index].spans.push(Span {
246                        start: new_start,
247                        end: new_end,
248                        style: span.style.clone(),
249                    });
250                }
251            }
252        }
253        lines
254    }
255
256    /// Split on `separator`. Port of `Text.split`.
257    ///
258    /// `include_separator` keeps the separator at the end of each piece.
259    /// `allow_blank` keeps the trailing empty piece that a text ending in the
260    /// separator would otherwise produce.
261    ///
262    /// # Panics
263    /// If `separator` is empty, which upstream asserts against.
264    pub fn split(&self, separator: &str, include_separator: bool, allow_blank: bool) -> Vec<Text> {
265        assert!(!separator.is_empty(), "separator must not be empty");
266        if !self.plain.contains(separator) {
267            return vec![self.clone()];
268        }
269        let matches: Vec<usize> = self
270            .plain
271            .match_indices(separator)
272            .map(|(i, _)| i)
273            .collect();
274        let mut lines = if include_separator {
275            let offsets: Vec<usize> = matches.iter().map(|s| s + separator.len()).collect();
276            self.divide(&offsets)
277        } else {
278            // Cut on both sides of every separator, then drop the separators.
279            let mut offsets = Vec::with_capacity(matches.len() * 2);
280            for start in &matches {
281                offsets.push(*start);
282                offsets.push(start + separator.len());
283            }
284            self.divide(&offsets)
285                .into_iter()
286                .filter(|line| line.plain != separator)
287                .collect()
288        };
289        if !allow_blank && self.plain.ends_with(separator) {
290            lines.pop();
291        }
292        lines
293    }
294
295    /// Pad both sides with `count` copies of `character`. Port of `Text.pad`.
296    pub fn pad(&mut self, count: usize, character: char) {
297        self.pad_left(count, character);
298        self.pad_right(count, character);
299    }
300
301    /// Pad the left with `count` copies of `character`, shifting every span to
302    /// follow the text. Port of `Text.pad_left`.
303    pub fn pad_left(&mut self, count: usize, character: char) {
304        if count == 0 {
305            return;
306        }
307        let padding: String = std::iter::repeat_n(character, count).collect();
308        let offset = padding.len();
309        self.plain.insert_str(0, &padding);
310        for span in &mut self.spans {
311            span.start += offset;
312            span.end += offset;
313        }
314    }
315
316    /// Pad the right with `count` copies of `character`. Port of
317    /// `Text.pad_right`. Spans are untouched, so the padding is unstyled.
318    pub fn pad_right(&mut self, count: usize, character: char) {
319        if count == 0 {
320            return;
321        }
322        self.plain.extend(std::iter::repeat_n(character, count));
323    }
324
325    /// Drop the last `amount` bytes, clipping any span that reached into them.
326    /// Port of `Text.right_crop`.
327    pub fn right_crop(&mut self, amount: usize) {
328        if amount == 0 {
329            return;
330        }
331        let max_offset = self.plain.len().saturating_sub(amount);
332        let plain = self.plain[..max_offset].to_string();
333        self.set_plain(plain);
334    }
335
336    /// Remove trailing whitespace. Port of `Text.rstrip`.
337    pub fn rstrip(&mut self) {
338        let plain = self.plain.trim_end().to_string();
339        self.set_plain(plain);
340    }
341
342    /// Remove *only as much* trailing whitespace as it takes to get down to
343    /// `size` cells, leaving the rest. Port of `Text.rstrip_end`.
344    ///
345    /// This is what lets a wrapped line keep the space that ended it while a
346    /// line that overshot the width gives its padding back.
347    pub fn rstrip_end(&mut self, size: usize) {
348        let length = self.cell_len();
349        if length <= size {
350            return;
351        }
352        let excess = length - size;
353        let whitespace = self.plain.len() - self.plain.trim_end().len();
354        if whitespace > 0 {
355            self.right_crop(whitespace.min(excess));
356        }
357    }
358
359    /// Replace tabs with spaces up to the next `tab_size` stop. Port of
360    /// `Text.expand_tabs`.
361    ///
362    /// Styles extend over the inserted spaces, so a styled tab pads in its own
363    /// style rather than punching an unstyled hole (upstream reaches the same
364    /// result via `extend_style`).
365    /// Append `count` spaces, extending any span that reached the end so the
366    /// padding takes its style. Port of `Text.extend_style`.
367    fn extend_style(&mut self, count: usize) {
368        if count == 0 {
369            return;
370        }
371        let length = self.plain.len();
372        self.plain.extend(std::iter::repeat_n(' ', count));
373        for span in &mut self.spans {
374            if span.end >= length {
375                span.end += count;
376            }
377        }
378    }
379
380    pub fn expand_tabs(&mut self, tab_size: usize) {
381        if !self.plain.contains('\t') || tab_size == 0 {
382            return;
383        }
384        // Rebuilt part-by-part rather than by remapping offsets, because the
385        // *split* is observable: upstream turns each tab-terminated run into its
386        // own piece, so a span crossing several tabs comes back as several spans
387        // and renders as several segments. Remapping offsets keeps one span and
388        // emits one segment — same colours, different bytes.
389        let mut result = Text::new("");
390        for line in self.split("\n", true, false) {
391            if !line.plain.contains('\t') {
392                result = result.append_text(&line);
393                continue;
394            }
395            let mut cell_position = 0usize;
396            for mut part in line.split("\t", true, false) {
397                if part.plain.ends_with('\t') {
398                    // The tab becomes one space, then the run is padded out to
399                    // the next stop — so a tab always advances at least one cell.
400                    part.plain.pop();
401                    part.plain.push(' ');
402                    cell_position += part.cell_len();
403                    let remainder = cell_position % tab_size;
404                    if remainder != 0 {
405                        let spaces = tab_size - remainder;
406                        part.extend_style(spaces);
407                        cell_position += spaces;
408                    }
409                } else {
410                    cell_position += part.cell_len();
411                }
412                result = result.append_text(&part);
413            }
414        }
415        self.plain = result.plain;
416        self.spans = result.spans;
417    }
418
419    /// Join `lines` with this text as the separator, carrying each piece's base
420    /// style across as a covering span. Port of `Text.join`.
421    pub fn join(&self, lines: &[Text]) -> Text {
422        let mut joined = self.blank_copy();
423        let last = lines.len().saturating_sub(1);
424        for (index, line) in lines.iter().enumerate() {
425            joined = joined.append_text(line);
426            if !self.plain.is_empty() && index != last {
427                joined = joined.append_text(self);
428            }
429        }
430        joined
431    }
432
433    /// Style every occurrence of any of `words`. Port of `Text.highlight_words`,
434    /// returning the number of matches.
435    pub fn highlight_words(
436        &mut self,
437        words: &[&str],
438        style: impl Into<StyleType>,
439        case_sensitive: bool,
440    ) -> Result<usize> {
441        let alternation = words
442            .iter()
443            .map(|word| fancy_regex::escape(word).into_owned())
444            .collect::<Vec<_>>()
445            .join("|");
446        if alternation.is_empty() {
447            return Ok(0);
448        }
449        let pattern = if case_sensitive {
450            alternation
451        } else {
452            format!("(?i){alternation}")
453        };
454        self.highlight_regex(&pattern, Some(style.into()), "")
455    }
456
457    /// Style every match of `pattern`, returning the number of matches. Full port
458    /// of `Text.highlight_regex`.
459    ///
460    /// `style`, when given, styles the whole match. Each **named group** is then
461    /// styled with `{style_prefix}{name}` as a style *name*, left for the theme
462    /// to resolve at render time — which is how a highlighter colours its groups
463    /// without ever seeing a console.
464    ///
465    /// Groups that did not participate in the match, and zero-width ones, are
466    /// skipped.
467    pub fn highlight_regex(
468        &mut self,
469        pattern: &str,
470        style: Option<StyleType>,
471        style_prefix: &str,
472    ) -> Result<usize> {
473        let regex = fancy_regex::Regex::new(pattern)
474            .map_err(|e| crate::errors::RichError::Regex(format!("invalid pattern: {e}")))?;
475        Ok(self.highlight_with_regex(&regex, style, style_prefix))
476    }
477
478    /// As [`highlight_regex`](Self::highlight_regex) with an already-compiled
479    /// pattern, for callers that apply the same patterns repeatedly.
480    ///
481    /// A match that errors mid-scan (a `fancy-regex` backtrack-limit hit) stops
482    /// the scan and keeps the spans found so far, rather than discarding them.
483    pub(crate) fn highlight_with_regex(
484        &mut self,
485        regex: &fancy_regex::Regex,
486        style: Option<StyleType>,
487        style_prefix: &str,
488    ) -> usize {
489        // Capture-definition order, matching upstream's `match.groupdict()`.
490        let names: Vec<(usize, String)> = regex
491            .capture_names()
492            .enumerate()
493            .filter_map(|(index, name)| name.map(|name| (index, name.to_string())))
494            .collect();
495
496        // Scanning borrows the plain string while the spans are pushed, so move
497        // it out and put it back — no copy, and no fighting the borrow checker.
498        let plain = std::mem::take(&mut self.plain);
499        let mut count = 0;
500        for captures in regex.captures_iter(&plain) {
501            let Ok(captures) = captures else { break };
502            if let (Some(style), Some(whole)) = (style.as_ref(), captures.get(0)) {
503                if whole.end() > whole.start() {
504                    self.spans.push(Span {
505                        start: whole.start(),
506                        end: whole.end(),
507                        style: style.clone(),
508                    });
509                }
510            }
511            count += 1;
512            for (index, name) in &names {
513                if let Some(group) = captures.get(*index) {
514                    if group.end() > group.start() {
515                        self.spans.push(Span {
516                            start: group.start(),
517                            end: group.end(),
518                            style: StyleType::Name(format!("{style_prefix}{name}")),
519                        });
520                    }
521                }
522            }
523        }
524        self.plain = plain;
525        count
526    }
527
528    /// Build styled text from console markup. Port of `Text.from_markup`.
529    ///
530    /// Tag names are stored on the spans and resolved when the text is rendered,
531    /// so no theme is needed here.
532    pub fn from_markup(markup_text: &str) -> Result<Text> {
533        markup::render(markup_text)
534    }
535
536    /// The unstyled string content.
537    pub fn plain(&self) -> &str {
538        &self.plain
539    }
540
541    /// The spans currently applied.
542    pub fn spans(&self) -> &[Span] {
543        &self.spans
544    }
545
546    /// Length in terminal cells.
547    pub fn cell_len(&self) -> usize {
548        cell_len(&self.plain)
549    }
550
551    /// True when there is no content.
552    pub fn is_empty(&self) -> bool {
553        self.plain.is_empty()
554    }
555
556    /// Append more text, optionally under `style` (a resolved [`Style`] or a
557    /// style name).
558    pub fn append(&mut self, text: &str, style: Option<StyleType>) {
559        let start = self.plain.len();
560        // Strip here as well as in `new`: upstream's `Text.append` runs the same
561        // `strip_control_codes`, and skipping it let BEL, backspace, vertical
562        // tab and form feed reach the terminal through every path that builds
563        // text incrementally — Markdown, Syntax and plain files. A backspace run
564        // is a spoofing tool: `FAILED\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}PASSED`
565        // displays as `PASSED`.
566        self.plain.push_str(&Text::strip_control_codes(text));
567        let end = self.plain.len();
568        if let Some(style) = style {
569            self.spans.push(Span { start, end, style });
570        }
571    }
572
573    /// Append another `Text`, carrying over its base style (as a covering span)
574    /// and all of its spans, shifted to their new offsets. Port of
575    /// `Text.append_text`. Consumes `self` and returns it for chaining.
576    pub fn append_text(mut self, other: &Text) -> Text {
577        let offset = self.plain.len();
578        self.plain.push_str(&other.plain);
579        let end = self.plain.len();
580        if !other.style.is_null_style() {
581            self.spans.push(Span {
582                start: offset,
583                end,
584                style: other.style.clone(),
585            });
586        }
587        for span in &other.spans {
588            self.spans.push(Span {
589                start: span.start + offset,
590                end: span.end + offset,
591                style: span.style.clone(),
592            });
593        }
594        self
595    }
596
597    /// Apply `style` to the byte range `[start, end)`. Port of `Text.stylize`,
598    /// including its argument order.
599    ///
600    /// `style` may be a resolved [`Style`] or a name (`"repr.number"`) left for
601    /// the renderer to look up. Byte offsets, not char offsets; ASCII-only
602    /// callers such as highlighters are unaffected by the distinction.
603    ///
604    /// A range that is empty or inverted is ignored, which is what gives us
605    /// upstream's `end > start` skip for non-participating regex groups.
606    pub fn stylize(&mut self, style: impl Into<StyleType>, start: usize, end: usize) {
607        let end = end.min(self.plain.len());
608        if start >= end {
609            return;
610        }
611        self.spans.push(Span {
612            start,
613            end,
614            style: style.into(),
615        });
616    }
617
618    /// Push a raw span (used by the markup parser).
619    pub(crate) fn push_span(&mut self, span: Span) {
620        self.spans.push(span);
621    }
622
623    /// Set the whole-text base style, resolved or named.
624    pub fn set_base_style(&mut self, style: impl Into<StyleType>) {
625        self.style = style.into();
626    }
627
628    /// Flatten into non-overlapping segments (newlines become [`Segment::line`]),
629    /// combining `base_style`, this text's base style, and every covering span.
630    /// Does **not** wrap. Port of the core of `Text.render`.
631    ///
632    /// Named span styles are resolved against `theme`.
633    pub fn render(&self, theme: &Theme, base_style: &Style) -> Vec<Segment> {
634        self.render_joined(theme, base_style, None)
635    }
636
637    /// The `(minimum, maximum)` cell width of this text: `maximum` is the widest
638    /// hard line, `minimum` the widest word. Port of `Text.__rich_measure__`.
639    pub fn measurement(&self) -> (usize, usize) {
640        // Measured against the tab-EXPANDED text. Upstream measures the raw
641        // string, where `cell_len` counts a tab as zero cells, and gets away
642        // with it because nothing upstream feeds a `Text`'s own measurement back
643        // in as its render width.
644        //
645        // This port does: `Console::render_segments` shrinks `max_width` to the
646        // measurement before rendering, standing in for upstream's
647        // `_collect_renderables`, which rebuilds a printed `Text` through
648        // `Text.join` and drops its `justify` on the way (which is why
649        // `print(Text("hi", justify="center"))` is *not* centred upstream).
650        // Measuring raw here therefore hands the renderer three cells for
651        // `"a\tb\tc"` and it comes back as `a`/`b`/`c` on three lines, where
652        // upstream prints `a       b       c`.
653        //
654        // So this is knowingly non-upstream, and it is the wrong half of the
655        // pair to fix: the measurement should be raw and the shrink-to-fit in
656        // `console.rs` should be replaced by the `Text.join` semantics. Both
657        // ends have to move together, and `console.rs` is not this file. See
658        // DIVERGENCES for the tabbed-`Panel` width this leaves too wide.
659        let expanded;
660        let plain = if self.plain.contains('\t') {
661            let mut text = self.clone();
662            text.expand_tabs(DEFAULT_TAB_SIZE);
663            expanded = text.plain;
664            &expanded
665        } else {
666            &self.plain
667        };
668        let max_line = plain.split('\n').map(cell_len).max().unwrap_or(0);
669        let min_word = plain
670            .split_whitespace()
671            .map(cell_len)
672            .max()
673            .unwrap_or(max_line);
674        (min_word, max_line)
675    }
676
677    /// Render into visual lines, wrapping each hard line to `width` cells when
678    /// `Some`, and justifying per this text's own justify.
679    pub fn render_lines(
680        &self,
681        theme: &Theme,
682        base_style: &Style,
683        width: Option<usize>,
684    ) -> Vec<Vec<Segment>> {
685        self.render_lines_justified(theme, base_style, width, self.justify)
686    }
687
688    /// Like [`render_lines`](Self::render_lines) but with an explicit `justify`
689    /// (used by the console to apply `options.justify`).
690    pub fn render_lines_justified(
691        &self,
692        theme: &Theme,
693        base_style: &Style,
694        width: Option<usize>,
695        justify: Justify,
696    ) -> Vec<Vec<Segment>> {
697        self.render_lines_wrapped(
698            theme,
699            base_style,
700            width,
701            justify,
702            self.overflow.unwrap_or(Overflow::Fold),
703            self.no_wrap.unwrap_or(false),
704        )
705    }
706
707    /// The full wrap-justify-truncate pipeline, with every knob resolved by the
708    /// caller. Port of `Text.wrap`.
709    ///
710    /// Lines are split on `\n`, wrapped to `width` (folding over-long words only
711    /// when `overflow` is [`Overflow::Fold`]), justified, and finally truncated
712    /// to `width`. [`Overflow::Ignore`] skips wrapping and truncation both, so
713    /// lines may come back wider than `width`.
714    pub fn render_lines_wrapped(
715        &self,
716        theme: &Theme,
717        base_style: &Style,
718        width: Option<usize>,
719        justify: Justify,
720        overflow: Overflow,
721        no_wrap: bool,
722    ) -> Vec<Vec<Segment>> {
723        // Tabs are expanded before anything measures or wraps the text, as
724        // upstream's `Text.wrap` does per line. Without this a tab occupies one
725        // cell everywhere in the layout and then eight on the terminal, so every
726        // width calculation downstream is wrong.
727        if self.plain.contains('\t') {
728            let mut expanded = self.clone();
729            expanded.expand_tabs(DEFAULT_TAB_SIZE);
730            return expanded
731                .render_lines_wrapped(theme, base_style, width, justify, overflow, no_wrap);
732        }
733
734        // Resolve every span's style once, up front, into a vector parallel to
735        // `self.spans` — upstream's `style_map`. Resolving inside the per-line
736        // loop would re-parse the same names for every visual line.
737        let resolved: Vec<Style> = self
738            .spans
739            .iter()
740            .map(|span| theme.get_style_or_null(&span.style))
741            .collect();
742        let effective_base = base_style.combine(&theme.get_style_or_null(&self.style));
743        // Upstream folds `overflow == "ignore"` into no_wrap before splitting.
744        let no_wrap = no_wrap || overflow == Overflow::Ignore;
745        let groups = self.wrapped_ranges(width, overflow, no_wrap);
746        let Some(width) = width else {
747            return groups
748                .into_iter()
749                .flatten()
750                .map(|(start, end)| self.line_segments(&resolved, start, end, &effective_base))
751                .collect();
752        };
753
754        let mut lines: Vec<Vec<Segment>> = Vec::new();
755        // One hard line at a time, as upstream's `for line in self.split(...)`
756        // does — the paragraph boundary is what full justification treats as
757        // ragged, so the groups cannot be flattened first.
758        for group in groups {
759            let mut new_lines: Vec<Vec<Segment>> = group
760                .into_iter()
761                .map(|(start, end)| self.line_segments(&resolved, start, end, &effective_base))
762                .collect();
763
764            // `overflow == "ignore"` is a hard stop upstream: the line is
765            // appended verbatim and the loop `continue`s, so it is neither
766            // justified nor truncated. Padding it out to the width here was
767            // adding trailing spaces to text upstream returns untouched.
768            if overflow == Overflow::Ignore {
769                lines.append(&mut new_lines);
770                continue;
771            }
772
773            // Give each wrapped line back the padding it overshot by, exactly
774            // where upstream's `Text.wrap` does it — after dividing, before
775            // justifying. `divide_line` counts a word *including* its trailing
776            // space, so a line whose last word ends flush with the width comes
777            // back one cell too long; without this the ellipsis overflow then
778            // chops a real character to make room for a `…` that upstream never
779            // emits ("abcdefghij more" at width 10 became "abcdefghi…", not
780            // "abcdefghij").
781            //
782            // Only in the wrapping branch: upstream's `rstrip_end` loop sits
783            // inside `Text.wrap`'s `else`, which `no_wrap` skips entirely.
784            if !no_wrap {
785                for line in &mut new_lines {
786                    rstrip_end_line(line, width);
787                }
788            }
789            if justify != Justify::Default {
790                let last = new_lines.len().saturating_sub(1);
791                for (index, line) in new_lines.iter_mut().enumerate() {
792                    // Full justification leaves the final line of the paragraph
793                    // ragged, so it needs to know where it is in the group.
794                    *line = justify_line(
795                        line,
796                        width,
797                        justify,
798                        overflow,
799                        &effective_base,
800                        index == last,
801                    );
802                }
803            }
804            for line in &mut new_lines {
805                *line = truncate_line(line, width, overflow);
806            }
807            lines.append(&mut new_lines);
808        }
809        lines
810    }
811
812    /// As [`render_lines_wrapped`](Self::render_lines_wrapped), flattened into a
813    /// single segment stream with [`Segment::line`] between visual lines.
814    pub fn render_joined_wrapped(
815        &self,
816        theme: &Theme,
817        base_style: &Style,
818        width: usize,
819        justify: Justify,
820        overflow: Overflow,
821        no_wrap: bool,
822    ) -> Vec<Segment> {
823        let lines =
824            self.render_lines_wrapped(theme, base_style, Some(width), justify, overflow, no_wrap);
825        let mut segments = Vec::new();
826        let last = lines.len().saturating_sub(1);
827        for (index, line) in lines.into_iter().enumerate() {
828            segments.extend(line);
829            if index != last {
830                segments.push(Segment::line());
831            }
832        }
833        segments
834    }
835
836    /// Render into a flat segment stream with [`Segment::line`] between visual
837    /// lines (wrapping when `width` is `Some`), using this text's own justify.
838    fn render_joined(
839        &self,
840        theme: &Theme,
841        base_style: &Style,
842        width: Option<usize>,
843    ) -> Vec<Segment> {
844        let lines = self.render_lines(theme, base_style, width);
845        let mut segments = Vec::new();
846        let last = lines.len().saturating_sub(1);
847        for (index, line) in lines.into_iter().enumerate() {
848            segments.extend(line);
849            if index != last {
850                segments.push(Segment::line());
851            }
852        }
853        segments
854    }
855
856    /// The `(start_byte, end_byte)` range of each visual line, **grouped by the
857    /// hard line it came from**: hard lines split on `\n`, then each wrapped to
858    /// `width` cells when `Some`.
859    ///
860    /// The grouping is not cosmetic. Upstream wraps and justifies one hard line
861    /// at a time (`for line in self.split(...)`), so full justification leaves
862    /// the last visual line of *each paragraph* ragged. Flattening first makes
863    /// every paragraph but the final one get stretched, which turned
864    /// `"line here"` into `"line  here"`.
865    fn wrapped_ranges(
866        &self,
867        width: Option<usize>,
868        overflow: Overflow,
869        no_wrap: bool,
870    ) -> Vec<Vec<(usize, usize)>> {
871        let mut hard: Vec<(usize, usize)> = Vec::new();
872        let mut start = 0;
873        for (i, byte) in self.plain.bytes().enumerate() {
874            if byte == b'\n' {
875                hard.push((start, i));
876                start = i + 1;
877            }
878        }
879        hard.push((start, self.plain.len()));
880
881        let Some(width) = width else {
882            return hard.into_iter().map(|range| vec![range]).collect();
883        };
884        if no_wrap {
885            return hard.into_iter().map(|range| vec![range]).collect();
886        }
887
888        let mut groups: Vec<Vec<(usize, usize)>> = Vec::with_capacity(hard.len());
889        for (a, b) in hard {
890            let sub = &self.plain[a..b];
891            // Only `fold` breaks a word that is wider than the whole line; the
892            // cropping methods leave it long and let truncation cut it.
893            let breaks = crate::wrap::divide_line(sub, width, overflow == Overflow::Fold);
894            let mut cuts = vec![a];
895            for char_offset in breaks {
896                cuts.push(a + char_to_byte(sub, char_offset));
897            }
898            cuts.push(b);
899            groups.push(cuts.windows(2).map(|w| (w[0], w[1])).collect());
900        }
901        groups
902    }
903
904    /// Combine `effective_base` with every span covering `[start, end)`,
905    /// producing non-overlapping segments for that byte range.
906    ///
907    /// `resolved` is the per-render style map, index-parallel to `self.spans`.
908    /// Spans are folded in vector order, and spans that resolved to nothing are
909    /// **not** skipped — they still contribute a boundary. Upstream behaves the
910    /// same way, and the highlighter fixtures depend on it: an ISO-8601 date
911    /// emits separate segments per sub-field even where the field styles are
912    /// identical.
913    fn line_segments(
914        &self,
915        resolved: &[Style],
916        start: usize,
917        end: usize,
918        effective_base: &Style,
919    ) -> Vec<Segment> {
920        if start >= end {
921            return Vec::new();
922        }
923        let mut points: Vec<usize> = vec![start, end];
924        for span in &self.spans {
925            let span_start = span.start.clamp(start, end);
926            let span_end = span.end.clamp(start, end);
927            points.push(span_start);
928            points.push(span_end);
929        }
930        points.sort_unstable();
931        points.dedup();
932
933        let mut segments = Vec::new();
934        for window in points.windows(2) {
935            let (a, b) = (window[0], window[1]);
936            if a >= b {
937                continue;
938            }
939            let slice = &self.plain[a..b];
940            if slice.is_empty() {
941                continue;
942            }
943            let mut style = effective_base.clone();
944            for (span, span_style) in self.spans.iter().zip(resolved) {
945                if span.start <= a && span.end >= b {
946                    style = style.combine(span_style);
947                }
948            }
949            segments.push(Segment::new(slice, Some(style)));
950        }
951        segments
952    }
953}
954
955/// Byte offset of the `char_idx`-th char in `text` (clamped to `text.len()`).
956fn char_to_byte(text: &str, char_idx: usize) -> usize {
957    text.char_indices()
958        .nth(char_idx)
959        .map(|(byte, _)| byte)
960        .unwrap_or(text.len())
961}
962
963/// Cut a rendered line down to `width` cells, applying `overflow`. Segment-level
964/// counterpart of [`Text::truncate`], used once per line at the end of the wrap
965/// pipeline.
966///
967/// [`Overflow::Fold`] and [`Overflow::Crop`] both plain-cut: by this point the
968/// line has already been wrapped, so anything still over-long is an unbreakable
969/// run that folding cannot help with.
970///
971/// [`Overflow::Ellipsis`] cuts one cell short and appends `…`. The marker takes
972/// the style of the first segment the cut did *not* keep whole — upstream writes
973/// the ellipsis into the plain string and lets span-trimming decide, which works
974/// out to the same rule, including when the cut lands exactly on a boundary.
975fn truncate_line(line: &[Segment], width: usize, overflow: Overflow) -> Vec<Segment> {
976    if overflow == Overflow::Ignore {
977        return line.to_vec();
978    }
979    // Measured and cut over the WHOLE line, exactly as upstream's `Text.truncate`
980    // works on `self.plain`. Summing the segments instead is wrong wherever a
981    // grapheme spans a segment boundary — a zero-width joiner at the end of one
982    // segment swallows the first character of the next, so the per-segment sum
983    // reads one cell too wide and cuts text upstream keeps.
984    let plain: String = line.iter().map(|segment| segment.text.as_str()).collect();
985    if cell_len(&plain) <= width {
986        return line.to_vec();
987    }
988    let ellipsis = overflow == Overflow::Ellipsis;
989    // `…` occupies one cell, so the kept text must stop one cell early.
990    let keep = if ellipsis {
991        width.saturating_sub(1)
992    } else {
993        width
994    };
995    // Never longer than `plain`, and only ever differs from a byte prefix of it
996    // in its final byte (a wide grapheme straddling the cut becomes a space), so
997    // slicing it at the original segment boundaries stays on char boundaries.
998    let kept = set_cell_size(&plain, keep);
999
1000    let mut result: Vec<Segment> = Vec::new();
1001    // Style the ellipsis inherits: that of the first segment the cut did not
1002    // keep whole, falling back to the last segment's when the cut lands exactly
1003    // on the end of the line's bytes.
1004    let mut cut_style: Option<Style> = line.last().and_then(|segment| segment.style.clone());
1005    let mut offset = 0usize;
1006    for segment in line {
1007        if offset >= kept.len() {
1008            cut_style = segment.style.clone();
1009            break;
1010        }
1011        let end = (offset + segment.text.len()).min(kept.len());
1012        if end > offset {
1013            result.push(Segment::new(&kept[offset..end], segment.style.clone()));
1014        }
1015        if offset + segment.text.len() > kept.len() {
1016            cut_style = segment.style.clone();
1017            break;
1018        }
1019        offset = end;
1020    }
1021    if ellipsis {
1022        // Upstream appends the marker to the plain string and re-renders, so it
1023        // lands inside the preceding run rather than beside it. Merging keeps
1024        // the byte stream identical — a separate segment would re-emit the style.
1025        match result.last_mut() {
1026            Some(last) if !last.control && last.style == cut_style => last.text.push('…'),
1027            _ => result.push(Segment::new("…", cut_style)),
1028        }
1029    }
1030    result
1031}
1032
1033/// Split a rendered line into whitespace-separated words, each word keeping its
1034/// own styled segments. Separator spaces are dropped — [`full_justify`] decides
1035/// the new gaps. Port of the `line.split(" ")` in upstream's `full` branch.
1036fn split_words(line: &[Segment]) -> Vec<Vec<Segment>> {
1037    let mut words: Vec<Vec<Segment>> = Vec::new();
1038    let mut current: Vec<Segment> = Vec::new();
1039    for segment in line {
1040        // A segment can straddle a space, so split within it and keep the style.
1041        for (index, piece) in segment.text.split(' ').enumerate() {
1042            if index > 0 {
1043                words.push(std::mem::take(&mut current));
1044            }
1045            if !piece.is_empty() {
1046                current.push(Segment::new(piece, segment.style.clone()));
1047            }
1048        }
1049    }
1050    words.push(current);
1051    // Wrapping leaves a trailing space on every line but the last, so the naive
1052    // split ends with an empty word. Upstream's `Text.split` drops it, and the
1053    // count matters: it decides how many gaps share the slack.
1054    if words.last().is_some_and(|w| w.is_empty()) {
1055        words.pop();
1056    }
1057    words
1058}
1059
1060/// Distribute `width` across `line`'s words by widening the gaps between them.
1061/// Direct port of the `justify == "full"` branch of upstream's `Lines.justify`:
1062/// every gap starts at one space, and the extra columns are handed out from the
1063/// rightmost gap backwards, cycling.
1064fn full_justify(line: &[Segment], width: usize, style: &Style) -> Vec<Segment> {
1065    let words = split_words(line);
1066    let words_size: usize = words
1067        .iter()
1068        .map(|word| word.iter().map(Segment::cell_length).sum::<usize>())
1069        .sum();
1070    let mut num_spaces = words.len().saturating_sub(1);
1071    let mut spaces = vec![1usize; num_spaces];
1072    if !spaces.is_empty() {
1073        let mut index = 0;
1074        while words_size + num_spaces < width {
1075            let slot = spaces.len() - index - 1;
1076            spaces[slot] += 1;
1077            num_spaces += 1;
1078            index = (index + 1) % spaces.len();
1079        }
1080    }
1081
1082    let mut out: Vec<Segment> = Vec::new();
1083    for (index, word) in words.iter().enumerate() {
1084        out.extend(word.iter().cloned());
1085        if let Some(&gap) = spaces.get(index) {
1086            // Upstream styles the gap with the surrounding style when the two
1087            // neighbours agree, else with the line's base style.
1088            let before = word.last().and_then(|s| s.style.clone());
1089            let after = words
1090                .get(index + 1)
1091                .and_then(|w| w.first())
1092                .and_then(|s| s.style.clone());
1093            let gap_style = if before == after {
1094                before.unwrap_or_else(|| style.clone())
1095            } else {
1096                style.clone()
1097            };
1098            out.push(Segment::new(" ".repeat(gap), Some(gap_style)));
1099        }
1100    }
1101    out
1102}
1103
1104/// Pad `line` to `width` cells according to `justify`, using `style` for the
1105/// pad (so e.g. a styled table cell fills with its own style).
1106///
1107/// `is_last` marks the final line of the paragraph, which full justification
1108/// leaves ragged rather than stretching.
1109fn justify_line(
1110    line: &[Segment],
1111    width: usize,
1112    justify: Justify,
1113    overflow: Overflow,
1114    style: &Style,
1115    is_last: bool,
1116) -> Vec<Segment> {
1117    // Full justification rewrites the interior gaps instead of padding an edge.
1118    if justify == Justify::Full {
1119        // Upstream `break`s before the final line, so it is left exactly as
1120        // wrapped — not even padded out to the width, unlike every other mode.
1121        return if is_last {
1122            line.to_vec()
1123        } else {
1124            full_justify(line, width, style)
1125        };
1126    }
1127    let mut content = line.to_vec();
1128    // Upstream's `Lines.justify` calls `line.rstrip()` in its `center` and
1129    // `right` branches — and only there — so the space wrapping left at the end
1130    // of a line is *not* content to be positioned. Keeping it shifts the visible
1131    // text half a space left when centring (`" abcd efgh ijklmnop "` became
1132    // `"abcd efgh ijklmnop  "`) and a whole column left when right-aligning.
1133    // `left`/`full` deliberately keep it: upstream pads them without stripping.
1134    if matches!(justify, Justify::Center | Justify::Right) {
1135        rstrip_line(&mut content);
1136        // …and then TRUNCATES, before it pads. The order is load-bearing: cell
1137        // width is not additive across a cut, so a line whose over-long tail is
1138        // chopped can measure *less* than the width afterwards and still want
1139        // padding. A leading zero-width joiner is the clearest case — it eats the
1140        // character after it, so cutting the line hands one of its cells back —
1141        // and padding first computes the gap from the pre-cut measurement, which
1142        // is zero, and leaves the line short.
1143        content = truncate_line(&content, width, overflow);
1144    }
1145
1146    let mut out = Vec::with_capacity(content.len() + 2);
1147    match justify {
1148        Justify::Right => {
1149            // `line.pad_left(width - cell_len(line.plain))`.
1150            let excess = width.saturating_sub(line_cell_len(&content));
1151            if excess > 0 {
1152                out.push(Segment::new(" ".repeat(excess), Some(style.clone())));
1153            }
1154            out.append(&mut content);
1155        }
1156        Justify::Center => {
1157            // `pad_left((width - cell_len) // 2)` and then `pad_right(width -
1158            // cell_len)` — the second `cell_len` is re-measured *after* the left
1159            // pad, so the two halves are not simply `excess / 2` and the rest.
1160            let left = width.saturating_sub(line_cell_len(&content)) / 2;
1161            if left > 0 {
1162                out.push(Segment::new(" ".repeat(left), Some(style.clone())));
1163            }
1164            out.append(&mut content);
1165            let right = width.saturating_sub(line_cell_len(&out));
1166            if right > 0 {
1167                out.push(Segment::new(" ".repeat(right), Some(style.clone())));
1168            }
1169        }
1170        // Left, Default, and full justification's ragged last line pad right.
1171        // Upstream reaches this through `truncate(width, pad=True)`, whose pad
1172        // is driven by the *pre*-truncate length — so padding and truncating are
1173        // mutually exclusive here and the order does not matter.
1174        Justify::Left | Justify::Full | Justify::Default => {
1175            let excess = width.saturating_sub(line_cell_len(&content));
1176            out.append(&mut content);
1177            if excess > 0 {
1178                out.push(Segment::new(" ".repeat(excess), Some(style.clone())));
1179            }
1180        }
1181    }
1182    out
1183}
1184
1185/// The cell width of a rendered line.
1186fn line_cell_len(line: &[Segment]) -> usize {
1187    line.iter().map(Segment::cell_length).sum()
1188}
1189
1190/// The number of trailing whitespace *characters* on a rendered line.
1191///
1192/// Segment-level, because by the time the wrap pipeline justifies a line the
1193/// spans have already been flattened into [`Segment`]s and there is no `Text`
1194/// left to call `rstrip` on.
1195fn trailing_whitespace(line: &[Segment]) -> usize {
1196    let mut count = 0usize;
1197    for segment in line.iter().rev() {
1198        let trimmed = segment.text.trim_end();
1199        count += segment.text[trimmed.len()..].chars().count();
1200        if !trimmed.is_empty() {
1201            break;
1202        }
1203    }
1204    count
1205}
1206
1207/// Drop the last `count` characters, discarding segments that empty out.
1208/// Segment-level counterpart of `Text.right_crop`.
1209fn right_crop_line(line: &mut Vec<Segment>, count: usize) {
1210    let mut remaining = count;
1211    while remaining > 0 {
1212        let Some(last) = line.last_mut() else { break };
1213        let length = last.text.chars().count();
1214        if length <= remaining {
1215            remaining -= length;
1216            line.pop();
1217        } else {
1218            let keep = char_to_byte(&last.text, length - remaining);
1219            last.text.truncate(keep);
1220            remaining = 0;
1221        }
1222    }
1223}
1224
1225/// Remove all trailing whitespace. Segment-level counterpart of `Text.rstrip`.
1226fn rstrip_line(line: &mut Vec<Segment>) {
1227    right_crop_line(line, trailing_whitespace(line));
1228}
1229
1230/// Remove *only as much* trailing whitespace as it takes to get the line down to
1231/// `size`, leaving the rest. Segment-level counterpart of `Text.rstrip_end`.
1232///
1233/// The length compared against `size` is a **character** count, not a cell
1234/// count: upstream's `Text.rstrip_end` uses `len(self)`, which is
1235/// `len(self.plain)`. The two only diverge on wide characters, and copying the
1236/// quirk is cheaper than explaining a one-column difference later.
1237fn rstrip_end_line(line: &mut Vec<Segment>, size: usize) {
1238    let length: usize = line.iter().map(|s| s.text.chars().count()).sum();
1239    let Some(excess) = length.checked_sub(size).filter(|excess| *excess > 0) else {
1240        return;
1241    };
1242    let whitespace = trailing_whitespace(line);
1243    if whitespace > 0 {
1244        right_crop_line(line, whitespace.min(excess));
1245    }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250    use super::*;
1251
1252    /// An unbroken run of VS16 emoji must fold at the width like anything else.
1253    /// Measured per code point it did not: the heart reads one cell and the
1254    /// variation selector zero, so twenty hearts "fit" in thirty cells and came
1255    /// back as a single forty-cell row — wide enough to punch through the panel
1256    /// or table border drawn around it.
1257    ///
1258    /// Real rich 15.0.0, `[cell_len(l.plain) for l in Text("❤️"*20).wrap(c, 30)]`
1259    /// is `[30, 10]`.
1260    #[test]
1261    fn an_emoji_run_folds_at_the_width_instead_of_overflowing() {
1262        let hearts = "\u{2764}\u{fe0f}".repeat(20);
1263        let widths: Vec<usize> = wrapped_plain(&Text::new(&hearts), 30)
1264            .iter()
1265            .map(|line| cell_len(line))
1266            .collect();
1267        assert_eq!(widths, vec![30, 10]);
1268    }
1269
1270    /// Upstream wraps and justifies **one hard line at a time**, so the line
1271    /// full justification leaves ragged is the last of each paragraph — not just
1272    /// the last of the whole text. Flattening first stretched every paragraph
1273    /// but the final one.
1274    ///
1275    /// Real rich 15.0.0, `Text(case, justify="full").wrap(console, width)`:
1276    ///
1277    /// ```text
1278    /// width 10 -> ['word', '  indented', 'line here', 'last']
1279    /// width 30 -> ['word', '  indented line here', 'last']   (no_wrap)
1280    /// ```
1281    ///
1282    /// `line here` is the giveaway: it ends its paragraph, so upstream leaves
1283    /// the single gap alone where we widened it to `line  here`.
1284    #[test]
1285    fn full_justify_leaves_each_paragraphs_last_line_ragged() {
1286        let text = Text::new("word\n  indented line here\nlast").justify(Justify::Full);
1287        assert_eq!(
1288            wrapped_plain(&text, 10),
1289            vec!["word", "  indented", "line here", "last"]
1290        );
1291        let no_wrap = Text::new("word\n  indented line here\nlast")
1292            .justify(Justify::Full)
1293            .no_wrap(true);
1294        assert_eq!(
1295            wrapped_plain(&no_wrap, 30),
1296            vec!["word", "  indented line here", "last"]
1297        );
1298    }
1299
1300    /// `overflow="ignore"` is a hard stop in upstream's `Text.wrap`: the line is
1301    /// appended verbatim and the loop `continue`s, so it is neither justified nor
1302    /// truncated. Padding it out to the width added trailing spaces to content
1303    /// upstream returns byte-for-byte.
1304    ///
1305    /// Real rich 15.0.0, `Text(case, justify=…, overflow="ignore").wrap(c, w)`:
1306    ///
1307    /// ```text
1308    /// left   'hello'         @12 -> ['hello']
1309    /// center 'hello'         @12 -> ['hello']
1310    /// right  'trailing   '   @3  -> ['trailing   ']
1311    /// ```
1312    #[test]
1313    fn overflow_ignore_is_neither_justified_nor_truncated() {
1314        for justify in [Justify::Left, Justify::Center, Justify::Right] {
1315            let text = Text::new("hello")
1316                .justify(justify)
1317                .overflow(Overflow::Ignore);
1318            assert_eq!(wrapped_plain(&text, 12), vec!["hello"], "{justify:?}");
1319        }
1320        let text = Text::new("trailing   ")
1321            .justify(Justify::Right)
1322            .overflow(Overflow::Ignore);
1323        assert_eq!(wrapped_plain(&text, 3), vec!["trailing   "]);
1324    }
1325
1326    /// Upstream's `Lines.justify` truncates *inside* its center and right
1327    /// branches, before it pads. The order matters because cell width is not
1328    /// additive across a cut: a leading zero-width joiner eats the character
1329    /// after it, so chopping the line's tail hands a cell back and the line then
1330    /// wants padding it did not want before. Padding first measures the un-cut
1331    /// line, finds no slack, and leaves the line a column short.
1332    ///
1333    /// Real rich 15.0.0:
1334    ///
1335    /// ```text
1336    /// right    '‍┬┴⠁├╰⠃⡁⠃╯⠆┴' @9, ellipsis -> [' ‍┬┴⠁├╰⠃⡁⠃…']
1337    /// right    '‍⠃╯⠆┴'         @2, crop, no_wrap -> [' ‍⠃╯']
1338    /// center   's ‍8-o🧠e'      @4, ellipsis -> [' s  ', '‍8-o… ']
1339    /// ```
1340    #[test]
1341    fn center_and_right_truncate_before_they_pad() {
1342        let text = Text::new("\u{200d}┬┴⠁├╰⠃⡁⠃╯⠆┴")
1343            .justify(Justify::Right)
1344            .overflow(Overflow::Ellipsis);
1345        assert_eq!(wrapped_plain(&text, 9), vec![" \u{200d}┬┴⠁├╰⠃⡁⠃…"]);
1346
1347        let cropped = Text::new("\u{200d}⠃╯⠆┴")
1348            .justify(Justify::Right)
1349            .overflow(Overflow::Crop)
1350            .no_wrap(true);
1351        assert_eq!(wrapped_plain(&cropped, 2), vec![" \u{200d}⠃╯"]);
1352
1353        let centered = Text::new("s \u{200d}8-o\u{1f9e0}e")
1354            .justify(Justify::Center)
1355            .overflow(Overflow::Ellipsis);
1356        assert_eq!(wrapped_plain(&centered, 4), vec![" s  ", "\u{200d}8-o… "]);
1357    }
1358
1359    /// A line is measured and cut as one string, the way upstream's
1360    /// `Text.truncate` works on `self.plain` — not segment by segment. Cell
1361    /// width is not additive across a segment boundary: full justification
1362    /// splits the line into one segment per word, which strands the zero-width
1363    /// joiner at the end of `π‍` away from the space it swallows, so the
1364    /// per-segment sum reads eight cells for a seven-cell line and an ellipsis
1365    /// eats a character upstream keeps.
1366    ///
1367    /// Real rich 15.0.0,
1368    /// `Text("⚠1️;& π‍  ψ\u{a0}τ\u{a0}γ ⡀", justify="full", overflow="ellipsis").wrap(c, 7)`:
1369    ///
1370    /// ```text
1371    /// ['⚠1️;& ', 'π‍  ψ τ γ', '⡀']   with cell widths [7, 7, 1]
1372    /// ```
1373    #[test]
1374    fn a_line_is_measured_whole_not_segment_by_segment() {
1375        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}")
1376            .justify(Justify::Full)
1377            .overflow(Overflow::Ellipsis);
1378        assert_eq!(
1379            wrapped_plain(&text, 7),
1380            vec![
1381                "\u{26a0}1\u{fe0f};&\u{3000}",
1382                "\u{3c0}\u{200d}  \u{3c8}\u{a0}\u{3c4}\u{a0}\u{3b3}",
1383                "\u{2840}"
1384            ]
1385        );
1386    }
1387
1388    /// Full justification widens the gaps between words so every line but the
1389    /// last fills the width exactly.
1390    ///
1391    /// Captured verbatim from real rich 15.0.0 —
1392    /// `Lines.justify(console, 20, justify="full")` on
1393    /// `"aaa bbb ccc ddddddddddddddddddd ee ff"` yields:
1394    ///
1395    /// ```text
1396    /// 'aaa     bbb      ccc'   <- stretched to exactly 20
1397    /// 'ddddddddddddddddddd'    <- one word: nothing to widen, and the
1398    ///                             trailing space wrapping left is dropped
1399    /// 'ee ff'                  <- final line untouched: NOT padded to width
1400    /// ```
1401    ///
1402    /// Two details worth pinning: the slack is handed out from the rightmost
1403    /// gap backwards (so the gaps are 5 then 6, not 6 then 5), and the last
1404    /// line is the one case where a justified line is left short of the width.
1405    #[test]
1406    fn full_justify_matches_upstream() {
1407        let text = Text::new("aaa bbb ccc ddddddddddddddddddd ee ff").justify(Justify::Full);
1408        let plain: Vec<String> = text
1409            .render_lines(&Theme::default_theme(), &Style::new(), Some(20))
1410            .iter()
1411            .map(|line| line.iter().map(|s| s.text.as_str()).collect())
1412            .collect();
1413        assert_eq!(
1414            plain,
1415            vec!["aaa     bbb      ccc", "ddddddddddddddddddd", "ee ff"]
1416        );
1417        assert_eq!(plain[0].chars().count(), 20);
1418    }
1419
1420    fn wrapped_plain(text: &Text, width: usize) -> Vec<String> {
1421        text.render_lines(&Theme::default_theme(), &Style::new(), Some(width))
1422            .iter()
1423            .map(|line| line.iter().map(|s| s.text.as_str()).collect())
1424            .collect()
1425    }
1426
1427    /// Wrapping hands each line the space that ended it, and upstream's
1428    /// `Lines.justify` throws that space away (`line.rstrip()`) before centring
1429    /// or right-aligning — but *not* before left-aligning or full-justifying.
1430    ///
1431    /// Captured verbatim from real rich 15.0.0,
1432    /// `Text(case, justify=…).wrap(console, 20)`:
1433    ///
1434    /// ```text
1435    /// center 'abcd efgh ijklmnop qrst'  -> ' abcd efgh ijklmnop '
1436    /// right  'abcd efgh ijklmnop qrst'  -> '  abcd efgh ijklmnop'
1437    /// right  'aaaa bbbb cccc dddd eeee' -> ' aaaa bbbb cccc dddd'
1438    /// left   'abcd efgh ijklmnop qrst'  -> 'abcd efgh ijklmnop  '
1439    /// ```
1440    ///
1441    /// Counting the wrap space as content puts the centred line one column too
1442    /// far left and the right-aligned line a whole column short of the edge.
1443    #[test]
1444    fn center_and_right_rstrip_the_wrap_space() {
1445        let wrapped = "abcd efgh ijklmnop qrst";
1446        assert_eq!(
1447            wrapped_plain(&Text::new(wrapped).justify(Justify::Center), 20)[0],
1448            " abcd efgh ijklmnop "
1449        );
1450        assert_eq!(
1451            wrapped_plain(&Text::new(wrapped).justify(Justify::Right), 20)[0],
1452            "  abcd efgh ijklmnop"
1453        );
1454        assert_eq!(
1455            wrapped_plain(
1456                &Text::new("aaaa bbbb cccc dddd eeee").justify(Justify::Right),
1457                20
1458            )[0],
1459            " aaaa bbbb cccc dddd"
1460        );
1461        // Left is the control: upstream pads it without stripping, so the
1462        // trailing space stays part of the line and nothing shifts.
1463        assert_eq!(
1464            wrapped_plain(&Text::new(wrapped).justify(Justify::Left), 20)[0],
1465            "abcd efgh ijklmnop  "
1466        );
1467    }
1468
1469    /// `divide_line` measures a word *with* its trailing space, so a line whose
1470    /// last word ends flush with the width comes back one character too long.
1471    /// Upstream's `Text.wrap` calls `rstrip_end(width)` on every divided line to
1472    /// hand that back before overflow is applied.
1473    ///
1474    /// Real rich 15.0.0, `Text(case, overflow="ellipsis").wrap(console, 10)`:
1475    ///
1476    /// ```text
1477    /// 'abcdefghij more'   -> ['abcdefghij', 'more']   <- no ellipsis
1478    /// 'abcdefghijkl more' -> ['abcdefghi…', 'more']   <- genuinely too long
1479    /// ```
1480    ///
1481    /// Skipping the rstrip makes the first case measure 11 cells, so the
1482    /// ellipsis fires and eats the `j` that upstream keeps.
1483    #[test]
1484    fn rstrip_end_stops_the_wrap_space_from_triggering_an_ellipsis() {
1485        assert_eq!(
1486            wrapped_plain(
1487                &Text::new("abcdefghij more").overflow(Overflow::Ellipsis),
1488                10
1489            ),
1490            vec!["abcdefghij", "more"]
1491        );
1492        assert_eq!(
1493            wrapped_plain(
1494                &Text::new("abcdefghijkl more").overflow(Overflow::Ellipsis),
1495                10
1496            ),
1497            vec!["abcdefghi…", "more"]
1498        );
1499    }
1500
1501    #[test]
1502    fn append_creates_spans() {
1503        let mut text = Text::new("");
1504        text.append("hello", Some(Style::parse("bold").unwrap().into()));
1505        text.append(" world", None);
1506        assert_eq!(text.plain(), "hello world");
1507        assert_eq!(text.spans().len(), 1);
1508    }
1509
1510    #[test]
1511    fn render_flattens_overlapping_spans() {
1512        let mut text = Text::new("abcdef");
1513        text.stylize(Style::parse("bold").unwrap(), 0, 4);
1514        text.stylize(Style::parse("red").unwrap(), 2, 6);
1515        let segments = text.render(&Theme::default_theme(), &Style::new());
1516        // Boundaries at 0,2,4,6 -> "ab"(bold) "cd"(bold+red) "ef"(red)
1517        let rendered: Vec<_> = segments.iter().map(|s| s.text.clone()).collect();
1518        assert_eq!(rendered, vec!["ab", "cd", "ef"]);
1519    }
1520
1521    /// `Text::truncate` on its own, against real rich 15.0.0. `fold` and `crop`
1522    /// deliberately agree: folding is a wrapping behaviour, and truncation has
1523    /// no line to fold onto.
1524    #[test]
1525    fn truncate_matches_upstream() {
1526        for (overflow, expected) in [
1527            (Overflow::Fold, "hello"),
1528            (Overflow::Crop, "hello"),
1529            (Overflow::Ellipsis, "hell…"),
1530            (Overflow::Ignore, "hello world"),
1531        ] {
1532            let mut text = Text::new("hello world");
1533            text.truncate(5, Some(overflow), false);
1534            assert_eq!(text.plain(), expected, "overflow {overflow:?}");
1535        }
1536    }
1537
1538    /// `pad` fills out to the width, but only when the text is short — a text
1539    /// that is already too long is cut, never padded.
1540    #[test]
1541    fn truncate_pads_only_when_short() {
1542        let mut short = Text::new("hi");
1543        short.truncate(6, Some(Overflow::Crop), true);
1544        assert_eq!(short.plain(), "hi    ");
1545
1546        let mut exact = Text::new("hi");
1547        exact.truncate(2, Some(Overflow::Crop), true);
1548        assert_eq!(exact.plain(), "hi");
1549    }
1550
1551    /// Truncating must not leave a span pointing past the end of the string.
1552    #[test]
1553    fn truncate_trims_dangling_spans() {
1554        let mut text = Text::new("hello world");
1555        text.stylize(Style::parse("bold").unwrap(), 6, 11);
1556        text.stylize(Style::parse("red").unwrap(), 0, 5);
1557        text.truncate(3, Some(Overflow::Crop), false);
1558        assert_eq!(text.plain(), "hel");
1559        // The "world" span starts past the new end and is dropped entirely; the
1560        // "hello" span survives, clamped.
1561        assert_eq!(text.spans().len(), 1);
1562        assert!(text.spans().iter().all(|s| s.end <= text.plain().len()));
1563    }
1564
1565    use crate::protocol::Renderable;
1566
1567    /// The overflow method may come from the text or from the console options,
1568    /// and the text's own setting wins — mirroring upstream's
1569    /// `self.overflow or options.overflow or DEFAULT_OVERFLOW`.
1570    #[test]
1571    fn text_overflow_beats_console_options() {
1572        let console = crate::Console::builder().width(8).build();
1573        let mut options = console.options();
1574        options.overflow = Some(Overflow::Ellipsis);
1575        options.no_wrap = Some(true);
1576
1577        // Nothing set on the text: the options decide.
1578        let from_options = Text::new("the quick brown fox");
1579        assert_eq!(
1580            plain_of(&from_options.rich_render(&console, &options)),
1581            "the qui…"
1582        );
1583
1584        // Set on the text: the text decides, and the options are ignored.
1585        let from_text = Text::new("the quick brown fox").overflow(Overflow::Crop);
1586        assert_eq!(
1587            plain_of(&from_text.rich_render(&console, &options)),
1588            "the quic"
1589        );
1590    }
1591
1592    /// With no overflow anywhere, upstream's default applies: fold.
1593    #[test]
1594    fn overflow_defaults_to_fold() {
1595        let console = crate::Console::builder().width(8).build();
1596        let text = Text::new("supercalifragilistic");
1597        let rendered = plain_of(&text.rich_render(&console, &console.options()));
1598        assert_eq!(rendered, "supercal\nifragili\nstic");
1599    }
1600
1601    /// Concatenate the visible text of a segment stream, for assertions that
1602    /// care about layout rather than styling.
1603    fn plain_of(segments: &[Segment]) -> String {
1604        segments
1605            .iter()
1606            .filter(|s| !s.control)
1607            .map(|s| s.text.as_str())
1608            .collect()
1609    }
1610
1611    /// `Text::new` stripped control codes but `append` did not, so every path
1612    /// that builds text incrementally — Markdown, Syntax, plain files — leaked
1613    /// them to the terminal. A backspace run is a spoofing tool: the reader sees
1614    /// the overwritten text, not what the file says.
1615    #[test]
1616    fn append_strips_control_codes_like_new() {
1617        let mut text = Text::new("");
1618        text.append("FAILED\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}PASSED", None);
1619        assert_eq!(text.plain(), "FAILEDPASSED");
1620
1621        for code in ['\u{7}', '\u{8}', '\u{b}', '\u{c}', '\u{d}'] {
1622            let mut text = Text::new("");
1623            text.append(&format!("a{code}b"), None);
1624            assert_eq!(text.plain(), "ab", "control code {code:?} survived append");
1625        }
1626    }
1627
1628    /// Upstream's `strip_control_codes` keeps NUL and ESC; only BEL, backspace,
1629    /// vertical tab, form feed and carriage return go.
1630    #[test]
1631    fn append_keeps_the_codes_upstream_keeps() {
1632        let mut text = Text::new("");
1633        text.append("a\u{0}b\u{1b}c", None);
1634        assert_eq!(text.plain(), "a\u{0}b\u{1b}c");
1635    }
1636}