Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/rich/text.py: 67%
601 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-18 06:13 +0000
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-18 06:13 +0000
1import re
2from functools import partial, reduce
3from math import gcd
4from operator import itemgetter
5from typing import (
6 TYPE_CHECKING,
7 Any,
8 Callable,
9 Dict,
10 Iterable,
11 List,
12 NamedTuple,
13 Optional,
14 Tuple,
15 Union,
16)
18from ._loop import loop_last
19from ._pick import pick_bool
20from ._wrap import divide_line
21from .align import AlignMethod
22from .cells import cell_len, set_cell_size
23from .containers import Lines
24from .control import strip_control_codes
25from .emoji import EmojiVariant
26from .jupyter import JupyterMixin
27from .measure import Measurement
28from .segment import Segment
29from .style import Style, StyleType
31if TYPE_CHECKING: # pragma: no cover
32 from .console import Console, ConsoleOptions, JustifyMethod, OverflowMethod
34DEFAULT_JUSTIFY: "JustifyMethod" = "default"
35DEFAULT_OVERFLOW: "OverflowMethod" = "fold"
38_re_whitespace = re.compile(r"\s+$")
40TextType = Union[str, "Text"]
41"""A plain string or a [Text][rich.text.Text] instance."""
43GetStyleCallable = Callable[[str], Optional[StyleType]]
46class Span(NamedTuple):
47 """A marked up region in some text."""
49 start: int
50 """Span start index."""
51 end: int
52 """Span end index."""
53 style: Union[str, Style]
54 """Style associated with the span."""
56 def __repr__(self) -> str:
57 return f"Span({self.start}, {self.end}, {self.style!r})"
59 def __bool__(self) -> bool:
60 return self.end > self.start
62 def split(self, offset: int) -> Tuple["Span", Optional["Span"]]:
63 """Split a span in to 2 from a given offset."""
65 if offset < self.start:
66 return self, None
67 if offset >= self.end:
68 return self, None
70 start, end, style = self
71 span1 = Span(start, min(end, offset), style)
72 span2 = Span(span1.end, end, style)
73 return span1, span2
75 def move(self, offset: int) -> "Span":
76 """Move start and end by a given offset.
78 Args:
79 offset (int): Number of characters to add to start and end.
81 Returns:
82 TextSpan: A new TextSpan with adjusted position.
83 """
84 start, end, style = self
85 return Span(start + offset, end + offset, style)
87 def right_crop(self, offset: int) -> "Span":
88 """Crop the span at the given offset.
90 Args:
91 offset (int): A value between start and end.
93 Returns:
94 Span: A new (possibly smaller) span.
95 """
96 start, end, style = self
97 if offset >= end:
98 return self
99 return Span(start, min(offset, end), style)
101 def extend(self, cells: int) -> "Span":
102 """Extend the span by the given number of cells.
104 Args:
105 cells (int): Additional space to add to end of span.
107 Returns:
108 Span: A span.
109 """
110 if cells:
111 start, end, style = self
112 return Span(start, end + cells, style)
113 else:
114 return self
117class Text(JupyterMixin):
118 """Text with color / style.
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 """
131 __slots__ = [
132 "_text",
133 "style",
134 "justify",
135 "overflow",
136 "no_wrap",
137 "end",
138 "tab_size",
139 "_spans",
140 "_length",
141 ]
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 = style
158 self.justify: Optional["JustifyMethod"] = justify
159 self.overflow: Optional["OverflowMethod"] = overflow
160 self.no_wrap = no_wrap
161 self.end = end
162 self.tab_size = tab_size
163 self._spans: List[Span] = spans or []
164 self._length: int = len(sanitized_text)
166 def __len__(self) -> int:
167 return self._length
169 def __bool__(self) -> bool:
170 return bool(self._length)
172 def __str__(self) -> str:
173 return self.plain
175 def __repr__(self) -> str:
176 return f"<text {self.plain!r} {self._spans!r}>"
178 def __add__(self, other: Any) -> "Text":
179 if isinstance(other, (str, Text)):
180 result = self.copy()
181 result.append(other)
182 return result
183 return NotImplemented
185 def __eq__(self, other: object) -> bool:
186 if not isinstance(other, Text):
187 return NotImplemented
188 return self.plain == other.plain and self._spans == other._spans
190 def __contains__(self, other: object) -> bool:
191 if isinstance(other, str):
192 return other in self.plain
193 elif isinstance(other, Text):
194 return other.plain in self.plain
195 return False
197 def __getitem__(self, slice: Union[int, slice]) -> "Text":
198 def get_text_at(offset: int) -> "Text":
199 _Span = Span
200 text = Text(
201 self.plain[offset],
202 spans=[
203 _Span(0, 1, style)
204 for start, end, style in self._spans
205 if end > offset >= start
206 ],
207 end="",
208 )
209 return text
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 efficiently
220 # For now, its not required
221 raise TypeError("slices with step!=1 are not supported")
223 @property
224 def cell_len(self) -> int:
225 """Get the number of cells required to render this text."""
226 return cell_len(self.plain)
228 @property
229 def markup(self) -> str:
230 """Get console markup to render this Text.
232 Returns:
233 str: A string potentially creating markup tags.
234 """
235 from .markup import escape
237 output: List[str] = []
239 plain = self.plain
240 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 = 0
248 append = output.append
249 for offset, closing, style in markup_spans:
250 if offset > position:
251 append(escape(plain[position:offset]))
252 position = offset
253 if style:
254 append(f"[/{style}]" if closing else f"[{style}]")
255 markup = "".join(output)
256 return markup
258 @classmethod
259 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.
272 Args:
273 text (str): A string containing console markup.
274 emoji (bool, optional): Also render emoji code. Defaults to True.
275 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
276 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
277 end (str, optional): Character to end text with. Defaults to "\\\\n".
279 Returns:
280 Text: A Text instance with markup rendered.
281 """
282 from .markup import render
284 rendered_text = render(text, style, emoji=emoji, emoji_variant=emoji_variant)
285 rendered_text.justify = justify
286 rendered_text.overflow = overflow
287 rendered_text.end = end
288 return rendered_text
290 @classmethod
291 def from_ansi(
292 cls,
293 text: str,
294 *,
295 style: Union[str, Style] = "",
296 justify: Optional["JustifyMethod"] = None,
297 overflow: Optional["OverflowMethod"] = None,
298 no_wrap: Optional[bool] = None,
299 end: str = "\n",
300 tab_size: Optional[int] = 8,
301 ) -> "Text":
302 """Create a Text object from a string containing ANSI escape codes.
304 Args:
305 text (str): A string containing escape codes.
306 style (Union[str, Style], optional): Base style for text. Defaults to "".
307 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
308 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
309 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.
310 end (str, optional): Character to end text with. Defaults to "\\\\n".
311 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None.
312 """
313 from .ansi import AnsiDecoder
315 joiner = Text(
316 "\n",
317 justify=justify,
318 overflow=overflow,
319 no_wrap=no_wrap,
320 end=end,
321 tab_size=tab_size,
322 style=style,
323 )
324 decoder = AnsiDecoder()
325 result = joiner.join(line for line in decoder.decode(text))
326 return result
328 @classmethod
329 def styled(
330 cls,
331 text: str,
332 style: StyleType = "",
333 *,
334 justify: Optional["JustifyMethod"] = None,
335 overflow: Optional["OverflowMethod"] = None,
336 ) -> "Text":
337 """Construct a Text instance with a pre-applied styled. A style applied in this way won't be used
338 to pad the text when it is justified.
340 Args:
341 text (str): A string containing console markup.
342 style (Union[str, Style]): Style to apply to the text. Defaults to "".
343 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
344 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
346 Returns:
347 Text: A text instance with a style applied to the entire string.
348 """
349 styled_text = cls(text, justify=justify, overflow=overflow)
350 styled_text.stylize(style)
351 return styled_text
353 @classmethod
354 def assemble(
355 cls,
356 *parts: Union[str, "Text", Tuple[str, StyleType]],
357 style: Union[str, Style] = "",
358 justify: Optional["JustifyMethod"] = None,
359 overflow: Optional["OverflowMethod"] = None,
360 no_wrap: Optional[bool] = None,
361 end: str = "\n",
362 tab_size: int = 8,
363 meta: Optional[Dict[str, Any]] = None,
364 ) -> "Text":
365 """Construct a text instance by combining a sequence of strings with optional styles.
366 The positional arguments should be either strings, or a tuple of string + style.
368 Args:
369 style (Union[str, Style], optional): Base style for text. Defaults to "".
370 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
371 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
372 end (str, optional): Character to end text with. Defaults to "\\\\n".
373 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None.
374 meta (Dict[str, Any], optional). Meta data to apply to text, or None for no meta data. Default to None
376 Returns:
377 Text: A new text instance.
378 """
379 text = cls(
380 style=style,
381 justify=justify,
382 overflow=overflow,
383 no_wrap=no_wrap,
384 end=end,
385 tab_size=tab_size,
386 )
387 append = text.append
388 _Text = Text
389 for part in parts:
390 if isinstance(part, (_Text, str)):
391 append(part)
392 else:
393 append(*part)
394 if meta:
395 text.apply_meta(meta)
396 return text
398 @property
399 def plain(self) -> str:
400 """Get the text as a single string."""
401 if len(self._text) != 1:
402 self._text[:] = ["".join(self._text)]
403 return self._text[0]
405 @plain.setter
406 def plain(self, new_text: str) -> None:
407 """Set the text to a new value."""
408 if new_text != self.plain:
409 sanitized_text = strip_control_codes(new_text)
410 self._text[:] = [sanitized_text]
411 old_length = self._length
412 self._length = len(sanitized_text)
413 if old_length > self._length:
414 self._trim_spans()
416 @property
417 def spans(self) -> List[Span]:
418 """Get a reference to the internal list of spans."""
419 return self._spans
421 @spans.setter
422 def spans(self, spans: List[Span]) -> None:
423 """Set spans."""
424 self._spans = spans[:]
426 def blank_copy(self, plain: str = "") -> "Text":
427 """Return a new Text instance with copied meta data (but not the string or spans)."""
428 copy_self = Text(
429 plain,
430 style=self.style,
431 justify=self.justify,
432 overflow=self.overflow,
433 no_wrap=self.no_wrap,
434 end=self.end,
435 tab_size=self.tab_size,
436 )
437 return copy_self
439 def copy(self) -> "Text":
440 """Return a copy of this instance."""
441 copy_self = Text(
442 self.plain,
443 style=self.style,
444 justify=self.justify,
445 overflow=self.overflow,
446 no_wrap=self.no_wrap,
447 end=self.end,
448 tab_size=self.tab_size,
449 )
450 copy_self._spans[:] = self._spans
451 return copy_self
453 def stylize(
454 self,
455 style: Union[str, Style],
456 start: int = 0,
457 end: Optional[int] = None,
458 ) -> None:
459 """Apply a style to the text, or a portion of the text.
461 Args:
462 style (Union[str, Style]): Style instance or style definition to apply.
463 start (int): Start offset (negative indexing is supported). Defaults to 0.
464 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.
465 """
466 if style:
467 length = len(self)
468 if start < 0:
469 start = length + start
470 if end is None:
471 end = length
472 if end < 0:
473 end = length + end
474 if start >= length or end <= start:
475 # Span not in text or not valid
476 return
477 self._spans.append(Span(start, min(length, end), style))
479 def stylize_before(
480 self,
481 style: Union[str, Style],
482 start: int = 0,
483 end: Optional[int] = None,
484 ) -> None:
485 """Apply a style to the text, or a portion of the text. Styles will be applied before other styles already present.
487 Args:
488 style (Union[str, Style]): Style instance or style definition to apply.
489 start (int): Start offset (negative indexing is supported). Defaults to 0.
490 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.
491 """
492 if style:
493 length = len(self)
494 if start < 0:
495 start = length + start
496 if end is None:
497 end = length
498 if end < 0:
499 end = length + end
500 if start >= length or end <= start:
501 # Span not in text or not valid
502 return
503 self._spans.insert(0, Span(start, min(length, end), style))
505 def apply_meta(
506 self, meta: Dict[str, Any], start: int = 0, end: Optional[int] = None
507 ) -> None:
508 """Apply meta data to the text, or a portion of the text.
510 Args:
511 meta (Dict[str, Any]): A dict of meta information.
512 start (int): Start offset (negative indexing is supported). Defaults to 0.
513 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.
515 """
516 style = Style.from_meta(meta)
517 self.stylize(style, start=start, end=end)
519 def on(self, meta: Optional[Dict[str, Any]] = None, **handlers: Any) -> "Text":
520 """Apply event handlers (used by Textual project).
522 Example:
523 >>> from rich.text import Text
524 >>> text = Text("hello world")
525 >>> text.on(click="view.toggle('world')")
527 Args:
528 meta (Dict[str, Any]): Mapping of meta information.
529 **handlers: Keyword args are prefixed with "@" to defined handlers.
531 Returns:
532 Text: Self is returned to method may be chained.
533 """
534 meta = {} if meta is None else meta
535 meta.update({f"@{key}": value for key, value in handlers.items()})
536 self.stylize(Style.from_meta(meta))
537 return self
539 def remove_suffix(self, suffix: str) -> None:
540 """Remove a suffix if it exists.
542 Args:
543 suffix (str): Suffix to remove.
544 """
545 if self.plain.endswith(suffix):
546 self.right_crop(len(suffix))
548 def get_style_at_offset(self, console: "Console", offset: int) -> Style:
549 """Get the style of a character at give offset.
551 Args:
552 console (~Console): Console where text will be rendered.
553 offset (int): Offset in to text (negative indexing supported)
555 Returns:
556 Style: A Style instance.
557 """
558 # TODO: This is a little inefficient, it is only used by full justify
559 if offset < 0:
560 offset = len(self) + offset
561 get_style = console.get_style
562 style = get_style(self.style).copy()
563 for start, end, span_style in self._spans:
564 if end > offset >= start:
565 style += get_style(span_style, default="")
566 return style
568 def extend_style(self, spaces: int) -> None:
569 """Extend the Text given number of spaces where the spaces have the same style as the last character.
571 Args:
572 spaces (int): Number of spaces to add to the Text.
573 """
574 if spaces <= 0:
575 return
576 spans = self.spans
577 new_spaces = " " * spaces
578 if spans:
579 end_offset = len(self)
580 self._spans[:] = [
581 span.extend(spaces) if span.end >= end_offset else span
582 for span in spans
583 ]
584 self._text.append(new_spaces)
585 self._length += spaces
586 else:
587 self.plain += new_spaces
589 def highlight_regex(
590 self,
591 re_highlight: str,
592 style: Optional[Union[GetStyleCallable, StyleType]] = None,
593 *,
594 style_prefix: str = "",
595 ) -> int:
596 """Highlight text with a regular expression, where group names are
597 translated to styles.
599 Args:
600 re_highlight (str): A regular expression.
601 style (Union[GetStyleCallable, StyleType]): Optional style to apply to whole match, or a callable
602 which accepts the matched text and returns a style. Defaults to None.
603 style_prefix (str, optional): Optional prefix to add to style group names.
605 Returns:
606 int: Number of regex matches
607 """
608 count = 0
609 append_span = self._spans.append
610 _Span = Span
611 plain = self.plain
612 for match in re.finditer(re_highlight, plain):
613 get_span = match.span
614 if style:
615 start, end = get_span()
616 match_style = style(plain[start:end]) if callable(style) else style
617 if match_style is not None and end > start:
618 append_span(_Span(start, end, match_style))
620 count += 1
621 for name in match.groupdict().keys():
622 start, end = get_span(name)
623 if start != -1 and end > start:
624 append_span(_Span(start, end, f"{style_prefix}{name}"))
625 return count
627 def highlight_words(
628 self,
629 words: Iterable[str],
630 style: Union[str, Style],
631 *,
632 case_sensitive: bool = True,
633 ) -> int:
634 """Highlight words with a style.
636 Args:
637 words (Iterable[str]): Worlds to highlight.
638 style (Union[str, Style]): Style to apply.
639 case_sensitive (bool, optional): Enable case sensitive matchings. Defaults to True.
641 Returns:
642 int: Number of words highlighted.
643 """
644 re_words = "|".join(re.escape(word) for word in words)
645 add_span = self._spans.append
646 count = 0
647 _Span = Span
648 for match in re.finditer(
649 re_words, self.plain, flags=0 if case_sensitive else re.IGNORECASE
650 ):
651 start, end = match.span(0)
652 add_span(_Span(start, end, style))
653 count += 1
654 return count
656 def rstrip(self) -> None:
657 """Strip whitespace from end of text."""
658 self.plain = self.plain.rstrip()
660 def rstrip_end(self, size: int) -> None:
661 """Remove whitespace beyond a certain width at the end of the text.
663 Args:
664 size (int): The desired size of the text.
665 """
666 text_length = len(self)
667 if text_length > size:
668 excess = text_length - size
669 whitespace_match = _re_whitespace.search(self.plain)
670 if whitespace_match is not None:
671 whitespace_count = len(whitespace_match.group(0))
672 self.right_crop(min(whitespace_count, excess))
674 def set_length(self, new_length: int) -> None:
675 """Set new length of the text, clipping or padding is required."""
676 length = len(self)
677 if length != new_length:
678 if length < new_length:
679 self.pad_right(new_length - length)
680 else:
681 self.right_crop(length - new_length)
683 def __rich_console__(
684 self, console: "Console", options: "ConsoleOptions"
685 ) -> Iterable[Segment]:
686 tab_size: int = console.tab_size if self.tab_size is None else self.tab_size
687 justify = self.justify or options.justify or DEFAULT_JUSTIFY
689 overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW
691 lines = self.wrap(
692 console,
693 options.max_width,
694 justify=justify,
695 overflow=overflow,
696 tab_size=tab_size or 8,
697 no_wrap=pick_bool(self.no_wrap, options.no_wrap, False),
698 )
699 all_lines = Text("\n").join(lines)
700 yield from all_lines.render(console, end=self.end)
702 def __rich_measure__(
703 self, console: "Console", options: "ConsoleOptions"
704 ) -> Measurement:
705 text = self.plain
706 lines = text.splitlines()
707 max_text_width = max(cell_len(line) for line in lines) if lines else 0
708 words = text.split()
709 min_text_width = (
710 max(cell_len(word) for word in words) if words else max_text_width
711 )
712 return Measurement(min_text_width, max_text_width)
714 def render(self, console: "Console", end: str = "") -> Iterable["Segment"]:
715 """Render the text as Segments.
717 Args:
718 console (Console): Console instance.
719 end (Optional[str], optional): Optional end character.
721 Returns:
722 Iterable[Segment]: Result of render that may be written to the console.
723 """
724 _Segment = Segment
725 text = self.plain
726 if not self._spans:
727 yield Segment(text)
728 if end:
729 yield _Segment(end)
730 return
731 get_style = partial(console.get_style, default=Style.null())
733 enumerated_spans = list(enumerate(self._spans, 1))
734 style_map = {index: get_style(span.style) for index, span in enumerated_spans}
735 style_map[0] = get_style(self.style)
737 spans = [
738 (0, False, 0),
739 *((span.start, False, index) for index, span in enumerated_spans),
740 *((span.end, True, index) for index, span in enumerated_spans),
741 (len(text), True, 0),
742 ]
743 spans.sort(key=itemgetter(0, 1))
745 stack: List[int] = []
746 stack_append = stack.append
747 stack_pop = stack.remove
749 style_cache: Dict[Tuple[Style, ...], Style] = {}
750 style_cache_get = style_cache.get
751 combine = Style.combine
753 def get_current_style() -> Style:
754 """Construct current style from stack."""
755 styles = tuple(style_map[_style_id] for _style_id in sorted(stack))
756 cached_style = style_cache_get(styles)
757 if cached_style is not None:
758 return cached_style
759 current_style = combine(styles)
760 style_cache[styles] = current_style
761 return current_style
763 for (offset, leaving, style_id), (next_offset, _, _) in zip(spans, spans[1:]):
764 if leaving:
765 stack_pop(style_id)
766 else:
767 stack_append(style_id)
768 if next_offset > offset:
769 yield _Segment(text[offset:next_offset], get_current_style())
770 if end:
771 yield _Segment(end)
773 def join(self, lines: Iterable["Text"]) -> "Text":
774 """Join text together with this instance as the separator.
776 Args:
777 lines (Iterable[Text]): An iterable of Text instances to join.
779 Returns:
780 Text: A new text instance containing join text.
781 """
783 new_text = self.blank_copy()
785 def iter_text() -> Iterable["Text"]:
786 if self.plain:
787 for last, line in loop_last(lines):
788 yield line
789 if not last:
790 yield self
791 else:
792 yield from lines
794 extend_text = new_text._text.extend
795 append_span = new_text._spans.append
796 extend_spans = new_text._spans.extend
797 offset = 0
798 _Span = Span
800 for text in iter_text():
801 extend_text(text._text)
802 if text.style:
803 append_span(_Span(offset, offset + len(text), text.style))
804 extend_spans(
805 _Span(offset + start, offset + end, style)
806 for start, end, style in text._spans
807 )
808 offset += len(text)
809 new_text._length = offset
810 return new_text
812 def expand_tabs(self, tab_size: Optional[int] = None) -> None:
813 """Converts tabs to spaces.
815 Args:
816 tab_size (int, optional): Size of tabs. Defaults to 8.
818 """
819 if "\t" not in self.plain:
820 return
821 if tab_size is None:
822 tab_size = self.tab_size
823 if tab_size is None:
824 tab_size = 8
826 result = self.blank_copy()
828 new_text: List[Text] = []
829 append = new_text.append
831 for line in self.split("\n", include_separator=True):
832 if "\t" not in line.plain:
833 append(line)
834 else:
835 cell_position = 0
836 parts = line.split("\t", include_separator=True)
837 for part in parts:
838 if part.plain.endswith("\t"):
839 part._text[-1] = part._text[-1][:-1] + " "
840 cell_position += part.cell_len
841 tab_remainder = cell_position % tab_size
842 if tab_remainder:
843 spaces = tab_size - tab_remainder
844 part.extend_style(spaces)
845 cell_position += spaces
846 else:
847 cell_position += part.cell_len
848 append(part)
850 result = Text("").join(new_text)
852 self._text = [result.plain]
853 self._length = len(self.plain)
854 self._spans[:] = result._spans
856 def truncate(
857 self,
858 max_width: int,
859 *,
860 overflow: Optional["OverflowMethod"] = None,
861 pad: bool = False,
862 ) -> None:
863 """Truncate text if it is longer that a given width.
865 Args:
866 max_width (int): Maximum number of characters in text.
867 overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None, to use self.overflow.
868 pad (bool, optional): Pad with spaces if the length is less than max_width. Defaults to False.
869 """
870 _overflow = overflow or self.overflow or DEFAULT_OVERFLOW
871 if _overflow != "ignore":
872 length = cell_len(self.plain)
873 if length > max_width:
874 if _overflow == "ellipsis":
875 self.plain = set_cell_size(self.plain, max_width - 1) + "…"
876 else:
877 self.plain = set_cell_size(self.plain, max_width)
878 if pad and length < max_width:
879 spaces = max_width - length
880 self._text = [f"{self.plain}{' ' * spaces}"]
881 self._length = len(self.plain)
883 def _trim_spans(self) -> None:
884 """Remove or modify any spans that are over the end of the text."""
885 max_offset = len(self.plain)
886 _Span = Span
887 self._spans[:] = [
888 (
889 span
890 if span.end < max_offset
891 else _Span(span.start, min(max_offset, span.end), span.style)
892 )
893 for span in self._spans
894 if span.start < max_offset
895 ]
897 def pad(self, count: int, character: str = " ") -> None:
898 """Pad left and right with a given number of characters.
900 Args:
901 count (int): Width of padding.
902 """
903 assert len(character) == 1, "Character must be a string of length 1"
904 if count:
905 pad_characters = character * count
906 self.plain = f"{pad_characters}{self.plain}{pad_characters}"
907 _Span = Span
908 self._spans[:] = [
909 _Span(start + count, end + count, style)
910 for start, end, style in self._spans
911 ]
913 def pad_left(self, count: int, character: str = " ") -> None:
914 """Pad the left with a given character.
916 Args:
917 count (int): Number of characters to pad.
918 character (str, optional): Character to pad with. Defaults to " ".
919 """
920 assert len(character) == 1, "Character must be a string of length 1"
921 if count:
922 self.plain = f"{character * count}{self.plain}"
923 _Span = Span
924 self._spans[:] = [
925 _Span(start + count, end + count, style)
926 for start, end, style in self._spans
927 ]
929 def pad_right(self, count: int, character: str = " ") -> None:
930 """Pad the right with a given character.
932 Args:
933 count (int): Number of characters to pad.
934 character (str, optional): Character to pad with. Defaults to " ".
935 """
936 assert len(character) == 1, "Character must be a string of length 1"
937 if count:
938 self.plain = f"{self.plain}{character * count}"
940 def align(self, align: AlignMethod, width: int, character: str = " ") -> None:
941 """Align text to a given width.
943 Args:
944 align (AlignMethod): One of "left", "center", or "right".
945 width (int): Desired width.
946 character (str, optional): Character to pad with. Defaults to " ".
947 """
948 self.truncate(width)
949 excess_space = width - cell_len(self.plain)
950 if excess_space:
951 if align == "left":
952 self.pad_right(excess_space, character)
953 elif align == "center":
954 left = excess_space // 2
955 self.pad_left(left, character)
956 self.pad_right(excess_space - left, character)
957 else:
958 self.pad_left(excess_space, character)
960 def append(
961 self, text: Union["Text", str], style: Optional[Union[str, "Style"]] = None
962 ) -> "Text":
963 """Add text with an optional style.
965 Args:
966 text (Union[Text, str]): A str or Text to append.
967 style (str, optional): A style name. Defaults to None.
969 Returns:
970 Text: Returns self for chaining.
971 """
973 if not isinstance(text, (str, Text)):
974 raise TypeError("Only str or Text can be appended to Text")
976 if len(text):
977 if isinstance(text, str):
978 sanitized_text = strip_control_codes(text)
979 self._text.append(sanitized_text)
980 offset = len(self)
981 text_length = len(sanitized_text)
982 if style:
983 self._spans.append(Span(offset, offset + text_length, style))
984 self._length += text_length
985 elif isinstance(text, Text):
986 _Span = Span
987 if style is not None:
988 raise ValueError(
989 "style must not be set when appending Text instance"
990 )
991 text_length = self._length
992 if text.style:
993 self._spans.append(
994 _Span(text_length, text_length + len(text), text.style)
995 )
996 self._text.append(text.plain)
997 self._spans.extend(
998 _Span(start + text_length, end + text_length, style)
999 for start, end, style in text._spans
1000 )
1001 self._length += len(text)
1002 return self
1004 def append_text(self, text: "Text") -> "Text":
1005 """Append another Text instance. This method is more performant that Text.append, but
1006 only works for Text.
1008 Returns:
1009 Text: Returns self for chaining.
1010 """
1011 _Span = Span
1012 text_length = self._length
1013 if text.style:
1014 self._spans.append(_Span(text_length, text_length + len(text), text.style))
1015 self._text.append(text.plain)
1016 self._spans.extend(
1017 _Span(start + text_length, end + text_length, style)
1018 for start, end, style in text._spans
1019 )
1020 self._length += len(text)
1021 return self
1023 def append_tokens(
1024 self, tokens: Iterable[Tuple[str, Optional[StyleType]]]
1025 ) -> "Text":
1026 """Append iterable of str and style. Style may be a Style instance or a str style definition.
1028 Args:
1029 pairs (Iterable[Tuple[str, Optional[StyleType]]]): An iterable of tuples containing str content and style.
1031 Returns:
1032 Text: Returns self for chaining.
1033 """
1034 append_text = self._text.append
1035 append_span = self._spans.append
1036 _Span = Span
1037 offset = len(self)
1038 for content, style in tokens:
1039 append_text(content)
1040 if style:
1041 append_span(_Span(offset, offset + len(content), style))
1042 offset += len(content)
1043 self._length = offset
1044 return self
1046 def copy_styles(self, text: "Text") -> None:
1047 """Copy styles from another Text instance.
1049 Args:
1050 text (Text): A Text instance to copy styles from, must be the same length.
1051 """
1052 self._spans.extend(text._spans)
1054 def split(
1055 self,
1056 separator: str = "\n",
1057 *,
1058 include_separator: bool = False,
1059 allow_blank: bool = False,
1060 ) -> Lines:
1061 """Split rich text in to lines, preserving styles.
1063 Args:
1064 separator (str, optional): String to split on. Defaults to "\\\\n".
1065 include_separator (bool, optional): Include the separator in the lines. Defaults to False.
1066 allow_blank (bool, optional): Return a blank line if the text ends with a separator. Defaults to False.
1068 Returns:
1069 List[RichText]: A list of rich text, one per line of the original.
1070 """
1071 assert separator, "separator must not be empty"
1073 text = self.plain
1074 if separator not in text:
1075 return Lines([self.copy()])
1077 if include_separator:
1078 lines = self.divide(
1079 match.end() for match in re.finditer(re.escape(separator), text)
1080 )
1081 else:
1083 def flatten_spans() -> Iterable[int]:
1084 for match in re.finditer(re.escape(separator), text):
1085 start, end = match.span()
1086 yield start
1087 yield end
1089 lines = Lines(
1090 line for line in self.divide(flatten_spans()) if line.plain != separator
1091 )
1093 if not allow_blank and text.endswith(separator):
1094 lines.pop()
1096 return lines
1098 def divide(self, offsets: Iterable[int]) -> Lines:
1099 """Divide text in to a number of lines at given offsets.
1101 Args:
1102 offsets (Iterable[int]): Offsets used to divide text.
1104 Returns:
1105 Lines: New RichText instances between offsets.
1106 """
1107 _offsets = list(offsets)
1109 if not _offsets:
1110 return Lines([self.copy()])
1112 text = self.plain
1113 text_length = len(text)
1114 divide_offsets = [0, *_offsets, text_length]
1115 line_ranges = list(zip(divide_offsets, divide_offsets[1:]))
1117 style = self.style
1118 justify = self.justify
1119 overflow = self.overflow
1120 _Text = Text
1121 new_lines = Lines(
1122 _Text(
1123 text[start:end],
1124 style=style,
1125 justify=justify,
1126 overflow=overflow,
1127 )
1128 for start, end in line_ranges
1129 )
1130 if not self._spans:
1131 return new_lines
1133 _line_appends = [line._spans.append for line in new_lines._lines]
1134 line_count = len(line_ranges)
1135 _Span = Span
1137 for span_start, span_end, style in self._spans:
1138 lower_bound = 0
1139 upper_bound = line_count
1140 start_line_no = (lower_bound + upper_bound) // 2
1142 while True:
1143 line_start, line_end = line_ranges[start_line_no]
1144 if span_start < line_start:
1145 upper_bound = start_line_no - 1
1146 elif span_start > line_end:
1147 lower_bound = start_line_no + 1
1148 else:
1149 break
1150 start_line_no = (lower_bound + upper_bound) // 2
1152 if span_end < line_end:
1153 end_line_no = start_line_no
1154 else:
1155 end_line_no = lower_bound = start_line_no
1156 upper_bound = line_count
1158 while True:
1159 line_start, line_end = line_ranges[end_line_no]
1160 if span_end < line_start:
1161 upper_bound = end_line_no - 1
1162 elif span_end > line_end:
1163 lower_bound = end_line_no + 1
1164 else:
1165 break
1166 end_line_no = (lower_bound + upper_bound) // 2
1168 for line_no in range(start_line_no, end_line_no + 1):
1169 line_start, line_end = line_ranges[line_no]
1170 new_start = max(0, span_start - line_start)
1171 new_end = min(span_end - line_start, line_end - line_start)
1172 if new_end > new_start:
1173 _line_appends[line_no](_Span(new_start, new_end, style))
1175 return new_lines
1177 def right_crop(self, amount: int = 1) -> None:
1178 """Remove a number of characters from the end of the text."""
1179 max_offset = len(self.plain) - amount
1180 _Span = Span
1181 self._spans[:] = [
1182 (
1183 span
1184 if span.end < max_offset
1185 else _Span(span.start, min(max_offset, span.end), span.style)
1186 )
1187 for span in self._spans
1188 if span.start < max_offset
1189 ]
1190 self._text = [self.plain[:-amount]]
1191 self._length -= amount
1193 def wrap(
1194 self,
1195 console: "Console",
1196 width: int,
1197 *,
1198 justify: Optional["JustifyMethod"] = None,
1199 overflow: Optional["OverflowMethod"] = None,
1200 tab_size: int = 8,
1201 no_wrap: Optional[bool] = None,
1202 ) -> Lines:
1203 """Word wrap the text.
1205 Args:
1206 console (Console): Console instance.
1207 width (int): Number of characters per line.
1208 emoji (bool, optional): Also render emoji code. Defaults to True.
1209 justify (str, optional): Justify method: "default", "left", "center", "full", "right". Defaults to "default".
1210 overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None.
1211 tab_size (int, optional): Default tab size. Defaults to 8.
1212 no_wrap (bool, optional): Disable wrapping, Defaults to False.
1214 Returns:
1215 Lines: Number of lines.
1216 """
1217 wrap_justify = justify or self.justify or DEFAULT_JUSTIFY
1218 wrap_overflow = overflow or self.overflow or DEFAULT_OVERFLOW
1220 no_wrap = pick_bool(no_wrap, self.no_wrap, False) or overflow == "ignore"
1222 lines = Lines()
1223 for line in self.split(allow_blank=True):
1224 if "\t" in line:
1225 line.expand_tabs(tab_size)
1226 if no_wrap:
1227 new_lines = Lines([line])
1228 else:
1229 offsets = divide_line(str(line), width, fold=wrap_overflow == "fold")
1230 new_lines = line.divide(offsets)
1231 for line in new_lines:
1232 line.rstrip_end(width)
1233 if wrap_justify:
1234 new_lines.justify(
1235 console, width, justify=wrap_justify, overflow=wrap_overflow
1236 )
1237 for line in new_lines:
1238 line.truncate(width, overflow=wrap_overflow)
1239 lines.extend(new_lines)
1240 return lines
1242 def fit(self, width: int) -> Lines:
1243 """Fit the text in to given width by chopping in to lines.
1245 Args:
1246 width (int): Maximum characters in a line.
1248 Returns:
1249 Lines: Lines container.
1250 """
1251 lines: Lines = Lines()
1252 append = lines.append
1253 for line in self.split():
1254 line.set_length(width)
1255 append(line)
1256 return lines
1258 def detect_indentation(self) -> int:
1259 """Auto-detect indentation of code.
1261 Returns:
1262 int: Number of spaces used to indent code.
1263 """
1265 _indentations = {
1266 len(match.group(1))
1267 for match in re.finditer(r"^( *)(.*)$", self.plain, flags=re.MULTILINE)
1268 }
1270 try:
1271 indentation = (
1272 reduce(gcd, [indent for indent in _indentations if not indent % 2]) or 1
1273 )
1274 except TypeError:
1275 indentation = 1
1277 return indentation
1279 def with_indent_guides(
1280 self,
1281 indent_size: Optional[int] = None,
1282 *,
1283 character: str = "│",
1284 style: StyleType = "dim green",
1285 ) -> "Text":
1286 """Adds indent guide lines to text.
1288 Args:
1289 indent_size (Optional[int]): Size of indentation, or None to auto detect. Defaults to None.
1290 character (str, optional): Character to use for indentation. Defaults to "│".
1291 style (Union[Style, str], optional): Style of indent guides.
1293 Returns:
1294 Text: New text with indentation guides.
1295 """
1297 _indent_size = self.detect_indentation() if indent_size is None else indent_size
1299 text = self.copy()
1300 text.expand_tabs()
1301 indent_line = f"{character}{' ' * (_indent_size - 1)}"
1303 re_indent = re.compile(r"^( *)(.*)$")
1304 new_lines: List[Text] = []
1305 add_line = new_lines.append
1306 blank_lines = 0
1307 for line in text.split(allow_blank=True):
1308 match = re_indent.match(line.plain)
1309 if not match or not match.group(2):
1310 blank_lines += 1
1311 continue
1312 indent = match.group(1)
1313 full_indents, remaining_space = divmod(len(indent), _indent_size)
1314 new_indent = f"{indent_line * full_indents}{' ' * remaining_space}"
1315 line.plain = new_indent + line.plain[len(new_indent) :]
1316 line.stylize(style, 0, len(new_indent))
1317 if blank_lines:
1318 new_lines.extend([Text(new_indent, style=style)] * blank_lines)
1319 blank_lines = 0
1320 add_line(line)
1321 if blank_lines:
1322 new_lines.extend([Text("", style=style)] * blank_lines)
1324 new_text = text.blank_copy("\n").join(new_lines)
1325 return new_text
1328if __name__ == "__main__": # pragma: no cover
1329 from rich.console import Console
1331 text = Text(
1332 """\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"""
1333 )
1334 text.highlight_words(["Lorem"], "bold")
1335 text.highlight_words(["ipsum"], "italic")
1337 console = Console()
1339 console.rule("justify='left'")
1340 console.print(text, style="red")
1341 console.print()
1343 console.rule("justify='center'")
1344 console.print(text, style="green", justify="center")
1345 console.print()
1347 console.rule("justify='right'")
1348 console.print(text, style="blue", justify="right")
1349 console.print()
1351 console.rule("justify='full'")
1352 console.print(text, style="magenta", justify="full")
1353 console.print()