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