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/// Whether `text` holds any [`is_control_code`] character. They are all
30/// ASCII, and no byte of a multi-byte UTF-8 character is, so the bytes can
31/// be searched without decoding the characters.
32pub(crate) fn has_control_code(text: &str) -> bool {
33 text.bytes()
34 .any(|b| matches!(b, 0x07 | 0x08 | 0x0b | 0x0c | b'\r'))
35}
36
37/// Cell width of a tab stop: upstream's `Console.tab_size` default, and the
38/// fallback when neither a text nor its console sets one (`tab_size or 8`).
39pub const DEFAULT_TAB_SIZE: usize = 8;
40
41/// A style applied to a byte range `[start, end)` of a [`Text`]'s plain string.
42/// Mirrors `rich.text.Span`.
43///
44/// The style may be a *name* rather than a resolved [`Style`]; see [`StyleType`].
45/// Names are resolved when the text is rendered, against the theme of whichever
46/// console renders it.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Span {
49 pub start: usize,
50 pub end: usize,
51 pub style: StyleType,
52}
53
54impl Span {
55 /// A span of `style` over `[start, end)`.
56 pub fn new(start: usize, end: usize, style: impl Into<StyleType>) -> Self {
57 Span {
58 start,
59 end,
60 style: style.into(),
61 }
62 }
63
64 /// Split at `offset`, when it falls inside the span. Port of
65 /// `Span.split`.
66 pub fn split(&self, offset: usize) -> (Span, Option<Span>) {
67 if offset < self.start || offset >= self.end {
68 return (self.clone(), None);
69 }
70 let first = Span::new(self.start, self.end.min(offset), self.style.clone());
71 let second = Span::new(first.end, self.end, self.style.clone());
72 (first, Some(second))
73 }
74
75 /// The span moved by `offset`. Port of `Span.move`.
76 ///
77 /// # Panics
78 ///
79 /// When the move would take an offset below zero.
80 pub fn move_by(&self, offset: isize) -> Span {
81 let shift = |value: usize| {
82 value
83 .checked_add_signed(offset)
84 .expect("span moved before the start")
85 };
86 Span::new(shift(self.start), shift(self.end), self.style.clone())
87 }
88
89 /// The span cropped to end at `offset`. Port of `Span.right_crop`.
90 pub fn right_crop(&self, offset: usize) -> Span {
91 if offset >= self.end {
92 return self.clone();
93 }
94 Span::new(self.start, offset.min(self.end), self.style.clone())
95 }
96
97 /// The span with its end moved `cells` further. Port of `Span.extend`.
98 pub fn extend(&self, cells: usize) -> Span {
99 Span::new(self.start, self.end + cells, self.style.clone())
100 }
101}
102
103/// One part of [`Text::assemble`]: a string, a string with a style, or a
104/// `Text`.
105#[derive(Debug, Clone)]
106pub enum TextPart {
107 /// Unstyled text.
108 Str(String),
109 /// Text under a style.
110 Styled(String, StyleType),
111 /// A `Text`, with its own spans.
112 Text(Text),
113}
114
115impl From<&str> for TextPart {
116 fn from(text: &str) -> Self {
117 TextPart::Str(text.to_string())
118 }
119}
120
121impl From<String> for TextPart {
122 fn from(text: String) -> Self {
123 TextPart::Str(text)
124 }
125}
126
127impl From<Text> for TextPart {
128 fn from(text: Text) -> Self {
129 TextPart::Text(text)
130 }
131}
132
133impl<S: Into<StyleType>> From<(&str, S)> for TextPart {
134 fn from((text, style): (&str, S)) -> Self {
135 TextPart::Styled(text.to_string(), style.into())
136 }
137}
138
139/// Styled text. Mirrors `rich.text.Text`.
140#[derive(Debug, Clone, Default)]
141pub struct Text {
142 plain: String,
143 spans: Vec<Span>,
144 /// A base style applied to the whole text. May be an unresolved name.
145 style: StyleType,
146 /// How lines are justified within the render width. `None` defers to
147 /// the console options (upstream's `justify=None`); `Some(Default)` is an
148 /// explicit `justify="default"`, which a container's justify does not
149 /// override.
150 justify: Option<Justify>,
151 /// What to do with lines wider than the render width. `None` defers to the
152 /// console options, then to [`Overflow::Fold`].
153 overflow: Option<Overflow>,
154 /// Whether to skip wrapping. `None` defers to the console options, then to
155 /// `false`.
156 no_wrap: Option<bool>,
157 /// Tab stop width. `None` defers to the console's `tab_size` when
158 /// rendered, then to [`DEFAULT_TAB_SIZE`]. Upstream's `Text.tab_size`.
159 tab_size: Option<usize>,
160 /// Upstream's `Text.end`; `None` is its default, a newline.
161 end: Option<String>,
162}
163
164/// Two texts are equal when their plain strings and spans are. Port of
165/// `Text.__eq__`.
166impl PartialEq for Text {
167 fn eq(&self, other: &Text) -> bool {
168 self.plain == other.plain && self.spans == other.spans
169 }
170}
171
172/// `text + other`: a copy with `other` appended. Port of `Text.__add__`.
173impl std::ops::Add<&Text> for &Text {
174 type Output = Text;
175
176 fn add(self, other: &Text) -> Text {
177 self.clone().append_text(other)
178 }
179}
180
181/// `text + "str"`. Port of `Text.__add__`.
182impl std::ops::Add<&str> for &Text {
183 type Output = Text;
184
185 fn add(self, other: &str) -> Text {
186 let mut text = self.clone();
187 text.append(other, None);
188 text
189 }
190}
191
192impl Text {
193 /// Strip the control codes upstream removes in `Text.__init__`
194 /// (`strip_control_codes`): BEL, backspace, vertical tab, form feed and
195 /// carriage return. Tab and newline are deliberately kept — they are layout,
196 /// not control.
197 fn strip_control_codes(text: &str) -> String {
198 if has_control_code(text) {
199 text.chars().filter(|c| !is_control_code(*c)).collect()
200 } else {
201 text.to_string()
202 }
203 }
204
205 /// [`strip_control_codes`](Self::strip_control_codes) of a string the
206 /// caller owns: handed back as it is when it has none, rather than
207 /// copied (docs/PORTING.md, "Performance notes").
208 fn strip_control_codes_owned(text: String) -> String {
209 if has_control_code(&text) {
210 Text::strip_control_codes(&text)
211 } else {
212 text
213 }
214 }
215
216 /// Plain, unstyled text.
217 pub fn new(plain: impl Into<String>) -> Self {
218 Text {
219 plain: Text::strip_control_codes_owned(plain.into()),
220 spans: Vec::new(),
221 style: StyleType::default(),
222 justify: None,
223 overflow: None,
224 no_wrap: None,
225 tab_size: None,
226 end: None,
227 }
228 }
229
230 /// Text with a base style, which may be a style *name* resolved at render
231 /// time (`Text::styled("hi", "repr.number")`) or a resolved [`Style`].
232 pub fn styled(plain: impl Into<String>, style: impl Into<StyleType>) -> Self {
233 Text {
234 // Strips too: upstream's `Text.__init__` does this regardless of
235 // style, and a constructor that skipped it would reintroduce the
236 // offset divergence the moment a caller added spans.
237 plain: Text::strip_control_codes_owned(plain.into()),
238 spans: Vec::new(),
239 style: style.into(),
240 justify: None,
241 overflow: None,
242 no_wrap: None,
243 tab_size: None,
244 end: None,
245 }
246 }
247
248 /// Set how lines are justified within the render width (builder form).
249 pub fn justify(mut self, justify: Justify) -> Self {
250 self.set_justify(justify);
251 self
252 }
253
254 /// Set how lines are justified within the render width.
255 ///
256 /// [`Justify::Default`] means *unset* here (upstream's `justify=None`),
257 /// deferring to the console options; use
258 /// [`set_justify_option`](Self::set_justify_option) for an explicit
259 /// `justify="default"`.
260 pub fn set_justify(&mut self, justify: Justify) {
261 self.justify = (justify != Justify::Default).then_some(justify);
262 }
263
264 /// This text's own justify method ([`Justify::Default`] when unset).
265 pub fn get_justify(&self) -> Justify {
266 self.justify.unwrap_or_default()
267 }
268
269 /// Set this text's justify, distinguishing an explicit
270 /// `Some(Justify::Default)` (upstream's `justify="default"`, which a
271 /// table column's or the print call's justify does not override) from
272 /// `None` (upstream's `justify=None`, which defers to them).
273 pub fn set_justify_option(&mut self, justify: Option<Justify>) {
274 self.justify = justify;
275 }
276
277 /// This text's own justify, `None` when unset. See
278 /// [`set_justify_option`](Self::set_justify_option).
279 pub fn get_justify_option(&self) -> Option<Justify> {
280 self.justify
281 }
282
283 /// Set what happens to lines wider than the render width (builder form).
284 pub fn overflow(mut self, overflow: Overflow) -> Self {
285 self.overflow = Some(overflow);
286 self
287 }
288
289 /// Set what happens to lines wider than the render width. Pass `None` to
290 /// defer to the console options.
291 pub fn set_overflow(&mut self, overflow: Option<Overflow>) {
292 self.overflow = overflow;
293 }
294
295 /// This text's own overflow method, if it set one.
296 pub fn get_overflow(&self) -> Option<Overflow> {
297 self.overflow
298 }
299
300 /// Disable (or re-enable) wrapping for this text (builder form).
301 pub fn no_wrap(mut self, no_wrap: bool) -> Self {
302 self.no_wrap = Some(no_wrap);
303 self
304 }
305
306 /// Disable (or re-enable) wrapping. Pass `None` to defer to the console
307 /// options.
308 pub fn set_no_wrap(&mut self, no_wrap: Option<bool>) {
309 self.no_wrap = no_wrap;
310 }
311
312 /// This text's own no-wrap setting, if it set one.
313 pub fn get_no_wrap(&self) -> Option<bool> {
314 self.no_wrap
315 }
316
317 /// Set the tab stop width (builder form). Port of `Text(tab_size=…)`.
318 pub fn tab_size(mut self, tab_size: usize) -> Self {
319 self.tab_size = Some(tab_size);
320 self
321 }
322
323 /// Set the tab stop width. Pass `None` to defer to the console's
324 /// `tab_size`. Upstream's `Text.tab_size` attribute.
325 pub fn set_tab_size(&mut self, tab_size: Option<usize>) {
326 self.tab_size = tab_size;
327 }
328
329 /// This text's own tab stop width, if it set one.
330 pub fn get_tab_size(&self) -> Option<usize> {
331 self.tab_size
332 }
333
334 /// Shorten this text to at most `max_width` cells, optionally padding it out
335 /// to exactly `max_width` when it is shorter. Port of `Text.truncate`.
336 ///
337 /// `overflow` defaults to this text's own method, then to [`Overflow::Fold`];
338 /// [`Overflow::Ignore`] leaves the text alone entirely. Note that `Fold` and
339 /// `Crop` behave identically here — folding is a property of *wrapping*, and
340 /// a line that has already been wrapped can only be cut.
341 pub fn truncate(&mut self, max_width: usize, overflow: Option<Overflow>, pad: bool) {
342 let overflow = overflow.or(self.overflow).unwrap_or(Overflow::Fold);
343 if overflow == Overflow::Ignore {
344 return;
345 }
346 let length = cell_len(&self.plain);
347 if length > max_width {
348 let plain = if overflow == Overflow::Ellipsis {
349 // `…` is one cell wide, so cut one short and add it back.
350 format!(
351 "{}…",
352 set_cell_size(&self.plain, max_width.saturating_sub(1))
353 )
354 } else {
355 set_cell_size(&self.plain, max_width)
356 };
357 self.set_plain(plain);
358 } else if pad {
359 let plain = set_cell_size(&self.plain, max_width);
360 self.set_plain(plain);
361 }
362 }
363
364 /// Replace the plain string, clamping every span into the new length so no
365 /// span can dangle past the end. Upstream's `Text.plain` setter does the
366 /// same via `_trim_spans`.
367 fn set_plain(&mut self, plain: String) {
368 // Upstream spans use character offsets. A truncation may replace a
369 // wide glyph with padding or ASCII with a multibyte ellipsis, so byte
370 // offsets alone cannot be clamped into the replacement string.
371 if !self.spans.is_empty()
372 && !self.plain.starts_with(&plain)
373 && !plain.starts_with(&self.plain)
374 {
375 let old_offsets: Vec<usize> = self
376 .plain
377 .char_indices()
378 .map(|(i, _)| i)
379 .chain(std::iter::once(self.plain.len()))
380 .collect();
381 let new_offsets: Vec<usize> = plain
382 .char_indices()
383 .map(|(i, _)| i)
384 .chain(std::iter::once(plain.len()))
385 .collect();
386 let map_offset = |offset: usize| {
387 let character = old_offsets.partition_point(|old| *old < offset);
388 new_offsets[character.min(new_offsets.len() - 1)]
389 };
390 for span in &mut self.spans {
391 span.start = map_offset(span.start);
392 span.end = map_offset(span.end);
393 }
394 }
395 let length = plain.len();
396 self.plain = plain;
397 self.spans.retain(|span| span.start < length);
398 for span in &mut self.spans {
399 span.end = span.end.min(length);
400 }
401 }
402
403 /// An empty `Text` carrying this one's style, justify, overflow and no-wrap.
404 /// Port of `Text.blank_copy`.
405 pub fn blank_copy(&self) -> Text {
406 Text {
407 plain: String::new(),
408 spans: Vec::new(),
409 style: self.style.clone(),
410 justify: self.justify,
411 overflow: self.overflow,
412 no_wrap: self.no_wrap,
413 tab_size: self.tab_size,
414 end: self.end.clone(),
415 }
416 }
417
418 /// Cut this text at each byte offset in `offsets`, returning the pieces.
419 /// Port of `Text.divide`.
420 ///
421 /// Every piece inherits the base style, justify, overflow and no-wrap, and
422 /// each span is re-based onto the pieces it covers. Spans that would come out
423 /// empty are dropped, matching upstream's `new_end > new_start`.
424 ///
425 /// Offsets are **byte** offsets (as everywhere else in this port's span
426 /// arithmetic) and must fall on `char` boundaries.
427 pub fn divide(&self, offsets: &[usize]) -> Vec<Text> {
428 if offsets.is_empty() {
429 return vec![self.clone()];
430 }
431 let mut bounds = Vec::with_capacity(offsets.len() + 2);
432 bounds.push(0);
433 bounds.extend(offsets.iter().copied());
434 bounds.push(self.plain.len());
435
436 let mut lines: Vec<Text> = bounds
437 .windows(2)
438 .map(|w| {
439 let (start, end) = (w[0].min(self.plain.len()), w[1].min(self.plain.len()));
440 let mut line = self.blank_copy();
441 if start < end {
442 line.plain = self.plain[start..end].to_string();
443 }
444 line
445 })
446 .collect();
447
448 // Upstream binary-searches for the first and last line a span touches
449 // rather than visiting every line for every span, which made
450 // splitting a highlighted file quadratic in its line count. Offsets
451 // from `split` are sorted; for anything else keep the full scan, whose
452 // result the search would not reproduce.
453 let sorted = bounds.windows(2).all(|w| w[0] <= w[1]);
454 for span in &self.spans {
455 let lines_touched = if sorted {
456 // Lines ending at or before the span starts cannot hold it,
457 // nor can lines starting at or after it ends.
458 let first = bounds[1..].partition_point(|&end| end <= span.start);
459 let last = bounds[..bounds.len() - 1].partition_point(|&start| start < span.end);
460 first..last.max(first)
461 } else {
462 0..bounds.len() - 1
463 };
464 for index in lines_touched {
465 let (line_start, line_end) = (bounds[index], bounds[index + 1]);
466 let new_start = span.start.max(line_start) - line_start;
467 let new_end = span.end.min(line_end).saturating_sub(line_start);
468 if new_end > new_start {
469 lines[index].spans.push(Span {
470 start: new_start,
471 end: new_end,
472 style: span.style.clone(),
473 });
474 }
475 }
476 }
477 lines
478 }
479
480 /// Split on `separator`. Port of `Text.split`.
481 ///
482 /// `include_separator` keeps the separator at the end of each piece.
483 /// `allow_blank` keeps the trailing empty piece that a text ending in the
484 /// separator would otherwise produce.
485 ///
486 /// # Panics
487 /// If `separator` is empty, which upstream asserts against.
488 pub fn split(&self, separator: &str, include_separator: bool, allow_blank: bool) -> Vec<Text> {
489 assert!(!separator.is_empty(), "separator must not be empty");
490 if !self.plain.contains(separator) {
491 return vec![self.clone()];
492 }
493 let matches: Vec<usize> = self
494 .plain
495 .match_indices(separator)
496 .map(|(i, _)| i)
497 .collect();
498 let mut lines = if include_separator {
499 let offsets: Vec<usize> = matches.iter().map(|s| s + separator.len()).collect();
500 self.divide(&offsets)
501 } else {
502 // Cut on both sides of every separator, then drop the separators.
503 let mut offsets = Vec::with_capacity(matches.len() * 2);
504 for start in &matches {
505 offsets.push(*start);
506 offsets.push(start + separator.len());
507 }
508 self.divide(&offsets)
509 .into_iter()
510 .filter(|line| line.plain != separator)
511 .collect()
512 };
513 if !allow_blank && self.plain.ends_with(separator) {
514 lines.pop();
515 }
516 lines
517 }
518
519 /// Pad both sides with `count` copies of `character`. Port of `Text.pad`.
520 pub fn pad(&mut self, count: usize, character: char) {
521 self.pad_left(count, character);
522 self.pad_right(count, character);
523 }
524
525 /// Pad the left with `count` copies of `character`, shifting every span to
526 /// follow the text. Port of `Text.pad_left`.
527 pub fn pad_left(&mut self, count: usize, character: char) {
528 if count == 0 {
529 return;
530 }
531 let padding: String = std::iter::repeat_n(character, count).collect();
532 let offset = padding.len();
533 self.plain.insert_str(0, &padding);
534 for span in &mut self.spans {
535 span.start += offset;
536 span.end += offset;
537 }
538 }
539
540 /// Pad the right with `count` copies of `character`. Port of
541 /// `Text.pad_right`. Spans are untouched, so the padding is unstyled.
542 pub fn pad_right(&mut self, count: usize, character: char) {
543 if count == 0 {
544 return;
545 }
546 self.plain.extend(std::iter::repeat_n(character, count));
547 }
548
549 /// Drop the last `amount` bytes, clipping any span that reached into them.
550 /// Port of `Text.right_crop`.
551 pub fn right_crop(&mut self, amount: usize) {
552 if amount == 0 {
553 return;
554 }
555 let max_offset = self.plain.len().saturating_sub(amount);
556 let plain = self.plain[..max_offset].to_string();
557 self.set_plain(plain);
558 }
559
560 /// Remove trailing whitespace. Port of `Text.rstrip`.
561 pub fn rstrip(&mut self) {
562 // Python's `str.rstrip()` whitespace, which includes U+001C..U+001F.
563 let plain = self.plain.trim_end_matches(is_python_space).to_string();
564 self.set_plain(plain);
565 }
566
567 /// Remove *only as much* trailing whitespace as it takes to get down to
568 /// `size` characters, leaving the rest. Port of `Text.rstrip_end`.
569 ///
570 /// This is what lets a wrapped line keep the space that ended it while a
571 /// line that overshot the width gives its padding back. As upstream, the
572 /// length is `len(self)` — characters, not cells.
573 pub fn rstrip_end(&mut self, size: usize) {
574 let length = self.plain.chars().count();
575 if length <= size {
576 return;
577 }
578 let excess = length - size;
579 let trimmed = self.plain.trim_end_matches(is_python_space);
580 let whitespace = self.plain[trimmed.len()..].chars().count();
581 if whitespace > 0 {
582 let keep = length - whitespace.min(excess);
583 let bytes = self.plain.len() - char_to_byte(&self.plain, keep);
584 self.right_crop(bytes);
585 }
586 }
587
588 /// Append `count` spaces, extending any span that reached the end so the
589 /// padding takes its style. Port of `Text.extend_style`.
590 pub fn extend_style(&mut self, count: usize) {
591 if count == 0 {
592 return;
593 }
594 let length = self.plain.len();
595 self.plain.extend(std::iter::repeat_n(' ', count));
596 for span in &mut self.spans {
597 if span.end >= length {
598 span.end += count;
599 }
600 }
601 }
602
603 /// Replace tabs with spaces up to the next `tab_size` stop. Port of
604 /// `Text.expand_tabs`.
605 ///
606 /// Styles extend over the inserted spaces, so a styled tab pads in its own
607 /// style rather than punching an unstyled hole (upstream reaches the same
608 /// result via `extend_style`).
609 pub fn expand_tabs(&mut self, tab_size: usize) {
610 if !self.plain.contains('\t') || tab_size == 0 {
611 return;
612 }
613 // Rebuilt part-by-part rather than by remapping offsets, because the
614 // *split* is observable: upstream turns each tab-terminated run into its
615 // own piece, so a span crossing several tabs comes back as several spans
616 // and renders as several segments. Remapping offsets keeps one span and
617 // emits one segment — same colours, different bytes.
618 let mut result = Text::new("");
619 for line in self.split("\n", true, false) {
620 if !line.plain.contains('\t') {
621 result = result.append_text(&line);
622 continue;
623 }
624 let mut cell_position = 0usize;
625 for mut part in line.split("\t", true, false) {
626 if part.plain.ends_with('\t') {
627 // The tab becomes one space, then the run is padded out to
628 // the next stop — so a tab always advances at least one cell.
629 part.plain.pop();
630 part.plain.push(' ');
631 cell_position += part.cell_len();
632 let remainder = cell_position % tab_size;
633 if remainder != 0 {
634 let spaces = tab_size - remainder;
635 part.extend_style(spaces);
636 cell_position += spaces;
637 }
638 } else {
639 cell_position += part.cell_len();
640 }
641 result = result.append_text(&part);
642 }
643 }
644 self.plain = result.plain;
645 self.spans = result.spans;
646 }
647
648 /// Join `lines` with this text as the separator, carrying each piece's base
649 /// style across as a covering span. Port of `Text.join`.
650 pub fn join(&self, lines: &[Text]) -> Text {
651 let mut joined = self.blank_copy();
652 let last = lines.len().saturating_sub(1);
653 for (index, line) in lines.iter().enumerate() {
654 joined = joined.append_text(line);
655 if !self.plain.is_empty() && index != last {
656 joined = joined.append_text(self);
657 }
658 }
659 joined
660 }
661
662 /// A `Text` from a string containing ANSI escape codes, one decoded line
663 /// per line joined by newlines, with base `style`. Port of
664 /// `Text.from_ansi` (its `tab_size` defaults to 8; set `justify`,
665 /// `overflow` and `no_wrap` with the builders).
666 pub fn from_ansi(text: &str, style: impl Into<StyleType>) -> Text {
667 let mut joiner = Text::styled("\n", style);
668 joiner.tab_size = Some(DEFAULT_TAB_SIZE);
669 let lines = crate::ansi::AnsiDecoder::new().decode_split_newlines(text);
670 joiner.join(&lines)
671 }
672
673 /// The indentation unit of the text: the greatest common divisor of its
674 /// even line indents, or 1. Port of `Text.detect_indentation`.
675 pub fn detect_indentation(&self) -> usize {
676 fn gcd(a: usize, b: usize) -> usize {
677 if b == 0 { a } else { gcd(b, a % b) }
678 }
679 let mut indents: Vec<usize> = self
680 .plain
681 .split('\n')
682 .map(|line| line.len() - line.trim_start_matches(' ').len())
683 .filter(|indent| indent % 2 == 0)
684 .collect();
685 indents.sort_unstable();
686 indents.dedup();
687 match indents.into_iter().reduce(gcd) {
688 None | Some(0) => 1,
689 Some(indentation) => indentation,
690 }
691 }
692
693 /// A copy with indent guides drawn in each line's leading spaces: every
694 /// `indent_size` spaces (auto-detected for `None`) become `character`
695 /// then padding, styled `style`. Port of `Text.with_indent_guides`
696 /// (upstream's defaults are `"│"` and `"dim green"`).
697 pub fn with_indent_guides(
698 &self,
699 indent_size: Option<usize>,
700 character: &str,
701 style: impl Into<StyleType>,
702 ) -> Text {
703 let style = style.into();
704 let indent_size = indent_size.unwrap_or_else(|| self.detect_indentation());
705 let mut text = self.clone();
706 text.expand_tabs(match self.tab_size {
707 None => DEFAULT_TAB_SIZE,
708 Some(tab_size) => tab_size,
709 });
710 let indent_line = format!("{character}{}", " ".repeat(indent_size.saturating_sub(1)));
711 let mut new_lines: Vec<Text> = Vec::new();
712 let mut blank_lines = 0usize;
713 for mut line in text.split("\n", false, true) {
714 let indent = line.plain.len() - line.plain.trim_start_matches(' ').len();
715 // `re.match(r"^( *)(.*)$")`: `.` stops at a line break, so a
716 // line with nothing after its indent (or a `\r`) counts as blank.
717 let rest = &line.plain[indent..];
718 if rest.is_empty() || rest.starts_with('\r') {
719 blank_lines += 1;
720 continue;
721 }
722 let (full_indents, remaining_space) = (
723 indent.checked_div(indent_size).unwrap_or(0),
724 indent.checked_rem(indent_size).unwrap_or(indent),
725 );
726 let new_indent = format!(
727 "{}{}",
728 indent_line.repeat(full_indents),
729 " ".repeat(remaining_space)
730 );
731 // `line.plain = new_indent + line.plain[len(new_indent):]`: the
732 // same number of characters, so upstream's (character) spans stay
733 // put; here the byte offsets past the indent shift.
734 let old_bytes = char_to_byte(&line.plain, new_indent.chars().count());
735 let plain = format!("{new_indent}{}", &line.plain[old_bytes..]);
736 let remap = |position: usize| {
737 if position >= old_bytes {
738 position - old_bytes + new_indent.len()
739 } else {
740 char_to_byte(&new_indent, position)
741 }
742 };
743 for span in &mut line.spans {
744 span.start = remap(span.start);
745 span.end = remap(span.end);
746 }
747 line.plain = plain;
748 line.stylize(style.clone(), 0, new_indent.len());
749 for _ in 0..std::mem::take(&mut blank_lines) {
750 new_lines.push(Text::styled(new_indent.clone(), style.clone()));
751 }
752 new_lines.push(line);
753 }
754 for _ in 0..blank_lines {
755 new_lines.push(Text::styled("", style.clone()));
756 }
757 let mut separator = text.blank_copy();
758 separator.plain.push('\n');
759 separator.join(&new_lines)
760 }
761
762 /// Style every occurrence of any of `words`. Port of `Text.highlight_words`,
763 /// returning the number of matches.
764 pub fn highlight_words(
765 &mut self,
766 words: &[&str],
767 style: impl Into<StyleType>,
768 case_sensitive: bool,
769 ) -> Result<usize> {
770 let alternation = words
771 .iter()
772 .map(|word| fancy_regex::escape(word).into_owned())
773 .collect::<Vec<_>>()
774 .join("|");
775 if alternation.is_empty() {
776 return Ok(0);
777 }
778 let pattern = if case_sensitive {
779 alternation
780 } else {
781 format!("(?i){alternation}")
782 };
783 self.highlight_regex(&pattern, Some(style.into()), "")
784 }
785
786 /// Style every match of `pattern`, returning the number of matches. Full port
787 /// of `Text.highlight_regex`.
788 ///
789 /// `style`, when given, styles the whole match. Each **named group** is then
790 /// styled with `{style_prefix}{name}` as a style *name*, left for the theme
791 /// to resolve at render time — which is how a highlighter colours its groups
792 /// without ever seeing a console.
793 ///
794 /// Groups that did not participate in the match, and zero-width ones, are
795 /// skipped.
796 pub fn highlight_regex(
797 &mut self,
798 pattern: &str,
799 style: Option<StyleType>,
800 style_prefix: &str,
801 ) -> Result<usize> {
802 let regex = fancy_regex::Regex::new(pattern)
803 .map_err(|e| crate::errors::RichError::Regex(format!("invalid pattern: {e}")))?;
804 Ok(self.highlight_with_regex(®ex, style, style_prefix))
805 }
806
807 /// As [`highlight_regex`](Self::highlight_regex) with an already-compiled
808 /// pattern, for callers that apply the same patterns repeatedly.
809 ///
810 /// A match that errors mid-scan (a `fancy-regex` backtrack-limit hit) stops
811 /// the scan and keeps the spans found so far, rather than discarding them.
812 pub(crate) fn highlight_with_regex(
813 &mut self,
814 regex: &fancy_regex::Regex,
815 style: Option<StyleType>,
816 style_prefix: &str,
817 ) -> usize {
818 // Capture-definition order, matching upstream's `match.groupdict()`.
819 let names: Vec<(usize, String)> = regex
820 .capture_names()
821 .enumerate()
822 .filter_map(|(index, name)| name.map(|name| (index, name.to_string())))
823 .collect();
824
825 // Scanning borrows the plain string while the spans are pushed, so move
826 // it out and put it back — no copy, and no fighting the borrow checker.
827 let plain = std::mem::take(&mut self.plain);
828 let mut count = 0;
829 for captures in regex.captures_iter(&plain) {
830 let Ok(captures) = captures else { break };
831 if let (Some(style), Some(whole)) = (style.as_ref(), captures.get(0))
832 && whole.end() > whole.start()
833 {
834 self.spans.push(Span {
835 start: whole.start(),
836 end: whole.end(),
837 style: style.clone(),
838 });
839 }
840 count += 1;
841 for (index, name) in &names {
842 if let Some(group) = captures.get(*index)
843 && group.end() > group.start()
844 {
845 self.spans.push(Span {
846 start: group.start(),
847 end: group.end(),
848 style: StyleType::Name(format!("{style_prefix}{name}")),
849 });
850 }
851 }
852 }
853 self.plain = plain;
854 count
855 }
856
857 /// Build styled text from console markup. Port of `Text.from_markup`.
858 ///
859 /// Tag names are stored on the spans and resolved when the text is rendered,
860 /// so no theme is needed here. `:emoji:` codes are replaced, as upstream's
861 /// default `emoji=True` does.
862 pub fn from_markup(markup_text: &str) -> Result<Text> {
863 Text::from_markup_with(markup_text, StyleType::default(), true, None)
864 }
865
866 /// Text from console markup with a base `style`, `:emoji:` codes replaced
867 /// when `emoji` is set. Port of `Text.from_markup(text, style=…,
868 /// emoji=…, emoji_variant=…)`; set `justify`, `overflow` and `end` with
869 /// the builders.
870 pub fn from_markup_with(
871 markup_text: &str,
872 style: impl Into<StyleType>,
873 emoji: bool,
874 emoji_variant: Option<crate::emoji::EmojiVariant>,
875 ) -> Result<Text> {
876 markup::render_with(markup_text, style, emoji, emoji_variant)
877 }
878
879 /// Console markup that renders this text: the plain string with every
880 /// tag-opening `[` escaped, and a tag around each span and the base style.
881 /// Port of the `Text.markup` property.
882 ///
883 /// As upstream, tags are emitted in `(offset, closing)` order with the base
884 /// style outermost, so at a shared offset every opening tag precedes every
885 /// closing one, and a falsy style (a null [`Style`] or an empty name) adds
886 /// no tag. A resolved style is written as its [`Style::definition`].
887 pub fn markup(&self) -> String {
888 let plain = &self.plain;
889 let mut markup_spans: Vec<(usize, bool, &StyleType)> =
890 Vec::with_capacity(self.spans.len() * 2 + 2);
891 markup_spans.push((0, false, &self.style));
892 markup_spans.extend(
893 self.spans
894 .iter()
895 .map(|span| (span.start, false, &span.style)),
896 );
897 markup_spans.extend(self.spans.iter().map(|span| (span.end, true, &span.style)));
898 markup_spans.push((plain.len(), true, &self.style));
899 // Stable, like Python's `sort(key=itemgetter(0, 1))`.
900 markup_spans.sort_by_key(|&(offset, closing, _)| (offset, closing));
901
902 let mut output = String::with_capacity(plain.len());
903 let mut position = 0;
904 for (offset, closing, style) in markup_spans {
905 let offset = offset.min(plain.len());
906 if offset > position {
907 output.push_str(&markup::escape(&plain[position..offset]));
908 position = offset;
909 }
910 if !is_falsy_style(style) {
911 output.push('[');
912 if closing {
913 output.push('/');
914 }
915 match style {
916 StyleType::Name(name) => output.push_str(name),
917 StyleType::Style(style) => output.push_str(&style.definition()),
918 }
919 output.push(']');
920 }
921 }
922 output
923 }
924
925 /// Text assembled from `parts`, under a base `style`. Port of
926 /// `Text.assemble` (set `justify`, `overflow`, `no_wrap` and `tab_size`
927 /// with the builders, and `meta` with [`apply_meta`](Self::apply_meta)).
928 pub fn assemble<P: Into<TextPart>>(
929 parts: impl IntoIterator<Item = P>,
930 style: impl Into<StyleType>,
931 ) -> Text {
932 let mut text = Text::styled("", style);
933 for part in parts {
934 match part.into() {
935 TextPart::Str(plain) => text.append(&plain, None),
936 TextPart::Styled(plain, style) => text.append(&plain, Some(style)),
937 TextPart::Text(other) => text = text.append_text(&other),
938 }
939 }
940 text
941 }
942
943 /// Replace the plain string. Port of the `Text.plain` setter: control
944 /// codes are stripped, and when the text gets shorter, spans past the
945 /// new end are dropped and the rest clipped to it.
946 pub fn set_plain_text(&mut self, plain: &str) {
947 if plain == self.plain {
948 return;
949 }
950 let plain = Text::strip_control_codes(plain);
951 let length = plain.len();
952 let shorter = length < self.plain.len();
953 self.plain = plain;
954 if shorter {
955 self.spans.retain(|span| span.start < length);
956 for span in &mut self.spans {
957 span.end = span.end.min(length);
958 }
959 }
960 }
961
962 /// Whether `needle` occurs in the plain text. Port of `Text.__contains__`
963 /// (pass a `Text`'s [`plain`](Self::plain) for a `Text`).
964 pub fn contains(&self, needle: &str) -> bool {
965 self.plain.contains(needle)
966 }
967
968 /// The text from byte `start` to byte `end`, with its spans. Port of
969 /// `Text.__getitem__` with a slice (`text[start:end]`).
970 pub fn slice(&self, start: usize, end: usize) -> Text {
971 let length = self.plain.len();
972 let end = end.min(length);
973 let start = start.min(end);
974 self.divide(&[start, end]).swap_remove(1)
975 }
976
977 /// Remove `suffix` from the end, if the text ends with it. Port of
978 /// `Text.remove_suffix`.
979 pub fn remove_suffix(&mut self, suffix: &str) {
980 if self.plain.ends_with(suffix) {
981 self.right_crop(suffix.len());
982 }
983 }
984
985 /// The style at byte `offset` (from the end when negative): the base style
986 /// combined with every span covering it, resolved against `console`'s
987 /// theme. Port of `Text.get_style_at_offset`.
988 pub fn get_style_at_offset(&self, console: &crate::console::Console, offset: isize) -> Style {
989 let offset = if offset < 0 {
990 self.plain.len() as isize + offset
991 } else {
992 offset
993 };
994 let mut style = console.get_style(&self.style).unwrap_or_default();
995 for span in &self.spans {
996 if (span.end as isize) > offset && offset >= span.start as isize {
997 let span_style = console.get_style(&span.style).unwrap_or_default();
998 style = style.combine(&span_style);
999 }
1000 }
1001 style
1002 }
1003
1004 /// Pad or crop to exactly `new_length` bytes. Port of `Text.set_length`
1005 /// (padding with spaces, which are one byte each).
1006 pub fn set_length(&mut self, new_length: usize) {
1007 let length = self.plain.len();
1008 if length < new_length {
1009 self.pad_right(new_length - length, ' ');
1010 } else if length > new_length {
1011 self.right_crop(length - new_length);
1012 }
1013 }
1014
1015 /// Truncate to `width` cells, then pad with `character` to align the text
1016 /// `left`, `center` or `right` (any other value is right). Port of
1017 /// `Text.align`.
1018 pub fn align(&mut self, align: crate::align::HorizontalAlign, width: usize, character: char) {
1019 self.truncate(width, None, false);
1020 let excess_space = width.saturating_sub(cell_len(&self.plain));
1021 if excess_space > 0 {
1022 match align {
1023 crate::align::HorizontalAlign::Left => self.pad_right(excess_space, character),
1024 crate::align::HorizontalAlign::Center => {
1025 let left = excess_space / 2;
1026 self.pad_left(left, character);
1027 self.pad_right(excess_space - left, character);
1028 }
1029 crate::align::HorizontalAlign::Right => self.pad_left(excess_space, character),
1030 }
1031 }
1032 }
1033
1034 /// Append `(content, style)` tokens, faster than appending one at a
1035 /// time. Port of `Text.append_tokens`.
1036 pub fn append_tokens<S: Into<StyleType>>(
1037 &mut self,
1038 tokens: impl IntoIterator<Item = (String, Option<S>)>,
1039 ) -> &mut Self {
1040 for (content, style) in tokens {
1041 let content = Text::strip_control_codes(&content);
1042 let start = self.plain.len();
1043 self.plain.push_str(&content);
1044 if let Some(style) = style.map(Into::into)
1045 && !is_falsy_style(&style)
1046 {
1047 self.spans.push(Span::new(start, self.plain.len(), style));
1048 }
1049 }
1050 self
1051 }
1052
1053 /// Add the spans of `text` to this one, unchanged. Port of
1054 /// `Text.copy_styles`.
1055 pub fn copy_styles(&mut self, text: &Text) {
1056 self.spans.extend(text.spans.iter().cloned());
1057 }
1058
1059 /// One line per line of the text, each padded or cropped to `width` bytes.
1060 /// Port of `Text.fit`.
1061 pub fn fit(&self, width: usize) -> crate::containers::Lines {
1062 let mut lines = crate::containers::Lines::default();
1063 for mut line in self.split("\n", false, false) {
1064 line.set_length(width);
1065 lines.append(line);
1066 }
1067 lines
1068 }
1069
1070 /// Word-wrap into lines no wider than `width` cells, justified and
1071 /// overflowed. Port of `Text.wrap`: `justify`, `overflow` and `no_wrap`
1072 /// fall back to the text's own, then to the defaults.
1073 pub fn wrap(
1074 &self,
1075 console: &crate::console::Console,
1076 width: usize,
1077 justify: Option<Justify>,
1078 overflow: Option<Overflow>,
1079 tab_size: usize,
1080 no_wrap: Option<bool>,
1081 ) -> crate::containers::Lines {
1082 let wrap_justify = justify.or(self.justify).unwrap_or(Justify::Default);
1083 let wrap_overflow = overflow.or(self.overflow).unwrap_or(Overflow::Fold);
1084 let ignore = overflow == Some(Overflow::Ignore);
1085 let no_wrap = no_wrap.or(self.no_wrap).unwrap_or(false) || ignore;
1086 let mut lines = crate::containers::Lines::default();
1087 for mut line in self.split("\n", false, true) {
1088 if line.plain.contains('\t') {
1089 line.expand_tabs(tab_size);
1090 }
1091 let mut new_lines = if no_wrap {
1092 if ignore {
1093 lines.append(line);
1094 continue;
1095 }
1096 crate::containers::Lines::new(vec![line])
1097 } else {
1098 let breaks =
1099 crate::wrap::divide_line(&line.plain, width, wrap_overflow == Overflow::Fold);
1100 let offsets: Vec<usize> = breaks
1101 .into_iter()
1102 .map(|offset| char_to_byte(&line.plain, offset))
1103 .collect();
1104 let mut divided = line.divide(&offsets);
1105 for piece in &mut divided {
1106 piece.rstrip_end(width);
1107 }
1108 crate::containers::Lines::new(divided)
1109 };
1110 if wrap_justify != Justify::Default {
1111 new_lines.justify(console, width, wrap_justify, wrap_overflow);
1112 }
1113 for piece in new_lines.lines_mut() {
1114 piece.truncate(width, Some(wrap_overflow), false);
1115 }
1116 lines.extend(new_lines);
1117 }
1118 lines
1119 }
1120
1121 /// What a printed `Text` ends with: a newline unless set otherwise.
1122 /// Upstream's `Text.end`. Containers in this port always break lines
1123 /// between renderables (DIVERGENCES §29), so only callers that join
1124 /// texts themselves see it.
1125 pub fn get_end(&self) -> &str {
1126 self.end.as_deref().unwrap_or("\n")
1127 }
1128
1129 /// Set what the text ends with. Upstream's `Text(end=…)`.
1130 pub fn end(mut self, end: impl Into<String>) -> Self {
1131 self.end = Some(end.into());
1132 self
1133 }
1134
1135 /// The unstyled string content.
1136 pub fn plain(&self) -> &str {
1137 &self.plain
1138 }
1139
1140 /// The spans currently applied.
1141 pub fn spans(&self) -> &[Span] {
1142 &self.spans
1143 }
1144
1145 /// Length in terminal cells.
1146 pub fn cell_len(&self) -> usize {
1147 cell_len(&self.plain)
1148 }
1149
1150 /// True when there is no content.
1151 pub fn is_empty(&self) -> bool {
1152 self.plain.is_empty()
1153 }
1154
1155 /// Append more text, optionally under `style` (a resolved [`Style`] or a
1156 /// style name).
1157 pub fn append(&mut self, text: &str, style: Option<StyleType>) {
1158 let start = self.plain.len();
1159 // Strip here as well as in `new`: upstream's `Text.append` runs the same
1160 // `strip_control_codes`, and skipping it let BEL, backspace, vertical
1161 // tab and form feed reach the terminal through every path that builds
1162 // text incrementally — Markdown, Syntax and plain files. A backspace run
1163 // is a spoofing tool: `FAILED\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}PASSED`
1164 // displays as `PASSED`.
1165 self.plain.push_str(&Text::strip_control_codes(text));
1166 let end = self.plain.len();
1167 // `if style:` — a null style or an empty name adds no span, so it
1168 // leaves no segment boundary behind either.
1169 if let Some(style) = style.filter(|style| !is_falsy_style(style)) {
1170 self.spans.push(Span { start, end, style });
1171 }
1172 }
1173
1174 /// Append another `Text`, carrying over its base style (as a covering span)
1175 /// and all of its spans, shifted to their new offsets. Port of
1176 /// `Text.append_text`. Consumes `self` and returns it for chaining.
1177 pub fn append_text(mut self, other: &Text) -> Text {
1178 let offset = self.plain.len();
1179 self.plain.push_str(&other.plain);
1180 let end = self.plain.len();
1181 if !other.style.is_null_style() {
1182 self.spans.push(Span {
1183 start: offset,
1184 end,
1185 style: other.style.clone(),
1186 });
1187 }
1188 for span in &other.spans {
1189 self.spans.push(Span {
1190 start: span.start + offset,
1191 end: span.end + offset,
1192 style: span.style.clone(),
1193 });
1194 }
1195 self
1196 }
1197
1198 /// Apply `style` to the byte range `[start, end)`. Port of `Text.stylize`,
1199 /// including its argument order.
1200 ///
1201 /// `style` may be a resolved [`Style`] or a name (`"repr.number"`) left for
1202 /// the renderer to look up. Byte offsets, not char offsets; ASCII-only
1203 /// callers such as highlighters are unaffected by the distinction.
1204 ///
1205 /// A range that is empty or inverted is ignored, which is what gives us
1206 /// upstream's `end > start` skip for non-participating regex groups.
1207 pub fn stylize(&mut self, style: impl Into<StyleType>, start: usize, end: usize) {
1208 let style = style.into();
1209 let end = end.min(self.plain.len());
1210 // `if style:` — a falsy style adds no span.
1211 if start >= end || is_falsy_style(&style) {
1212 return;
1213 }
1214 self.spans.push(Span { start, end, style });
1215 }
1216
1217 /// As [`stylize`](Self::stylize), but the span goes *first*, beneath every
1218 /// existing span. Port of `Text.stylize_before`.
1219 pub fn stylize_before(&mut self, style: impl Into<StyleType>, start: usize, end: usize) {
1220 let style = style.into();
1221 let end = end.min(self.plain.len());
1222 if start >= end || is_falsy_style(&style) {
1223 return;
1224 }
1225 self.spans.insert(0, Span { start, end, style });
1226 }
1227
1228 /// Apply metadata to the byte range `start..end` (the rest of the text
1229 /// when `end` is `None`). Port of `Text.apply_meta`: a span of
1230 /// `Style.from_meta(meta)`, which an empty map (a null style) skips.
1231 pub fn apply_meta(&mut self, meta: crate::style::Meta, start: usize, end: Option<usize>) {
1232 let end = end.unwrap_or(self.plain.len());
1233 self.stylize(Style::from_meta(meta), start, end);
1234 }
1235
1236 /// Apply event-handler metadata to the whole text. Port of `Text.on`:
1237 /// handlers are stored as `"@name"` keys over `meta`.
1238 pub fn on(
1239 &mut self,
1240 meta: Option<crate::style::Meta>,
1241 handlers: &[(&str, crate::style::MetaValue)],
1242 ) -> &mut Self {
1243 let len = self.plain.len();
1244 self.stylize(Style::on(meta, handlers), 0, len);
1245 self
1246 }
1247
1248 /// Append a raw span, as upstream's `text.spans.append(span)` does: no
1249 /// clamping and no falsy-style check.
1250 ///
1251 /// Offsets are **byte** offsets into [`plain`](Self::plain) and must fall
1252 /// on `char` boundaries (upstream's are character offsets; convert first).
1253 /// A span may extend past the end of the text, as upstream allows.
1254 pub fn push_span(&mut self, span: Span) {
1255 self.spans.push(span);
1256 }
1257
1258 /// The spans, mutably. Upstream's `Text.spans` is a public, mutable list;
1259 /// the same byte-offset rules as [`push_span`](Self::push_span) apply.
1260 pub fn spans_mut(&mut self) -> &mut Vec<Span> {
1261 &mut self.spans
1262 }
1263
1264 /// Replace every span. Port of the `Text.spans` setter.
1265 pub fn set_spans(&mut self, spans: Vec<Span>) {
1266 self.spans = spans;
1267 }
1268
1269 /// Drop this text's own `justify`, `overflow` and `no_wrap`, so they defer to
1270 /// the console options as upstream's `Text.join` result does.
1271 pub(crate) fn clear_layout_options(&mut self) {
1272 self.justify = None;
1273 self.overflow = None;
1274 self.no_wrap = None;
1275 self.tab_size = None;
1276 }
1277
1278 /// Move the base style into a span over the whole text, ahead of the
1279 /// existing spans — what `Text.join` does to each joined text. A falsy
1280 /// style (null, or an empty name) adds no span, as `if text.style:` skips
1281 /// it upstream.
1282 pub(crate) fn base_style_to_span(&mut self) {
1283 let style = std::mem::take(&mut self.style);
1284 let falsy =
1285 style.is_null_style() || matches!(&style, StyleType::Name(name) if name.is_empty());
1286 if !falsy {
1287 self.spans.insert(
1288 0,
1289 Span {
1290 start: 0,
1291 end: self.plain.len(),
1292 style,
1293 },
1294 );
1295 }
1296 }
1297
1298 /// The whole-text base style, resolved or named. Port of the `Text.style`
1299 /// attribute.
1300 pub fn base_style(&self) -> &StyleType {
1301 &self.style
1302 }
1303
1304 /// Set the whole-text base style, resolved or named.
1305 pub fn set_base_style(&mut self, style: impl Into<StyleType>) {
1306 self.style = style.into();
1307 }
1308
1309 /// Flatten into non-overlapping segments (newlines become [`Segment::line`]),
1310 /// combining `base_style`, this text's base style, and every covering span.
1311 /// Does **not** wrap. Port of the core of `Text.render`.
1312 ///
1313 /// Named span styles are resolved against `theme`.
1314 pub fn render(&self, theme: &Theme, base_style: &Style) -> Vec<Segment> {
1315 self.render_joined(theme, base_style, None)
1316 }
1317
1318 /// The `(minimum, maximum)` cell width of this text: `maximum` is the widest
1319 /// hard line, `minimum` the widest word. Port of `Text.__rich_measure__`.
1320 pub fn measurement(&self) -> (usize, usize) {
1321 // Measured against the raw string, as upstream does: `cell_len` counts
1322 // a tab as zero cells, and tabs expand only at render time. A printed
1323 // `Text` renders at the full width (see `Renderable::printed_text`), so
1324 // this measurement never becomes its own render width (#447).
1325 measure_plain(&self.plain)
1326 }
1327
1328 /// Render into visual lines, wrapping each hard line to `width` cells when
1329 /// `Some`, and justifying per this text's own justify.
1330 pub fn render_lines(
1331 &self,
1332 theme: &Theme,
1333 base_style: &Style,
1334 width: Option<usize>,
1335 ) -> Vec<Vec<Segment>> {
1336 self.render_lines_justified(theme, base_style, width, self.get_justify())
1337 }
1338
1339 /// Like [`render_lines`](Self::render_lines) but with an explicit `justify`
1340 /// (used by the console to apply `options.justify`).
1341 pub fn render_lines_justified(
1342 &self,
1343 theme: &Theme,
1344 base_style: &Style,
1345 width: Option<usize>,
1346 justify: Justify,
1347 ) -> Vec<Vec<Segment>> {
1348 self.render_lines_wrapped(
1349 theme,
1350 base_style,
1351 width,
1352 justify,
1353 self.overflow.unwrap_or(Overflow::Fold),
1354 self.no_wrap.unwrap_or(false),
1355 )
1356 }
1357
1358 /// The full wrap-justify-truncate pipeline, with every knob resolved by the
1359 /// caller. Port of `Text.wrap`.
1360 ///
1361 /// Lines are split on `\n`, wrapped to `width` (folding over-long words only
1362 /// when `overflow` is [`Overflow::Fold`]), justified, and finally truncated
1363 /// to `width`. [`Overflow::Ignore`] skips wrapping and truncation both, so
1364 /// lines may come back wider than `width`.
1365 ///
1366 /// Tabs expand to this text's own [`tab_size`](Self::get_tab_size), else
1367 /// [`DEFAULT_TAB_SIZE`]; [`render_lines_wrapped_tabs`](Self::render_lines_wrapped_tabs)
1368 /// takes the width explicitly (upstream's `wrap(tab_size=…)`).
1369 pub fn render_lines_wrapped(
1370 &self,
1371 theme: &Theme,
1372 base_style: &Style,
1373 width: Option<usize>,
1374 justify: Justify,
1375 overflow: Overflow,
1376 no_wrap: bool,
1377 ) -> Vec<Vec<Segment>> {
1378 self.render_lines_wrapped_tabs(
1379 theme,
1380 base_style,
1381 width,
1382 justify,
1383 overflow,
1384 no_wrap,
1385 match self.tab_size {
1386 None | Some(0) => DEFAULT_TAB_SIZE,
1387 Some(tab_size) => tab_size,
1388 },
1389 )
1390 }
1391
1392 /// The tab stop width `Text.__rich_console__` wraps with on `console`:
1393 /// this text's own, else the console's, and `8` for zero
1394 /// (`tab_size or 8`).
1395 pub fn console_tab_size(&self, console: &crate::console::Console) -> usize {
1396 match self.tab_size.unwrap_or_else(|| console.tab_size()) {
1397 0 => DEFAULT_TAB_SIZE,
1398 tab_size => tab_size,
1399 }
1400 }
1401
1402 /// [`render_lines_wrapped`](Self::render_lines_wrapped) with an explicit
1403 /// tab stop width. Port of `Text.wrap(…, tab_size=…)`.
1404 #[allow(clippy::too_many_arguments)]
1405 pub fn render_lines_wrapped_tabs(
1406 &self,
1407 theme: &Theme,
1408 base_style: &Style,
1409 width: Option<usize>,
1410 justify: Justify,
1411 overflow: Overflow,
1412 no_wrap: bool,
1413 tab_size: usize,
1414 ) -> Vec<Vec<Segment>> {
1415 // Tabs are expanded before anything measures or wraps the text, as
1416 // upstream's `Text.wrap` does per line. Without this a tab occupies one
1417 // cell everywhere in the layout and then eight on the terminal, so every
1418 // width calculation downstream is wrong.
1419 if self.plain.contains('\t') {
1420 let mut expanded = self.clone();
1421 expanded.expand_tabs(tab_size);
1422 return expanded.render_lines_wrapped_tabs(
1423 theme, base_style, width, justify, overflow, no_wrap, tab_size,
1424 );
1425 }
1426
1427 // Resolve every span's style once, up front, into a vector parallel to
1428 // `self.spans` — upstream's `style_map`. Resolving inside the per-line
1429 // loop would re-parse the same names for every visual line.
1430 let resolved: Vec<Style> = self
1431 .spans
1432 .iter()
1433 .map(|span| theme.get_style_or_null(&span.style))
1434 .collect();
1435 let effective_base = base_style.combine(&theme.get_style_or_null(&self.style));
1436 // Upstream folds `overflow == "ignore"` into no_wrap before splitting.
1437 let no_wrap = no_wrap || overflow == Overflow::Ignore;
1438 let groups = self.wrapped_ranges(width, overflow, no_wrap);
1439 // Which spans touch which visual line, found once by binary search as
1440 // upstream's `divide` does. Scanning every span for every line (and
1441 // every cut within it) made printing a long highlighted repr quadratic.
1442 let line_spans = self.spans_by_line(&groups);
1443 let mut line_spans = line_spans.iter();
1444 let Some(width) = width else {
1445 return groups
1446 .into_iter()
1447 .flatten()
1448 .map(|(start, end)| {
1449 let ids = line_spans.next().map(Vec::as_slice).unwrap_or(&[]);
1450 self.line_segments(&resolved, start, end, &effective_base, ids)
1451 })
1452 .collect();
1453 };
1454
1455 let mut lines: Vec<Vec<Segment>> = Vec::new();
1456 // One hard line at a time, as upstream's `for line in self.split(...)`
1457 // does — the paragraph boundary is what full justification treats as
1458 // ragged, so the groups cannot be flattened first.
1459 for group in groups {
1460 let group_spans: Vec<&[usize]> = group
1461 .iter()
1462 .map(|_| line_spans.next().map(Vec::as_slice).unwrap_or(&[]))
1463 .collect();
1464 let mut new_lines: Vec<Vec<Segment>> = group
1465 .iter()
1466 .zip(&group_spans)
1467 .map(|(&(start, end), ids)| {
1468 self.line_segments(&resolved, start, end, &effective_base, ids)
1469 })
1470 .collect();
1471
1472 // `overflow == "ignore"` is a hard stop upstream: the line is
1473 // appended verbatim and the loop `continue`s, so it is neither
1474 // justified nor truncated. Padding it out to the width here was
1475 // adding trailing spaces to text upstream returns untouched.
1476 if overflow == Overflow::Ignore {
1477 lines.append(&mut new_lines);
1478 continue;
1479 }
1480
1481 // Give each wrapped line back the padding it overshot by, exactly
1482 // where upstream's `Text.wrap` does it — after dividing, before
1483 // justifying. `divide_line` counts a word *including* its trailing
1484 // space, so a line whose last word ends flush with the width comes
1485 // back one cell too long; without this the ellipsis overflow then
1486 // chops a real character to make room for a `…` that upstream never
1487 // emits ("abcdefghij more" at width 10 became "abcdefghi…", not
1488 // "abcdefghij").
1489 //
1490 // Only in the wrapping branch: upstream's `rstrip_end` loop sits
1491 // inside `Text.wrap`'s `else`, which `no_wrap` skips entirely.
1492 if !no_wrap {
1493 for line in &mut new_lines {
1494 rstrip_end_line(line, width);
1495 }
1496 }
1497 if justify != Justify::Default {
1498 let last = new_lines.len().saturating_sub(1);
1499 for (index, line) in new_lines.iter_mut().enumerate() {
1500 // Full justification leaves the final line of the paragraph
1501 // ragged, so it needs to know where it is in the group.
1502 let (start, end) = group[index];
1503 *line = justify_line(
1504 line,
1505 width,
1506 justify,
1507 overflow,
1508 &effective_base,
1509 index == last,
1510 &|char_index| self.covered_at(start, end, char_index, group_spans[index]),
1511 );
1512 }
1513 }
1514 for line in &mut new_lines {
1515 *line = truncate_line(std::mem::take(line), width, overflow);
1516 }
1517 lines.append(&mut new_lines);
1518 }
1519 lines
1520 }
1521
1522 /// Whether any span covers the `char_index`-th character of the line
1523 /// `[start, end)` — i.e. whether upstream's `Text.render` has a span
1524 /// boundary at that character's edge of the line. Justify padding joins
1525 /// the neighbouring run only where it does not.
1526 ///
1527 /// `ids` are the spans touching that line (see [`spans_by_line`](Self::spans_by_line)).
1528 fn covered_at(&self, start: usize, end: usize, char_index: usize, ids: &[usize]) -> bool {
1529 let Some((offset, _)) = self.plain[start..end].char_indices().nth(char_index) else {
1530 return false;
1531 };
1532 let position = start + offset;
1533 ids.iter().any(|&id| {
1534 let span = &self.spans[id];
1535 span.start <= position && position < span.end
1536 })
1537 }
1538
1539 /// For each visual line of `groups` (flattened, in order), the indices of
1540 /// the spans that overlap it, in span order. Lines are ordered and
1541 /// disjoint, so each span binary-searches its first and last line, as
1542 /// upstream's `Text.divide` does; the cost is the number of (span, line)
1543 /// pairs that actually overlap rather than spans × lines.
1544 fn spans_by_line(&self, groups: &[Vec<(usize, usize)>]) -> Vec<Vec<usize>> {
1545 let lines: Vec<(usize, usize)> = groups.iter().flatten().copied().collect();
1546 let mut by_line: Vec<Vec<usize>> = vec![Vec::new(); lines.len()];
1547 // Upstream only divides a text that has a newline or a wrap cut, and
1548 // `divide` drops spans that come out empty. An undivided text keeps
1549 // its empty spans, whose start/end events still split the run
1550 // (`[b]a[i][/i]b[/b]` renders `a` and `b` as two segments).
1551 let undivided = lines.len() == 1;
1552 for (id, span) in self.spans.iter().enumerate() {
1553 if span.start >= span.end {
1554 if undivided && span.start == span.end {
1555 by_line[0].push(id);
1556 }
1557 continue;
1558 }
1559 let first = lines.partition_point(|&(_, end)| end <= span.start);
1560 let last = lines.partition_point(|&(start, _)| start < span.end);
1561 for index in first..last.max(first) {
1562 let (start, end) = lines[index];
1563 if span.start.max(start) < span.end.min(end) {
1564 by_line[index].push(id);
1565 }
1566 }
1567 }
1568 by_line
1569 }
1570
1571 /// As [`render_lines_wrapped`](Self::render_lines_wrapped), flattened into a
1572 /// single segment stream with [`Segment::line`] between visual lines.
1573 pub fn render_joined_wrapped(
1574 &self,
1575 theme: &Theme,
1576 base_style: &Style,
1577 width: usize,
1578 justify: Justify,
1579 overflow: Overflow,
1580 no_wrap: bool,
1581 ) -> Vec<Segment> {
1582 self.render_joined_wrapped_tabs(
1583 theme,
1584 base_style,
1585 width,
1586 justify,
1587 overflow,
1588 no_wrap,
1589 match self.tab_size {
1590 None | Some(0) => DEFAULT_TAB_SIZE,
1591 Some(tab_size) => tab_size,
1592 },
1593 )
1594 }
1595
1596 /// [`render_joined_wrapped`](Self::render_joined_wrapped) with an explicit
1597 /// tab stop width (see [`console_tab_size`](Self::console_tab_size)).
1598 #[allow(clippy::too_many_arguments)]
1599 pub fn render_joined_wrapped_tabs(
1600 &self,
1601 theme: &Theme,
1602 base_style: &Style,
1603 width: usize,
1604 justify: Justify,
1605 overflow: Overflow,
1606 no_wrap: bool,
1607 tab_size: usize,
1608 ) -> Vec<Segment> {
1609 let lines = self.render_lines_wrapped_tabs(
1610 theme,
1611 base_style,
1612 Some(width),
1613 justify,
1614 overflow,
1615 no_wrap,
1616 tab_size,
1617 );
1618 let mut segments = Vec::new();
1619 let last = lines.len().saturating_sub(1);
1620 for (index, line) in lines.into_iter().enumerate() {
1621 // A final empty line (a trailing newline, or an empty text left
1622 // unpadded, as with `overflow="ignore"`) is still a line upstream:
1623 // `Text.render` yields `Segment("")` before the `end`.
1624 if index == last && line.is_empty() {
1625 segments.push(Segment::new("", None));
1626 }
1627 segments.extend(line);
1628 if index != last {
1629 segments.push(Segment::line());
1630 }
1631 }
1632 segments
1633 }
1634
1635 /// Render into a flat segment stream with [`Segment::line`] between visual
1636 /// lines (wrapping when `width` is `Some`), using this text's own justify.
1637 fn render_joined(
1638 &self,
1639 theme: &Theme,
1640 base_style: &Style,
1641 width: Option<usize>,
1642 ) -> Vec<Segment> {
1643 let lines = self.render_lines(theme, base_style, width);
1644 let mut segments = Vec::new();
1645 let last = lines.len().saturating_sub(1);
1646 for (index, line) in lines.into_iter().enumerate() {
1647 segments.extend(line);
1648 if index != last {
1649 segments.push(Segment::line());
1650 }
1651 }
1652 segments
1653 }
1654
1655 /// The `(start_byte, end_byte)` range of each visual line, **grouped by the
1656 /// hard line it came from**: hard lines split on `\n`, then each wrapped to
1657 /// `width` cells when `Some`.
1658 ///
1659 /// The grouping is not cosmetic. Upstream wraps and justifies one hard line
1660 /// at a time (`for line in self.split(...)`), so full justification leaves
1661 /// the last visual line of *each paragraph* ragged. Flattening first makes
1662 /// every paragraph but the final one get stretched, which turned
1663 /// `"line here"` into `"line here"`.
1664 fn wrapped_ranges(
1665 &self,
1666 width: Option<usize>,
1667 overflow: Overflow,
1668 no_wrap: bool,
1669 ) -> Vec<Vec<(usize, usize)>> {
1670 let mut hard: Vec<(usize, usize)> = Vec::new();
1671 let mut start = 0;
1672 for (i, byte) in self.plain.bytes().enumerate() {
1673 if byte == b'\n' {
1674 hard.push((start, i));
1675 start = i + 1;
1676 }
1677 }
1678 hard.push((start, self.plain.len()));
1679
1680 let Some(width) = width else {
1681 return hard.into_iter().map(|range| vec![range]).collect();
1682 };
1683 if no_wrap {
1684 return hard.into_iter().map(|range| vec![range]).collect();
1685 }
1686
1687 let mut groups: Vec<Vec<(usize, usize)>> = Vec::with_capacity(hard.len());
1688 for (a, b) in hard {
1689 let sub = &self.plain[a..b];
1690 // Only `fold` breaks a word that is wider than the whole line; the
1691 // cropping methods leave it long and let truncation cut it.
1692 let breaks = crate::wrap::divide_line(sub, width, overflow == Overflow::Fold);
1693 let mut cuts = vec![a];
1694 let mut previous_char = 0;
1695 let mut previous_byte = 0;
1696 for char_offset in breaks {
1697 // Breaks are ordered char offsets. Scan only the next slice;
1698 // rescanning the prefix for every line is quadratic.
1699 previous_byte += char_to_byte(&sub[previous_byte..], char_offset - previous_char);
1700 previous_char = char_offset;
1701 cuts.push(a + previous_byte);
1702 }
1703 cuts.push(b);
1704 groups.push(cuts.windows(2).map(|w| (w[0], w[1])).collect());
1705 }
1706 groups
1707 }
1708
1709 /// Combine `effective_base` with every span covering `[start, end)`,
1710 /// producing non-overlapping segments for that byte range. Port of the
1711 /// sweep in upstream's `Text.render`: span start/end events are sorted
1712 /// once and walked with a stack of active spans.
1713 ///
1714 /// `resolved` is the per-render style map, index-parallel to `self.spans`;
1715 /// `ids` are the spans overlapping this line, in span order. Active spans
1716 /// are combined in span order (upstream's `sorted(stack)`), and spans that
1717 /// resolved to nothing are **not** skipped — they still contribute a
1718 /// boundary. The highlighter fixtures depend on it: an ISO-8601 date emits
1719 /// separate segments per sub-field even where the field styles are
1720 /// identical.
1721 fn line_segments(
1722 &self,
1723 resolved: &[Style],
1724 start: usize,
1725 end: usize,
1726 effective_base: &Style,
1727 ids: &[usize],
1728 ) -> Vec<Segment> {
1729 if start >= end {
1730 return Vec::new();
1731 }
1732 // (offset, leaving, span id); entering sorts before leaving.
1733 let mut events: Vec<(usize, bool, usize)> = Vec::with_capacity(ids.len() * 2 + 1);
1734 for &id in ids {
1735 let span = &self.spans[id];
1736 let (span_start, span_end) = (span.start.max(start), span.end.min(end));
1737 // An empty span (kept only on an undivided line) is a boundary.
1738 let empty = span.start == span.end && start < span.start && span.end < end;
1739 if span_start < span_end || empty {
1740 events.push((span_start, false, id));
1741 events.push((span_end, true, id));
1742 }
1743 }
1744 events.sort_unstable();
1745
1746 let mut segments = Vec::new();
1747 let mut stack: Vec<usize> = Vec::new();
1748 let mut offset = start;
1749 let mut events = events.into_iter().peekable();
1750 loop {
1751 // Apply every event at `offset`.
1752 while let Some(&(position, leaving, id)) = events.peek() {
1753 if position > offset {
1754 break;
1755 }
1756 events.next();
1757 match stack.binary_search(&id) {
1758 Ok(index) if leaving => {
1759 stack.remove(index);
1760 }
1761 Err(index) if !leaving => stack.insert(index, id),
1762 _ => {}
1763 }
1764 }
1765 let next = events
1766 .peek()
1767 .map_or(end, |&(position, _, _)| position.min(end));
1768 if next <= offset {
1769 break;
1770 }
1771 let mut style = effective_base.clone();
1772 for &id in &stack {
1773 style = style.combine(&resolved[id]);
1774 }
1775 segments.push(Segment::new(&self.plain[offset..next], Some(style)));
1776 offset = next;
1777 }
1778 segments
1779 }
1780}
1781
1782/// Python truthiness of a style argument: a null `Style` and an empty style
1783/// name are falsy, and upstream's `append`/`stylize` skip them.
1784fn is_falsy_style(style: &StyleType) -> bool {
1785 style.is_null_style() || matches!(style, StyleType::Name(name) if name.is_empty())
1786}
1787
1788/// Byte offset of the `char_idx`-th char in `text` (clamped to `text.len()`).
1789fn char_to_byte(text: &str, char_idx: usize) -> usize {
1790 text.char_indices()
1791 .nth(char_idx)
1792 .map(|(byte, _)| byte)
1793 .unwrap_or(text.len())
1794}
1795
1796/// Cut a rendered line down to `width` cells, applying `overflow`. Segment-level
1797/// counterpart of [`Text::truncate`], used once per line at the end of the wrap
1798/// pipeline.
1799///
1800/// [`Overflow::Fold`] and [`Overflow::Crop`] both plain-cut: by this point the
1801/// line has already been wrapped, so anything still over-long is an unbreakable
1802/// run that folding cannot help with.
1803///
1804/// [`Overflow::Ellipsis`] cuts one cell short and appends `…`. The marker takes
1805/// the style of the first segment the cut did *not* keep whole — upstream writes
1806/// the ellipsis into the plain string and lets span-trimming decide, which works
1807/// out to the same rule, including when the cut lands exactly on a boundary.
1808fn truncate_line(line: Vec<Segment>, width: usize, overflow: Overflow) -> Vec<Segment> {
1809 if overflow == Overflow::Ignore {
1810 return line;
1811 }
1812 // A line that fits comes back as it is, measured without joining it: with
1813 // no joiner or variation selector in it, no grapheme can span a segment
1814 // boundary, so the segments' widths add up to the whole line's
1815 // (docs/PORTING.md, "Performance notes").
1816 let unjoined: Option<usize> = line
1817 .iter()
1818 .map(|segment| crate::cells::unjoined_cell_len(&segment.text))
1819 .sum();
1820 if unjoined.is_some_and(|length| length <= width) {
1821 return line;
1822 }
1823 // Measured and cut over the WHOLE line, exactly as upstream's `Text.truncate`
1824 // works on `self.plain`. Summing the segments instead is wrong wherever a
1825 // grapheme spans a segment boundary — a zero-width joiner at the end of one
1826 // segment swallows the first character of the next, so the per-segment sum
1827 // reads one cell too wide and cuts text upstream keeps.
1828 let plain: String = line.iter().map(|segment| segment.text.as_str()).collect();
1829 if cell_len(&plain) <= width {
1830 return line;
1831 }
1832 let ellipsis = overflow == Overflow::Ellipsis;
1833 // `…` occupies one cell, so the kept text must stop one cell early.
1834 let keep = if ellipsis {
1835 width.saturating_sub(1)
1836 } else {
1837 width
1838 };
1839 // Never longer than `plain`, and only ever differs from a byte prefix of it
1840 // in its final byte (a wide grapheme straddling the cut becomes a space), so
1841 // slicing it at the original segment boundaries stays on char boundaries.
1842 let kept = set_cell_size(&plain, keep);
1843
1844 let mut result: Vec<Segment> = Vec::new();
1845 // Style the ellipsis inherits: that of the first segment the cut did not
1846 // keep whole, falling back to the last segment's when the cut lands exactly
1847 // on the end of the line's bytes.
1848 let mut cut_style: Option<Style> = line.last().and_then(|segment| segment.style.clone());
1849 let mut offset = 0usize;
1850 for segment in &line {
1851 if offset >= kept.len() {
1852 cut_style = segment.style.clone();
1853 break;
1854 }
1855 let end = (offset + segment.text.len()).min(kept.len());
1856 if end > offset {
1857 result.push(Segment::new(&kept[offset..end], segment.style.clone()));
1858 }
1859 if offset + segment.text.len() > kept.len() {
1860 cut_style = segment.style.clone();
1861 break;
1862 }
1863 offset = end;
1864 }
1865 if ellipsis {
1866 // Upstream appends the marker to the plain string and re-renders, so it
1867 // lands inside the preceding run rather than beside it. Merging keeps
1868 // the byte stream identical — a separate segment would re-emit the style.
1869 match result.last_mut() {
1870 Some(last) if !last.control && last.style == cut_style => last.text.push('…'),
1871 _ => result.push(Segment::new("…", cut_style)),
1872 }
1873 }
1874 result
1875}
1876
1877/// Split a rendered line into whitespace-separated words, each word keeping its
1878/// own styled segments. Separator spaces are dropped — [`full_justify`] decides
1879/// the new gaps. Port of the `line.split(" ")` in upstream's `full` branch.
1880fn split_words(line: &[Segment]) -> Vec<Vec<Segment>> {
1881 let mut words: Vec<Vec<Segment>> = Vec::new();
1882 let mut current: Vec<Segment> = Vec::new();
1883 for segment in line {
1884 // A segment can straddle a space, so split within it and keep the style.
1885 for (index, piece) in segment.text.split(' ').enumerate() {
1886 if index > 0 {
1887 words.push(std::mem::take(&mut current));
1888 }
1889 if !piece.is_empty() {
1890 current.push(Segment::new(piece, segment.style.clone()));
1891 }
1892 }
1893 }
1894 words.push(current);
1895 // Wrapping leaves a trailing space on every line but the last, so the naive
1896 // split ends with an empty word. Upstream's `Text.split` drops it, and the
1897 // count matters: it decides how many gaps share the slack.
1898 if words.last().is_some_and(|w| w.is_empty()) {
1899 words.pop();
1900 }
1901 words
1902}
1903
1904/// Distribute `width` across `line`'s words by widening the gaps between them.
1905/// Direct port of the `justify == "full"` branch of upstream's `Lines.justify`:
1906/// every gap starts at one space, and the extra columns are handed out from the
1907/// rightmost gap backwards, cycling.
1908fn full_justify(line: &[Segment], width: usize, style: &Style) -> Vec<Segment> {
1909 let words = split_words(line);
1910 let words_size: usize = words
1911 .iter()
1912 .map(|word| word.iter().map(Segment::cell_length).sum::<usize>())
1913 .sum();
1914 let mut num_spaces = words.len().saturating_sub(1);
1915 let mut spaces = vec![1usize; num_spaces];
1916 if !spaces.is_empty() {
1917 let mut index = 0;
1918 while words_size + num_spaces < width {
1919 let slot = spaces.len() - index - 1;
1920 spaces[slot] += 1;
1921 num_spaces += 1;
1922 index = (index + 1) % spaces.len();
1923 }
1924 }
1925
1926 let mut out: Vec<Segment> = Vec::new();
1927 for (index, word) in words.iter().enumerate() {
1928 out.extend(word.iter().cloned());
1929 if let Some(&gap) = spaces.get(index) {
1930 // Upstream styles the gap with the surrounding style when the two
1931 // neighbours agree, else with the line's base style.
1932 let before = word.last().and_then(|s| s.style.clone());
1933 let after = words
1934 .get(index + 1)
1935 .and_then(|w| w.first())
1936 .and_then(|s| s.style.clone());
1937 let gap_style = if before == after {
1938 before.unwrap_or_else(|| style.clone())
1939 } else {
1940 style.clone()
1941 };
1942 out.push(Segment::new(" ".repeat(gap), Some(gap_style)));
1943 }
1944 }
1945 out
1946}
1947
1948/// Pad `line` to `width` cells according to `justify`, using `style` for the
1949/// pad (so e.g. a styled table cell fills with its own style).
1950///
1951/// `is_last` marks the final line of the paragraph, which full justification
1952/// leaves ragged rather than stretching.
1953///
1954/// `covered(i)` says whether a span covers the line's `i`-th character.
1955/// Upstream pads the plain string, so its padding joins the adjacent run
1956/// unless a span ends (or starts) at the text's edge; a separate segment
1957/// would re-emit the style as a second SGR run.
1958fn justify_line(
1959 line: &[Segment],
1960 width: usize,
1961 justify: Justify,
1962 overflow: Overflow,
1963 style: &Style,
1964 is_last: bool,
1965 covered: &dyn Fn(usize) -> bool,
1966) -> Vec<Segment> {
1967 // Full justification rewrites the interior gaps instead of padding an edge.
1968 if justify == Justify::Full {
1969 // Upstream `break`s before the final line, so it is left exactly as
1970 // wrapped — not even padded out to the width, unlike every other mode.
1971 return if is_last {
1972 line.to_vec()
1973 } else {
1974 full_justify(line, width, style)
1975 };
1976 }
1977 let mut content = line.to_vec();
1978 // Upstream's `Lines.justify` calls `line.rstrip()` in its `center` and
1979 // `right` branches — and only there — so the space wrapping left at the end
1980 // of a line is *not* content to be positioned. Keeping it shifts the visible
1981 // text half a space left when centring (`" abcd efgh ijklmnop "` became
1982 // `"abcd efgh ijklmnop "`) and a whole column left when right-aligning.
1983 // `left`/`full` deliberately keep it: upstream pads them without stripping.
1984 if matches!(justify, Justify::Center | Justify::Right) {
1985 rstrip_line(&mut content);
1986 // …and then TRUNCATES, before it pads. The order is load-bearing: cell
1987 // width is not additive across a cut, so a line whose over-long tail is
1988 // chopped can measure *less* than the width afterwards and still want
1989 // padding. A leading zero-width joiner is the clearest case — it eats the
1990 // character after it, so cutting the line hands one of its cells back —
1991 // and padding first computes the gap from the pre-cut measurement, which
1992 // is zero, and leaves the line short.
1993 content = truncate_line(content, width, overflow);
1994 }
1995
1996 // Whether padding may join the content's first / last run: no span
1997 // covers the character at that edge, so upstream's run there is the base
1998 // style alone and the padding extends it.
1999 let content_chars: usize = content.iter().map(|s| s.text.chars().count()).sum();
2000 // With no content at all, the two pads of a centred line are one run.
2001 let join_left = content_chars > 0 && !covered(0);
2002 let join_right = content_chars == 0 || !covered(content_chars - 1);
2003
2004 let mut out = Vec::with_capacity(content.len() + 2);
2005 match justify {
2006 Justify::Right => {
2007 // `line.pad_left(width - cell_len(line.plain))`.
2008 let excess = width.saturating_sub(line_cell_len(&content));
2009 if excess > 0 {
2010 out.push(Segment::new(" ".repeat(excess), Some(style.clone())));
2011 }
2012 append_joined(&mut out, content, join_left);
2013 }
2014 Justify::Center => {
2015 // `pad_left((width - cell_len) // 2)` and then `pad_right(width -
2016 // cell_len)` — the second `cell_len` is re-measured *after* the left
2017 // pad, so the two halves are not simply `excess / 2` and the rest.
2018 let left = width.saturating_sub(line_cell_len(&content)) / 2;
2019 if left > 0 {
2020 out.push(Segment::new(" ".repeat(left), Some(style.clone())));
2021 }
2022 append_joined(&mut out, content, join_left);
2023 let right = width.saturating_sub(line_cell_len(&out));
2024 if right > 0 {
2025 push_joined(
2026 &mut out,
2027 Segment::new(" ".repeat(right), Some(style.clone())),
2028 join_right,
2029 );
2030 }
2031 }
2032 // Left, Default, and full justification's ragged last line pad right.
2033 // Upstream reaches this through `truncate(width, pad=True)`, whose pad
2034 // is driven by the *pre*-truncate length — so padding and truncating are
2035 // mutually exclusive here and the order does not matter.
2036 Justify::Left | Justify::Full | Justify::Default => {
2037 let excess = width.saturating_sub(line_cell_len(&content));
2038 out.append(&mut content);
2039 if excess > 0 {
2040 push_joined(
2041 &mut out,
2042 Segment::new(" ".repeat(excess), Some(style.clone())),
2043 join_right,
2044 );
2045 }
2046 }
2047 }
2048 out
2049}
2050
2051/// Append `segment`, extending the last segment instead when `join` allows it
2052/// and the two share a style.
2053fn push_joined(out: &mut Vec<Segment>, segment: Segment, join: bool) {
2054 match out.last_mut() {
2055 Some(last) if join && !last.control && last.style == segment.style => {
2056 last.text.push_str(&segment.text);
2057 }
2058 _ => out.push(segment),
2059 }
2060}
2061
2062/// Append `content`, joining its first segment onto the last of `out` when
2063/// `join` allows it and the two share a style.
2064fn append_joined(out: &mut Vec<Segment>, content: Vec<Segment>, join: bool) {
2065 let mut content = content.into_iter();
2066 if let Some(first) = content.next() {
2067 push_joined(out, first, join);
2068 }
2069 out.extend(content);
2070}
2071
2072/// The cell width of a rendered line.
2073fn line_cell_len(line: &[Segment]) -> usize {
2074 line.iter().map(Segment::cell_length).sum()
2075}
2076
2077/// The number of trailing whitespace *characters* on a rendered line.
2078///
2079/// Segment-level, because by the time the wrap pipeline justifies a line the
2080/// spans have already been flattened into [`Segment`]s and there is no `Text`
2081/// left to call `rstrip` on.
2082fn trailing_whitespace(line: &[Segment]) -> usize {
2083 let mut count = 0usize;
2084 for segment in line.iter().rev() {
2085 let trimmed = segment.text.trim_end_matches(is_python_space);
2086 count += segment.text[trimmed.len()..].chars().count();
2087 if !trimmed.is_empty() {
2088 break;
2089 }
2090 }
2091 count
2092}
2093
2094/// Drop the last `count` characters, discarding segments that empty out.
2095/// Segment-level counterpart of `Text.right_crop`.
2096fn right_crop_line(line: &mut Vec<Segment>, count: usize) {
2097 let mut remaining = count;
2098 while remaining > 0 {
2099 let Some(last) = line.last_mut() else { break };
2100 let length = last.text.chars().count();
2101 if length <= remaining {
2102 remaining -= length;
2103 line.pop();
2104 } else {
2105 let keep = char_to_byte(&last.text, length - remaining);
2106 last.text.truncate(keep);
2107 remaining = 0;
2108 }
2109 }
2110}
2111
2112/// Remove all trailing whitespace. Segment-level counterpart of `Text.rstrip`.
2113fn rstrip_line(line: &mut Vec<Segment>) {
2114 right_crop_line(line, trailing_whitespace(line));
2115}
2116
2117/// Remove *only as much* trailing whitespace as it takes to get the line down to
2118/// `size`, leaving the rest. Segment-level counterpart of `Text.rstrip_end`.
2119///
2120/// The length compared against `size` is a **character** count, not a cell
2121/// count: upstream's `Text.rstrip_end` uses `len(self)`, which is
2122/// `len(self.plain)`. The two only diverge on wide characters, and copying the
2123/// quirk is cheaper than explaining a one-column difference later.
2124fn rstrip_end_line(line: &mut Vec<Segment>, size: usize) {
2125 let length: usize = line.iter().map(|s| s.text.chars().count()).sum();
2126 let Some(excess) = length.checked_sub(size).filter(|excess| *excess > 0) else {
2127 return;
2128 };
2129 let whitespace = trailing_whitespace(line);
2130 if whitespace > 0 {
2131 right_crop_line(line, whitespace.min(excess));
2132 }
2133}
2134
2135/// `Text.__rich_measure__` on a plain string: `(widest word, widest line)`.
2136pub(crate) fn measure_plain(plain: &str) -> (usize, usize) {
2137 // `text.splitlines()` and `text.split()`: Python's line boundaries
2138 // (U+2028, U+0085, …) and whitespace (including U+001C..U+001F), not
2139 // just `\n` and Rust's `White_Space`.
2140 let max_line = python_splitlines(plain)
2141 .into_iter()
2142 .map(cell_len)
2143 .max()
2144 .unwrap_or(0);
2145 let min_word = plain
2146 .split(is_python_space)
2147 .filter(|word| !word.is_empty())
2148 .map(cell_len)
2149 .max()
2150 .unwrap_or(max_line);
2151 (min_word, max_line)
2152}
2153
2154/// Python's `str.isspace()`: Rust's `White_Space` plus the four ASCII
2155/// information separators U+001C..=U+001F.
2156pub(crate) fn is_python_space(c: char) -> bool {
2157 c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c)
2158}
2159
2160/// Port of Python's `str.splitlines()`: every Unicode line boundary ends a
2161/// line, `\r\n` counts once, and a trailing boundary adds no empty line.
2162pub(crate) fn python_splitlines(text: &str) -> Vec<&str> {
2163 let mut lines = Vec::new();
2164 let mut start = 0;
2165 let mut chars = text.char_indices().peekable();
2166 while let Some((i, c)) = chars.next() {
2167 if matches!(
2168 c,
2169 '\n' | '\r'
2170 | '\x0b'
2171 | '\x0c'
2172 | '\x1c'
2173 | '\x1d'
2174 | '\x1e'
2175 | '\u{85}'
2176 | '\u{2028}'
2177 | '\u{2029}'
2178 ) {
2179 lines.push(&text[start..i]);
2180 start = i + c.len_utf8();
2181 if c == '\r' && chars.peek().map(|&(_, n)| n) == Some('\n') {
2182 chars.next();
2183 start += 1;
2184 }
2185 }
2186 }
2187 if start < text.len() {
2188 lines.push(&text[start..]);
2189 }
2190 lines
2191}
2192
2193#[cfg(test)]
2194mod tests {
2195 use super::*;
2196
2197 /// Each divided line's text and its spans as (start, end, style).
2198 type Divided = Vec<(String, Vec<(usize, usize, String)>)>;
2199
2200 /// `divide` as it was before the per-span line search: every line for
2201 /// every span. The search must give the same lines, spans and span order.
2202 fn divide_by_scanning(text: &Text, offsets: &[usize]) -> Divided {
2203 if offsets.is_empty() {
2204 let spans = text
2205 .spans
2206 .iter()
2207 .map(|s| (s.start, s.end, format!("{:?}", s.style)))
2208 .collect();
2209 return vec![(text.plain.clone(), spans)];
2210 }
2211 let mut bounds = vec![0];
2212 bounds.extend(offsets.iter().copied());
2213 bounds.push(text.plain.len());
2214 let mut lines: Divided = bounds
2215 .windows(2)
2216 .map(|w| {
2217 let (start, end) = (w[0].min(text.plain.len()), w[1].min(text.plain.len()));
2218 let plain = if start < end {
2219 text.plain[start..end].to_string()
2220 } else {
2221 String::new()
2222 };
2223 (plain, Vec::new())
2224 })
2225 .collect();
2226 for span in &text.spans {
2227 for (index, window) in bounds.windows(2).enumerate() {
2228 let (line_start, line_end) = (window[0], window[1]);
2229 let new_start = span.start.max(line_start) - line_start;
2230 let new_end = span.end.min(line_end).saturating_sub(line_start);
2231 if new_end > new_start {
2232 lines[index]
2233 .1
2234 .push((new_start, new_end, format!("{:?}", span.style)));
2235 }
2236 }
2237 }
2238 lines
2239 }
2240
2241 #[test]
2242 fn divide_matches_the_full_scan_for_sorted_offsets() {
2243 let mut state = 0x5eed_u64;
2244 let mut next = |bound: usize| {
2245 state = state
2246 .wrapping_mul(6364136223846793005)
2247 .wrapping_add(1442695040888963407);
2248 (state >> 33) as usize % bound.max(1)
2249 };
2250 for case in 0..500 {
2251 let len = next(40);
2252 let plain: String = (0..len)
2253 .map(|i| if i % 7 == 3 { '\n' } else { 'a' })
2254 .collect();
2255 let mut text = Text::new(plain.clone());
2256 for i in 0..next(12) {
2257 // Empty, overlapping and past-the-end spans included.
2258 let start = next(len + 3);
2259 let end = start + next(len + 3);
2260 text.spans.push(Span {
2261 start,
2262 end,
2263 style: format!("s{i}").as_str().into(),
2264 });
2265 }
2266 let mut offsets: Vec<usize> = (0..next(10)).map(|_| next(len + 4)).collect();
2267 offsets.sort_unstable();
2268 let divided: Divided = text
2269 .divide(&offsets)
2270 .into_iter()
2271 .map(|line| {
2272 let spans = line
2273 .spans
2274 .iter()
2275 .map(|s| (s.start, s.end, format!("{:?}", s.style)))
2276 .collect();
2277 (line.plain, spans)
2278 })
2279 .collect();
2280 assert_eq!(
2281 divided,
2282 divide_by_scanning(&text, &offsets),
2283 "case {case}: {plain:?} {offsets:?}"
2284 );
2285 }
2286 }
2287
2288 /// An unbroken run of VS16 emoji must fold at the width like anything else.
2289 /// Measured per code point it did not: the heart reads one cell and the
2290 /// variation selector zero, so twenty hearts "fit" in thirty cells and came
2291 /// back as a single forty-cell row — wide enough to punch through the panel
2292 /// or table border drawn around it.
2293 ///
2294 /// Real rich 15.0.0, `[cell_len(l.plain) for l in Text("❤️"*20).wrap(c, 30)]`
2295 /// is `[30, 10]`.
2296 #[test]
2297 fn an_emoji_run_folds_at_the_width_instead_of_overflowing() {
2298 let hearts = "\u{2764}\u{fe0f}".repeat(20);
2299 let widths: Vec<usize> = wrapped_plain(&Text::new(&hearts), 30)
2300 .iter()
2301 .map(|line| cell_len(line))
2302 .collect();
2303 assert_eq!(widths, vec![30, 10]);
2304 }
2305
2306 /// Upstream wraps and justifies **one hard line at a time**, so the line
2307 /// full justification leaves ragged is the last of each paragraph — not just
2308 /// the last of the whole text. Flattening first stretched every paragraph
2309 /// but the final one.
2310 ///
2311 /// Real rich 15.0.0, `Text(case, justify="full").wrap(console, width)`:
2312 ///
2313 /// ```text
2314 /// width 10 -> ['word', ' indented', 'line here', 'last']
2315 /// width 30 -> ['word', ' indented line here', 'last'] (no_wrap)
2316 /// ```
2317 ///
2318 /// `line here` is the giveaway: it ends its paragraph, so upstream leaves
2319 /// the single gap alone where we widened it to `line here`.
2320 #[test]
2321 fn full_justify_leaves_each_paragraphs_last_line_ragged() {
2322 let text = Text::new("word\n indented line here\nlast").justify(Justify::Full);
2323 assert_eq!(
2324 wrapped_plain(&text, 10),
2325 vec!["word", " indented", "line here", "last"]
2326 );
2327 let no_wrap = Text::new("word\n indented line here\nlast")
2328 .justify(Justify::Full)
2329 .no_wrap(true);
2330 assert_eq!(
2331 wrapped_plain(&no_wrap, 30),
2332 vec!["word", " indented line here", "last"]
2333 );
2334 }
2335
2336 /// `overflow="ignore"` is a hard stop in upstream's `Text.wrap`: the line is
2337 /// appended verbatim and the loop `continue`s, so it is neither justified nor
2338 /// truncated. Padding it out to the width added trailing spaces to content
2339 /// upstream returns byte-for-byte.
2340 ///
2341 /// Real rich 15.0.0, `Text(case, justify=…, overflow="ignore").wrap(c, w)`:
2342 ///
2343 /// ```text
2344 /// left 'hello' @12 -> ['hello']
2345 /// center 'hello' @12 -> ['hello']
2346 /// right 'trailing ' @3 -> ['trailing ']
2347 /// ```
2348 #[test]
2349 fn overflow_ignore_is_neither_justified_nor_truncated() {
2350 for justify in [Justify::Left, Justify::Center, Justify::Right] {
2351 let text = Text::new("hello")
2352 .justify(justify)
2353 .overflow(Overflow::Ignore);
2354 assert_eq!(wrapped_plain(&text, 12), vec!["hello"], "{justify:?}");
2355 }
2356 let text = Text::new("trailing ")
2357 .justify(Justify::Right)
2358 .overflow(Overflow::Ignore);
2359 assert_eq!(wrapped_plain(&text, 3), vec!["trailing "]);
2360 }
2361
2362 /// Upstream's `Lines.justify` truncates *inside* its center and right
2363 /// branches, before it pads. The order matters because cell width is not
2364 /// additive across a cut: a leading zero-width joiner eats the character
2365 /// after it, so chopping the line's tail hands a cell back and the line then
2366 /// wants padding it did not want before. Padding first measures the un-cut
2367 /// line, finds no slack, and leaves the line a column short.
2368 ///
2369 /// Real rich 15.0.0:
2370 ///
2371 /// ```text
2372 /// right '┬┴⠁├╰⠃⡁⠃╯⠆┴' @9, ellipsis -> [' ┬┴⠁├╰⠃⡁⠃…']
2373 /// right '⠃╯⠆┴' @2, crop, no_wrap -> [' ⠃╯']
2374 /// center 's 8-o🧠e' @4, ellipsis -> [' s ', '8-o… ']
2375 /// ```
2376 #[test]
2377 fn center_and_right_truncate_before_they_pad() {
2378 let text = Text::new("\u{200d}┬┴⠁├╰⠃⡁⠃╯⠆┴")
2379 .justify(Justify::Right)
2380 .overflow(Overflow::Ellipsis);
2381 assert_eq!(wrapped_plain(&text, 9), vec![" \u{200d}┬┴⠁├╰⠃⡁⠃…"]);
2382
2383 let cropped = Text::new("\u{200d}⠃╯⠆┴")
2384 .justify(Justify::Right)
2385 .overflow(Overflow::Crop)
2386 .no_wrap(true);
2387 assert_eq!(wrapped_plain(&cropped, 2), vec![" \u{200d}⠃╯"]);
2388
2389 let centered = Text::new("s \u{200d}8-o\u{1f9e0}e")
2390 .justify(Justify::Center)
2391 .overflow(Overflow::Ellipsis);
2392 assert_eq!(wrapped_plain(¢ered, 4), vec![" s ", "\u{200d}8-o… "]);
2393 }
2394
2395 /// A line is measured and cut as one string, the way upstream's
2396 /// `Text.truncate` works on `self.plain` — not segment by segment. Cell
2397 /// width is not additive across a segment boundary: full justification
2398 /// splits the line into one segment per word, which strands the zero-width
2399 /// joiner at the end of `π` away from the space it swallows, so the
2400 /// per-segment sum reads eight cells for a seven-cell line and an ellipsis
2401 /// eats a character upstream keeps.
2402 ///
2403 /// Real rich 15.0.0,
2404 /// `Text("⚠1️;& π ψ\u{a0}τ\u{a0}γ ⡀", justify="full", overflow="ellipsis").wrap(c, 7)`:
2405 ///
2406 /// ```text
2407 /// ['⚠1️;& ', 'π ψ τ γ', '⡀'] with cell widths [7, 7, 1]
2408 /// ```
2409 #[test]
2410 fn a_line_is_measured_whole_not_segment_by_segment() {
2411 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}")
2412 .justify(Justify::Full)
2413 .overflow(Overflow::Ellipsis);
2414 assert_eq!(
2415 wrapped_plain(&text, 7),
2416 vec![
2417 "\u{26a0}1\u{fe0f};&\u{3000}",
2418 "\u{3c0}\u{200d} \u{3c8}\u{a0}\u{3c4}\u{a0}\u{3b3}",
2419 "\u{2840}"
2420 ]
2421 );
2422 }
2423
2424 /// Full justification widens the gaps between words so every line but the
2425 /// last fills the width exactly.
2426 ///
2427 /// Captured verbatim from real rich 15.0.0 —
2428 /// `Lines.justify(console, 20, justify="full")` on
2429 /// `"aaa bbb ccc ddddddddddddddddddd ee ff"` yields:
2430 ///
2431 /// ```text
2432 /// 'aaa bbb ccc' <- stretched to exactly 20
2433 /// 'ddddddddddddddddddd' <- one word: nothing to widen, and the
2434 /// trailing space wrapping left is dropped
2435 /// 'ee ff' <- final line untouched: NOT padded to width
2436 /// ```
2437 ///
2438 /// Two details worth pinning: the slack is handed out from the rightmost
2439 /// gap backwards (so the gaps are 5 then 6, not 6 then 5), and the last
2440 /// line is the one case where a justified line is left short of the width.
2441 #[test]
2442 fn full_justify_matches_upstream() {
2443 let text = Text::new("aaa bbb ccc ddddddddddddddddddd ee ff").justify(Justify::Full);
2444 let plain: Vec<String> = text
2445 .render_lines(&Theme::default_theme(), &Style::new(), Some(20))
2446 .iter()
2447 .map(|line| line.iter().map(|s| s.text.as_str()).collect())
2448 .collect();
2449 assert_eq!(
2450 plain,
2451 vec!["aaa bbb ccc", "ddddddddddddddddddd", "ee ff"]
2452 );
2453 assert_eq!(plain[0].chars().count(), 20);
2454 }
2455
2456 fn wrapped_plain(text: &Text, width: usize) -> Vec<String> {
2457 text.render_lines(&Theme::default_theme(), &Style::new(), Some(width))
2458 .iter()
2459 .map(|line| line.iter().map(|s| s.text.as_str()).collect())
2460 .collect()
2461 }
2462
2463 /// Wrapping hands each line the space that ended it, and upstream's
2464 /// `Lines.justify` throws that space away (`line.rstrip()`) before centring
2465 /// or right-aligning — but *not* before left-aligning or full-justifying.
2466 ///
2467 /// Captured verbatim from real rich 15.0.0,
2468 /// `Text(case, justify=…).wrap(console, 20)`:
2469 ///
2470 /// ```text
2471 /// center 'abcd efgh ijklmnop qrst' -> ' abcd efgh ijklmnop '
2472 /// right 'abcd efgh ijklmnop qrst' -> ' abcd efgh ijklmnop'
2473 /// right 'aaaa bbbb cccc dddd eeee' -> ' aaaa bbbb cccc dddd'
2474 /// left 'abcd efgh ijklmnop qrst' -> 'abcd efgh ijklmnop '
2475 /// ```
2476 ///
2477 /// Counting the wrap space as content puts the centred line one column too
2478 /// far left and the right-aligned line a whole column short of the edge.
2479 #[test]
2480 fn center_and_right_rstrip_the_wrap_space() {
2481 let wrapped = "abcd efgh ijklmnop qrst";
2482 assert_eq!(
2483 wrapped_plain(&Text::new(wrapped).justify(Justify::Center), 20)[0],
2484 " abcd efgh ijklmnop "
2485 );
2486 assert_eq!(
2487 wrapped_plain(&Text::new(wrapped).justify(Justify::Right), 20)[0],
2488 " abcd efgh ijklmnop"
2489 );
2490 assert_eq!(
2491 wrapped_plain(
2492 &Text::new("aaaa bbbb cccc dddd eeee").justify(Justify::Right),
2493 20
2494 )[0],
2495 " aaaa bbbb cccc dddd"
2496 );
2497 // Left is the control: upstream pads it without stripping, so the
2498 // trailing space stays part of the line and nothing shifts.
2499 assert_eq!(
2500 wrapped_plain(&Text::new(wrapped).justify(Justify::Left), 20)[0],
2501 "abcd efgh ijklmnop "
2502 );
2503 }
2504
2505 /// `divide_line` measures a word *with* its trailing space, so a line whose
2506 /// last word ends flush with the width comes back one character too long.
2507 /// Upstream's `Text.wrap` calls `rstrip_end(width)` on every divided line to
2508 /// hand that back before overflow is applied.
2509 ///
2510 /// Real rich 15.0.0, `Text(case, overflow="ellipsis").wrap(console, 10)`:
2511 ///
2512 /// ```text
2513 /// 'abcdefghij more' -> ['abcdefghij', 'more'] <- no ellipsis
2514 /// 'abcdefghijkl more' -> ['abcdefghi…', 'more'] <- genuinely too long
2515 /// ```
2516 ///
2517 /// Skipping the rstrip makes the first case measure 11 cells, so the
2518 /// ellipsis fires and eats the `j` that upstream keeps.
2519 #[test]
2520 fn rstrip_end_stops_the_wrap_space_from_triggering_an_ellipsis() {
2521 assert_eq!(
2522 wrapped_plain(
2523 &Text::new("abcdefghij more").overflow(Overflow::Ellipsis),
2524 10
2525 ),
2526 vec!["abcdefghij", "more"]
2527 );
2528 assert_eq!(
2529 wrapped_plain(
2530 &Text::new("abcdefghijkl more").overflow(Overflow::Ellipsis),
2531 10
2532 ),
2533 vec!["abcdefghi…", "more"]
2534 );
2535 }
2536
2537 /// `Text.apply_meta` / `Text.on`, as rich 15.0.0 records them.
2538 #[test]
2539 fn apply_meta_and_on_add_meta_spans() {
2540 use crate::style::{Meta, MetaValue};
2541 let mut text = Text::new("hello");
2542 text.apply_meta([("a", MetaValue::Int(1))].into_iter().collect(), 1, Some(3));
2543 text.on(None, &[("click", MetaValue::Str("x".into()))]);
2544 text.apply_meta(Meta::new(), 0, None);
2545 let spans = text.spans();
2546 assert_eq!(spans.len(), 2);
2547 assert_eq!((spans[0].start, spans[0].end), (1, 3));
2548 let meta = |span: &Span| match &span.style {
2549 StyleType::Style(style) => style.meta(),
2550 StyleType::Name(_) => Meta::new(),
2551 };
2552 assert_eq!(
2553 meta(&spans[0]),
2554 [("a", MetaValue::Int(1))].into_iter().collect()
2555 );
2556 assert_eq!(
2557 meta(&spans[1]),
2558 [("@click", MetaValue::Str("x".into()))]
2559 .into_iter()
2560 .collect()
2561 );
2562 }
2563
2564 #[test]
2565 fn append_creates_spans() {
2566 let mut text = Text::new("");
2567 text.append("hello", Some(Style::parse("bold").unwrap().into()));
2568 text.append(" world", None);
2569 assert_eq!(text.plain(), "hello world");
2570 assert_eq!(text.spans().len(), 1);
2571 }
2572
2573 #[test]
2574 fn render_flattens_overlapping_spans() {
2575 let mut text = Text::new("abcdef");
2576 text.stylize(Style::parse("bold").unwrap(), 0, 4);
2577 text.stylize(Style::parse("red").unwrap(), 2, 6);
2578 let segments = text.render(&Theme::default_theme(), &Style::new());
2579 // Boundaries at 0,2,4,6 -> "ab"(bold) "cd"(bold+red) "ef"(red)
2580 let rendered: Vec<_> = segments.iter().map(|s| s.text.clone()).collect();
2581 assert_eq!(rendered, vec!["ab", "cd", "ef"]);
2582 }
2583
2584 /// `Text::truncate` on its own, against real rich 15.0.0. `fold` and `crop`
2585 /// deliberately agree: folding is a wrapping behaviour, and truncation has
2586 /// no line to fold onto.
2587 #[test]
2588 fn truncate_matches_upstream() {
2589 for (overflow, expected) in [
2590 (Overflow::Fold, "hello"),
2591 (Overflow::Crop, "hello"),
2592 (Overflow::Ellipsis, "hell…"),
2593 (Overflow::Ignore, "hello world"),
2594 ] {
2595 let mut text = Text::new("hello world");
2596 text.truncate(5, Some(overflow), false);
2597 assert_eq!(text.plain(), expected, "overflow {overflow:?}");
2598 }
2599 }
2600
2601 /// `pad` fills out to the width, but only when the text is short — a text
2602 /// that is already too long is cut, never padded.
2603 #[test]
2604 fn truncate_pads_only_when_short() {
2605 let mut short = Text::new("hi");
2606 short.truncate(6, Some(Overflow::Crop), true);
2607 assert_eq!(short.plain(), "hi ");
2608
2609 let mut exact = Text::new("hi");
2610 exact.truncate(2, Some(Overflow::Crop), true);
2611 assert_eq!(exact.plain(), "hi");
2612 }
2613
2614 /// Truncating must not leave a span pointing past the end of the string.
2615 #[test]
2616 fn truncate_trims_dangling_spans() {
2617 let mut text = Text::new("hello world");
2618 text.stylize(Style::parse("bold").unwrap(), 6, 11);
2619 text.stylize(Style::parse("red").unwrap(), 0, 5);
2620 text.truncate(3, Some(Overflow::Crop), false);
2621 assert_eq!(text.plain(), "hel");
2622 // The "world" span starts past the new end and is dropped entirely; the
2623 // "hello" span survives, clamped.
2624 assert_eq!(text.spans().len(), 1);
2625 assert!(text.spans().iter().all(|s| s.end <= text.plain().len()));
2626 }
2627
2628 use crate::protocol::Renderable;
2629
2630 /// The overflow method may come from the text or from the console options,
2631 /// and the text's own setting wins — mirroring upstream's
2632 /// `self.overflow or options.overflow or DEFAULT_OVERFLOW`.
2633 #[test]
2634 fn text_overflow_beats_console_options() {
2635 let console = crate::Console::builder().width(8).build();
2636 let mut options = console.options();
2637 options.overflow = Some(Overflow::Ellipsis);
2638 options.no_wrap = Some(true);
2639
2640 // Nothing set on the text: the options decide.
2641 let from_options = Text::new("the quick brown fox");
2642 assert_eq!(
2643 plain_of(&from_options.rich_render(&console, &options)),
2644 "the qui…"
2645 );
2646
2647 // Set on the text: the text decides, and the options are ignored.
2648 let from_text = Text::new("the quick brown fox").overflow(Overflow::Crop);
2649 assert_eq!(
2650 plain_of(&from_text.rich_render(&console, &options)),
2651 "the quic"
2652 );
2653 }
2654
2655 /// With no overflow anywhere, upstream's default applies: fold.
2656 #[test]
2657 fn overflow_defaults_to_fold() {
2658 let console = crate::Console::builder().width(8).build();
2659 let text = Text::new("supercalifragilistic");
2660 let rendered = plain_of(&text.rich_render(&console, &console.options()));
2661 assert_eq!(rendered, "supercal\nifragili\nstic");
2662 }
2663
2664 /// Concatenate the visible text of a segment stream, for assertions that
2665 /// care about layout rather than styling.
2666 fn plain_of(segments: &[Segment]) -> String {
2667 segments
2668 .iter()
2669 .filter(|s| !s.control)
2670 .map(|s| s.text.as_str())
2671 .collect()
2672 }
2673
2674 /// `Text::new` stripped control codes but `append` did not, so every path
2675 /// that builds text incrementally — Markdown, Syntax, plain files — leaked
2676 /// them to the terminal. A backspace run is a spoofing tool: the reader sees
2677 /// the overwritten text, not what the file says.
2678 #[test]
2679 fn append_strips_control_codes_like_new() {
2680 let mut text = Text::new("");
2681 text.append("FAILED\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}PASSED", None);
2682 assert_eq!(text.plain(), "FAILEDPASSED");
2683
2684 for code in ['\u{7}', '\u{8}', '\u{b}', '\u{c}', '\u{d}'] {
2685 let mut text = Text::new("");
2686 text.append(&format!("a{code}b"), None);
2687 assert_eq!(text.plain(), "ab", "control code {code:?} survived append");
2688 }
2689 }
2690
2691 /// Upstream's `strip_control_codes` keeps NUL and ESC; only BEL, backspace,
2692 /// vertical tab, form feed and carriage return go.
2693 #[test]
2694 fn append_keeps_the_codes_upstream_keeps() {
2695 let mut text = Text::new("");
2696 text.append("a\u{0}b\u{1b}c", None);
2697 assert_eq!(text.plain(), "a\u{0}b\u{1b}c");
2698 }
2699
2700 /// A resolved style is written as its definition, a null one adds no tag,
2701 /// and a style that sets only metadata is truthy, so it is written as
2702 /// `none` — `str(Style)` upstream.
2703 #[test]
2704 fn markup_writes_resolved_styles_as_definitions() {
2705 let mut text = Text::styled("a[b]", Style::parse("bold").unwrap());
2706 text.stylize(Style::parse("red on blue").unwrap(), 0, 1);
2707 assert_eq!(
2708 text.markup(),
2709 "[bold][red on blue]a[/red on blue]\\[b][/bold]"
2710 );
2711
2712 let mut text = Text::new("ab");
2713 text.spans_mut().push(Span {
2714 start: 0,
2715 end: 1,
2716 style: StyleType::Style(Style::new()),
2717 });
2718 assert_eq!(text.markup(), "ab");
2719
2720 use crate::style::MetaValue;
2721 let mut text = Text::new("ab");
2722 text.apply_meta([("k", MetaValue::Int(1))].into_iter().collect(), 0, Some(1));
2723 assert_eq!(text.markup(), "[none]a[/none]b");
2724 }
2725
2726 /// The markup re-parses to the same plain text and spans. (A trailing
2727 /// backslash does not: `escape` doubles it, upstream too.)
2728 #[test]
2729 fn markup_round_trips_through_from_markup() {
2730 let source = "[bold]a[i]b\\[c][/i][/bold] [link=https://x.y]宽[/link]";
2731 let text = Text::from_markup(source).unwrap();
2732 let again = Text::from_markup(&text.markup()).unwrap();
2733 assert_eq!(again.plain(), text.plain());
2734 assert_eq!(again.spans(), text.spans());
2735 }
2736}