codekingpro/portable-devtools
114k
1import re2from functools import partial, reduce3from math import gcd4from operator import itemgetter5from typing import (6 TYPE_CHECKING,7 Any,8 Callable,9 Dict,10 Iterable,11 List,12 NamedTuple,13 Optional,14 Tuple,15 Union,16)17 18from ._loop import loop_last19from ._pick import pick_bool20from ._wrap import divide_line21from .align import AlignMethod22from .cells import cell_len, set_cell_size23from .containers import Lines24from .control import strip_control_codes25from .emoji import EmojiVariant26from .jupyter import JupyterMixin27from .measure import Measurement28from .segment import Segment29from .style import Style, StyleType30 31if TYPE_CHECKING: # pragma: no cover32 from .console import Console, ConsoleOptions, JustifyMethod, OverflowMethod33 34DEFAULT_JUSTIFY: "JustifyMethod" = "default"35DEFAULT_OVERFLOW: "OverflowMethod" = "fold"36 37 38_re_whitespace = re.compile(r"\s+$")39 40TextType = Union[str, "Text"]41"""A plain string or a :class:`Text` instance."""42 43GetStyleCallable = Callable[[str], Optional[StyleType]]44 45 46class Span(NamedTuple):47 """A marked up region in some text."""48 49 start: int50 """Span start index."""51 end: int52 """Span end index."""53 style: Union[str, Style]54 """Style associated with the span."""55 56 def __repr__(self) -> str:57 return f"Span({self.start}, {self.end}, {self.style!r})"58 59 def __bool__(self) -> bool:60 return self.end > self.start61 62 def split(self, offset: int) -> Tuple["Span", Optional["Span"]]:63 """Split a span in to 2 from a given offset."""64 65 if offset < self.start:66 return self, None67 if offset >= self.end:68 return self, None69 70 start, end, style = self71 span1 = Span(start, min(end, offset), style)72 span2 = Span(span1.end, end, style)73 return span1, span274 75 def move(self, offset: int) -> "Span":76 """Move start and end by a given offset.77 78 Args:79 offset (int): Number of characters to add to start and end.80 81 Returns:82 TextSpan: A new TextSpan with adjusted position.83 """84 start, end, style = self85 return Span(start + offset, end + offset, style)86 87 def right_crop(self, offset: int) -> "Span":88 """Crop the span at the given offset.89 90 Args:91 offset (int): A value between start and end.92 93 Returns:94 Span: A new (possibly smaller) span.95 """96 start, end, style = self97 if offset >= end:98 return self99 return Span(start, min(offset, end), style)100 101 def extend(self, cells: int) -> "Span":102 """Extend the span by the given number of cells.103 104 Args:105 cells (int): Additional space to add to end of span.106 107 Returns:108 Span: A span.109 """110 if cells:111 start, end, style = self112 return Span(start, end + cells, style)113 else:114 return self115 116 117class Text(JupyterMixin):118 """Text with color / style.119 120 Args:121 text (str, optional): Default unstyled text. Defaults to "".122 style (Union[str, Style], optional): Base style for text. Defaults to "".123 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.124 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.125 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.126 end (str, optional): Character to end text with. Defaults to "\\\\n".127 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None.128 spans (List[Span], optional). A list of predefined style spans. Defaults to None.129 """130 131 __slots__ = [132 "_text",133 "style",134 "justify",135 "overflow",136 "no_wrap",137 "end",138 "tab_size",139 "_spans",140 "_length",141 ]142 143 def __init__(144 self,145 text: str = "",146 style: Union[str, Style] = "",147 *,148 justify: Optional["JustifyMethod"] = None,149 overflow: Optional["OverflowMethod"] = None,150 no_wrap: Optional[bool] = None,151 end: str = "\n",152 tab_size: Optional[int] = None,153 spans: Optional[List[Span]] = None,154 ) -> None:155 sanitized_text = strip_control_codes(text)156 self._text = [sanitized_text]157 self.style = style158 self.justify: Optional["JustifyMethod"] = justify159 self.overflow: Optional["OverflowMethod"] = overflow160 self.no_wrap = no_wrap161 self.end = end162 self.tab_size = tab_size163 self._spans: List[Span] = spans or []164 self._length: int = len(sanitized_text)165 166 def __len__(self) -> int:167 return self._length168 169 def __bool__(self) -> bool:170 return bool(self._length)171 172 def __str__(self) -> str:173 return self.plain174 175 def __repr__(self) -> str:176 return f"<text {self.plain!r} {self._spans!r}>"177 178 def __add__(self, other: Any) -> "Text":179 if isinstance(other, (str, Text)):180 result = self.copy()181 result.append(other)182 return result183 return NotImplemented184 185 def __eq__(self, other: object) -> bool:186 if not isinstance(other, Text):187 return NotImplemented188 return self.plain == other.plain and self._spans == other._spans189 190 def __contains__(self, other: object) -> bool:191 if isinstance(other, str):192 return other in self.plain193 elif isinstance(other, Text):194 return other.plain in self.plain195 return False196 197 def __getitem__(self, slice: Union[int, slice]) -> "Text":198 def get_text_at(offset: int) -> "Text":199 _Span = Span200 text = Text(201 self.plain[offset],202 spans=[203 _Span(0, 1, style)204 for start, end, style in self._spans205 if end > offset >= start206 ],207 end="",208 )209 return text210 211 if isinstance(slice, int):212 return get_text_at(slice)213 else:214 start, stop, step = slice.indices(len(self.plain))215 if step == 1:216 lines = self.divide([start, stop])217 return lines[1]218 else:219 # This would be a bit of work to implement efficiently220 # For now, its not required221 raise TypeError("slices with step!=1 are not supported")222 223 @property224 def cell_len(self) -> int:225 """Get the number of cells required to render this text."""226 return cell_len(self.plain)227 228 @property229 def markup(self) -> str:230 """Get console markup to render this Text.231 232 Returns:233 str: A string potentially creating markup tags.234 """235 from .markup import escape236 237 output: List[str] = []238 239 plain = self.plain240 markup_spans = [241 (0, False, self.style),242 *((span.start, False, span.style) for span in self._spans),243 *((span.end, True, span.style) for span in self._spans),244 (len(plain), True, self.style),245 ]246 markup_spans.sort(key=itemgetter(0, 1))247 position = 0248 append = output.append249 for offset, closing, style in markup_spans:250 if offset > position:251 append(escape(plain[position:offset]))252 position = offset253 if style:254 append(f"[/{style}]" if closing else f"[{style}]")255 markup = "".join(output)256 return markup257 258 @classmethod259 def from_markup(260 cls,261 text: str,262 *,263 style: Union[str, Style] = "",264 emoji: bool = True,265 emoji_variant: Optional[EmojiVariant] = None,266 justify: Optional["JustifyMethod"] = None,267 overflow: Optional["OverflowMethod"] = None,268 end: str = "\n",269 ) -> "Text":270 """Create Text instance from markup.271 272 Args:273 text (str): A string containing console markup.274 style (Union[str, Style], optional): Base style for text. Defaults to "".275 emoji (bool, optional): Also render emoji code. Defaults to True.276 emoji_variant (str, optional): Optional emoji variant, either "text" or "emoji". Defaults to None.277 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.278 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.279 end (str, optional): Character to end text with. Defaults to "\\\\n".280 281 Returns:282 Text: A Text instance with markup rendered.283 """284 from .markup import render285 286 rendered_text = render(text, style, emoji=emoji, emoji_variant=emoji_variant)287 rendered_text.justify = justify288 rendered_text.overflow = overflow289 rendered_text.end = end290 return rendered_text291 292 @classmethod293 def from_ansi(294 cls,295 text: str,296 *,297 style: Union[str, Style] = "",298 justify: Optional["JustifyMethod"] = None,299 overflow: Optional["OverflowMethod"] = None,300 no_wrap: Optional[bool] = None,301 end: str = "\n",302 tab_size: Optional[int] = 8,303 ) -> "Text":304 """Create a Text object from a string containing ANSI escape codes.305 306 Args:307 text (str): A string containing escape codes.308 style (Union[str, Style], optional): Base style for text. Defaults to "".309 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.310 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.311 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.312 end (str, optional): Character to end text with. Defaults to "\\\\n".313 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None.314 """315 from .ansi import AnsiDecoder316 317 joiner = Text(318 "\n",319 justify=justify,320 overflow=overflow,321 no_wrap=no_wrap,322 end=end,323 tab_size=tab_size,324 style=style,325 )326 decoder = AnsiDecoder()327 result = joiner.join(line for line in decoder.decode(text))328 return result329 330 @classmethod331 def styled(332 cls,333 text: str,334 style: StyleType = "",335 *,336 justify: Optional["JustifyMethod"] = None,337 overflow: Optional["OverflowMethod"] = None,338 ) -> "Text":339 """Construct a Text instance with a pre-applied styled. A style applied in this way won't be used340 to pad the text when it is justified.341 342 Args:343 text (str): A string containing console markup.344 style (Union[str, Style]): Style to apply to the text. Defaults to "".345 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.346 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.347 348 Returns:349 Text: A text instance with a style applied to the entire string.350 """351 styled_text = cls(text, justify=justify, overflow=overflow)352 styled_text.stylize(style)353 return styled_text354 355 @classmethod356 def assemble(357 cls,358 *parts: Union[str, "Text", Tuple[str, StyleType]],359 style: Union[str, Style] = "",360 justify: Optional["JustifyMethod"] = None,361 overflow: Optional["OverflowMethod"] = None,362 no_wrap: Optional[bool] = None,363 end: str = "\n",364 tab_size: int = 8,365 meta: Optional[Dict[str, Any]] = None,366 ) -> "Text":367 """Construct a text instance by combining a sequence of strings with optional styles.368 The positional arguments should be either strings, or a tuple of string + style.369 370 Args:371 style (Union[str, Style], optional): Base style for text. Defaults to "".372 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.373 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.374 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.375 end (str, optional): Character to end text with. Defaults to "\\\\n".376 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None.377 meta (Dict[str, Any], optional). Meta data to apply to text, or None for no meta data. Default to None378 379 Returns:380 Text: A new text instance.381 """382 text = cls(383 style=style,384 justify=justify,385 overflow=overflow,386 no_wrap=no_wrap,387 end=end,388 tab_size=tab_size,389 )390 append = text.append391 _Text = Text392 for part in parts:393 if isinstance(part, (_Text, str)):394 append(part)395 else:396 append(*part)397 if meta:398 text.apply_meta(meta)399 return text400 401 @property402 def plain(self) -> str:403 """Get the text as a single string."""404 if len(self._text) != 1:405 self._text[:] = ["".join(self._text)]406 return self._text[0]407 408 @plain.setter409 def plain(self, new_text: str) -> None:410 """Set the text to a new value."""411 if new_text != self.plain:412 sanitized_text = strip_control_codes(new_text)413 self._text[:] = [sanitized_text]414 old_length = self._length415 self._length = len(sanitized_text)416 if old_length > self._length:417 self._trim_spans()418 419 @property420 def spans(self) -> List[Span]:421 """Get a reference to the internal list of spans."""422 return self._spans423 424 @spans.setter425 def spans(self, spans: List[Span]) -> None:426 """Set spans."""427 self._spans = spans[:]428 429 def blank_copy(self, plain: str = "") -> "Text":430 """Return a new Text instance with copied metadata (but not the string or spans)."""431 copy_self = Text(432 plain,433 style=self.style,434 justify=self.justify,435 overflow=self.overflow,436 no_wrap=self.no_wrap,437 end=self.end,438 tab_size=self.tab_size,439 )440 return copy_self441 442 def copy(self) -> "Text":443 """Return a copy of this instance."""444 copy_self = Text(445 self.plain,446 style=self.style,447 justify=self.justify,448 overflow=self.overflow,449 no_wrap=self.no_wrap,450 end=self.end,451 tab_size=self.tab_size,452 )453 copy_self._spans[:] = self._spans454 return copy_self455 456 def stylize(457 self,458 style: Union[str, Style],459 start: int = 0,460 end: Optional[int] = None,461 ) -> None:462 """Apply a style to the text, or a portion of the text.463 464 Args:465 style (Union[str, Style]): Style instance or style definition to apply.466 start (int): Start offset (negative indexing is supported). Defaults to 0.467 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.468 """469 if style:470 length = len(self)471 if start < 0:472 start = length + start473 if end is None:474 end = length475 if end < 0:476 end = length + end477 if start >= length or end <= start:478 # Span not in text or not valid479 return480 self._spans.append(Span(start, min(length, end), style))481 482 def stylize_before(483 self,484 style: Union[str, Style],485 start: int = 0,486 end: Optional[int] = None,487 ) -> None:488 """Apply a style to the text, or a portion of the text. Styles will be applied before other styles already present.489 490 Args:491 style (Union[str, Style]): Style instance or style definition to apply.492 start (int): Start offset (negative indexing is supported). Defaults to 0.493 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.494 """495 if style:496 length = len(self)497 if start < 0:498 start = length + start499 if end is None:500 end = length501 if end < 0:502 end = length + end503 if start >= length or end <= start:504 # Span not in text or not valid505 return506 self._spans.insert(0, Span(start, min(length, end), style))507 508 def apply_meta(509 self, meta: Dict[str, Any], start: int = 0, end: Optional[int] = None510 ) -> None:511 """Apply metadata to the text, or a portion of the text.512 513 Args:514 meta (Dict[str, Any]): A dict of meta information.515 start (int): Start offset (negative indexing is supported). Defaults to 0.516 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.517 518 """519 style = Style.from_meta(meta)520 self.stylize(style, start=start, end=end)521 522 def on(self, meta: Optional[Dict[str, Any]] = None, **handlers: Any) -> "Text":523 """Apply event handlers (used by Textual project).524 525 Example:526 >>> from rich.text import Text527 >>> text = Text("hello world")528 >>> text.on(click="view.toggle('world')")529 530 Args:531 meta (Dict[str, Any]): Mapping of meta information.532 **handlers: Keyword args are prefixed with "@" to defined handlers.533 534 Returns:535 Text: Self is returned to method may be chained.536 """537 meta = {} if meta is None else meta538 meta.update({f"@{key}": value for key, value in handlers.items()})539 self.stylize(Style.from_meta(meta))540 return self541 542 def remove_suffix(self, suffix: str) -> None:543 """Remove a suffix if it exists.544 545 Args:546 suffix (str): Suffix to remove.547 """548 if self.plain.endswith(suffix):549 self.right_crop(len(suffix))550 551 def get_style_at_offset(self, console: "Console", offset: int) -> Style:552 """Get the style of a character at give offset.553 554 Args:555 console (~Console): Console where text will be rendered.556 offset (int): Offset in to text (negative indexing supported)557 558 Returns:559 Style: A Style instance.560 """561 # TODO: This is a little inefficient, it is only used by full justify562 if offset < 0:563 offset = len(self) + offset564 get_style = console.get_style565 style = get_style(self.style).copy()566 for start, end, span_style in self._spans:567 if end > offset >= start:568 style += get_style(span_style, default="")569 return style570 571 def extend_style(self, spaces: int) -> None:572 """Extend the Text given number of spaces where the spaces have the same style as the last character.573 574 Args:575 spaces (int): Number of spaces to add to the Text.576 """577 if spaces <= 0:578 return579 spans = self.spans580 new_spaces = " " * spaces581 if spans:582 end_offset = len(self)583 self._spans[:] = [584 span.extend(spaces) if span.end >= end_offset else span585 for span in spans586 ]587 self._text.append(new_spaces)588 self._length += spaces589 else:590 self.plain += new_spaces591 592 def highlight_regex(593 self,594 re_highlight: str,595 style: Optional[Union[GetStyleCallable, StyleType]] = None,596 *,597 style_prefix: str = "",598 ) -> int:599 """Highlight text with a regular expression, where group names are600 translated to styles.601 602 Args:603 re_highlight (str): A regular expression.604 style (Union[GetStyleCallable, StyleType]): Optional style to apply to whole match, or a callable605 which accepts the matched text and returns a style. Defaults to None.606 style_prefix (str, optional): Optional prefix to add to style group names.607 608 Returns:609 int: Number of regex matches610 """611 count = 0612 append_span = self._spans.append613 _Span = Span614 plain = self.plain615 for match in re.finditer(re_highlight, plain):616 get_span = match.span617 if style:618 start, end = get_span()619 match_style = style(plain[start:end]) if callable(style) else style620 if match_style is not None and end > start:621 append_span(_Span(start, end, match_style))622 623 count += 1624 for name in match.groupdict().keys():625 start, end = get_span(name)626 if start != -1 and end > start:627 append_span(_Span(start, end, f"{style_prefix}{name}"))628 return count629 630 def highlight_words(631 self,632 words: Iterable[str],633 style: Union[str, Style],634 *,635 case_sensitive: bool = True,636 ) -> int:637 """Highlight words with a style.638 639 Args:640 words (Iterable[str]): Words to highlight.641 style (Union[str, Style]): Style to apply.642 case_sensitive (bool, optional): Enable case sensitive matching. Defaults to True.643 644 Returns:645 int: Number of words highlighted.646 """647 re_words = "|".join(re.escape(word) for word in words)648 add_span = self._spans.append649 count = 0650 _Span = Span651 for match in re.finditer(652 re_words, self.plain, flags=0 if case_sensitive else re.IGNORECASE653 ):654 start, end = match.span(0)655 add_span(_Span(start, end, style))656 count += 1657 return count658 659 def rstrip(self) -> None:660 """Strip whitespace from end of text."""661 self.plain = self.plain.rstrip()662 663 def rstrip_end(self, size: int) -> None:664 """Remove whitespace beyond a certain width at the end of the text.665 666 Args:667 size (int): The desired size of the text.668 """669 text_length = len(self)670 if text_length > size:671 excess = text_length - size672 whitespace_match = _re_whitespace.search(self.plain)673 if whitespace_match is not None:674 whitespace_count = len(whitespace_match.group(0))675 self.right_crop(min(whitespace_count, excess))676 677 def set_length(self, new_length: int) -> None:678 """Set new length of the text, clipping or padding is required."""679 length = len(self)680 if length != new_length:681 if length < new_length:682 self.pad_right(new_length - length)683 else:684 self.right_crop(length - new_length)685 686 def __rich_console__(687 self, console: "Console", options: "ConsoleOptions"688 ) -> Iterable[Segment]:689 tab_size: int = console.tab_size if self.tab_size is None else self.tab_size690 justify = self.justify or options.justify or DEFAULT_JUSTIFY691 692 overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW693 694 lines = self.wrap(695 console,696 options.max_width,697 justify=justify,698 overflow=overflow,699 tab_size=tab_size or 8,700 no_wrap=pick_bool(self.no_wrap, options.no_wrap, False),701 )702 all_lines = Text("\n").join(lines)703 yield from all_lines.render(console, end=self.end)704 705 def __rich_measure__(706 self, console: "Console", options: "ConsoleOptions"707 ) -> Measurement:708 text = self.plain709 lines = text.splitlines()710 max_text_width = max(cell_len(line) for line in lines) if lines else 0711 words = text.split()712 min_text_width = (713 max(cell_len(word) for word in words) if words else max_text_width714 )715 return Measurement(min_text_width, max_text_width)716 717 def render(self, console: "Console", end: str = "") -> Iterable["Segment"]:718 """Render the text as Segments.719 720 Args:721 console (Console): Console instance.722 end (Optional[str], optional): Optional end character.723 724 Returns:725 Iterable[Segment]: Result of render that may be written to the console.726 """727 _Segment = Segment728 text = self.plain729 if not self._spans:730 yield Segment(text)731 if end:732 yield _Segment(end)733 return734 get_style = partial(console.get_style, default=Style.null())735 736 enumerated_spans = list(enumerate(self._spans, 1))737 style_map = {index: get_style(span.style) for index, span in enumerated_spans}738 style_map[0] = get_style(self.style)739 740 spans = [741 (0, False, 0),742 *((span.start, False, index) for index, span in enumerated_spans),743 *((span.end, True, index) for index, span in enumerated_spans),744 (len(text), True, 0),745 ]746 spans.sort(key=itemgetter(0, 1))747 748 stack: List[int] = []749 stack_append = stack.append750 stack_pop = stack.remove751 752 style_cache: Dict[Tuple[Style, ...], Style] = {}753 style_cache_get = style_cache.get754 combine = Style.combine755 756 def get_current_style() -> Style:757 """Construct current style from stack."""758 styles = tuple(style_map[_style_id] for _style_id in sorted(stack))759 cached_style = style_cache_get(styles)760 if cached_style is not None:761 return cached_style762 current_style = combine(styles)763 style_cache[styles] = current_style764 return current_style765 766 for (offset, leaving, style_id), (next_offset, _, _) in zip(spans, spans[1:]):767 if leaving:768 stack_pop(style_id)769 else:770 stack_append(style_id)771 if next_offset > offset:772 yield _Segment(text[offset:next_offset], get_current_style())773 if end:774 yield _Segment(end)775 776 def join(self, lines: Iterable["Text"]) -> "Text":777 """Join text together with this instance as the separator.778 779 Args:780 lines (Iterable[Text]): An iterable of Text instances to join.781 782 Returns:783 Text: A new text instance containing join text.784 """785 786 new_text = self.blank_copy()787 788 def iter_text() -> Iterable["Text"]:789 if self.plain:790 for last, line in loop_last(lines):791 yield line792 if not last:793 yield self794 else:795 yield from lines796 797 extend_text = new_text._text.extend798 append_span = new_text._spans.append799 extend_spans = new_text._spans.extend800 offset = 0801 _Span = Span802 803 for text in iter_text():804 extend_text(text._text)805 if text.style:806 append_span(_Span(offset, offset + len(text), text.style))807 extend_spans(808 _Span(offset + start, offset + end, style)809 for start, end, style in text._spans810 )811 offset += len(text)812 new_text._length = offset813 return new_text814 815 def expand_tabs(self, tab_size: Optional[int] = None) -> None:816 """Converts tabs to spaces.817 818 Args:819 tab_size (int, optional): Size of tabs. Defaults to 8.820 821 """822 if "\t" not in self.plain:823 return824 if tab_size is None:825 tab_size = self.tab_size826 if tab_size is None:827 tab_size = 8828 829 new_text: List[Text] = []830 append = new_text.append831 832 for line in self.split("\n", include_separator=True):833 if "\t" not in line.plain:834 append(line)835 else:836 cell_position = 0837 parts = line.split("\t", include_separator=True)838 for part in parts:839 if part.plain.endswith("\t"):840 part._text[-1] = part._text[-1][:-1] + " "841 cell_position += part.cell_len842 tab_remainder = cell_position % tab_size843 if tab_remainder:844 spaces = tab_size - tab_remainder845 part.extend_style(spaces)846 cell_position += spaces847 else:848 cell_position += part.cell_len849 append(part)850 851 result = Text("").join(new_text)852 853 self._text = [result.plain]854 self._length = len(self.plain)855 self._spans[:] = result._spans856 857 def truncate(858 self,859 max_width: int,860 *,861 overflow: Optional["OverflowMethod"] = None,862 pad: bool = False,863 ) -> None:864 """Truncate text if it is longer that a given width.865 866 Args:867 max_width (int): Maximum number of characters in text.868 overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None, to use self.overflow.869 pad (bool, optional): Pad with spaces if the length is less than max_width. Defaults to False.870 """871 _overflow = overflow or self.overflow or DEFAULT_OVERFLOW872 if _overflow != "ignore":873 length = cell_len(self.plain)874 if length > max_width:875 if _overflow == "ellipsis":876 self.plain = set_cell_size(self.plain, max_width - 1) + "…"877 else:878 self.plain = set_cell_size(self.plain, max_width)879 if pad and length < max_width:880 spaces = max_width - length881 self._text = [f"{self.plain}{' ' * spaces}"]882 self._length = len(self.plain)883 884 def _trim_spans(self) -> None:885 """Remove or modify any spans that are over the end of the text."""886 max_offset = len(self.plain)887 _Span = Span888 self._spans[:] = [889 (890 span891 if span.end < max_offset892 else _Span(span.start, min(max_offset, span.end), span.style)893 )894 for span in self._spans895 if span.start < max_offset896 ]897 898 def pad(self, count: int, character: str = " ") -> None:899 """Pad left and right with a given number of characters.900 901 Args:902 count (int): Width of padding.903 character (str): The character to pad with. Must be a string of length 1.904 """905 assert len(character) == 1, "Character must be a string of length 1"906 if count:907 pad_characters = character * count908 self.plain = f"{pad_characters}{self.plain}{pad_characters}"909 _Span = Span910 self._spans[:] = [911 _Span(start + count, end + count, style)912 for start, end, style in self._spans913 ]914 915 def pad_left(self, count: int, character: str = " ") -> None:916 """Pad the left with a given character.917 918 Args:919 count (int): Number of characters to pad.920 character (str, optional): Character to pad with. Defaults to " ".921 """922 assert len(character) == 1, "Character must be a string of length 1"923 if count:924 self.plain = f"{character * count}{self.plain}"925 _Span = Span926 self._spans[:] = [927 _Span(start + count, end + count, style)928 for start, end, style in self._spans929 ]930 931 def pad_right(self, count: int, character: str = " ") -> None:932 """Pad the right with a given character.933 934 Args:935 count (int): Number of characters to pad.936 character (str, optional): Character to pad with. Defaults to " ".937 """938 assert len(character) == 1, "Character must be a string of length 1"939 if count:940 self.plain = f"{self.plain}{character * count}"941 942 def align(self, align: AlignMethod, width: int, character: str = " ") -> None:943 """Align text to a given width.944 945 Args:946 align (AlignMethod): One of "left", "center", or "right".947 width (int): Desired width.948 character (str, optional): Character to pad with. Defaults to " ".949 """950 self.truncate(width)951 excess_space = width - cell_len(self.plain)952 if excess_space:953 if align == "left":954 self.pad_right(excess_space, character)955 elif align == "center":956 left = excess_space // 2957 self.pad_left(left, character)958 self.pad_right(excess_space - left, character)959 else:960 self.pad_left(excess_space, character)961 962 def append(963 self, text: Union["Text", str], style: Optional[Union[str, "Style"]] = None964 ) -> "Text":965 """Add text with an optional style.966 967 Args:968 text (Union[Text, str]): A str or Text to append.969 style (str, optional): A style name. Defaults to None.970 971 Returns:972 Text: Returns self for chaining.973 """974 975 if not isinstance(text, (str, Text)):976 raise TypeError("Only str or Text can be appended to Text")977 978 if len(text):979 if isinstance(text, str):980 sanitized_text = strip_control_codes(text)981 self._text.append(sanitized_text)982 offset = len(self)983 text_length = len(sanitized_text)984 if style:985 self._spans.append(Span(offset, offset + text_length, style))986 self._length += text_length987 elif isinstance(text, Text):988 _Span = Span989 if style is not None:990 raise ValueError(991 "style must not be set when appending Text instance"992 )993 text_length = self._length994 if text.style:995 self._spans.append(996 _Span(text_length, text_length + len(text), text.style)997 )998 self._text.append(text.plain)999 self._spans.extend(1000 _Span(start + text_length, end + text_length, style)1001 for start, end, style in text._spans1002 )1003 self._length += len(text)1004 return self1005 1006 def append_text(self, text: "Text") -> "Text":1007 """Append another Text instance. This method is more performant that Text.append, but1008 only works for Text.1009 1010 Args:1011 text (Text): The Text instance to append to this instance.1012 1013 Returns:1014 Text: Returns self for chaining.1015 """1016 _Span = Span1017 text_length = self._length1018 if text.style:1019 self._spans.append(_Span(text_length, text_length + len(text), text.style))1020 self._text.append(text.plain)1021 self._spans.extend(1022 _Span(start + text_length, end + text_length, style)1023 for start, end, style in text._spans1024 )1025 self._length += len(text)1026 return self1027 1028 def append_tokens(1029 self, tokens: Iterable[Tuple[str, Optional[StyleType]]]1030 ) -> "Text":1031 """Append iterable of str and style. Style may be a Style instance or a str style definition.1032 1033 Args:1034 tokens (Iterable[Tuple[str, Optional[StyleType]]]): An iterable of tuples containing str content and style.1035 1036 Returns:1037 Text: Returns self for chaining.1038 """1039 append_text = self._text.append1040 append_span = self._spans.append1041 _Span = Span1042 offset = len(self)1043 for content, style in tokens:1044 append_text(content)1045 if style:1046 append_span(_Span(offset, offset + len(content), style))1047 offset += len(content)1048 self._length = offset1049 return self1050 1051 def copy_styles(self, text: "Text") -> None:1052 """Copy styles from another Text instance.1053 1054 Args:1055 text (Text): A Text instance to copy styles from, must be the same length.1056 """1057 self._spans.extend(text._spans)1058 1059 def split(1060 self,1061 separator: str = "\n",1062 *,1063 include_separator: bool = False,1064 allow_blank: bool = False,1065 ) -> Lines:1066 """Split rich text in to lines, preserving styles.1067 1068 Args:1069 separator (str, optional): String to split on. Defaults to "\\\\n".1070 include_separator (bool, optional): Include the separator in the lines. Defaults to False.1071 allow_blank (bool, optional): Return a blank line if the text ends with a separator. Defaults to False.1072 1073 Returns:1074 List[RichText]: A list of rich text, one per line of the original.1075 """1076 assert separator, "separator must not be empty"1077 1078 text = self.plain1079 if separator not in text:1080 return Lines([self.copy()])1081 1082 if include_separator:1083 lines = self.divide(1084 match.end() for match in re.finditer(re.escape(separator), text)1085 )1086 else:1087 1088 def flatten_spans() -> Iterable[int]:1089 for match in re.finditer(re.escape(separator), text):1090 start, end = match.span()1091 yield start1092 yield end1093 1094 lines = Lines(1095 line for line in self.divide(flatten_spans()) if line.plain != separator1096 )1097 1098 if not allow_blank and text.endswith(separator):1099 lines.pop()1100 1101 return lines1102 1103 def divide(self, offsets: Iterable[int]) -> Lines:1104 """Divide text in to a number of lines at given offsets.1105 1106 Args:1107 offsets (Iterable[int]): Offsets used to divide text.1108 1109 Returns:1110 Lines: New RichText instances between offsets.1111 """1112 _offsets = list(offsets)1113 1114 if not _offsets:1115 return Lines([self.copy()])1116 1117 text = self.plain1118 text_length = len(text)1119 divide_offsets = [0, *_offsets, text_length]1120 line_ranges = list(zip(divide_offsets, divide_offsets[1:]))1121 1122 style = self.style1123 justify = self.justify1124 overflow = self.overflow1125 _Text = Text1126 new_lines = Lines(1127 _Text(1128 text[start:end],1129 style=style,1130 justify=justify,1131 overflow=overflow,1132 )1133 for start, end in line_ranges1134 )1135 if not self._spans:1136 return new_lines1137 1138 _line_appends = [line._spans.append for line in new_lines._lines]1139 line_count = len(line_ranges)1140 _Span = Span1141 1142 for span_start, span_end, style in self._spans:1143 lower_bound = 01144 upper_bound = line_count1145 start_line_no = (lower_bound + upper_bound) // 21146 1147 while True:1148 line_start, line_end = line_ranges[start_line_no]1149 if span_start < line_start:1150 upper_bound = start_line_no - 11151 elif span_start > line_end:1152 lower_bound = start_line_no + 11153 else:1154 break1155 start_line_no = (lower_bound + upper_bound) // 21156 1157 if span_end < line_end:1158 end_line_no = start_line_no1159 else:1160 end_line_no = lower_bound = start_line_no1161 upper_bound = line_count1162 1163 while True:1164 line_start, line_end = line_ranges[end_line_no]1165 if span_end < line_start:1166 upper_bound = end_line_no - 11167 elif span_end > line_end:1168 lower_bound = end_line_no + 11169 else:1170 break1171 end_line_no = (lower_bound + upper_bound) // 21172 1173 for line_no in range(start_line_no, end_line_no + 1):1174 line_start, line_end = line_ranges[line_no]1175 new_start = max(0, span_start - line_start)1176 new_end = min(span_end - line_start, line_end - line_start)1177 if new_end > new_start:1178 _line_appends[line_no](_Span(new_start, new_end, style))1179 1180 return new_lines1181 1182 def right_crop(self, amount: int = 1) -> None:1183 """Remove a number of characters from the end of the text."""1184 max_offset = len(self.plain) - amount1185 _Span = Span1186 self._spans[:] = [1187 (1188 span1189 if span.end < max_offset1190 else _Span(span.start, min(max_offset, span.end), span.style)1191 )1192 for span in self._spans1193 if span.start < max_offset1194 ]1195 self._text = [self.plain[:-amount]]1196 self._length -= amount1197 1198 def wrap(1199 self,1200 console: "Console",