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            for char_offset in breaks {
923                cuts.push(a + char_to_byte(sub, char_offset));
924            }
925            cuts.push(b);
926            groups.push(cuts.windows(2).map(|w| (w[0], w[1])).collect());
927        }
928        groups
929    }
930
931    /// Combine `effective_base` with every span covering `[start, end)`,
932    /// producing non-overlapping segments for that byte range.
933    ///
934    /// `resolved` is the per-render style map, index-parallel to `self.spans`.
935    /// Spans are folded in vector order, and spans that resolved to nothing are
936    /// **not** skipped — they still contribute a boundary. Upstream behaves the
937    /// same way, and the highlighter fixtures depend on it: an ISO-8601 date
938    /// emits separate segments per sub-field even where the field styles are
939    /// identical.
940    fn line_segments(
941        &self,
942        resolved: &[Style],
943        start: usize,
944        end: usize,
945        effective_base: &Style,
946    ) -> Vec<Segment> {
947        if start >= end {
948            return Vec::new();
949        }
950        let mut points: Vec<usize> = vec![start, end];
951        for span in &self.spans {
952            let span_start = span.start.clamp(start, end);
953            let span_end = span.end.clamp(start, end);
954            points.push(span_start);
955            points.push(span_end);
956        }
957        points.sort_unstable();
958        points.dedup();
959
960        let mut segments = Vec::new();
961        for window in points.windows(2) {
962            let (a, b) = (window[0], window[1]);
963            if a >= b {
964                continue;
965            }
966            let slice = &self.plain[a..b];
967            if slice.is_empty() {
968                continue;
969            }
970            let mut style = effective_base.clone();
971            for (span, span_style) in self.spans.iter().zip(resolved) {
972                if span.start <= a && span.end >= b {
973                    style = style.combine(span_style);
974                }
975            }
976            segments.push(Segment::new(slice, Some(style)));
977        }
978        segments
979    }
980}
981
982/// Byte offset of the `char_idx`-th char in `text` (clamped to `text.len()`).
983fn char_to_byte(text: &str, char_idx: usize) -> usize {
984    text.char_indices()
985        .nth(char_idx)
986        .map(|(byte, _)| byte)
987        .unwrap_or(text.len())
988}
989
990/// Cut a rendered line down to `width` cells, applying `overflow`. Segment-level
991/// counterpart of [`Text::truncate`], used once per line at the end of the wrap
992/// pipeline.
993///
994/// [`Overflow::Fold`] and [`Overflow::Crop`] both plain-cut: by this point the
995/// line has already been wrapped, so anything still over-long is an unbreakable
996/// run that folding cannot help with.
997///
998/// [`Overflow::Ellipsis`] cuts one cell short and appends `…`. The marker takes
999/// the style of the first segment the cut did *not* keep whole — upstream writes
1000/// the ellipsis into the plain string and lets span-trimming decide, which works
1001/// out to the same rule, including when the cut lands exactly on a boundary.
1002fn truncate_line(line: &[Segment], width: usize, overflow: Overflow) -> Vec<Segment> {
1003    if overflow == Overflow::Ignore {
1004        return line.to_vec();
1005    }
1006    // Measured and cut over the WHOLE line, exactly as upstream's `Text.truncate`
1007    // works on `self.plain`. Summing the segments instead is wrong wherever a
1008    // grapheme spans a segment boundary — a zero-width joiner at the end of one
1009    // segment swallows the first character of the next, so the per-segment sum
1010    // reads one cell too wide and cuts text upstream keeps.
1011    let plain: String = line.iter().map(|segment| segment.text.as_str()).collect();
1012    if cell_len(&plain) <= width {
1013        return line.to_vec();
1014    }
1015    let ellipsis = overflow == Overflow::Ellipsis;
1016    // `…` occupies one cell, so the kept text must stop one cell early.
1017    let keep = if ellipsis {
1018        width.saturating_sub(1)
1019    } else {
1020        width
1021    };
1022    // Never longer than `plain`, and only ever differs from a byte prefix of it
1023    // in its final byte (a wide grapheme straddling the cut becomes a space), so
1024    // slicing it at the original segment boundaries stays on char boundaries.
1025    let kept = set_cell_size(&plain, keep);
1026
1027    let mut result: Vec<Segment> = Vec::new();
1028    // Style the ellipsis inherits: that of the first segment the cut did not
1029    // keep whole, falling back to the last segment's when the cut lands exactly
1030    // on the end of the line's bytes.
1031    let mut cut_style: Option<Style> = line.last().and_then(|segment| segment.style.clone());
1032    let mut offset = 0usize;
1033    for segment in line {
1034        if offset >= kept.len() {
1035            cut_style = segment.style.clone();
1036            break;
1037        }
1038        let end = (offset + segment.text.len()).min(kept.len());
1039        if end > offset {
1040            result.push(Segment::new(&kept[offset..end], segment.style.clone()));
1041        }
1042        if offset + segment.text.len() > kept.len() {
1043            cut_style = segment.style.clone();
1044            break;
1045        }
1046        offset = end;
1047    }
1048    if ellipsis {
1049        // Upstream appends the marker to the plain string and re-renders, so it
1050        // lands inside the preceding run rather than beside it. Merging keeps
1051        // the byte stream identical — a separate segment would re-emit the style.
1052        match result.last_mut() {
1053            Some(last) if !last.control && last.style == cut_style => last.text.push('…'),
1054            _ => result.push(Segment::new("…", cut_style)),
1055        }
1056    }
1057    result
1058}
1059
1060/// Split a rendered line into whitespace-separated words, each word keeping its
1061/// own styled segments. Separator spaces are dropped — [`full_justify`] decides
1062/// the new gaps. Port of the `line.split(" ")` in upstream's `full` branch.
1063fn split_words(line: &[Segment]) -> Vec<Vec<Segment>> {
1064    let mut words: Vec<Vec<Segment>> = Vec::new();
1065    let mut current: Vec<Segment> = Vec::new();
1066    for segment in line {
1067        // A segment can straddle a space, so split within it and keep the style.
1068        for (index, piece) in segment.text.split(' ').enumerate() {
1069            if index > 0 {
1070                words.push(std::mem::take(&mut current));
1071            }
1072            if !piece.is_empty() {
1073                current.push(Segment::new(piece, segment.style.clone()));
1074            }
1075        }
1076    }
1077    words.push(current);
1078    // Wrapping leaves a trailing space on every line but the last, so the naive
1079    // split ends with an empty word. Upstream's `Text.split` drops it, and the
1080    // count matters: it decides how many gaps share the slack.
1081    if words.last().is_some_and(|w| w.is_empty()) {
1082        words.pop();
1083    }
1084    words
1085}
1086
1087/// Distribute `width` across `line`'s words by widening the gaps between them.
1088/// Direct port of the `justify == "full"` branch of upstream's `Lines.justify`:
1089/// every gap starts at one space, and the extra columns are handed out from the
1090/// rightmost gap backwards, cycling.
1091fn full_justify(line: &[Segment], width: usize, style: &Style) -> Vec<Segment> {
1092    let words = split_words(line);
1093    let words_size: usize = words
1094        .iter()
1095        .map(|word| word.iter().map(Segment::cell_length).sum::<usize>())
1096        .sum();
1097    let mut num_spaces = words.len().saturating_sub(1);
1098    let mut spaces = vec![1usize; num_spaces];
1099    if !spaces.is_empty() {
1100        let mut index = 0;
1101        while words_size + num_spaces < width {
1102            let slot = spaces.len() - index - 1;
1103            spaces[slot] += 1;
1104            num_spaces += 1;
1105            index = (index + 1) % spaces.len();
1106        }
1107    }
1108
1109    let mut out: Vec<Segment> = Vec::new();
1110    for (index, word) in words.iter().enumerate() {
1111        out.extend(word.iter().cloned());
1112        if let Some(&gap) = spaces.get(index) {
1113            // Upstream styles the gap with the surrounding style when the two
1114            // neighbours agree, else with the line's base style.
1115            let before = word.last().and_then(|s| s.style.clone());
1116            let after = words
1117                .get(index + 1)
1118                .and_then(|w| w.first())
1119                .and_then(|s| s.style.clone());
1120            let gap_style = if before == after {
1121                before.unwrap_or_else(|| style.clone())
1122            } else {
1123                style.clone()
1124            };
1125            out.push(Segment::new(" ".repeat(gap), Some(gap_style)));
1126        }
1127    }
1128    out
1129}
1130
1131/// Pad `line` to `width` cells according to `justify`, using `style` for the
1132/// pad (so e.g. a styled table cell fills with its own style).
1133///
1134/// `is_last` marks the final line of the paragraph, which full justification
1135/// leaves ragged rather than stretching.
1136fn justify_line(
1137    line: &[Segment],
1138    width: usize,
1139    justify: Justify,
1140    overflow: Overflow,
1141    style: &Style,
1142    is_last: bool,
1143) -> Vec<Segment> {
1144    // Full justification rewrites the interior gaps instead of padding an edge.
1145    if justify == Justify::Full {
1146        // Upstream `break`s before the final line, so it is left exactly as
1147        // wrapped — not even padded out to the width, unlike every other mode.
1148        return if is_last {
1149            line.to_vec()
1150        } else {
1151            full_justify(line, width, style)
1152        };
1153    }
1154    let mut content = line.to_vec();
1155    // Upstream's `Lines.justify` calls `line.rstrip()` in its `center` and
1156    // `right` branches — and only there — so the space wrapping left at the end
1157    // of a line is *not* content to be positioned. Keeping it shifts the visible
1158    // text half a space left when centring (`" abcd efgh ijklmnop "` became
1159    // `"abcd efgh ijklmnop  "`) and a whole column left when right-aligning.
1160    // `left`/`full` deliberately keep it: upstream pads them without stripping.
1161    if matches!(justify, Justify::Center | Justify::Right) {
1162        rstrip_line(&mut content);
1163        // …and then TRUNCATES, before it pads. The order is load-bearing: cell
1164        // width is not additive across a cut, so a line whose over-long tail is
1165        // chopped can measure *less* than the width afterwards and still want
1166        // padding. A leading zero-width joiner is the clearest case — it eats the
1167        // character after it, so cutting the line hands one of its cells back —
1168        // and padding first computes the gap from the pre-cut measurement, which
1169        // is zero, and leaves the line short.
1170        content = truncate_line(&content, width, overflow);
1171    }
1172
1173    let mut out = Vec::with_capacity(content.len() + 2);
1174    match justify {
1175        Justify::Right => {
1176            // `line.pad_left(width - cell_len(line.plain))`.
1177            let excess = width.saturating_sub(line_cell_len(&content));
1178            if excess > 0 {
1179                out.push(Segment::new(" ".repeat(excess), Some(style.clone())));
1180            }
1181            out.append(&mut content);
1182        }
1183        Justify::Center => {
1184            // `pad_left((width - cell_len) // 2)` and then `pad_right(width -
1185            // cell_len)` — the second `cell_len` is re-measured *after* the left
1186            // pad, so the two halves are not simply `excess / 2` and the rest.
1187            let left = width.saturating_sub(line_cell_len(&content)) / 2;
1188            if left > 0 {
1189                out.push(Segment::new(" ".repeat(left), Some(style.clone())));
1190            }
1191            out.append(&mut content);
1192            let right = width.saturating_sub(line_cell_len(&out));
1193            if right > 0 {
1194                out.push(Segment::new(" ".repeat(right), Some(style.clone())));
1195            }
1196        }
1197        // Left, Default, and full justification's ragged last line pad right.
1198        // Upstream reaches this through `truncate(width, pad=True)`, whose pad
1199        // is driven by the *pre*-truncate length — so padding and truncating are
1200        // mutually exclusive here and the order does not matter.
1201        Justify::Left | Justify::Full | Justify::Default => {
1202            let excess = width.saturating_sub(line_cell_len(&content));
1203            out.append(&mut content);
1204            if excess > 0 {
1205                out.push(Segment::new(" ".repeat(excess), Some(style.clone())));
1206            }
1207        }
1208    }
1209    out
1210}
1211
1212/// The cell width of a rendered line.
1213fn line_cell_len(line: &[Segment]) -> usize {
1214    line.iter().map(Segment::cell_length).sum()
1215}
1216
1217/// The number of trailing whitespace *characters* on a rendered line.
1218///
1219/// Segment-level, because by the time the wrap pipeline justifies a line the
1220/// spans have already been flattened into [`Segment`]s and there is no `Text`
1221/// left to call `rstrip` on.
1222fn trailing_whitespace(line: &[Segment]) -> usize {
1223    let mut count = 0usize;
1224    for segment in line.iter().rev() {
1225        let trimmed = segment.text.trim_end();
1226        count += segment.text[trimmed.len()..].chars().count();
1227        if !trimmed.is_empty() {
1228            break;
1229        }
1230    }
1231    count
1232}
1233
1234/// Drop the last `count` characters, discarding segments that empty out.
1235/// Segment-level counterpart of `Text.right_crop`.
1236fn right_crop_line(line: &mut Vec<Segment>, count: usize) {
1237    let mut remaining = count;
1238    while remaining > 0 {
1239        let Some(last) = line.last_mut() else { break };
1240        let length = last.text.chars().count();
1241        if length <= remaining {
1242            remaining -= length;
1243            line.pop();
1244        } else {
1245            let keep = char_to_byte(&last.text, length - remaining);
1246            last.text.truncate(keep);
1247            remaining = 0;
1248        }
1249    }
1250}
1251
1252/// Remove all trailing whitespace. Segment-level counterpart of `Text.rstrip`.
1253fn rstrip_line(line: &mut Vec<Segment>) {
1254    right_crop_line(line, trailing_whitespace(line));
1255}
1256
1257/// Remove *only as much* trailing whitespace as it takes to get the line down to
1258/// `size`, leaving the rest. Segment-level counterpart of `Text.rstrip_end`.
1259///
1260/// The length compared against `size` is a **character** count, not a cell
1261/// count: upstream's `Text.rstrip_end` uses `len(self)`, which is
1262/// `len(self.plain)`. The two only diverge on wide characters, and copying the
1263/// quirk is cheaper than explaining a one-column difference later.
1264fn rstrip_end_line(line: &mut Vec<Segment>, size: usize) {
1265    let length: usize = line.iter().map(|s| s.text.chars().count()).sum();
1266    let Some(excess) = length.checked_sub(size).filter(|excess| *excess > 0) else {
1267        return;
1268    };
1269    let whitespace = trailing_whitespace(line);
1270    if whitespace > 0 {
1271        right_crop_line(line, whitespace.min(excess));
1272    }
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277    use super::*;
1278
1279    /// An unbroken run of VS16 emoji must fold at the width like anything else.
1280    /// Measured per code point it did not: the heart reads one cell and the
1281    /// variation selector zero, so twenty hearts "fit" in thirty cells and came
1282    /// back as a single forty-cell row — wide enough to punch through the panel
1283    /// or table border drawn around it.
1284    ///
1285    /// Real rich 15.0.0, `[cell_len(l.plain) for l in Text("❤️"*20).wrap(c, 30)]`
1286    /// is `[30, 10]`.
1287    #[test]
1288    fn an_emoji_run_folds_at_the_width_instead_of_overflowing() {
1289        let hearts = "\u{2764}\u{fe0f}".repeat(20);
1290        let widths: Vec<usize> = wrapped_plain(&Text::new(&hearts), 30)
1291            .iter()
1292            .map(|line| cell_len(line))
1293            .collect();
1294        assert_eq!(widths, vec![30, 10]);
1295    }
1296
1297    /// Upstream wraps and justifies **one hard line at a time**, so the line
1298    /// full justification leaves ragged is the last of each paragraph — not just
1299    /// the last of the whole text. Flattening first stretched every paragraph
1300    /// but the final one.
1301    ///
1302    /// Real rich 15.0.0, `Text(case, justify="full").wrap(console, width)`:
1303    ///
1304    /// ```text
1305    /// width 10 -> ['word', '  indented', 'line here', 'last']
1306    /// width 30 -> ['word', '  indented line here', 'last']   (no_wrap)
1307    /// ```
1308    ///
1309    /// `line here` is the giveaway: it ends its paragraph, so upstream leaves
1310    /// the single gap alone where we widened it to `line  here`.
1311    #[test]
1312    fn full_justify_leaves_each_paragraphs_last_line_ragged() {
1313        let text = Text::new("word\n  indented line here\nlast").justify(Justify::Full);
1314        assert_eq!(
1315            wrapped_plain(&text, 10),
1316            vec!["word", "  indented", "line here", "last"]
1317        );
1318        let no_wrap = Text::new("word\n  indented line here\nlast")
1319            .justify(Justify::Full)
1320            .no_wrap(true);
1321        assert_eq!(
1322            wrapped_plain(&no_wrap, 30),
1323            vec!["word", "  indented line here", "last"]
1324        );
1325    }
1326
1327    /// `overflow="ignore"` is a hard stop in upstream's `Text.wrap`: the line is
1328    /// appended verbatim and the loop `continue`s, so it is neither justified nor
1329    /// truncated. Padding it out to the width added trailing spaces to content
1330    /// upstream returns byte-for-byte.
1331    ///
1332    /// Real rich 15.0.0, `Text(case, justify=…, overflow="ignore").wrap(c, w)`:
1333    ///
1334    /// ```text
1335    /// left   'hello'         @12 -> ['hello']
1336    /// center 'hello'         @12 -> ['hello']
1337    /// right  'trailing   '   @3  -> ['trailing   ']
1338    /// ```
1339    #[test]
1340    fn overflow_ignore_is_neither_justified_nor_truncated() {
1341        for justify in [Justify::Left, Justify::Center, Justify::Right] {
1342            let text = Text::new("hello")
1343                .justify(justify)
1344                .overflow(Overflow::Ignore);
1345            assert_eq!(wrapped_plain(&text, 12), vec!["hello"], "{justify:?}");
1346        }
1347        let text = Text::new("trailing   ")
1348            .justify(Justify::Right)
1349            .overflow(Overflow::Ignore);
1350        assert_eq!(wrapped_plain(&text, 3), vec!["trailing   "]);
1351    }
1352
1353    /// Upstream's `Lines.justify` truncates *inside* its center and right
1354    /// branches, before it pads. The order matters because cell width is not
1355    /// additive across a cut: a leading zero-width joiner eats the character
1356    /// after it, so chopping the line's tail hands a cell back and the line then
1357    /// wants padding it did not want before. Padding first measures the un-cut
1358    /// line, finds no slack, and leaves the line a column short.
1359    ///
1360    /// Real rich 15.0.0:
1361    ///
1362    /// ```text
1363    /// right    '‍┬┴⠁├╰⠃⡁⠃╯⠆┴' @9, ellipsis -> [' ‍┬┴⠁├╰⠃⡁⠃…']
1364    /// right    '‍⠃╯⠆┴'         @2, crop, no_wrap -> [' ‍⠃╯']
1365    /// center   's ‍8-o🧠e'      @4, ellipsis -> [' s  ', '‍8-o… ']
1366    /// ```
1367    #[test]
1368    fn center_and_right_truncate_before_they_pad() {
1369        let text = Text::new("\u{200d}┬┴⠁├╰⠃⡁⠃╯⠆┴")
1370            .justify(Justify::Right)
1371            .overflow(Overflow::Ellipsis);
1372        assert_eq!(wrapped_plain(&text, 9), vec![" \u{200d}┬┴⠁├╰⠃⡁⠃…"]);
1373
1374        let cropped = Text::new("\u{200d}⠃╯⠆┴")
1375            .justify(Justify::Right)
1376            .overflow(Overflow::Crop)
1377            .no_wrap(true);
1378        assert_eq!(wrapped_plain(&cropped, 2), vec![" \u{200d}⠃╯"]);
1379
1380        let centered = Text::new("s \u{200d}8-o\u{1f9e0}e")
1381            .justify(Justify::Center)
1382            .overflow(Overflow::Ellipsis);
1383        assert_eq!(wrapped_plain(&centered, 4), vec![" s  ", "\u{200d}8-o… "]);
1384    }
1385
1386    /// A line is measured and cut as one string, the way upstream's
1387    /// `Text.truncate` works on `self.plain` — not segment by segment. Cell
1388    /// width is not additive across a segment boundary: full justification
1389    /// splits the line into one segment per word, which strands the zero-width
1390    /// joiner at the end of `π‍` away from the space it swallows, so the
1391    /// per-segment sum reads eight cells for a seven-cell line and an ellipsis
1392    /// eats a character upstream keeps.
1393    ///
1394    /// Real rich 15.0.0,
1395    /// `Text("⚠1️;& π‍  ψ\u{a0}τ\u{a0}γ ⡀", justify="full", overflow="ellipsis").wrap(c, 7)`:
1396    ///
1397    /// ```text
1398    /// ['⚠1️;& ', 'π‍  ψ τ γ', '⡀']   with cell widths [7, 7, 1]
1399    /// ```
1400    #[test]
1401    fn a_line_is_measured_whole_not_segment_by_segment() {
1402        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}")
1403            .justify(Justify::Full)
1404            .overflow(Overflow::Ellipsis);
1405        assert_eq!(
1406            wrapped_plain(&text, 7),
1407            vec![
1408                "\u{26a0}1\u{fe0f};&\u{3000}",
1409                "\u{3c0}\u{200d}  \u{3c8}\u{a0}\u{3c4}\u{a0}\u{3b3}",
1410                "\u{2840}"
1411            ]
1412        );
1413    }
1414
1415    /// Full justification widens the gaps between words so every line but the
1416    /// last fills the width exactly.
1417    ///
1418    /// Captured verbatim from real rich 15.0.0 —
1419    /// `Lines.justify(console, 20, justify="full")` on
1420    /// `"aaa bbb ccc ddddddddddddddddddd ee ff"` yields:
1421    ///
1422    /// ```text
1423    /// 'aaa     bbb      ccc'   <- stretched to exactly 20
1424    /// 'ddddddddddddddddddd'    <- one word: nothing to widen, and the
1425    ///                             trailing space wrapping left is dropped
1426    /// 'ee ff'                  <- final line untouched: NOT padded to width
1427    /// ```
1428    ///
1429    /// Two details worth pinning: the slack is handed out from the rightmost
1430    /// gap backwards (so the gaps are 5 then 6, not 6 then 5), and the last
1431    /// line is the one case where a justified line is left short of the width.
1432    #[test]
1433    fn full_justify_matches_upstream() {
1434        let text = Text::new("aaa bbb ccc ddddddddddddddddddd ee ff").justify(Justify::Full);
1435        let plain: Vec<String> = text
1436            .render_lines(&Theme::default_theme(), &Style::new(), Some(20))
1437            .iter()
1438            .map(|line| line.iter().map(|s| s.text.as_str()).collect())
1439            .collect();
1440        assert_eq!(
1441            plain,
1442            vec!["aaa     bbb      ccc", "ddddddddddddddddddd", "ee ff"]
1443        );
1444        assert_eq!(plain[0].chars().count(), 20);
1445    }
1446
1447    fn wrapped_plain(text: &Text, width: usize) -> Vec<String> {
1448        text.render_lines(&Theme::default_theme(), &Style::new(), Some(width))
1449            .iter()
1450            .map(|line| line.iter().map(|s| s.text.as_str()).collect())
1451            .collect()
1452    }
1453
1454    /// Wrapping hands each line the space that ended it, and upstream's
1455    /// `Lines.justify` throws that space away (`line.rstrip()`) before centring
1456    /// or right-aligning — but *not* before left-aligning or full-justifying.
1457    ///
1458    /// Captured verbatim from real rich 15.0.0,
1459    /// `Text(case, justify=…).wrap(console, 20)`:
1460    ///
1461    /// ```text
1462    /// center 'abcd efgh ijklmnop qrst'  -> ' abcd efgh ijklmnop '
1463    /// right  'abcd efgh ijklmnop qrst'  -> '  abcd efgh ijklmnop'
1464    /// right  'aaaa bbbb cccc dddd eeee' -> ' aaaa bbbb cccc dddd'
1465    /// left   'abcd efgh ijklmnop qrst'  -> 'abcd efgh ijklmnop  '
1466    /// ```
1467    ///
1468    /// Counting the wrap space as content puts the centred line one column too
1469    /// far left and the right-aligned line a whole column short of the edge.
1470    #[test]
1471    fn center_and_right_rstrip_the_wrap_space() {
1472        let wrapped = "abcd efgh ijklmnop qrst";
1473        assert_eq!(
1474            wrapped_plain(&Text::new(wrapped).justify(Justify::Center), 20)[0],
1475            " abcd efgh ijklmnop "
1476        );
1477        assert_eq!(
1478            wrapped_plain(&Text::new(wrapped).justify(Justify::Right), 20)[0],
1479            "  abcd efgh ijklmnop"
1480        );
1481        assert_eq!(
1482            wrapped_plain(
1483                &Text::new("aaaa bbbb cccc dddd eeee").justify(Justify::Right),
1484                20
1485            )[0],
1486            " aaaa bbbb cccc dddd"
1487        );
1488        // Left is the control: upstream pads it without stripping, so the
1489        // trailing space stays part of the line and nothing shifts.
1490        assert_eq!(
1491            wrapped_plain(&Text::new(wrapped).justify(Justify::Left), 20)[0],
1492            "abcd efgh ijklmnop  "
1493        );
1494    }
1495
1496    /// `divide_line` measures a word *with* its trailing space, so a line whose
1497    /// last word ends flush with the width comes back one character too long.
1498    /// Upstream's `Text.wrap` calls `rstrip_end(width)` on every divided line to
1499    /// hand that back before overflow is applied.
1500    ///
1501    /// Real rich 15.0.0, `Text(case, overflow="ellipsis").wrap(console, 10)`:
1502    ///
1503    /// ```text
1504    /// 'abcdefghij more'   -> ['abcdefghij', 'more']   <- no ellipsis
1505    /// 'abcdefghijkl more' -> ['abcdefghi…', 'more']   <- genuinely too long
1506    /// ```
1507    ///
1508    /// Skipping the rstrip makes the first case measure 11 cells, so the
1509    /// ellipsis fires and eats the `j` that upstream keeps.
1510    #[test]
1511    fn rstrip_end_stops_the_wrap_space_from_triggering_an_ellipsis() {
1512        assert_eq!(
1513            wrapped_plain(
1514                &Text::new("abcdefghij more").overflow(Overflow::Ellipsis),
1515                10
1516            ),
1517            vec!["abcdefghij", "more"]
1518        );
1519        assert_eq!(
1520            wrapped_plain(
1521                &Text::new("abcdefghijkl more").overflow(Overflow::Ellipsis),
1522                10
1523            ),
1524            vec!["abcdefghi…", "more"]
1525        );
1526    }
1527
1528    #[test]
1529    fn append_creates_spans() {
1530        let mut text = Text::new("");
1531        text.append("hello", Some(Style::parse("bold").unwrap().into()));
1532        text.append(" world", None);
1533        assert_eq!(text.plain(), "hello world");
1534        assert_eq!(text.spans().len(), 1);
1535    }
1536
1537    #[test]
1538    fn render_flattens_overlapping_spans() {
1539        let mut text = Text::new("abcdef");
1540        text.stylize(Style::parse("bold").unwrap(), 0, 4);
1541        text.stylize(Style::parse("red").unwrap(), 2, 6);
1542        let segments = text.render(&Theme::default_theme(), &Style::new());
1543        // Boundaries at 0,2,4,6 -> "ab"(bold) "cd"(bold+red) "ef"(red)
1544        let rendered: Vec<_> = segments.iter().map(|s| s.text.clone()).collect();
1545        assert_eq!(rendered, vec!["ab", "cd", "ef"]);
1546    }
1547
1548    /// `Text::truncate` on its own, against real rich 15.0.0. `fold` and `crop`
1549    /// deliberately agree: folding is a wrapping behaviour, and truncation has
1550    /// no line to fold onto.
1551    #[test]
1552    fn truncate_matches_upstream() {
1553        for (overflow, expected) in [
1554            (Overflow::Fold, "hello"),
1555            (Overflow::Crop, "hello"),
1556            (Overflow::Ellipsis, "hell…"),
1557            (Overflow::Ignore, "hello world"),
1558        ] {
1559            let mut text = Text::new("hello world");
1560            text.truncate(5, Some(overflow), false);
1561            assert_eq!(text.plain(), expected, "overflow {overflow:?}");
1562        }
1563    }
1564
1565    /// `pad` fills out to the width, but only when the text is short — a text
1566    /// that is already too long is cut, never padded.
1567    #[test]
1568    fn truncate_pads_only_when_short() {
1569        let mut short = Text::new("hi");
1570        short.truncate(6, Some(Overflow::Crop), true);
1571        assert_eq!(short.plain(), "hi    ");
1572
1573        let mut exact = Text::new("hi");
1574        exact.truncate(2, Some(Overflow::Crop), true);
1575        assert_eq!(exact.plain(), "hi");
1576    }
1577
1578    /// Truncating must not leave a span pointing past the end of the string.
1579    #[test]
1580    fn truncate_trims_dangling_spans() {
1581        let mut text = Text::new("hello world");
1582        text.stylize(Style::parse("bold").unwrap(), 6, 11);
1583        text.stylize(Style::parse("red").unwrap(), 0, 5);
1584        text.truncate(3, Some(Overflow::Crop), false);
1585        assert_eq!(text.plain(), "hel");
1586        // The "world" span starts past the new end and is dropped entirely; the
1587        // "hello" span survives, clamped.
1588        assert_eq!(text.spans().len(), 1);
1589        assert!(text.spans().iter().all(|s| s.end <= text.plain().len()));
1590    }
1591
1592    use crate::protocol::Renderable;
1593
1594    /// The overflow method may come from the text or from the console options,
1595    /// and the text's own setting wins — mirroring upstream's
1596    /// `self.overflow or options.overflow or DEFAULT_OVERFLOW`.
1597    #[test]
1598    fn text_overflow_beats_console_options() {
1599        let console = crate::Console::builder().width(8).build();
1600        let mut options = console.options();
1601        options.overflow = Some(Overflow::Ellipsis);
1602        options.no_wrap = Some(true);
1603
1604        // Nothing set on the text: the options decide.
1605        let from_options = Text::new("the quick brown fox");
1606        assert_eq!(
1607            plain_of(&from_options.rich_render(&console, &options)),
1608            "the qui…"
1609        );
1610
1611        // Set on the text: the text decides, and the options are ignored.
1612        let from_text = Text::new("the quick brown fox").overflow(Overflow::Crop);
1613        assert_eq!(
1614            plain_of(&from_text.rich_render(&console, &options)),
1615            "the quic"
1616        );
1617    }
1618
1619    /// With no overflow anywhere, upstream's default applies: fold.
1620    #[test]
1621    fn overflow_defaults_to_fold() {
1622        let console = crate::Console::builder().width(8).build();
1623        let text = Text::new("supercalifragilistic");
1624        let rendered = plain_of(&text.rich_render(&console, &console.options()));
1625        assert_eq!(rendered, "supercal\nifragili\nstic");
1626    }
1627
1628    /// Concatenate the visible text of a segment stream, for assertions that
1629    /// care about layout rather than styling.
1630    fn plain_of(segments: &[Segment]) -> String {
1631        segments
1632            .iter()
1633            .filter(|s| !s.control)
1634            .map(|s| s.text.as_str())
1635            .collect()
1636    }
1637
1638    /// `Text::new` stripped control codes but `append` did not, so every path
1639    /// that builds text incrementally — Markdown, Syntax, plain files — leaked
1640    /// them to the terminal. A backspace run is a spoofing tool: the reader sees
1641    /// the overwritten text, not what the file says.
1642    #[test]
1643    fn append_strips_control_codes_like_new() {
1644        let mut text = Text::new("");
1645        text.append("FAILED\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}PASSED", None);
1646        assert_eq!(text.plain(), "FAILEDPASSED");
1647
1648        for code in ['\u{7}', '\u{8}', '\u{b}', '\u{c}', '\u{d}'] {
1649            let mut text = Text::new("");
1650            text.append(&format!("a{code}b"), None);
1651            assert_eq!(text.plain(), "ab", "control code {code:?} survived append");
1652        }
1653    }
1654
1655    /// Upstream's `strip_control_codes` keeps NUL and ESC; only BEL, backspace,
1656    /// vertical tab, form feed and carriage return go.
1657    #[test]
1658    fn append_keeps_the_codes_upstream_keeps() {
1659        let mut text = Text::new("");
1660        text.append("a\u{0}b\u{1b}c", None);
1661        assert_eq!(text.plain(), "a\u{0}b\u{1b}c");
1662    }
1663}