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/// Cell width of a tab stop. Upstream's `Console.tab_size` default; a per-console
17/// override is not ported yet (see `docs/DIVERGENCES.md`).
18pub const DEFAULT_TAB_SIZE: usize = 8;
19
20/// A style applied to a byte range `[start, end)` of a [`Text`]'s plain string.
21/// Mirrors `rich.text.Span`.
22///
23/// The style may be a *name* rather than a resolved [`Style`]; see [`StyleType`].
24/// Names are resolved when the text is rendered, against the theme of whichever
25/// console renders it.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Span {
28    pub start: usize,
29    pub end: usize,
30    pub style: StyleType,
31}
32
33/// Styled text. Mirrors `rich.text.Text`.
34#[derive(Debug, Clone, Default)]
35pub struct Text {
36    plain: String,
37    spans: Vec<Span>,
38    /// A base style applied to the whole text. May be an unresolved name.
39    style: StyleType,
40    /// How lines are justified within the render width.
41    justify: Justify,
42    /// What to do with lines wider than the render width. `None` defers to the
43    /// console options, then to [`Overflow::Fold`].
44    overflow: Option<Overflow>,
45    /// Whether to skip wrapping. `None` defers to the console options, then to
46    /// `false`.
47    no_wrap: Option<bool>,
48}
49
50impl Text {
51    /// Strip the control codes upstream removes in `Text.__init__`
52    /// (`strip_control_codes`): BEL, backspace, vertical tab, form feed and
53    /// carriage return. Tab and newline are deliberately kept — they are layout,
54    /// not control.
55    fn strip_control_codes(text: &str) -> String {
56        if text
57            .bytes()
58            .any(|b| matches!(b, 0x07 | 0x08 | 0x0b | 0x0c | 0x0d))
59        {
60            text.chars()
61                .filter(|c| !matches!(c, '\u{7}' | '\u{8}' | '\u{b}' | '\u{c}' | '\r'))
62                .collect()
63        } else {
64            text.to_string()
65        }
66    }
67
68    /// Plain, unstyled text.
69    pub fn new(plain: impl Into<String>) -> Self {
70        Text {
71            plain: Text::strip_control_codes(&plain.into()),
72            spans: Vec::new(),
73            style: StyleType::default(),
74            justify: Justify::Default,
75            overflow: None,
76            no_wrap: None,
77        }
78    }
79
80    /// Text with a base style, which may be a style *name* resolved at render
81    /// time (`Text::styled("hi", "repr.number")`) or a resolved [`Style`].
82    pub fn styled(plain: impl Into<String>, style: impl Into<StyleType>) -> Self {
83        Text {
84            plain: plain.into(),
85            spans: Vec::new(),
86            style: style.into(),
87            justify: Justify::Default,
88            overflow: None,
89            no_wrap: None,
90        }
91    }
92
93    /// Set how lines are justified within the render width (builder form).
94    pub fn justify(mut self, justify: Justify) -> Self {
95        self.justify = justify;
96        self
97    }
98
99    /// Set how lines are justified within the render width.
100    pub fn set_justify(&mut self, justify: Justify) {
101        self.justify = justify;
102    }
103
104    /// This text's own justify method.
105    pub fn get_justify(&self) -> Justify {
106        self.justify
107    }
108
109    /// Set what happens to lines wider than the render width (builder form).
110    pub fn overflow(mut self, overflow: Overflow) -> Self {
111        self.overflow = Some(overflow);
112        self
113    }
114
115    /// Set what happens to lines wider than the render width. Pass `None` to
116    /// defer to the console options.
117    pub fn set_overflow(&mut self, overflow: Option<Overflow>) {
118        self.overflow = overflow;
119    }
120
121    /// This text's own overflow method, if it set one.
122    pub fn get_overflow(&self) -> Option<Overflow> {
123        self.overflow
124    }
125
126    /// Disable (or re-enable) wrapping for this text (builder form).
127    pub fn no_wrap(mut self, no_wrap: bool) -> Self {
128        self.no_wrap = Some(no_wrap);
129        self
130    }
131
132    /// Disable (or re-enable) wrapping. Pass `None` to defer to the console
133    /// options.
134    pub fn set_no_wrap(&mut self, no_wrap: Option<bool>) {
135        self.no_wrap = no_wrap;
136    }
137
138    /// This text's own no-wrap setting, if it set one.
139    pub fn get_no_wrap(&self) -> Option<bool> {
140        self.no_wrap
141    }
142
143    /// Shorten this text to at most `max_width` cells, optionally padding it out
144    /// to exactly `max_width` when it is shorter. Port of `Text.truncate`.
145    ///
146    /// `overflow` defaults to this text's own method, then to [`Overflow::Fold`];
147    /// [`Overflow::Ignore`] leaves the text alone entirely. Note that `Fold` and
148    /// `Crop` behave identically here — folding is a property of *wrapping*, and
149    /// a line that has already been wrapped can only be cut.
150    pub fn truncate(&mut self, max_width: usize, overflow: Option<Overflow>, pad: bool) {
151        let overflow = overflow.or(self.overflow).unwrap_or(Overflow::Fold);
152        if overflow == Overflow::Ignore {
153            return;
154        }
155        let length = cell_len(&self.plain);
156        if length > max_width {
157            let plain = if overflow == Overflow::Ellipsis {
158                // `…` is one cell wide, so cut one short and add it back.
159                format!(
160                    "{}…",
161                    set_cell_size(&self.plain, max_width.saturating_sub(1))
162                )
163            } else {
164                set_cell_size(&self.plain, max_width)
165            };
166            self.set_plain(plain);
167        } else if pad {
168            let plain = set_cell_size(&self.plain, max_width);
169            self.set_plain(plain);
170        }
171    }
172
173    /// Replace the plain string, clamping every span into the new length so no
174    /// span can dangle past the end. Upstream's `Text.plain` setter does the
175    /// same via `_trim_spans`.
176    fn set_plain(&mut self, plain: String) {
177        let length = plain.len();
178        self.plain = plain;
179        self.spans.retain(|span| span.start < length);
180        for span in &mut self.spans {
181            span.end = span.end.min(length);
182        }
183    }
184
185    /// An empty `Text` carrying this one's style, justify, overflow and no-wrap.
186    /// Port of `Text.blank_copy`.
187    pub fn blank_copy(&self) -> Text {
188        Text {
189            plain: String::new(),
190            spans: Vec::new(),
191            style: self.style.clone(),
192            justify: self.justify,
193            overflow: self.overflow,
194            no_wrap: self.no_wrap,
195        }
196    }
197
198    /// Cut this text at each byte offset in `offsets`, returning the pieces.
199    /// Port of `Text.divide`.
200    ///
201    /// Every piece inherits the base style, justify, overflow and no-wrap, and
202    /// each span is re-based onto the pieces it covers. Spans that would come out
203    /// empty are dropped, matching upstream's `new_end > new_start`.
204    ///
205    /// Offsets are **byte** offsets (as everywhere else in this port's span
206    /// arithmetic) and must fall on `char` boundaries.
207    pub fn divide(&self, offsets: &[usize]) -> Vec<Text> {
208        if offsets.is_empty() {
209            return vec![self.clone()];
210        }
211        let mut bounds = Vec::with_capacity(offsets.len() + 2);
212        bounds.push(0);
213        bounds.extend(offsets.iter().copied());
214        bounds.push(self.plain.len());
215
216        let mut lines: Vec<Text> = bounds
217            .windows(2)
218            .map(|w| {
219                let (start, end) = (w[0].min(self.plain.len()), w[1].min(self.plain.len()));
220                let mut line = self.blank_copy();
221                if start < end {
222                    line.plain = self.plain[start..end].to_string();
223                }
224                line
225            })
226            .collect();
227
228        for span in &self.spans {
229            for (index, window) in bounds.windows(2).enumerate() {
230                let (line_start, line_end) = (window[0], window[1]);
231                let new_start = span.start.max(line_start) - line_start;
232                let new_end = span.end.min(line_end).saturating_sub(line_start);
233                if new_end > new_start {
234                    lines[index].spans.push(Span {
235                        start: new_start,
236                        end: new_end,
237                        style: span.style.clone(),
238                    });
239                }
240            }
241        }
242        lines
243    }
244
245    /// Split on `separator`. Port of `Text.split`.
246    ///
247    /// `include_separator` keeps the separator at the end of each piece.
248    /// `allow_blank` keeps the trailing empty piece that a text ending in the
249    /// separator would otherwise produce.
250    ///
251    /// # Panics
252    /// If `separator` is empty, which upstream asserts against.
253    pub fn split(&self, separator: &str, include_separator: bool, allow_blank: bool) -> Vec<Text> {
254        assert!(!separator.is_empty(), "separator must not be empty");
255        if !self.plain.contains(separator) {
256            return vec![self.clone()];
257        }
258        let matches: Vec<usize> = self
259            .plain
260            .match_indices(separator)
261            .map(|(i, _)| i)
262            .collect();
263        let mut lines = if include_separator {
264            let offsets: Vec<usize> = matches.iter().map(|s| s + separator.len()).collect();
265            self.divide(&offsets)
266        } else {
267            // Cut on both sides of every separator, then drop the separators.
268            let mut offsets = Vec::with_capacity(matches.len() * 2);
269            for start in &matches {
270                offsets.push(*start);
271                offsets.push(start + separator.len());
272            }
273            self.divide(&offsets)
274                .into_iter()
275                .filter(|line| line.plain != separator)
276                .collect()
277        };
278        if !allow_blank && self.plain.ends_with(separator) {
279            lines.pop();
280        }
281        lines
282    }
283
284    /// Pad both sides with `count` copies of `character`. Port of `Text.pad`.
285    pub fn pad(&mut self, count: usize, character: char) {
286        self.pad_left(count, character);
287        self.pad_right(count, character);
288    }
289
290    /// Pad the left with `count` copies of `character`, shifting every span to
291    /// follow the text. Port of `Text.pad_left`.
292    pub fn pad_left(&mut self, count: usize, character: char) {
293        if count == 0 {
294            return;
295        }
296        let padding: String = std::iter::repeat_n(character, count).collect();
297        let offset = padding.len();
298        self.plain.insert_str(0, &padding);
299        for span in &mut self.spans {
300            span.start += offset;
301            span.end += offset;
302        }
303    }
304
305    /// Pad the right with `count` copies of `character`. Port of
306    /// `Text.pad_right`. Spans are untouched, so the padding is unstyled.
307    pub fn pad_right(&mut self, count: usize, character: char) {
308        if count == 0 {
309            return;
310        }
311        self.plain.extend(std::iter::repeat_n(character, count));
312    }
313
314    /// Drop the last `amount` bytes, clipping any span that reached into them.
315    /// Port of `Text.right_crop`.
316    pub fn right_crop(&mut self, amount: usize) {
317        if amount == 0 {
318            return;
319        }
320        let max_offset = self.plain.len().saturating_sub(amount);
321        let plain = self.plain[..max_offset].to_string();
322        self.set_plain(plain);
323    }
324
325    /// Remove trailing whitespace. Port of `Text.rstrip`.
326    pub fn rstrip(&mut self) {
327        let plain = self.plain.trim_end().to_string();
328        self.set_plain(plain);
329    }
330
331    /// Remove *only as much* trailing whitespace as it takes to get down to
332    /// `size` cells, leaving the rest. Port of `Text.rstrip_end`.
333    ///
334    /// This is what lets a wrapped line keep the space that ended it while a
335    /// line that overshot the width gives its padding back.
336    pub fn rstrip_end(&mut self, size: usize) {
337        let length = self.cell_len();
338        if length <= size {
339            return;
340        }
341        let excess = length - size;
342        let whitespace = self.plain.len() - self.plain.trim_end().len();
343        if whitespace > 0 {
344            self.right_crop(whitespace.min(excess));
345        }
346    }
347
348    /// Replace tabs with spaces up to the next `tab_size` stop. Port of
349    /// `Text.expand_tabs`.
350    ///
351    /// Styles extend over the inserted spaces, so a styled tab pads in its own
352    /// style rather than punching an unstyled hole (upstream reaches the same
353    /// result via `extend_style`).
354    /// Append `count` spaces, extending any span that reached the end so the
355    /// padding takes its style. Port of `Text.extend_style`.
356    fn extend_style(&mut self, count: usize) {
357        if count == 0 {
358            return;
359        }
360        let length = self.plain.len();
361        self.plain.extend(std::iter::repeat_n(' ', count));
362        for span in &mut self.spans {
363            if span.end >= length {
364                span.end += count;
365            }
366        }
367    }
368
369    pub fn expand_tabs(&mut self, tab_size: usize) {
370        if !self.plain.contains('\t') || tab_size == 0 {
371            return;
372        }
373        // Rebuilt part-by-part rather than by remapping offsets, because the
374        // *split* is observable: upstream turns each tab-terminated run into its
375        // own piece, so a span crossing several tabs comes back as several spans
376        // and renders as several segments. Remapping offsets keeps one span and
377        // emits one segment — same colours, different bytes.
378        let mut result = Text::new("");
379        for line in self.split("\n", true, false) {
380            if !line.plain.contains('\t') {
381                result = result.append_text(&line);
382                continue;
383            }
384            let mut cell_position = 0usize;
385            for mut part in line.split("\t", true, false) {
386                if part.plain.ends_with('\t') {
387                    // The tab becomes one space, then the run is padded out to
388                    // the next stop — so a tab always advances at least one cell.
389                    part.plain.pop();
390                    part.plain.push(' ');
391                    cell_position += part.cell_len();
392                    let remainder = cell_position % tab_size;
393                    if remainder != 0 {
394                        let spaces = tab_size - remainder;
395                        part.extend_style(spaces);
396                        cell_position += spaces;
397                    }
398                } else {
399                    cell_position += part.cell_len();
400                }
401                result = result.append_text(&part);
402            }
403        }
404        self.plain = result.plain;
405        self.spans = result.spans;
406    }
407
408    /// Join `lines` with this text as the separator, carrying each piece's base
409    /// style across as a covering span. Port of `Text.join`.
410    pub fn join(&self, lines: &[Text]) -> Text {
411        let mut joined = self.blank_copy();
412        let last = lines.len().saturating_sub(1);
413        for (index, line) in lines.iter().enumerate() {
414            joined = joined.append_text(line);
415            if !self.plain.is_empty() && index != last {
416                joined = joined.append_text(self);
417            }
418        }
419        joined
420    }
421
422    /// Style every occurrence of any of `words`. Port of `Text.highlight_words`,
423    /// returning the number of matches.
424    pub fn highlight_words(
425        &mut self,
426        words: &[&str],
427        style: impl Into<StyleType>,
428        case_sensitive: bool,
429    ) -> Result<usize> {
430        let alternation = words
431            .iter()
432            .map(|word| fancy_regex::escape(word).into_owned())
433            .collect::<Vec<_>>()
434            .join("|");
435        if alternation.is_empty() {
436            return Ok(0);
437        }
438        let pattern = if case_sensitive {
439            alternation
440        } else {
441            format!("(?i){alternation}")
442        };
443        self.highlight_regex(&pattern, Some(style.into()), "")
444    }
445
446    /// Style every match of `pattern`, returning the number of matches. Full port
447    /// of `Text.highlight_regex`.
448    ///
449    /// `style`, when given, styles the whole match. Each **named group** is then
450    /// styled with `{style_prefix}{name}` as a style *name*, left for the theme
451    /// to resolve at render time — which is how a highlighter colours its groups
452    /// without ever seeing a console.
453    ///
454    /// Groups that did not participate in the match, and zero-width ones, are
455    /// skipped.
456    pub fn highlight_regex(
457        &mut self,
458        pattern: &str,
459        style: Option<StyleType>,
460        style_prefix: &str,
461    ) -> Result<usize> {
462        let regex = fancy_regex::Regex::new(pattern)
463            .map_err(|e| crate::errors::RichError::Regex(format!("invalid pattern: {e}")))?;
464        Ok(self.highlight_with_regex(&regex, style, style_prefix))
465    }
466
467    /// As [`highlight_regex`](Self::highlight_regex) with an already-compiled
468    /// pattern, for callers that apply the same patterns repeatedly.
469    ///
470    /// A match that errors mid-scan (a `fancy-regex` backtrack-limit hit) stops
471    /// the scan and keeps the spans found so far, rather than discarding them.
472    pub(crate) fn highlight_with_regex(
473        &mut self,
474        regex: &fancy_regex::Regex,
475        style: Option<StyleType>,
476        style_prefix: &str,
477    ) -> usize {
478        // Capture-definition order, matching upstream's `match.groupdict()`.
479        let names: Vec<(usize, String)> = regex
480            .capture_names()
481            .enumerate()
482            .filter_map(|(index, name)| name.map(|name| (index, name.to_string())))
483            .collect();
484
485        // Scanning borrows the plain string while the spans are pushed, so move
486        // it out and put it back — no copy, and no fighting the borrow checker.
487        let plain = std::mem::take(&mut self.plain);
488        let mut count = 0;
489        for captures in regex.captures_iter(&plain) {
490            let Ok(captures) = captures else { break };
491            if let (Some(style), Some(whole)) = (style.as_ref(), captures.get(0)) {
492                if whole.end() > whole.start() {
493                    self.spans.push(Span {
494                        start: whole.start(),
495                        end: whole.end(),
496                        style: style.clone(),
497                    });
498                }
499            }
500            count += 1;
501            for (index, name) in &names {
502                if let Some(group) = captures.get(*index) {
503                    if group.end() > group.start() {
504                        self.spans.push(Span {
505                            start: group.start(),
506                            end: group.end(),
507                            style: StyleType::Name(format!("{style_prefix}{name}")),
508                        });
509                    }
510                }
511            }
512        }
513        self.plain = plain;
514        count
515    }
516
517    /// Build styled text from console markup. Port of `Text.from_markup`.
518    ///
519    /// Tag names are stored on the spans and resolved when the text is rendered,
520    /// so no theme is needed here.
521    pub fn from_markup(markup_text: &str) -> Result<Text> {
522        markup::render(markup_text)
523    }
524
525    /// The unstyled string content.
526    pub fn plain(&self) -> &str {
527        &self.plain
528    }
529
530    /// The spans currently applied.
531    pub fn spans(&self) -> &[Span] {
532        &self.spans
533    }
534
535    /// Length in terminal cells.
536    pub fn cell_len(&self) -> usize {
537        cell_len(&self.plain)
538    }
539
540    /// True when there is no content.
541    pub fn is_empty(&self) -> bool {
542        self.plain.is_empty()
543    }
544
545    /// Append more text, optionally under `style` (a resolved [`Style`] or a
546    /// style name).
547    pub fn append(&mut self, text: &str, style: Option<StyleType>) {
548        let start = self.plain.len();
549        self.plain.push_str(text);
550        let end = self.plain.len();
551        if let Some(style) = style {
552            self.spans.push(Span { start, end, style });
553        }
554    }
555
556    /// Append another `Text`, carrying over its base style (as a covering span)
557    /// and all of its spans, shifted to their new offsets. Port of
558    /// `Text.append_text`. Consumes `self` and returns it for chaining.
559    pub fn append_text(mut self, other: &Text) -> Text {
560        let offset = self.plain.len();
561        self.plain.push_str(&other.plain);
562        let end = self.plain.len();
563        if !other.style.is_null_style() {
564            self.spans.push(Span {
565                start: offset,
566                end,
567                style: other.style.clone(),
568            });
569        }
570        for span in &other.spans {
571            self.spans.push(Span {
572                start: span.start + offset,
573                end: span.end + offset,
574                style: span.style.clone(),
575            });
576        }
577        self
578    }
579
580    /// Apply `style` to the byte range `[start, end)`. Port of `Text.stylize`,
581    /// including its argument order.
582    ///
583    /// `style` may be a resolved [`Style`] or a name (`"repr.number"`) left for
584    /// the renderer to look up. Byte offsets, not char offsets; ASCII-only
585    /// callers such as highlighters are unaffected by the distinction.
586    ///
587    /// A range that is empty or inverted is ignored, which is what gives us
588    /// upstream's `end > start` skip for non-participating regex groups.
589    pub fn stylize(&mut self, style: impl Into<StyleType>, start: usize, end: usize) {
590        let end = end.min(self.plain.len());
591        if start >= end {
592            return;
593        }
594        self.spans.push(Span {
595            start,
596            end,
597            style: style.into(),
598        });
599    }
600
601    /// Push a raw span (used by the markup parser).
602    pub(crate) fn push_span(&mut self, span: Span) {
603        self.spans.push(span);
604    }
605
606    /// Set the whole-text base style, resolved or named.
607    pub fn set_base_style(&mut self, style: impl Into<StyleType>) {
608        self.style = style.into();
609    }
610
611    /// Flatten into non-overlapping segments (newlines become [`Segment::line`]),
612    /// combining `base_style`, this text's base style, and every covering span.
613    /// Does **not** wrap. Port of the core of `Text.render`.
614    ///
615    /// Named span styles are resolved against `theme`.
616    pub fn render(&self, theme: &Theme, base_style: &Style) -> Vec<Segment> {
617        self.render_joined(theme, base_style, None)
618    }
619
620    /// The `(minimum, maximum)` cell width of this text: `maximum` is the widest
621    /// hard line, `minimum` the widest word. Port of `Text.__rich_measure__`.
622    pub fn measurement(&self) -> (usize, usize) {
623        // Measured against the tab-EXPANDED text. Upstream measures the raw
624        // string, where a tab counts as zero cells, and gets away with it
625        // because its measurement never narrows the render width. Ours does, so
626        // measuring raw would hand the renderer a width smaller than the text it
627        // is about to expand and `"a\tb\tc"` would wrap on nothing.
628        let expanded;
629        let plain = if self.plain.contains('\t') {
630            let mut text = self.clone();
631            text.expand_tabs(DEFAULT_TAB_SIZE);
632            expanded = text.plain;
633            &expanded
634        } else {
635            &self.plain
636        };
637        let max_line = plain.split('\n').map(cell_len).max().unwrap_or(0);
638        let min_word = plain
639            .split_whitespace()
640            .map(cell_len)
641            .max()
642            .unwrap_or(max_line);
643        (min_word, max_line)
644    }
645
646    /// Render into visual lines, wrapping each hard line to `width` cells when
647    /// `Some`, and justifying per this text's own justify.
648    pub fn render_lines(
649        &self,
650        theme: &Theme,
651        base_style: &Style,
652        width: Option<usize>,
653    ) -> Vec<Vec<Segment>> {
654        self.render_lines_justified(theme, base_style, width, self.justify)
655    }
656
657    /// Like [`render_lines`](Self::render_lines) but with an explicit `justify`
658    /// (used by the console to apply `options.justify`).
659    pub fn render_lines_justified(
660        &self,
661        theme: &Theme,
662        base_style: &Style,
663        width: Option<usize>,
664        justify: Justify,
665    ) -> Vec<Vec<Segment>> {
666        self.render_lines_wrapped(
667            theme,
668            base_style,
669            width,
670            justify,
671            self.overflow.unwrap_or(Overflow::Fold),
672            self.no_wrap.unwrap_or(false),
673        )
674    }
675
676    /// The full wrap-justify-truncate pipeline, with every knob resolved by the
677    /// caller. Port of `Text.wrap`.
678    ///
679    /// Lines are split on `\n`, wrapped to `width` (folding over-long words only
680    /// when `overflow` is [`Overflow::Fold`]), justified, and finally truncated
681    /// to `width`. [`Overflow::Ignore`] skips wrapping and truncation both, so
682    /// lines may come back wider than `width`.
683    pub fn render_lines_wrapped(
684        &self,
685        theme: &Theme,
686        base_style: &Style,
687        width: Option<usize>,
688        justify: Justify,
689        overflow: Overflow,
690        no_wrap: bool,
691    ) -> Vec<Vec<Segment>> {
692        // Tabs are expanded before anything measures or wraps the text, as
693        // upstream's `Text.wrap` does per line. Without this a tab occupies one
694        // cell everywhere in the layout and then eight on the terminal, so every
695        // width calculation downstream is wrong.
696        if self.plain.contains('\t') {
697            let mut expanded = self.clone();
698            expanded.expand_tabs(DEFAULT_TAB_SIZE);
699            return expanded
700                .render_lines_wrapped(theme, base_style, width, justify, overflow, no_wrap);
701        }
702
703        // Resolve every span's style once, up front, into a vector parallel to
704        // `self.spans` — upstream's `style_map`. Resolving inside the per-line
705        // loop would re-parse the same names for every visual line.
706        let resolved: Vec<Style> = self
707            .spans
708            .iter()
709            .map(|span| theme.get_style_or_null(&span.style))
710            .collect();
711        let effective_base = base_style.combine(&theme.get_style_or_null(&self.style));
712        // Upstream folds `overflow == "ignore"` into no_wrap before splitting.
713        let no_wrap = no_wrap || overflow == Overflow::Ignore;
714        let mut lines: Vec<Vec<Segment>> = Vec::new();
715        for (start, end) in self.wrapped_ranges(width, overflow, no_wrap) {
716            lines.push(self.line_segments(&resolved, start, end, &effective_base));
717        }
718        let Some(width) = width else {
719            return lines;
720        };
721        if justify != Justify::Default {
722            let last = lines.len().saturating_sub(1);
723            for (index, line) in lines.iter_mut().enumerate() {
724                // Full justification leaves the final line ragged, so it
725                // needs to know where it is in the paragraph.
726                *line = justify_line(line, width, justify, &effective_base, index == last);
727            }
728        }
729        if overflow != Overflow::Ignore {
730            for line in &mut lines {
731                *line = truncate_line(line, width, overflow);
732            }
733        }
734        lines
735    }
736
737    /// As [`render_lines_wrapped`](Self::render_lines_wrapped), flattened into a
738    /// single segment stream with [`Segment::line`] between visual lines.
739    pub fn render_joined_wrapped(
740        &self,
741        theme: &Theme,
742        base_style: &Style,
743        width: usize,
744        justify: Justify,
745        overflow: Overflow,
746        no_wrap: bool,
747    ) -> Vec<Segment> {
748        let lines =
749            self.render_lines_wrapped(theme, base_style, Some(width), justify, overflow, no_wrap);
750        let mut segments = Vec::new();
751        let last = lines.len().saturating_sub(1);
752        for (index, line) in lines.into_iter().enumerate() {
753            segments.extend(line);
754            if index != last {
755                segments.push(Segment::line());
756            }
757        }
758        segments
759    }
760
761    /// Render into a flat segment stream with [`Segment::line`] between visual
762    /// lines (wrapping when `width` is `Some`), using this text's own justify.
763    fn render_joined(
764        &self,
765        theme: &Theme,
766        base_style: &Style,
767        width: Option<usize>,
768    ) -> Vec<Segment> {
769        let lines = self.render_lines(theme, base_style, width);
770        let mut segments = Vec::new();
771        let last = lines.len().saturating_sub(1);
772        for (index, line) in lines.into_iter().enumerate() {
773            segments.extend(line);
774            if index != last {
775                segments.push(Segment::line());
776            }
777        }
778        segments
779    }
780
781    /// The `(start_byte, end_byte)` range of each visual line: hard lines split
782    /// on `\n`, then wrapped to `width` cells when `Some`.
783    fn wrapped_ranges(
784        &self,
785        width: Option<usize>,
786        overflow: Overflow,
787        no_wrap: bool,
788    ) -> Vec<(usize, usize)> {
789        let mut hard: Vec<(usize, usize)> = Vec::new();
790        let mut start = 0;
791        for (i, byte) in self.plain.bytes().enumerate() {
792            if byte == b'\n' {
793                hard.push((start, i));
794                start = i + 1;
795            }
796        }
797        hard.push((start, self.plain.len()));
798
799        let Some(width) = width else {
800            return hard;
801        };
802        if no_wrap {
803            return hard;
804        }
805
806        let mut ranges: Vec<(usize, usize)> = Vec::new();
807        for (a, b) in hard {
808            let sub = &self.plain[a..b];
809            // Only `fold` breaks a word that is wider than the whole line; the
810            // cropping methods leave it long and let truncation cut it.
811            let breaks = crate::wrap::divide_line(sub, width, overflow == Overflow::Fold);
812            let mut cuts = vec![a];
813            for char_offset in breaks {
814                cuts.push(a + char_to_byte(sub, char_offset));
815            }
816            cuts.push(b);
817            for window in cuts.windows(2) {
818                ranges.push((window[0], window[1]));
819            }
820        }
821        ranges
822    }
823
824    /// Combine `effective_base` with every span covering `[start, end)`,
825    /// producing non-overlapping segments for that byte range.
826    ///
827    /// `resolved` is the per-render style map, index-parallel to `self.spans`.
828    /// Spans are folded in vector order, and spans that resolved to nothing are
829    /// **not** skipped — they still contribute a boundary. Upstream behaves the
830    /// same way, and the highlighter fixtures depend on it: an ISO-8601 date
831    /// emits separate segments per sub-field even where the field styles are
832    /// identical.
833    fn line_segments(
834        &self,
835        resolved: &[Style],
836        start: usize,
837        end: usize,
838        effective_base: &Style,
839    ) -> Vec<Segment> {
840        if start >= end {
841            return Vec::new();
842        }
843        let mut points: Vec<usize> = vec![start, end];
844        for span in &self.spans {
845            let span_start = span.start.clamp(start, end);
846            let span_end = span.end.clamp(start, end);
847            points.push(span_start);
848            points.push(span_end);
849        }
850        points.sort_unstable();
851        points.dedup();
852
853        let mut segments = Vec::new();
854        for window in points.windows(2) {
855            let (a, b) = (window[0], window[1]);
856            if a >= b {
857                continue;
858            }
859            let slice = &self.plain[a..b];
860            if slice.is_empty() {
861                continue;
862            }
863            let mut style = effective_base.clone();
864            for (span, span_style) in self.spans.iter().zip(resolved) {
865                if span.start <= a && span.end >= b {
866                    style = style.combine(span_style);
867                }
868            }
869            segments.push(Segment::new(slice, Some(style)));
870        }
871        segments
872    }
873}
874
875/// Byte offset of the `char_idx`-th char in `text` (clamped to `text.len()`).
876fn char_to_byte(text: &str, char_idx: usize) -> usize {
877    text.char_indices()
878        .nth(char_idx)
879        .map(|(byte, _)| byte)
880        .unwrap_or(text.len())
881}
882
883/// Cut a rendered line down to `width` cells, applying `overflow`. Segment-level
884/// counterpart of [`Text::truncate`], used once per line at the end of the wrap
885/// pipeline.
886///
887/// [`Overflow::Fold`] and [`Overflow::Crop`] both plain-cut: by this point the
888/// line has already been wrapped, so anything still over-long is an unbreakable
889/// run that folding cannot help with.
890///
891/// [`Overflow::Ellipsis`] cuts one cell short and appends `…`. The marker takes
892/// the style of the first segment the cut did *not* keep whole — upstream writes
893/// the ellipsis into the plain string and lets span-trimming decide, which works
894/// out to the same rule, including when the cut lands exactly on a boundary.
895fn truncate_line(line: &[Segment], width: usize, overflow: Overflow) -> Vec<Segment> {
896    if overflow == Overflow::Ignore {
897        return line.to_vec();
898    }
899    let total: usize = line.iter().map(Segment::cell_length).sum();
900    if total <= width {
901        return line.to_vec();
902    }
903    let ellipsis = overflow == Overflow::Ellipsis;
904    // `…` occupies one cell, so the kept text must stop one cell early.
905    let keep = if ellipsis {
906        width.saturating_sub(1)
907    } else {
908        width
909    };
910
911    let mut result: Vec<Segment> = Vec::new();
912    let mut used = 0usize;
913    // Style the ellipsis inherits: that of the first segment reaching past the
914    // cut. `total > width >= keep` guarantees such a segment exists.
915    let mut cut_style: Option<Style> = None;
916    for segment in line {
917        let length = segment.cell_length();
918        if used + length <= keep {
919            result.push(segment.clone());
920            used += length;
921            continue;
922        }
923        cut_style = segment.style.clone();
924        if used < keep {
925            // This segment straddles the cut, so keep the part that fits. A wide
926            // character landing across the boundary is dropped whole and
927            // `set_cell_size` pads the gap with a space, as upstream does.
928            result.push(Segment::new(
929                set_cell_size(&segment.text, keep - used),
930                segment.style.clone(),
931            ));
932        }
933        break;
934    }
935    if ellipsis {
936        // Upstream appends the marker to the plain string and re-renders, so it
937        // lands inside the preceding run rather than beside it. Merging keeps
938        // the byte stream identical — a separate segment would re-emit the style.
939        match result.last_mut() {
940            Some(last) if !last.control && last.style == cut_style => last.text.push('…'),
941            _ => result.push(Segment::new("…", cut_style)),
942        }
943    }
944    result
945}
946
947/// Split a rendered line into whitespace-separated words, each word keeping its
948/// own styled segments. Separator spaces are dropped — [`full_justify`] decides
949/// the new gaps. Port of the `line.split(" ")` in upstream's `full` branch.
950fn split_words(line: &[Segment]) -> Vec<Vec<Segment>> {
951    let mut words: Vec<Vec<Segment>> = Vec::new();
952    let mut current: Vec<Segment> = Vec::new();
953    for segment in line {
954        // A segment can straddle a space, so split within it and keep the style.
955        for (index, piece) in segment.text.split(' ').enumerate() {
956            if index > 0 {
957                words.push(std::mem::take(&mut current));
958            }
959            if !piece.is_empty() {
960                current.push(Segment::new(piece, segment.style.clone()));
961            }
962        }
963    }
964    words.push(current);
965    // Wrapping leaves a trailing space on every line but the last, so the naive
966    // split ends with an empty word. Upstream's `Text.split` drops it, and the
967    // count matters: it decides how many gaps share the slack.
968    if words.last().is_some_and(|w| w.is_empty()) {
969        words.pop();
970    }
971    words
972}
973
974/// Distribute `width` across `line`'s words by widening the gaps between them.
975/// Direct port of the `justify == "full"` branch of upstream's `Lines.justify`:
976/// every gap starts at one space, and the extra columns are handed out from the
977/// rightmost gap backwards, cycling.
978fn full_justify(line: &[Segment], width: usize, style: &Style) -> Vec<Segment> {
979    let words = split_words(line);
980    let words_size: usize = words
981        .iter()
982        .map(|word| word.iter().map(Segment::cell_length).sum::<usize>())
983        .sum();
984    let mut num_spaces = words.len().saturating_sub(1);
985    let mut spaces = vec![1usize; num_spaces];
986    if !spaces.is_empty() {
987        let mut index = 0;
988        while words_size + num_spaces < width {
989            let slot = spaces.len() - index - 1;
990            spaces[slot] += 1;
991            num_spaces += 1;
992            index = (index + 1) % spaces.len();
993        }
994    }
995
996    let mut out: Vec<Segment> = Vec::new();
997    for (index, word) in words.iter().enumerate() {
998        out.extend(word.iter().cloned());
999        if let Some(&gap) = spaces.get(index) {
1000            // Upstream styles the gap with the surrounding style when the two
1001            // neighbours agree, else with the line's base style.
1002            let before = word.last().and_then(|s| s.style.clone());
1003            let after = words
1004                .get(index + 1)
1005                .and_then(|w| w.first())
1006                .and_then(|s| s.style.clone());
1007            let gap_style = if before == after {
1008                before.unwrap_or_else(|| style.clone())
1009            } else {
1010                style.clone()
1011            };
1012            out.push(Segment::new(" ".repeat(gap), Some(gap_style)));
1013        }
1014    }
1015    out
1016}
1017
1018/// Pad `line` to `width` cells according to `justify`, using `style` for the
1019/// pad (so e.g. a styled table cell fills with its own style).
1020///
1021/// `is_last` marks the final line of the paragraph, which full justification
1022/// leaves ragged rather than stretching.
1023fn justify_line(
1024    line: &[Segment],
1025    width: usize,
1026    justify: Justify,
1027    style: &Style,
1028    is_last: bool,
1029) -> Vec<Segment> {
1030    // Full justification rewrites the interior gaps instead of padding an edge.
1031    if justify == Justify::Full {
1032        // Upstream `break`s before the final line, so it is left exactly as
1033        // wrapped — not even padded out to the width, unlike every other mode.
1034        return if is_last {
1035            line.to_vec()
1036        } else {
1037            full_justify(line, width, style)
1038        };
1039    }
1040    let line_width: usize = line.iter().map(Segment::cell_length).sum();
1041    let excess = width.saturating_sub(line_width);
1042    let (left, right) = match justify {
1043        Justify::Right => (excess, 0),
1044        Justify::Center => (excess / 2, excess - excess / 2),
1045        // Left, Default, and full justification's ragged last line pad right.
1046        Justify::Left | Justify::Full | Justify::Default => (0, excess),
1047    };
1048    let mut out = Vec::with_capacity(line.len() + 2);
1049    if left > 0 {
1050        out.push(Segment::new(" ".repeat(left), Some(style.clone())));
1051    }
1052    out.extend(line.iter().cloned());
1053    if right > 0 {
1054        out.push(Segment::new(" ".repeat(right), Some(style.clone())));
1055    }
1056    out
1057}
1058
1059#[cfg(test)]
1060mod tests {
1061    use super::*;
1062
1063    /// Full justification widens the gaps between words so every line but the
1064    /// last fills the width exactly.
1065    ///
1066    /// Captured verbatim from real rich 15.0.0 —
1067    /// `Lines.justify(console, 20, justify="full")` on
1068    /// `"aaa bbb ccc ddddddddddddddddddd ee ff"` yields:
1069    ///
1070    /// ```text
1071    /// 'aaa     bbb      ccc'   <- stretched to exactly 20
1072    /// 'ddddddddddddddddddd'    <- one word: nothing to widen, and the
1073    ///                             trailing space wrapping left is dropped
1074    /// 'ee ff'                  <- final line untouched: NOT padded to width
1075    /// ```
1076    ///
1077    /// Two details worth pinning: the slack is handed out from the rightmost
1078    /// gap backwards (so the gaps are 5 then 6, not 6 then 5), and the last
1079    /// line is the one case where a justified line is left short of the width.
1080    #[test]
1081    fn full_justify_matches_upstream() {
1082        let text = Text::new("aaa bbb ccc ddddddddddddddddddd ee ff").justify(Justify::Full);
1083        let plain: Vec<String> = text
1084            .render_lines(&Theme::default_theme(), &Style::new(), Some(20))
1085            .iter()
1086            .map(|line| line.iter().map(|s| s.text.as_str()).collect())
1087            .collect();
1088        assert_eq!(
1089            plain,
1090            vec!["aaa     bbb      ccc", "ddddddddddddddddddd", "ee ff"]
1091        );
1092        assert_eq!(plain[0].chars().count(), 20);
1093    }
1094
1095    #[test]
1096    fn append_creates_spans() {
1097        let mut text = Text::new("");
1098        text.append("hello", Some(Style::parse("bold").unwrap().into()));
1099        text.append(" world", None);
1100        assert_eq!(text.plain(), "hello world");
1101        assert_eq!(text.spans().len(), 1);
1102    }
1103
1104    #[test]
1105    fn render_flattens_overlapping_spans() {
1106        let mut text = Text::new("abcdef");
1107        text.stylize(Style::parse("bold").unwrap(), 0, 4);
1108        text.stylize(Style::parse("red").unwrap(), 2, 6);
1109        let segments = text.render(&Theme::default_theme(), &Style::new());
1110        // Boundaries at 0,2,4,6 -> "ab"(bold) "cd"(bold+red) "ef"(red)
1111        let rendered: Vec<_> = segments.iter().map(|s| s.text.clone()).collect();
1112        assert_eq!(rendered, vec!["ab", "cd", "ef"]);
1113    }
1114
1115    /// `Text::truncate` on its own, against real rich 15.0.0. `fold` and `crop`
1116    /// deliberately agree: folding is a wrapping behaviour, and truncation has
1117    /// no line to fold onto.
1118    #[test]
1119    fn truncate_matches_upstream() {
1120        for (overflow, expected) in [
1121            (Overflow::Fold, "hello"),
1122            (Overflow::Crop, "hello"),
1123            (Overflow::Ellipsis, "hell…"),
1124            (Overflow::Ignore, "hello world"),
1125        ] {
1126            let mut text = Text::new("hello world");
1127            text.truncate(5, Some(overflow), false);
1128            assert_eq!(text.plain(), expected, "overflow {overflow:?}");
1129        }
1130    }
1131
1132    /// `pad` fills out to the width, but only when the text is short — a text
1133    /// that is already too long is cut, never padded.
1134    #[test]
1135    fn truncate_pads_only_when_short() {
1136        let mut short = Text::new("hi");
1137        short.truncate(6, Some(Overflow::Crop), true);
1138        assert_eq!(short.plain(), "hi    ");
1139
1140        let mut exact = Text::new("hi");
1141        exact.truncate(2, Some(Overflow::Crop), true);
1142        assert_eq!(exact.plain(), "hi");
1143    }
1144
1145    /// Truncating must not leave a span pointing past the end of the string.
1146    #[test]
1147    fn truncate_trims_dangling_spans() {
1148        let mut text = Text::new("hello world");
1149        text.stylize(Style::parse("bold").unwrap(), 6, 11);
1150        text.stylize(Style::parse("red").unwrap(), 0, 5);
1151        text.truncate(3, Some(Overflow::Crop), false);
1152        assert_eq!(text.plain(), "hel");
1153        // The "world" span starts past the new end and is dropped entirely; the
1154        // "hello" span survives, clamped.
1155        assert_eq!(text.spans().len(), 1);
1156        assert!(text.spans().iter().all(|s| s.end <= text.plain().len()));
1157    }
1158
1159    use crate::protocol::Renderable;
1160
1161    /// The overflow method may come from the text or from the console options,
1162    /// and the text's own setting wins — mirroring upstream's
1163    /// `self.overflow or options.overflow or DEFAULT_OVERFLOW`.
1164    #[test]
1165    fn text_overflow_beats_console_options() {
1166        let console = crate::Console::builder().width(8).build();
1167        let mut options = console.options();
1168        options.overflow = Some(Overflow::Ellipsis);
1169        options.no_wrap = Some(true);
1170
1171        // Nothing set on the text: the options decide.
1172        let from_options = Text::new("the quick brown fox");
1173        assert_eq!(
1174            plain_of(&from_options.rich_render(&console, &options)),
1175            "the qui…"
1176        );
1177
1178        // Set on the text: the text decides, and the options are ignored.
1179        let from_text = Text::new("the quick brown fox").overflow(Overflow::Crop);
1180        assert_eq!(
1181            plain_of(&from_text.rich_render(&console, &options)),
1182            "the quic"
1183        );
1184    }
1185
1186    /// With no overflow anywhere, upstream's default applies: fold.
1187    #[test]
1188    fn overflow_defaults_to_fold() {
1189        let console = crate::Console::builder().width(8).build();
1190        let text = Text::new("supercalifragilistic");
1191        let rendered = plain_of(&text.rich_render(&console, &console.options()));
1192        assert_eq!(rendered, "supercal\nifragili\nstic");
1193    }
1194
1195    /// Concatenate the visible text of a segment stream, for assertions that
1196    /// care about layout rather than styling.
1197    fn plain_of(segments: &[Segment]) -> String {
1198        segments
1199            .iter()
1200            .filter(|s| !s.control)
1201            .map(|s| s.text.as_str())
1202            .collect()
1203    }
1204}