Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/rich/text.py: 39%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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 :class:`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 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".
281 Returns:
282 Text: A Text instance with markup rendered.
283 """
284 from .markup import render
286 rendered_text = render(text, style, emoji=emoji, emoji_variant=emoji_variant)
287 rendered_text.justify = justify
288 rendered_text.overflow = overflow
289 rendered_text.end = end
290 return rendered_text
292 @classmethod
293 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.
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 AnsiDecoder
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 result
330 @classmethod
331 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 used
340 to pad the text when it is justified.
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.
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_text
355 @classmethod
356 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.
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 None
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.append
391 _Text = Text
392 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 text
401 @property
402 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]
408 @plain.setter
409 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._length
415 self._length = len(sanitized_text)
416 if old_length > self._length:
417 self._trim_spans()
419 @property
420 def spans(self) -> List[Span]:
421 """Get a reference to the internal list of spans."""
422 return self._spans
424 @spans.setter
425 def spans(self, spans: List[Span]) -> None:
426 """Set spans."""
427 self._spans = spans[:]
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_self
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._spans
454 return copy_self
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.
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 + start
473 if end is None:
474 end = length
475 if end < 0:
476 end = length + end
477 if start >= length or end <= start:
478 # Span not in text or not valid
479 return
480 self._spans.append(Span(start, min(length, end), style))
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.
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 + start
499 if end is None:
500 end = length
501 if end < 0:
502 end = length + end
503 if start >= length or end <= start:
504 # Span not in text or not valid
505 return
506 self._spans.insert(0, Span(start, min(length, end), style))
508 def apply_meta(
509 self, meta: Dict[str, Any], start: int = 0, end: Optional[int] = None
510 ) -> None:
511 """Apply metadata to the text, or a portion of the text.
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.
518 """
519 style = Style.from_meta(meta)
520 self.stylize(style, start=start, end=end)
522 def on(self, meta: Optional[Dict[str, Any]] = None, **handlers: Any) -> "Text":
523 """Apply event handlers (used by Textual project).
525 Example:
526 >>> from rich.text import Text
527 >>> text = Text("hello world")
528 >>> text.on(click="view.toggle('world')")
530 Args:
531 meta (Dict[str, Any]): Mapping of meta information.
532 **handlers: Keyword args are prefixed with "@" to defined handlers.
534 Returns:
535 Text: Self is returned to method may be chained.
536 """
537 meta = {} if meta is None else meta
538 meta.update({f"@{key}": value for key, value in handlers.items()})
539 self.stylize(Style.from_meta(meta))
540 return self
542 def remove_suffix(self, suffix: str) -> None:
543 """Remove a suffix if it exists.
545 Args:
546 suffix (str): Suffix to remove.
547 """
548 if self.plain.endswith(suffix):
549 self.right_crop(len(suffix))
551 def get_style_at_offset(self, console: "Console", offset: int) -> Style:
552 """Get the style of a character at give offset.
554 Args:
555 console (~Console): Console where text will be rendered.
556 offset (int): Offset in to text (negative indexing supported)
558 Returns:
559 Style: A Style instance.
560 """
561 # TODO: This is a little inefficient, it is only used by full justify
562 if offset < 0:
563 offset = len(self) + offset
564 get_style = console.get_style
565 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 style
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.
574 Args:
575 spaces (int): Number of spaces to add to the Text.
576 """
577 if spaces <= 0:
578 return
579 spans = self.spans
580 new_spaces = " " * spaces
581 if spans:
582 end_offset = len(self)
583 self._spans[:] = [
584 span.extend(spaces) if span.end >= end_offset else span
585 for span in spans
586 ]
587 self._text.append(new_spaces)
588 self._length += spaces
589 else:
590 self.plain += new_spaces
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 are
600 translated to styles.
602 Args:
603 re_highlight (str): A regular expression.
604 style (Union[GetStyleCallable, StyleType]): Optional style to apply to whole match, or a callable
605 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.
608 Returns:
609 int: Number of regex matches
610 """
611 count = 0
612 append_span = self._spans.append
613 _Span = Span
614 plain = self.plain
615 for match in re.finditer(re_highlight, plain):
616 get_span = match.span
617 if style:
618 start, end = get_span()
619 match_style = style(plain[start:end]) if callable(style) else style
620 if match_style is not None and end > start:
621 append_span(_Span(start, end, match_style))
623 count += 1
624 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 count
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.
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.
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.append
649 count = 0
650 _Span = Span
651 for match in re.finditer(
652 re_words, self.plain, flags=0 if case_sensitive else re.IGNORECASE
653 ):
654 start, end = match.span(0)
655 add_span(_Span(start, end, style))
656 count += 1
657 return count
659 def rstrip(self) -> None:
660 """Strip whitespace from end of text."""
661 self.plain = self.plain.rstrip()
663 def rstrip_end(self, size: int) -> None:
664 """Remove whitespace beyond a certain width at the end of the text.
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 - size
672 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))
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)
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_size
690 justify = self.justify or options.justify or DEFAULT_JUSTIFY
692 overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW
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)
705 def __rich_measure__(
706 self, console: "Console", options: "ConsoleOptions"
707 ) -> Measurement:
708 text = self.plain
709 lines = text.splitlines()
710 max_text_width = max(cell_len(line) for line in lines) if lines else 0
711 words = text.split()
712 min_text_width = (
713 max(cell_len(word) for word in words) if words else max_text_width
714 )
715 return Measurement(min_text_width, max_text_width)
717 def render(self, console: "Console", end: str = "") -> Iterable["Segment"]:
718 """Render the text as Segments.
720 Args:
721 console (Console): Console instance.
722 end (Optional[str], optional): Optional end character.
724 Returns:
725 Iterable[Segment]: Result of render that may be written to the console.
726 """
727 _Segment = Segment
728 text = self.plain
729 if not self._spans:
730 yield Segment(text)
731 if end:
732 yield _Segment(end)
733 return
734 get_style = partial(console.get_style, default=Style.null())
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)
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))
748 stack: List[int] = []
749 stack_append = stack.append
750 stack_pop = stack.remove
752 style_cache: Dict[Tuple[Style, ...], Style] = {}
753 style_cache_get = style_cache.get
754 combine = Style.combine
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_style
762 current_style = combine(styles)
763 style_cache[styles] = current_style
764 return current_style
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)
776 def join(self, lines: Iterable["Text"]) -> "Text":
777 """Join text together with this instance as the separator.
779 Args:
780 lines (Iterable[Text]): An iterable of Text instances to join.
782 Returns:
783 Text: A new text instance containing join text.
784 """
786 new_text = self.blank_copy()
788 def iter_text() -> Iterable["Text"]:
789 if self.plain:
790 for last, line in loop_last(lines):
791 yield line
792 if not last:
793 yield self
794 else:
795 yield from lines
797 extend_text = new_text._text.extend
798 append_span = new_text._spans.append
799 extend_spans = new_text._spans.extend
800 offset = 0
801 _Span = Span
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._spans
810 )
811 offset += len(text)
812 new_text._length = offset
813 return new_text
815 def expand_tabs(self, tab_size: Optional[int] = None) -> None:
816 """Converts tabs to spaces.
818 Args:
819 tab_size (int, optional): Size of tabs. Defaults to 8.
821 """
822 if "\t" not in self.plain:
823 return
824 if tab_size is None:
825 tab_size = self.tab_size
826 if tab_size is None:
827 tab_size = 8
829 new_text: List[Text] = []
830 append = new_text.append
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 = 0
837 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_len
842 tab_remainder = cell_position % tab_size
843 if tab_remainder:
844 spaces = tab_size - tab_remainder
845 part.extend_style(spaces)
846 cell_position += spaces
847 else:
848 cell_position += part.cell_len
849 append(part)
851 result = Text("").join(new_text)
853 self._text = [result.plain]
854 self._length = len(self.plain)
855 self._spans[:] = result._spans
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.
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_OVERFLOW
872 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 - length
881 self._text = [f"{self.plain}{' ' * spaces}"]
882 self._length = len(self.plain)
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 = Span
888 self._spans[:] = [
889 (
890 span
891 if span.end < max_offset
892 else _Span(span.start, min(max_offset, span.end), span.style)
893 )
894 for span in self._spans
895 if span.start < max_offset
896 ]
898 def pad(self, count: int, character: str = " ") -> None:
899 """Pad left and right with a given number of characters.
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 * count
908 self.plain = f"{pad_characters}{self.plain}{pad_characters}"
909 _Span = Span
910 self._spans[:] = [
911 _Span(start + count, end + count, style)
912 for start, end, style in self._spans
913 ]
915 def pad_left(self, count: int, character: str = " ") -> None:
916 """Pad the left with a given character.
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 = Span
926 self._spans[:] = [
927 _Span(start + count, end + count, style)
928 for start, end, style in self._spans
929 ]
931 def pad_right(self, count: int, character: str = " ") -> None:
932 """Pad the right with a given character.
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}"
942 def align(self, align: AlignMethod, width: int, character: str = " ") -> None:
943 """Align text to a given width.
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 // 2
957 self.pad_left(left, character)
958 self.pad_right(excess_space - left, character)
959 else:
960 self.pad_left(excess_space, character)
962 def append(
963 self, text: Union["Text", str], style: Optional[Union[str, "Style"]] = None
964 ) -> "Text":
965 """Add text with an optional style.
967 Args:
968 text (Union[Text, str]): A str or Text to append.
969 style (str, optional): A style name. Defaults to None.
971 Returns:
972 Text: Returns self for chaining.
973 """
975 if not isinstance(text, (str, Text)):
976 raise TypeError("Only str or Text can be appended to Text")
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_length
987 elif isinstance(text, Text):
988 _Span = Span
989 if style is not None:
990 raise ValueError(
991 "style must not be set when appending Text instance"
992 )
993 text_length = self._length
994 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._spans
1002 )
1003 self._length += len(text)
1004 return self
1006 def append_text(self, text: "Text") -> "Text":
1007 """Append another Text instance. This method is more performant that Text.append, but
1008 only works for Text.
1010 Args:
1011 text (Text): The Text instance to append to this instance.
1013 Returns:
1014 Text: Returns self for chaining.
1015 """
1016 _Span = Span
1017 text_length = self._length
1018 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._spans
1024 )
1025 self._length += len(text)
1026 return self
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.
1033 Args:
1034 tokens (Iterable[Tuple[str, Optional[StyleType]]]): An iterable of tuples containing str content and style.
1036 Returns:
1037 Text: Returns self for chaining.
1038 """
1039 append_text = self._text.append
1040 append_span = self._spans.append
1041 _Span = Span
1042 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 = offset
1049 return self
1051 def copy_styles(self, text: "Text") -> None:
1052 """Copy styles from another Text instance.
1054 Args:
1055 text (Text): A Text instance to copy styles from, must be the same length.
1056 """
1057 self._spans.extend(text._spans)
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.
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.
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"
1078 text = self.plain
1079 if separator not in text:
1080 return Lines([self.copy()])
1082 if include_separator:
1083 lines = self.divide(
1084 match.end() for match in re.finditer(re.escape(separator), text)
1085 )
1086 else:
1088 def flatten_spans() -> Iterable[int]:
1089 for match in re.finditer(re.escape(separator), text):
1090 start, end = match.span()
1091 yield start
1092 yield end
1094 lines = Lines(
1095 line for line in self.divide(flatten_spans()) if line.plain != separator
1096 )
1098 if not allow_blank and text.endswith(separator):
1099 lines.pop()
1101 return lines
1103 def divide(self, offsets: Iterable[int]) -> Lines:
1104 """Divide text in to a number of lines at given offsets.
1106 Args:
1107 offsets (Iterable[int]): Offsets used to divide text.
1109 Returns:
1110 Lines: New RichText instances between offsets.
1111 """
1112 _offsets = list(offsets)
1114 if not _offsets:
1115 return Lines([self.copy()])
1117 text = self.plain
1118 text_length = len(text)
1119 divide_offsets = [0, *_offsets, text_length]
1120 line_ranges = list(zip(divide_offsets, divide_offsets[1:]))
1122 style = self.style
1123 justify = self.justify
1124 overflow = self.overflow
1125 _Text = Text
1126 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_ranges
1134 )
1135 if not self._spans:
1136 return new_lines
1138 _line_appends = [line._spans.append for line in new_lines._lines]
1139 line_count = len(line_ranges)
1140 _Span = Span
1142 for span_start, span_end, style in self._spans:
1143 lower_bound = 0
1144 upper_bound = line_count
1145 start_line_no = (lower_bound + upper_bound) // 2
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 - 1
1151 elif span_start > line_end:
1152 lower_bound = start_line_no + 1
1153 else:
1154 break
1155 start_line_no = (lower_bound + upper_bound) // 2
1157 if span_end < line_end:
1158 end_line_no = start_line_no
1159 else:
1160 end_line_no = lower_bound = start_line_no
1161 upper_bound = line_count
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 - 1
1167 elif span_end > line_end:
1168 lower_bound = end_line_no + 1
1169 else:
1170 break
1171 end_line_no = (lower_bound + upper_bound) // 2
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))
1180 return new_lines
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) - amount
1185 _Span = Span
1186 self._spans[:] = [
1187 (
1188 span
1189 if span.end < max_offset
1190 else _Span(span.start, min(max_offset, span.end), span.style)
1191 )
1192 for span in self._spans
1193 if span.start < max_offset
1194 ]
1195 self._text = [self.plain[:-amount]]
1196 self._length -= amount
1198 def wrap(
1199 self,
1200 console: "Console",
1201 width: int,
1202 *,
1203 justify: Optional["JustifyMethod"] = None,
1204 overflow: Optional["OverflowMethod"] = None,
1205 tab_size: int = 8,
1206 no_wrap: Optional[bool] = None,
1207 ) -> Lines:
1208 """Word wrap the text.
1210 Args:
1211 console (Console): Console instance.
1212 width (int): Number of cells available per line.
1213 justify (str, optional): Justify method: "default", "left", "center", "full", "right". Defaults to "default".
1214 overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None.
1215 tab_size (int, optional): Default tab size. Defaults to 8.
1216 no_wrap (bool, optional): Disable wrapping, Defaults to False.
1218 Returns:
1219 Lines: Number of lines.
1220 """
1221 wrap_justify = justify or self.justify or DEFAULT_JUSTIFY
1222 wrap_overflow = overflow or self.overflow or DEFAULT_OVERFLOW
1224 no_wrap = pick_bool(no_wrap, self.no_wrap, False) or overflow == "ignore"
1226 lines = Lines()
1227 for line in self.split(allow_blank=True):
1228 if "\t" in line:
1229 line.expand_tabs(tab_size)
1230 if no_wrap:
1231 new_lines = Lines([line])
1232 else:
1233 offsets = divide_line(str(line), width, fold=wrap_overflow == "fold")
1234 new_lines = line.divide(offsets)
1235 for line in new_lines:
1236 line.rstrip_end(width)
1237 if wrap_justify:
1238 new_lines.justify(
1239 console, width, justify=wrap_justify, overflow=wrap_overflow
1240 )
1241 for line in new_lines:
1242 line.truncate(width, overflow=wrap_overflow)
1243 lines.extend(new_lines)
1244 return lines
1246 def fit(self, width: int) -> Lines:
1247 """Fit the text in to given width by chopping in to lines.
1249 Args:
1250 width (int): Maximum characters in a line.
1252 Returns:
1253 Lines: Lines container.
1254 """
1255 lines: Lines = Lines()
1256 append = lines.append
1257 for line in self.split():
1258 line.set_length(width)
1259 append(line)
1260 return lines
1262 def detect_indentation(self) -> int:
1263 """Auto-detect indentation of code.
1265 Returns:
1266 int: Number of spaces used to indent code.
1267 """
1269 _indentations = {
1270 len(match.group(1))
1271 for match in re.finditer(r"^( *)(.*)$", self.plain, flags=re.MULTILINE)
1272 }
1274 try:
1275 indentation = (
1276 reduce(gcd, [indent for indent in _indentations if not indent % 2]) or 1
1277 )
1278 except TypeError:
1279 indentation = 1
1281 return indentation
1283 def with_indent_guides(
1284 self,
1285 indent_size: Optional[int] = None,
1286 *,
1287 character: str = "│",
1288 style: StyleType = "dim green",
1289 ) -> "Text":
1290 """Adds indent guide lines to text.
1292 Args:
1293 indent_size (Optional[int]): Size of indentation, or None to auto detect. Defaults to None.
1294 character (str, optional): Character to use for indentation. Defaults to "│".
1295 style (Union[Style, str], optional): Style of indent guides.
1297 Returns:
1298 Text: New text with indentation guides.
1299 """
1301 _indent_size = self.detect_indentation() if indent_size is None else indent_size
1303 text = self.copy()
1304 text.expand_tabs()
1305 indent_line = f"{character}{' ' * (_indent_size - 1)}"
1307 re_indent = re.compile(r"^( *)(.*)$")
1308 new_lines: List[Text] = []
1309 add_line = new_lines.append
1310 blank_lines = 0
1311 for line in text.split(allow_blank=True):
1312 match = re_indent.match(line.plain)
1313 if not match or not match.group(2):
1314 blank_lines += 1
1315 continue
1316 indent = match.group(1)
1317 full_indents, remaining_space = divmod(len(indent), _indent_size)
1318 new_indent = f"{indent_line * full_indents}{' ' * remaining_space}"
1319 line.plain = new_indent + line.plain[len(new_indent) :]
1320 line.stylize(style, 0, len(new_indent))
1321 if blank_lines:
1322 new_lines.extend([Text(new_indent, style=style)] * blank_lines)
1323 blank_lines = 0
1324 add_line(line)
1325 if blank_lines:
1326 new_lines.extend([Text("", style=style)] * blank_lines)
1328 new_text = text.blank_copy("\n").join(new_lines)
1329 return new_text
1332if __name__ == "__main__": # pragma: no cover
1333 from rich.console import Console
1335 text = Text(
1336 """\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"""
1337 )
1338 text.highlight_words(["Lorem"], "bold")
1339 text.highlight_words(["ipsum"], "italic")
1341 console = Console()
1343 console.rule("justify='left'")
1344 console.print(text, style="red")
1345 console.print()
1347 console.rule("justify='center'")
1348 console.print(text, style="green", justify="center")
1349 console.print()
1351 console.rule("justify='right'")
1352 console.print(text, style="blue", justify="right")
1353 console.print()
1355 console.rule("justify='full'")
1356 console.print(text, style="magenta", justify="full")
1357 console.print()