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