Aluode/PerceptionLabPortable
0
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 Pattern,15 Tuple,16 Union,17)18 19from ._loop import loop_last20from ._pick import pick_bool21from ._wrap import divide_line22from .align import AlignMethod23from .cells import cell_len, set_cell_size24from .containers import Lines25from .control import strip_control_codes26from .emoji import EmojiVariant27from .jupyter import JupyterMixin28from .measure import Measurement29from .segment import Segment30from .style import Style, StyleType31 32if TYPE_CHECKING: # pragma: no cover33 from .console import Console, ConsoleOptions, JustifyMethod, OverflowMethod34 35DEFAULT_JUSTIFY: "JustifyMethod" = "default"36DEFAULT_OVERFLOW: "OverflowMethod" = "fold"37 38 39_re_whitespace = re.compile(r"\s+$")40 41TextType = Union[str, "Text"]42"""A plain string or a :class:`Text` instance."""43 44GetStyleCallable = Callable[[str], Optional[StyleType]]45 46 47class Span(NamedTuple):48 """A marked up region in some text."""49 50 start: int51 """Span start index."""52 end: int53 """Span end index."""54 style: Union[str, Style]55 """Style associated with the span."""56 57 def __repr__(self) -> str:58 return f"Span({self.start}, {self.end}, {self.style!r})"59 60 def __bool__(self) -> bool:61 return self.end > self.start62 63 def split(self, offset: int) -> Tuple["Span", Optional["Span"]]:64 """Split a span in to 2 from a given offset."""65 66 if offset < self.start:67 return self, None68 if offset >= self.end:69 return self, None70 71 start, end, style = self72 span1 = Span(start, min(end, offset), style)73 span2 = Span(span1.end, end, style)74 return span1, span275 76 def move(self, offset: int) -> "Span":77 """Move start and end by a given offset.78 79 Args:80 offset (int): Number of characters to add to start and end.81 82 Returns:83 TextSpan: A new TextSpan with adjusted position.84 """85 start, end, style = self86 return Span(start + offset, end + offset, style)87 88 def right_crop(self, offset: int) -> "Span":89 """Crop the span at the given offset.90 91 Args:92 offset (int): A value between start and end.93 94 Returns:95 Span: A new (possibly smaller) span.96 """97 start, end, style = self98 if offset >= end:99 return self100 return Span(start, min(offset, end), style)101 102 def extend(self, cells: int) -> "Span":103 """Extend the span by the given number of cells.104 105 Args:106 cells (int): Additional space to add to end of span.107 108 Returns:109 Span: A span.110 """111 if cells:112 start, end, style = self113 return Span(start, end + cells, style)114 else:115 return self116 117 118class Text(JupyterMixin):119 """Text with color / style.120 121 Args:122 text (str, optional): Default unstyled text. Defaults to "".123 style (Union[str, Style], optional): Base style for text. Defaults to "".124 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.125 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.126 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.127 end (str, optional): Character to end text with. Defaults to "\\\\n".128 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None.129 spans (List[Span], optional). A list of predefined style spans. Defaults to None.130 """131 132 __slots__ = [133 "_text",134 "style",135 "justify",136 "overflow",137 "no_wrap",138 "end",139 "tab_size",140 "_spans",141 "_length",142 ]143 144 def __init__(145 self,146 text: str = "",147 style: Union[str, Style] = "",148 *,149 justify: Optional["JustifyMethod"] = None,150 overflow: Optional["OverflowMethod"] = None,151 no_wrap: Optional[bool] = None,152 end: str = "\n",153 tab_size: Optional[int] = None,154 spans: Optional[List[Span]] = None,155 ) -> None:156 sanitized_text = strip_control_codes(text)157 self._text = [sanitized_text]158 self.style = style159 self.justify: Optional["JustifyMethod"] = justify160 self.overflow: Optional["OverflowMethod"] = overflow161 self.no_wrap = no_wrap162 self.end = end163 self.tab_size = tab_size164 self._spans: List[Span] = spans or []165 self._length: int = len(sanitized_text)166 167 def __len__(self) -> int:168 return self._length169 170 def __bool__(self) -> bool:171 return bool(self._length)172 173 def __str__(self) -> str:174 return self.plain175 176 def __repr__(self) -> str:177 return f"<text {self.plain!r} {self._spans!r} {self.style!r}>"178 179 def __add__(self, other: Any) -> "Text":180 if isinstance(other, (str, Text)):181 result = self.copy()182 result.append(other)183 return result184 return NotImplemented185 186 def __eq__(self, other: object) -> bool:187 if not isinstance(other, Text):188 return NotImplemented189 return self.plain == other.plain and self._spans == other._spans190 191 def __contains__(self, other: object) -> bool:192 if isinstance(other, str):193 return other in self.plain194 elif isinstance(other, Text):195 return other.plain in self.plain196 return False197 198 def __getitem__(self, slice: Union[int, slice]) -> "Text":199 def get_text_at(offset: int) -> "Text":200 _Span = Span201 text = Text(202 self.plain[offset],203 spans=[204 _Span(0, 1, style)205 for start, end, style in self._spans206 if end > offset >= start207 ],208 end="",209 )210 return text211 212 if isinstance(slice, int):213 return get_text_at(slice)214 else:215 start, stop, step = slice.indices(len(self.plain))216 if step == 1:217 lines = self.divide([start, stop])218 return lines[1]219 else:220 # This would be a bit of work to implement efficiently221 # For now, its not required222 raise TypeError("slices with step!=1 are not supported")223 224 @property225 def cell_len(self) -> int:226 """Get the number of cells required to render this text."""227 return cell_len(self.plain)228 229 @property230 def markup(self) -> str:231 """Get console markup to render this Text.232 233 Returns:234 str: A string potentially creating markup tags.235 """236 from .markup import escape237 238 output: List[str] = []239 240 plain = self.plain241 markup_spans = [242 (0, False, self.style),243 *((span.start, False, span.style) for span in self._spans),244 *((span.end, True, span.style) for span in self._spans),245 (len(plain), True, self.style),246 ]247 markup_spans.sort(key=itemgetter(0, 1))248 position = 0249 append = output.append250 for offset, closing, style in markup_spans:251 if offset > position:252 append(escape(plain[position:offset]))253 position = offset254 if style:255 append(f"[/{style}]" if closing else f"[{style}]")256 markup = "".join(output)257 return markup258 259 @classmethod260 def from_markup(261 cls,262 text: str,263 *,264 style: Union[str, Style] = "",265 emoji: bool = True,266 emoji_variant: Optional[EmojiVariant] = None,267 justify: Optional["JustifyMethod"] = None,268 overflow: Optional["OverflowMethod"] = None,269 end: str = "\n",270 ) -> "Text":271 """Create Text instance from markup.272 273 Args:274 text (str): A string containing console markup.275 style (Union[str, Style], optional): Base style for text. Defaults to "".276 emoji (bool, optional): Also render emoji code. Defaults to True.277 emoji_variant (str, optional): Optional emoji variant, either "text" or "emoji". Defaults to None.278 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.279 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.280 end (str, optional): Character to end text with. Defaults to "\\\\n".281 282 Returns:283 Text: A Text instance with markup rendered.284 """285 from .markup import render286 287 rendered_text = render(text, style, emoji=emoji, emoji_variant=emoji_variant)288 rendered_text.justify = justify289 rendered_text.overflow = overflow290 rendered_text.end = end291 return rendered_text292 293 @classmethod294 def from_ansi(295 cls,296 text: str,297 *,298 style: Union[str, Style] = "",299 justify: Optional["JustifyMethod"] = None,300 overflow: Optional["OverflowMethod"] = None,301 no_wrap: Optional[bool] = None,302 end: str = "\n",303 tab_size: Optional[int] = 8,304 ) -> "Text":305 """Create a Text object from a string containing ANSI escape codes.306 307 Args:308 text (str): A string containing escape codes.309 style (Union[str, Style], optional): Base style for text. Defaults to "".310 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.311 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.312 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.313 end (str, optional): Character to end text with. Defaults to "\\\\n".314 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None.315 """316 from .ansi import AnsiDecoder317 318 joiner = Text(319 "\n",320 justify=justify,321 overflow=overflow,322 no_wrap=no_wrap,323 end=end,324 tab_size=tab_size,325 style=style,326 )327 decoder = AnsiDecoder()328 result = joiner.join(line for line in decoder.decode(text))329 return result330 331 @classmethod332 def styled(333 cls,334 text: str,335 style: StyleType = "",336 *,337 justify: Optional["JustifyMethod"] = None,338 overflow: Optional["OverflowMethod"] = None,339 ) -> "Text":340 """Construct a Text instance with a pre-applied styled. A style applied in this way won't be used341 to pad the text when it is justified.342 343 Args:344 text (str): A string containing console markup.345 style (Union[str, Style]): Style to apply to the text. Defaults to "".346 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.347 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.348 349 Returns:350 Text: A text instance with a style applied to the entire string.351 """352 styled_text = cls(text, justify=justify, overflow=overflow)353 styled_text.stylize(style)354 return styled_text355 356 @classmethod357 def assemble(358 cls,359 *parts: Union[str, "Text", Tuple[str, StyleType]],360 style: Union[str, Style] = "",361 justify: Optional["JustifyMethod"] = None,362 overflow: Optional["OverflowMethod"] = None,363 no_wrap: Optional[bool] = None,364 end: str = "\n",365 tab_size: int = 8,366 meta: Optional[Dict[str, Any]] = None,367 ) -> "Text":368 """Construct a text instance by combining a sequence of strings with optional styles.369 The positional arguments should be either strings, or a tuple of string + style.370 371 Args:372 style (Union[str, Style], optional): Base style for text. Defaults to "".373 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.374 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.375 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.376 end (str, optional): Character to end text with. Defaults to "\\\\n".377 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None.378 meta (Dict[str, Any], optional). Meta data to apply to text, or None for no meta data. Default to None379 380 Returns:381 Text: A new text instance.382 """383 text = cls(384 style=style,385 justify=justify,386 overflow=overflow,387 no_wrap=no_wrap,388 end=end,389 tab_size=tab_size,390 )391 append = text.append392 _Text = Text393 for part in parts:394 if isinstance(part, (_Text, str)):395 append(part)396 else:397 append(*part)398 if meta:399 text.apply_meta(meta)400 return text401 402 @property403 def plain(self) -> str:404 """Get the text as a single string."""405 if len(self._text) != 1:406 self._text[:] = ["".join(self._text)]407 return self._text[0]408 409 @plain.setter410 def plain(self, new_text: str) -> None:411 """Set the text to a new value."""412 if new_text != self.plain:413 sanitized_text = strip_control_codes(new_text)414 self._text[:] = [sanitized_text]415 old_length = self._length416 self._length = len(sanitized_text)417 if old_length > self._length:418 self._trim_spans()419 420 @property421 def spans(self) -> List[Span]:422 """Get a reference to the internal list of spans."""423 return self._spans424 425 @spans.setter426 def spans(self, spans: List[Span]) -> None:427 """Set spans."""428 self._spans = spans[:]429 430 def blank_copy(self, plain: str = "") -> "Text":431 """Return a new Text instance with copied metadata (but not the string or spans)."""432 copy_self = Text(433 plain,434 style=self.style,435 justify=self.justify,436 overflow=self.overflow,437 no_wrap=self.no_wrap,438 end=self.end,439 tab_size=self.tab_size,440 )441 return copy_self442 443 def copy(self) -> "Text":444 """Return a copy of this instance."""445 copy_self = Text(446 self.plain,447 style=self.style,448 justify=self.justify,449 overflow=self.overflow,450 no_wrap=self.no_wrap,451 end=self.end,452 tab_size=self.tab_size,453 )454 copy_self._spans[:] = self._spans455 return copy_self456 457 def stylize(458 self,459 style: Union[str, Style],460 start: int = 0,461 end: Optional[int] = None,462 ) -> None:463 """Apply a style to the text, or a portion of the text.464 465 Args:466 style (Union[str, Style]): Style instance or style definition to apply.467 start (int): Start offset (negative indexing is supported). Defaults to 0.468 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.469 """470 if style:471 length = len(self)472 if start < 0:473 start = length + start474 if end is None:475 end = length476 if end < 0:477 end = length + end478 if start >= length or end <= start:479 # Span not in text or not valid480 return481 self._spans.append(Span(start, min(length, end), style))482 483 def stylize_before(484 self,485 style: Union[str, Style],486 start: int = 0,487 end: Optional[int] = None,488 ) -> None:489 """Apply a style to the text, or a portion of the text. Styles will be applied before other styles already present.490 491 Args:492 style (Union[str, Style]): Style instance or style definition to apply.493 start (int): Start offset (negative indexing is supported). Defaults to 0.494 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.495 """496 if style:497 length = len(self)498 if start < 0:499 start = length + start500 if end is None:501 end = length502 if end < 0:503 end = length + end504 if start >= length or end <= start:505 # Span not in text or not valid506 return507 self._spans.insert(0, Span(start, min(length, end), style))508 509 def apply_meta(510 self, meta: Dict[str, Any], start: int = 0, end: Optional[int] = None511 ) -> None:512 """Apply metadata to the text, or a portion of the text.513 514 Args:515 meta (Dict[str, Any]): A dict of meta information.516 start (int): Start offset (negative indexing is supported). Defaults to 0.517 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.518 519 """520 style = Style.from_meta(meta)521 self.stylize(style, start=start, end=end)522 523 def on(self, meta: Optional[Dict[str, Any]] = None, **handlers: Any) -> "Text":524 """Apply event handlers (used by Textual project).525 526 Example:527 >>> from rich.text import Text528 >>> text = Text("hello world")529 >>> text.on(click="view.toggle('world')")530 531 Args:532 meta (Dict[str, Any]): Mapping of meta information.533 **handlers: Keyword args are prefixed with "@" to defined handlers.534 535 Returns:536 Text: Self is returned to method may be chained.537 """538 meta = {} if meta is None else meta539 meta.update({f"@{key}": value for key, value in handlers.items()})540 self.stylize(Style.from_meta(meta))541 return self542 543 def remove_suffix(self, suffix: str) -> None:544 """Remove a suffix if it exists.545 546 Args:547 suffix (str): Suffix to remove.548 """549 if self.plain.endswith(suffix):550 self.right_crop(len(suffix))551 552 def get_style_at_offset(self, console: "Console", offset: int) -> Style:553 """Get the style of a character at give offset.554 555 Args:556 console (~Console): Console where text will be rendered.557 offset (int): Offset in to text (negative indexing supported)558 559 Returns:560 Style: A Style instance.561 """562 # TODO: This is a little inefficient, it is only used by full justify563 if offset < 0:564 offset = len(self) + offset565 get_style = console.get_style566 style = get_style(self.style).copy()567 for start, end, span_style in self._spans:568 if end > offset >= start:569 style += get_style(span_style, default="")570 return style571 572 def extend_style(self, spaces: int) -> None:573 """Extend the Text given number of spaces where the spaces have the same style as the last character.574 575 Args:576 spaces (int): Number of spaces to add to the Text.577 """578 if spaces <= 0:579 return580 spans = self.spans581 new_spaces = " " * spaces582 if spans:583 end_offset = len(self)584 self._spans[:] = [585 span.extend(spaces) if span.end >= end_offset else span586 for span in spans587 ]588 self._text.append(new_spaces)589 self._length += spaces590 else:591 self.plain += new_spaces592 593 def highlight_regex(594 self,595 re_highlight: Union[Pattern[str], str],596 style: Optional[Union[GetStyleCallable, StyleType]] = None,597 *,598 style_prefix: str = "",599 ) -> int:600 """Highlight text with a regular expression, where group names are601 translated to styles.602 603 Args:604 re_highlight (Union[re.Pattern, str]): A regular expression object or string.605 style (Union[GetStyleCallable, StyleType]): Optional style to apply to whole match, or a callable606 which accepts the matched text and returns a style. Defaults to None.607 style_prefix (str, optional): Optional prefix to add to style group names.608 609 Returns:610 int: Number of regex matches611 """612 count = 0613 append_span = self._spans.append614 _Span = Span615 plain = self.plain616 if isinstance(re_highlight, str):617 re_highlight = re.compile(re_highlight)618 for match in re_highlight.finditer(plain):619 get_span = match.span620 if style:621 start, end = get_span()622 match_style = style(plain[start:end]) if callable(style) else style623 if match_style is not None and end > start:624 append_span(_Span(start, end, match_style))625 626 count += 1627 for name in match.groupdict().keys():628 start, end = get_span(name)629 if start != -1 and end > start:630 append_span(_Span(start, end, f"{style_prefix}{name}"))631 return count632 633 def highlight_words(634 self,635 words: Iterable[str],636 style: Union[str, Style],637 *,638 case_sensitive: bool = True,639 ) -> int:640 """Highlight words with a style.641 642 Args:643 words (Iterable[str]): Words to highlight.644 style (Union[str, Style]): Style to apply.645 case_sensitive (bool, optional): Enable case sensitive matching. Defaults to True.646 647 Returns:648 int: Number of words highlighted.649 """650 re_words = "|".join(re.escape(word) for word in words)651 add_span = self._spans.append652 count = 0653 _Span = Span654 for match in re.finditer(655 re_words, self.plain, flags=0 if case_sensitive else re.IGNORECASE656 ):657 start, end = match.span(0)658 add_span(_Span(start, end, style))659 count += 1660 return count661 662 def rstrip(self) -> None:663 """Strip whitespace from end of text."""664 self.plain = self.plain.rstrip()665 666 def rstrip_end(self, size: int) -> None:667 """Remove whitespace beyond a certain width at the end of the text.668 669 Args:670 size (int): The desired size of the text.671 """672 text_length = len(self)673 if text_length > size:674 excess = text_length - size675 whitespace_match = _re_whitespace.search(self.plain)676 if whitespace_match is not None:677 whitespace_count = len(whitespace_match.group(0))678 self.right_crop(min(whitespace_count, excess))679 680 def set_length(self, new_length: int) -> None:681 """Set new length of the text, clipping or padding is required."""682 length = len(self)683 if length != new_length:684 if length < new_length:685 self.pad_right(new_length - length)686 else:687 self.right_crop(length - new_length)688 689 def __rich_console__(690 self, console: "Console", options: "ConsoleOptions"691 ) -> Iterable[Segment]:692 tab_size: int = console.tab_size if self.tab_size is None else self.tab_size693 justify = self.justify or options.justify or DEFAULT_JUSTIFY694 695 overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW696 697 lines = self.wrap(698 console,699 options.max_width,700 justify=justify,701 overflow=overflow,702 tab_size=tab_size or 8,703 no_wrap=pick_bool(self.no_wrap, options.no_wrap, False),704 )705 all_lines = Text("\n").join(lines)706 yield from all_lines.render(console, end=self.end)707 708 def __rich_measure__(709 self, console: "Console", options: "ConsoleOptions"710 ) -> Measurement:711 text = self.plain712 lines = text.splitlines()713 max_text_width = max(cell_len(line) for line in lines) if lines else 0714 words = text.split()715 min_text_width = (716 max(cell_len(word) for word in words) if words else max_text_width717 )718 return Measurement(min_text_width, max_text_width)719 720 def render(self, console: "Console", end: str = "") -> Iterable["Segment"]:721 """Render the text as Segments.722 723 Args:724 console (Console): Console instance.725 end (Optional[str], optional): Optional end character.726 727 Returns:728 Iterable[Segment]: Result of render that may be written to the console.729 """730 _Segment = Segment731 text = self.plain732 if not self._spans:733 yield Segment(text)734 if end:735 yield _Segment(end)736 return737 get_style = partial(console.get_style, default=Style.null())738 739 enumerated_spans = list(enumerate(self._spans, 1))740 style_map = {index: get_style(span.style) for index, span in enumerated_spans}741 style_map[0] = get_style(self.style)742 743 spans = [744 (0, False, 0),745 *((span.start, False, index) for index, span in enumerated_spans),746 *((span.end, True, index) for index, span in enumerated_spans),747 (len(text), True, 0),748 ]749 spans.sort(key=itemgetter(0, 1))750 751 stack: List[int] = []752 stack_append = stack.append753 stack_pop = stack.remove754 755 style_cache: Dict[Tuple[Style, ...], Style] = {}756 style_cache_get = style_cache.get757 combine = Style.combine758 759 def get_current_style() -> Style:760 """Construct current style from stack."""761 styles = tuple(style_map[_style_id] for _style_id in sorted(stack))762 cached_style = style_cache_get(styles)763 if cached_style is not None:764 return cached_style765 current_style = combine(styles)766 style_cache[styles] = current_style767 return current_style768 769 for (offset, leaving, style_id), (next_offset, _, _) in zip(spans, spans[1:]):770 if leaving:771 stack_pop(style_id)772 else:773 stack_append(style_id)774 if next_offset > offset:775 yield _Segment(text[offset:next_offset], get_current_style())776 if end:777 yield _Segment(end)778 779 def join(self, lines: Iterable["Text"]) -> "Text":780 """Join text together with this instance as the separator.781 782 Args:783 lines (Iterable[Text]): An iterable of Text instances to join.784 785 Returns:786 Text: A new text instance containing join text.787 """788 789 new_text = self.blank_copy()790 791 def iter_text() -> Iterable["Text"]:792 if self.plain:793 for last, line in loop_last(lines):794 yield line795 if not last:796 yield self797 else:798 yield from lines799 800 extend_text = new_text._text.extend801 append_span = new_text._spans.append802 extend_spans = new_text._spans.extend803 offset = 0804 _Span = Span805 806 for text in iter_text():807 extend_text(text._text)808 if text.style:809 append_span(_Span(offset, offset + len(text), text.style))810 extend_spans(811 _Span(offset + start, offset + end, style)812 for start, end, style in text._spans813 )814 offset += len(text)815 new_text._length = offset816 return new_text817 818 def expand_tabs(self, tab_size: Optional[int] = None) -> None:819 """Converts tabs to spaces.820 821 Args:822 tab_size (int, optional): Size of tabs. Defaults to 8.823 824 """825 if "\t" not in self.plain:826 return827 if tab_size is None:828 tab_size = self.tab_size829 if tab_size is None:830 tab_size = 8831 832 new_text: List[Text] = []833 append = new_text.append834 835 for line in self.split("\n", include_separator=True):836 if "\t" not in line.plain:837 append(line)838 else:839 cell_position = 0840 parts = line.split("\t", include_separator=True)841 for part in parts:842 if part.plain.endswith("\t"):843 part._text[-1] = part._text[-1][:-1] + " "844 cell_position += part.cell_len845 tab_remainder = cell_position % tab_size846 if tab_remainder:847 spaces = tab_size - tab_remainder848 part.extend_style(spaces)849 cell_position += spaces850 else:851 cell_position += part.cell_len852 append(part)853 854 result = Text("").join(new_text)855 856 self._text = [result.plain]857 self._length = len(self.plain)858 self._spans[:] = result._spans859 860 def truncate(861 self,862 max_width: int,863 *,864 overflow: Optional["OverflowMethod"] = None,865 pad: bool = False,866 ) -> None:867 """Truncate text if it is longer that a given width.868 869 Args:870 max_width (int): Maximum number of characters in text.871 overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None, to use self.overflow.872 pad (bool, optional): Pad with spaces if the length is less than max_width. Defaults to False.873 """874 _overflow = overflow or self.overflow or DEFAULT_OVERFLOW875 if _overflow != "ignore":876 length = cell_len(self.plain)877 if length > max_width:878 if _overflow == "ellipsis":879 self.plain = set_cell_size(self.plain, max_width - 1) + "…"880 else:881 self.plain = set_cell_size(self.plain, max_width)882 if pad and length < max_width:883 spaces = max_width - length884 self._text = [f"{self.plain}{' ' * spaces}"]885 self._length = len(self.plain)886 887 def _trim_spans(self) -> None:888 """Remove or modify any spans that are over the end of the text."""889 max_offset = len(self.plain)890 _Span = Span891 self._spans[:] = [892 (893 span894 if span.end < max_offset895 else _Span(span.start, min(max_offset, span.end), span.style)896 )897 for span in self._spans898 if span.start < max_offset899 ]900 901 def pad(self, count: int, character: str = " ") -> None:902 """Pad left and right with a given number of characters.903 904 Args:905 count (int): Width of padding.906 character (str): The character to pad with. Must be a string of length 1.907 """908 assert len(character) == 1, "Character must be a string of length 1"909 if count:910 pad_characters = character * count911 self.plain = f"{pad_characters}{self.plain}{pad_characters}"912 _Span = Span913 self._spans[:] = [914 _Span(start + count, end + count, style)915 for start, end, style in self._spans916 ]917 918 def pad_left(self, count: int, character: str = " ") -> None:919 """Pad the left with a given character.920 921 Args:922 count (int): Number of characters to pad.923 character (str, optional): Character to pad with. Defaults to " ".924 """925 assert len(character) == 1, "Character must be a string of length 1"926 if count:927 self.plain = f"{character * count}{self.plain}"928 _Span = Span929 self._spans[:] = [930 _Span(start + count, end + count, style)931 for start, end, style in self._spans932 ]933 934 def pad_right(self, count: int, character: str = " ") -> None:935 """Pad the right with a given character.936 937 Args:938 count (int): Number of characters to pad.939 character (str, optional): Character to pad with. Defaults to " ".940 """941 assert len(character) == 1, "Character must be a string of length 1"942 if count:943 self.plain = f"{self.plain}{character * count}"944 945 def align(self, align: AlignMethod, width: int, character: str = " ") -> None:946 """Align text to a given width.947 948 Args:949 align (AlignMethod): One of "left", "center", or "right".950 width (int): Desired width.951 character (str, optional): Character to pad with. Defaults to " ".952 """953 self.truncate(width)954 excess_space = width - cell_len(self.plain)955 if excess_space:956 if align == "left":957 self.pad_right(excess_space, character)958 elif align == "center":959 left = excess_space // 2960 self.pad_left(left, character)961 self.pad_right(excess_space - left, character)962 else:963 self.pad_left(excess_space, character)964 965 def append(966 self, text: Union["Text", str], style: Optional[Union[str, "Style"]] = None967 ) -> "Text":968 """Add text with an optional style.969 970 Args:971 text (Union[Text, str]): A str or Text to append.972 style (str, optional): A style name. Defaults to None.973 974 Returns:975 Text: Returns self for chaining.976 """977 978 if not isinstance(text, (str, Text)):979 raise TypeError("Only str or Text can be appended to Text")980 981 if len(text):982 if isinstance(text, str):983 sanitized_text = strip_control_codes(text)984 self._text.append(sanitized_text)985 offset = len(self)986 text_length = len(sanitized_text)987 if style:988 self._spans.append(Span(offset, offset + text_length, style))989 self._length += text_length990 elif isinstance(text, Text):991 _Span = Span992 if style is not None:993 raise ValueError(994 "style must not be set when appending Text instance"995 )996 text_length = self._length997 if text.style:998 self._spans.append(999 _Span(text_length, text_length + len(text), text.style)1000 )1001 self._text.append(text.plain)1002 self._spans.extend(1003 _Span(start + text_length, end + text_length, style)1004 for start, end, style in text._spans.copy()1005 )1006 self._length += len(text)1007 return self1008 1009 def append_text(self, text: "Text") -> "Text":1010 """Append another Text instance. This method is more performant that Text.append, but1011 only works for Text.1012 1013 Args:1014 text (Text): The Text instance to append to this instance.1015 1016 Returns:1017 Text: Returns self for chaining.1018 """1019 _Span = Span1020 text_length = self._length1021 if text.style:1022 self._spans.append(_Span(text_length, text_length + len(text), text.style))1023 self._text.append(text.plain)1024 self._spans.extend(1025 _Span(start + text_length, end + text_length, style)1026 for start, end, style in text._spans.copy()1027 )1028 self._length += len(text)1029 return self1030 1031 def append_tokens(1032 self, tokens: Iterable[Tuple[str, Optional[StyleType]]]1033 ) -> "Text":1034 """Append iterable of str and style. Style may be a Style instance or a str style definition.1035 1036 Args:1037 tokens (Iterable[Tuple[str, Optional[StyleType]]]): An iterable of tuples containing str content and style.1038 1039 Returns:1040 Text: Returns self for chaining.1041 """1042 append_text = self._text.append1043 append_span = self._spans.append1044 _Span = Span1045 offset = len(self)1046 for content, style in tokens:1047 content = strip_control_codes(content)1048 append_text(content)1049 if style:1050 append_span(_Span(offset, offset + len(content), style))1051 offset += len(content)1052 self._length = offset1053 return self1054 1055 def copy_styles(self, text: "Text") -> None:1056 """Copy styles from another Text instance.1057 1058 Args:1059 text (Text): A Text instance to copy styles from, must be the same length.1060 """1061 self._spans.extend(text._spans)1062 1063 def split(1064 self,1065 separator: str = "\n",1066 *,1067 include_separator: bool = False,1068 allow_blank: bool = False,1069 ) -> Lines:1070 """Split rich text in to lines, preserving styles.1071 1072 Args:1073 separator (str, optional): String to split on. Defaults to "\\\\n".1074 include_separator (bool, optional): Include the separator in the lines. Defaults to False.1075 allow_blank (bool, optional): Return a blank line if the text ends with a separator. Defaults to False.1076 1077 Returns:1078 List[RichText]: A list of rich text, one per line of the original.1079 """1080 assert separator, "separator must not be empty"1081 1082 text = self.plain1083 if separator not in text:1084 return Lines([self.copy()])1085 1086 if include_separator:1087 lines = self.divide(1088 match.end() for match in re.finditer(re.escape(separator), text)1089 )1090 else:1091 1092 def flatten_spans() -> Iterable[int]:1093 for match in re.finditer(re.escape(separator), text):1094 start, end = match.span()1095 yield start1096 yield end1097 1098 lines = Lines(1099 line for line in self.divide(flatten_spans()) if line.plain != separator1100 )1101 1102 if not allow_blank and text.endswith(separator):1103 lines.pop()1104 1105 return lines1106 1107 def divide(self, offsets: Iterable[int]) -> Lines:1108 """Divide text in to a number of lines at given offsets.1109 1110 Args:1111 offsets (Iterable[int]): Offsets used to divide text.1112 1113 Returns:1114 Lines: New RichText instances between offsets.1115 """1116 _offsets = list(offsets)1117 1118 if not _offsets:1119 return Lines([self.copy()])1120 1121 text = self.plain1122 text_length = len(text)1123 divide_offsets = [0, *_offsets, text_length]1124 line_ranges = list(zip(divide_offsets, divide_offsets[1:]))1125 1126 style = self.style1127 justify = self.justify1128 overflow = self.overflow1129 _Text = Text1130 new_lines = Lines(1131 _Text(1132 text[start:end],1133 style=style,1134 justify=justify,1135 overflow=overflow,1136 )1137 for start, end in line_ranges1138 )1139 if not self._spans:1140 return new_lines1141 1142 _line_appends = [line._spans.append for line in new_lines._lines]1143 line_count = len(line_ranges)1144 _Span = Span1145 1146 for span_start, span_end, style in self._spans:1147 lower_bound = 01148 upper_bound = line_count1149 start_line_no = (lower_bound + upper_bound) // 21150 1151 while True:1152 line_start, line_end = line_ranges[start_line_no]1153 if span_start < line_start:1154 upper_bound = start_line_no - 11155 elif span_start > line_end:1156 lower_bound = start_line_no + 11157 else:1158 break1159 start_line_no = (lower_bound + upper_bound) // 21160 1161 if span_end < line_end:1162 end_line_no = start_line_no1163 else:1164 end_line_no = lower_bound = start_line_no1165 upper_bound = line_count1166 1167 while True:1168 line_start, line_end = line_ranges[end_line_no]1169 if span_end < line_start:1170 upper_bound = end_line_no - 11171 elif span_end > line_end:1172 lower_bound = end_line_no + 11173 else:1174 break1175 end_line_no = (lower_bound + upper_bound) // 21176 1177 for line_no in range(start_line_no, end_line_no + 1):1178 line_start, line_end = line_ranges[line_no]1179 new_start = max(0, span_start - line_start)1180 new_end = min(span_end - line_start, line_end - line_start)1181 if new_end > new_start:1182 _line_appends[line_no](_Span(new_start, new_end, style))1183 1184 return new_lines1185 1186 def right_crop(self, amount: int = 1) -> None:1187 """Remove a number of characters from the end of the text."""1188 max_offset = len(self.plain) - amount1189 _Span = Span1190 self._spans[:] = [1191 (1192 span1193 if span.end < max_offset1194 else _Span(span.start, min(max_offset, span.end), span.style)1195 )1196 for span in self._spans1197 if span.start < max_offset1198 ]1199 self._text = [self.plain[:-amount]]1200 self._length -= amount