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(®ex, 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(¢ered, 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}