Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/rich/text.py: 40%
580 statements
« prev ^ index » next coverage.py v7.0.1, created at 2022-12-25 06:11 +0000
« prev ^ index » next coverage.py v7.0.1, created at 2022-12-25 06:11 +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"]
42GetStyleCallable = Callable[[str], Optional[StyleType]]
45class Span(NamedTuple):
46 """A marked up region in some text."""
48 start: int
49 """Span start index."""
50 end: int
51 """Span end index."""
52 style: Union[str, Style]
53 """Style associated with the span."""
55 def __repr__(self) -> str:
56 return (
57 f"Span({self.start}, {self.end}, {self.style!r})"
58 if (isinstance(self.style, Style) and self.style._meta)
59 else f"Span({self.start}, {self.end}, {repr(self.style)})"
60 )
62 def __bool__(self) -> bool:
63 return self.end > self.start
65 def split(self, offset: int) -> Tuple["Span", Optional["Span"]]:
66 """Split a span in to 2 from a given offset."""
68 if offset < self.start:
69 return self, None
70 if offset >= self.end:
71 return self, None
73 start, end, style = self
74 span1 = Span(start, min(end, offset), style)
75 span2 = Span(span1.end, end, style)
76 return span1, span2
78 def move(self, offset: int) -> "Span":
79 """Move start and end by a given offset.
81 Args:
82 offset (int): Number of characters to add to start and end.
84 Returns:
85 TextSpan: A new TextSpan with adjusted position.
86 """
87 start, end, style = self
88 return Span(start + offset, end + offset, style)
90 def right_crop(self, offset: int) -> "Span":
91 """Crop the span at the given offset.
93 Args:
94 offset (int): A value between start and end.
96 Returns:
97 Span: A new (possibly smaller) span.
98 """
99 start, end, style = self
100 if offset >= end:
101 return self
102 return Span(start, min(offset, end), style)
105class Text(JupyterMixin):
106 """Text with color / style.
108 Args:
109 text (str, optional): Default unstyled text. Defaults to "".
110 style (Union[str, Style], optional): Base style for text. Defaults to "".
111 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
112 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
113 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.
114 end (str, optional): Character to end text with. Defaults to "\\\\n".
115 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to 8.
116 spans (List[Span], optional). A list of predefined style spans. Defaults to None.
117 """
119 __slots__ = [
120 "_text",
121 "style",
122 "justify",
123 "overflow",
124 "no_wrap",
125 "end",
126 "tab_size",
127 "_spans",
128 "_length",
129 ]
131 def __init__(
132 self,
133 text: str = "",
134 style: Union[str, Style] = "",
135 *,
136 justify: Optional["JustifyMethod"] = None,
137 overflow: Optional["OverflowMethod"] = None,
138 no_wrap: Optional[bool] = None,
139 end: str = "\n",
140 tab_size: Optional[int] = 8,
141 spans: Optional[List[Span]] = None,
142 ) -> None:
143 sanitized_text = strip_control_codes(text)
144 self._text = [sanitized_text]
145 self.style = style
146 self.justify: Optional["JustifyMethod"] = justify
147 self.overflow: Optional["OverflowMethod"] = overflow
148 self.no_wrap = no_wrap
149 self.end = end
150 self.tab_size = tab_size
151 self._spans: List[Span] = spans or []
152 self._length: int = len(sanitized_text)
154 def __len__(self) -> int:
155 return self._length
157 def __bool__(self) -> bool:
158 return bool(self._length)
160 def __str__(self) -> str:
161 return self.plain
163 def __repr__(self) -> str:
164 return f"<text {self.plain!r} {self._spans!r}>"
166 def __add__(self, other: Any) -> "Text":
167 if isinstance(other, (str, Text)):
168 result = self.copy()
169 result.append(other)
170 return result
171 return NotImplemented
173 def __eq__(self, other: object) -> bool:
174 if not isinstance(other, Text):
175 return NotImplemented
176 return self.plain == other.plain and self._spans == other._spans
178 def __contains__(self, other: object) -> bool:
179 if isinstance(other, str):
180 return other in self.plain
181 elif isinstance(other, Text):
182 return other.plain in self.plain
183 return False
185 def __getitem__(self, slice: Union[int, slice]) -> "Text":
186 def get_text_at(offset: int) -> "Text":
187 _Span = Span
188 text = Text(
189 self.plain[offset],
190 spans=[
191 _Span(0, 1, style)
192 for start, end, style in self._spans
193 if end > offset >= start
194 ],
195 end="",
196 )
197 return text
199 if isinstance(slice, int):
200 return get_text_at(slice)
201 else:
202 start, stop, step = slice.indices(len(self.plain))
203 if step == 1:
204 lines = self.divide([start, stop])
205 return lines[1]
206 else:
207 # This would be a bit of work to implement efficiently
208 # For now, its not required
209 raise TypeError("slices with step!=1 are not supported")
211 @property
212 def cell_len(self) -> int:
213 """Get the number of cells required to render this text."""
214 return cell_len(self.plain)
216 @property
217 def markup(self) -> str:
218 """Get console markup to render this Text.
220 Returns:
221 str: A string potentially creating markup tags.
222 """
223 from .markup import escape
225 output: List[str] = []
227 plain = self.plain
228 markup_spans = [
229 (0, False, self.style),
230 *((span.start, False, span.style) for span in self._spans),
231 *((span.end, True, span.style) for span in self._spans),
232 (len(plain), True, self.style),
233 ]
234 markup_spans.sort(key=itemgetter(0, 1))
235 position = 0
236 append = output.append
237 for offset, closing, style in markup_spans:
238 if offset > position:
239 append(escape(plain[position:offset]))
240 position = offset
241 if style:
242 append(f"[/{style}]" if closing else f"[{style}]")
243 markup = "".join(output)
244 return markup
246 @classmethod
247 def from_markup(
248 cls,
249 text: str,
250 *,
251 style: Union[str, Style] = "",
252 emoji: bool = True,
253 emoji_variant: Optional[EmojiVariant] = None,
254 justify: Optional["JustifyMethod"] = None,
255 overflow: Optional["OverflowMethod"] = None,
256 end: str = "\n",
257 ) -> "Text":
258 """Create Text instance from markup.
260 Args:
261 text (str): A string containing console markup.
262 emoji (bool, optional): Also render emoji code. Defaults to True.
263 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
264 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
265 end (str, optional): Character to end text with. Defaults to "\\\\n".
267 Returns:
268 Text: A Text instance with markup rendered.
269 """
270 from .markup import render
272 rendered_text = render(text, style, emoji=emoji, emoji_variant=emoji_variant)
273 rendered_text.justify = justify
274 rendered_text.overflow = overflow
275 rendered_text.end = end
276 return rendered_text
278 @classmethod
279 def from_ansi(
280 cls,
281 text: str,
282 *,
283 style: Union[str, Style] = "",
284 justify: Optional["JustifyMethod"] = None,
285 overflow: Optional["OverflowMethod"] = None,
286 no_wrap: Optional[bool] = None,
287 end: str = "\n",
288 tab_size: Optional[int] = 8,
289 ) -> "Text":
290 """Create a Text object from a string containing ANSI escape codes.
292 Args:
293 text (str): A string containing escape codes.
294 style (Union[str, Style], optional): Base style for text. Defaults to "".
295 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
296 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
297 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.
298 end (str, optional): Character to end text with. Defaults to "\\\\n".
299 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to 8.
300 """
301 from .ansi import AnsiDecoder
303 joiner = Text(
304 "\n",
305 justify=justify,
306 overflow=overflow,
307 no_wrap=no_wrap,
308 end=end,
309 tab_size=tab_size,
310 style=style,
311 )
312 decoder = AnsiDecoder()
313 result = joiner.join(line for line in decoder.decode(text))
314 return result
316 @classmethod
317 def styled(
318 cls,
319 text: str,
320 style: StyleType = "",
321 *,
322 justify: Optional["JustifyMethod"] = None,
323 overflow: Optional["OverflowMethod"] = None,
324 ) -> "Text":
325 """Construct a Text instance with a pre-applied styled. A style applied in this way won't be used
326 to pad the text when it is justified.
328 Args:
329 text (str): A string containing console markup.
330 style (Union[str, Style]): Style to apply to the text. Defaults to "".
331 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
332 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
334 Returns:
335 Text: A text instance with a style applied to the entire string.
336 """
337 styled_text = cls(text, justify=justify, overflow=overflow)
338 styled_text.stylize(style)
339 return styled_text
341 @classmethod
342 def assemble(
343 cls,
344 *parts: Union[str, "Text", Tuple[str, StyleType]],
345 style: Union[str, Style] = "",
346 justify: Optional["JustifyMethod"] = None,
347 overflow: Optional["OverflowMethod"] = None,
348 no_wrap: Optional[bool] = None,
349 end: str = "\n",
350 tab_size: int = 8,
351 meta: Optional[Dict[str, Any]] = None,
352 ) -> "Text":
353 """Construct a text instance by combining a sequence of strings with optional styles.
354 The positional arguments should be either strings, or a tuple of string + style.
356 Args:
357 style (Union[str, Style], optional): Base style for text. Defaults to "".
358 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
359 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
360 end (str, optional): Character to end text with. Defaults to "\\\\n".
361 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to 8.
362 meta (Dict[str, Any], optional). Meta data to apply to text, or None for no meta data. Default to None
364 Returns:
365 Text: A new text instance.
366 """
367 text = cls(
368 style=style,
369 justify=justify,
370 overflow=overflow,
371 no_wrap=no_wrap,
372 end=end,
373 tab_size=tab_size,
374 )
375 append = text.append
376 _Text = Text
377 for part in parts:
378 if isinstance(part, (_Text, str)):
379 append(part)
380 else:
381 append(*part)
382 if meta:
383 text.apply_meta(meta)
384 return text
386 @property
387 def plain(self) -> str:
388 """Get the text as a single string."""
389 if len(self._text) != 1:
390 self._text[:] = ["".join(self._text)]
391 return self._text[0]
393 @plain.setter
394 def plain(self, new_text: str) -> None:
395 """Set the text to a new value."""
396 if new_text != self.plain:
397 sanitized_text = strip_control_codes(new_text)
398 self._text[:] = [sanitized_text]
399 old_length = self._length
400 self._length = len(sanitized_text)
401 if old_length > self._length:
402 self._trim_spans()
404 @property
405 def spans(self) -> List[Span]:
406 """Get a reference to the internal list of spans."""
407 return self._spans
409 @spans.setter
410 def spans(self, spans: List[Span]) -> None:
411 """Set spans."""
412 self._spans = spans[:]
414 def blank_copy(self, plain: str = "") -> "Text":
415 """Return a new Text instance with copied meta data (but not the string or spans)."""
416 copy_self = Text(
417 plain,
418 style=self.style,
419 justify=self.justify,
420 overflow=self.overflow,
421 no_wrap=self.no_wrap,
422 end=self.end,
423 tab_size=self.tab_size,
424 )
425 return copy_self
427 def copy(self) -> "Text":
428 """Return a copy of this instance."""
429 copy_self = Text(
430 self.plain,
431 style=self.style,
432 justify=self.justify,
433 overflow=self.overflow,
434 no_wrap=self.no_wrap,
435 end=self.end,
436 tab_size=self.tab_size,
437 )
438 copy_self._spans[:] = self._spans
439 return copy_self
441 def stylize(
442 self,
443 style: Union[str, Style],
444 start: int = 0,
445 end: Optional[int] = None,
446 ) -> None:
447 """Apply a style to the text, or a portion of the text.
449 Args:
450 style (Union[str, Style]): Style instance or style definition to apply.
451 start (int): Start offset (negative indexing is supported). Defaults to 0.
452 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.
453 """
454 if style:
455 length = len(self)
456 if start < 0:
457 start = length + start
458 if end is None:
459 end = length
460 if end < 0:
461 end = length + end
462 if start >= length or end <= start:
463 # Span not in text or not valid
464 return
465 self._spans.append(Span(start, min(length, end), style))
467 def stylize_before(
468 self,
469 style: Union[str, Style],
470 start: int = 0,
471 end: Optional[int] = None,
472 ) -> None:
473 """Apply a style to the text, or a portion of the text. Styles will be applied before other styles already present.
475 Args:
476 style (Union[str, Style]): Style instance or style definition to apply.
477 start (int): Start offset (negative indexing is supported). Defaults to 0.
478 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.
479 """
480 if style:
481 length = len(self)
482 if start < 0:
483 start = length + start
484 if end is None:
485 end = length
486 if end < 0:
487 end = length + end
488 if start >= length or end <= start:
489 # Span not in text or not valid
490 return
491 self._spans.insert(0, Span(start, min(length, end), style))
493 def apply_meta(
494 self, meta: Dict[str, Any], start: int = 0, end: Optional[int] = None
495 ) -> None:
496 """Apply meta data to the text, or a portion of the text.
498 Args:
499 meta (Dict[str, Any]): A dict of meta information.
500 start (int): Start offset (negative indexing is supported). Defaults to 0.
501 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.
503 """
504 style = Style.from_meta(meta)
505 self.stylize(style, start=start, end=end)
507 def on(self, meta: Optional[Dict[str, Any]] = None, **handlers: Any) -> "Text":
508 """Apply event handlers (used by Textual project).
510 Example:
511 >>> from rich.text import Text
512 >>> text = Text("hello world")
513 >>> text.on(click="view.toggle('world')")
515 Args:
516 meta (Dict[str, Any]): Mapping of meta information.
517 **handlers: Keyword args are prefixed with "@" to defined handlers.
519 Returns:
520 Text: Self is returned to method may be chained.
521 """
522 meta = {} if meta is None else meta
523 meta.update({f"@{key}": value for key, value in handlers.items()})
524 self.stylize(Style.from_meta(meta))
525 return self
527 def remove_suffix(self, suffix: str) -> None:
528 """Remove a suffix if it exists.
530 Args:
531 suffix (str): Suffix to remove.
532 """
533 if self.plain.endswith(suffix):
534 self.right_crop(len(suffix))
536 def get_style_at_offset(self, console: "Console", offset: int) -> Style:
537 """Get the style of a character at give offset.
539 Args:
540 console (~Console): Console where text will be rendered.
541 offset (int): Offset in to text (negative indexing supported)
543 Returns:
544 Style: A Style instance.
545 """
546 # TODO: This is a little inefficient, it is only used by full justify
547 if offset < 0:
548 offset = len(self) + offset
549 get_style = console.get_style
550 style = get_style(self.style).copy()
551 for start, end, span_style in self._spans:
552 if end > offset >= start:
553 style += get_style(span_style, default="")
554 return style
556 def highlight_regex(
557 self,
558 re_highlight: str,
559 style: Optional[Union[GetStyleCallable, StyleType]] = None,
560 *,
561 style_prefix: str = "",
562 ) -> int:
563 """Highlight text with a regular expression, where group names are
564 translated to styles.
566 Args:
567 re_highlight (str): A regular expression.
568 style (Union[GetStyleCallable, StyleType]): Optional style to apply to whole match, or a callable
569 which accepts the matched text and returns a style. Defaults to None.
570 style_prefix (str, optional): Optional prefix to add to style group names.
572 Returns:
573 int: Number of regex matches
574 """
575 count = 0
576 append_span = self._spans.append
577 _Span = Span
578 plain = self.plain
579 for match in re.finditer(re_highlight, plain):
580 get_span = match.span
581 if style:
582 start, end = get_span()
583 match_style = style(plain[start:end]) if callable(style) else style
584 if match_style is not None and end > start:
585 append_span(_Span(start, end, match_style))
587 count += 1
588 for name in match.groupdict().keys():
589 start, end = get_span(name)
590 if start != -1 and end > start:
591 append_span(_Span(start, end, f"{style_prefix}{name}"))
592 return count
594 def highlight_words(
595 self,
596 words: Iterable[str],
597 style: Union[str, Style],
598 *,
599 case_sensitive: bool = True,
600 ) -> int:
601 """Highlight words with a style.
603 Args:
604 words (Iterable[str]): Worlds to highlight.
605 style (Union[str, Style]): Style to apply.
606 case_sensitive (bool, optional): Enable case sensitive matchings. Defaults to True.
608 Returns:
609 int: Number of words highlighted.
610 """
611 re_words = "|".join(re.escape(word) for word in words)
612 add_span = self._spans.append
613 count = 0
614 _Span = Span
615 for match in re.finditer(
616 re_words, self.plain, flags=0 if case_sensitive else re.IGNORECASE
617 ):
618 start, end = match.span(0)
619 add_span(_Span(start, end, style))
620 count += 1
621 return count
623 def rstrip(self) -> None:
624 """Strip whitespace from end of text."""
625 self.plain = self.plain.rstrip()
627 def rstrip_end(self, size: int) -> None:
628 """Remove whitespace beyond a certain width at the end of the text.
630 Args:
631 size (int): The desired size of the text.
632 """
633 text_length = len(self)
634 if text_length > size:
635 excess = text_length - size
636 whitespace_match = _re_whitespace.search(self.plain)
637 if whitespace_match is not None:
638 whitespace_count = len(whitespace_match.group(0))
639 self.right_crop(min(whitespace_count, excess))
641 def set_length(self, new_length: int) -> None:
642 """Set new length of the text, clipping or padding is required."""
643 length = len(self)
644 if length != new_length:
645 if length < new_length:
646 self.pad_right(new_length - length)
647 else:
648 self.right_crop(length - new_length)
650 def __rich_console__(
651 self, console: "Console", options: "ConsoleOptions"
652 ) -> Iterable[Segment]:
653 tab_size: int = console.tab_size or self.tab_size or 8
654 justify = self.justify or options.justify or DEFAULT_JUSTIFY
656 overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW
658 lines = self.wrap(
659 console,
660 options.max_width,
661 justify=justify,
662 overflow=overflow,
663 tab_size=tab_size or 8,
664 no_wrap=pick_bool(self.no_wrap, options.no_wrap, False),
665 )
666 all_lines = Text("\n").join(lines)
667 yield from all_lines.render(console, end=self.end)
669 def __rich_measure__(
670 self, console: "Console", options: "ConsoleOptions"
671 ) -> Measurement:
672 text = self.plain
673 lines = text.splitlines()
674 max_text_width = max(cell_len(line) for line in lines) if lines else 0
675 words = text.split()
676 min_text_width = (
677 max(cell_len(word) for word in words) if words else max_text_width
678 )
679 return Measurement(min_text_width, max_text_width)
681 def render(self, console: "Console", end: str = "") -> Iterable["Segment"]:
682 """Render the text as Segments.
684 Args:
685 console (Console): Console instance.
686 end (Optional[str], optional): Optional end character.
688 Returns:
689 Iterable[Segment]: Result of render that may be written to the console.
690 """
691 _Segment = Segment
692 text = self.plain
693 if not self._spans:
694 yield Segment(text)
695 if end:
696 yield _Segment(end)
697 return
698 get_style = partial(console.get_style, default=Style.null())
700 enumerated_spans = list(enumerate(self._spans, 1))
701 style_map = {index: get_style(span.style) for index, span in enumerated_spans}
702 style_map[0] = get_style(self.style)
704 spans = [
705 (0, False, 0),
706 *((span.start, False, index) for index, span in enumerated_spans),
707 *((span.end, True, index) for index, span in enumerated_spans),
708 (len(text), True, 0),
709 ]
710 spans.sort(key=itemgetter(0, 1))
712 stack: List[int] = []
713 stack_append = stack.append
714 stack_pop = stack.remove
716 style_cache: Dict[Tuple[Style, ...], Style] = {}
717 style_cache_get = style_cache.get
718 combine = Style.combine
720 def get_current_style() -> Style:
721 """Construct current style from stack."""
722 styles = tuple(style_map[_style_id] for _style_id in sorted(stack))
723 cached_style = style_cache_get(styles)
724 if cached_style is not None:
725 return cached_style
726 current_style = combine(styles)
727 style_cache[styles] = current_style
728 return current_style
730 for (offset, leaving, style_id), (next_offset, _, _) in zip(spans, spans[1:]):
731 if leaving:
732 stack_pop(style_id)
733 else:
734 stack_append(style_id)
735 if next_offset > offset:
736 yield _Segment(text[offset:next_offset], get_current_style())
737 if end:
738 yield _Segment(end)
740 def join(self, lines: Iterable["Text"]) -> "Text":
741 """Join text together with this instance as the separator.
743 Args:
744 lines (Iterable[Text]): An iterable of Text instances to join.
746 Returns:
747 Text: A new text instance containing join text.
748 """
750 new_text = self.blank_copy()
752 def iter_text() -> Iterable["Text"]:
753 if self.plain:
754 for last, line in loop_last(lines):
755 yield line
756 if not last:
757 yield self
758 else:
759 yield from lines
761 extend_text = new_text._text.extend
762 append_span = new_text._spans.append
763 extend_spans = new_text._spans.extend
764 offset = 0
765 _Span = Span
767 for text in iter_text():
768 extend_text(text._text)
769 if text.style:
770 append_span(_Span(offset, offset + len(text), text.style))
771 extend_spans(
772 _Span(offset + start, offset + end, style)
773 for start, end, style in text._spans
774 )
775 offset += len(text)
776 new_text._length = offset
777 return new_text
779 def expand_tabs(self, tab_size: Optional[int] = None) -> None:
780 """Converts tabs to spaces.
782 Args:
783 tab_size (int, optional): Size of tabs. Defaults to 8.
785 """
786 if "\t" not in self.plain:
787 return
788 pos = 0
789 if tab_size is None:
790 tab_size = self.tab_size
791 assert tab_size is not None
792 result = self.blank_copy()
793 append = result.append
795 _style = self.style
796 for line in self.split("\n", include_separator=True):
797 parts = line.split("\t", include_separator=True)
798 for part in parts:
799 if part.plain.endswith("\t"):
800 part._text = [part.plain[:-1] + " "]
801 append(part)
802 pos += len(part)
803 spaces = tab_size - ((pos - 1) % tab_size) - 1
804 if spaces:
805 append(" " * spaces, _style)
806 pos += spaces
807 else:
808 append(part)
809 self._text = [result.plain]
810 self._length = len(self.plain)
811 self._spans[:] = result._spans
813 def truncate(
814 self,
815 max_width: int,
816 *,
817 overflow: Optional["OverflowMethod"] = None,
818 pad: bool = False,
819 ) -> None:
820 """Truncate text if it is longer that a given width.
822 Args:
823 max_width (int): Maximum number of characters in text.
824 overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None, to use self.overflow.
825 pad (bool, optional): Pad with spaces if the length is less than max_width. Defaults to False.
826 """
827 _overflow = overflow or self.overflow or DEFAULT_OVERFLOW
828 if _overflow != "ignore":
829 length = cell_len(self.plain)
830 if length > max_width:
831 if _overflow == "ellipsis":
832 self.plain = set_cell_size(self.plain, max_width - 1) + "…"
833 else:
834 self.plain = set_cell_size(self.plain, max_width)
835 if pad and length < max_width:
836 spaces = max_width - length
837 self._text = [f"{self.plain}{' ' * spaces}"]
838 self._length = len(self.plain)
840 def _trim_spans(self) -> None:
841 """Remove or modify any spans that are over the end of the text."""
842 max_offset = len(self.plain)
843 _Span = Span
844 self._spans[:] = [
845 (
846 span
847 if span.end < max_offset
848 else _Span(span.start, min(max_offset, span.end), span.style)
849 )
850 for span in self._spans
851 if span.start < max_offset
852 ]
854 def pad(self, count: int, character: str = " ") -> None:
855 """Pad left and right with a given number of characters.
857 Args:
858 count (int): Width of padding.
859 """
860 assert len(character) == 1, "Character must be a string of length 1"
861 if count:
862 pad_characters = character * count
863 self.plain = f"{pad_characters}{self.plain}{pad_characters}"
864 _Span = Span
865 self._spans[:] = [
866 _Span(start + count, end + count, style)
867 for start, end, style in self._spans
868 ]
870 def pad_left(self, count: int, character: str = " ") -> None:
871 """Pad the left with a given character.
873 Args:
874 count (int): Number of characters to pad.
875 character (str, optional): Character to pad with. Defaults to " ".
876 """
877 assert len(character) == 1, "Character must be a string of length 1"
878 if count:
879 self.plain = f"{character * count}{self.plain}"
880 _Span = Span
881 self._spans[:] = [
882 _Span(start + count, end + count, style)
883 for start, end, style in self._spans
884 ]
886 def pad_right(self, count: int, character: str = " ") -> None:
887 """Pad the right with a given character.
889 Args:
890 count (int): Number of characters to pad.
891 character (str, optional): Character to pad with. Defaults to " ".
892 """
893 assert len(character) == 1, "Character must be a string of length 1"
894 if count:
895 self.plain = f"{self.plain}{character * count}"
897 def align(self, align: AlignMethod, width: int, character: str = " ") -> None:
898 """Align text to a given width.
900 Args:
901 align (AlignMethod): One of "left", "center", or "right".
902 width (int): Desired width.
903 character (str, optional): Character to pad with. Defaults to " ".
904 """
905 self.truncate(width)
906 excess_space = width - cell_len(self.plain)
907 if excess_space:
908 if align == "left":
909 self.pad_right(excess_space, character)
910 elif align == "center":
911 left = excess_space // 2
912 self.pad_left(left, character)
913 self.pad_right(excess_space - left, character)
914 else:
915 self.pad_left(excess_space, character)
917 def append(
918 self, text: Union["Text", str], style: Optional[Union[str, "Style"]] = None
919 ) -> "Text":
920 """Add text with an optional style.
922 Args:
923 text (Union[Text, str]): A str or Text to append.
924 style (str, optional): A style name. Defaults to None.
926 Returns:
927 Text: Returns self for chaining.
928 """
930 if not isinstance(text, (str, Text)):
931 raise TypeError("Only str or Text can be appended to Text")
933 if len(text):
934 if isinstance(text, str):
935 sanitized_text = strip_control_codes(text)
936 self._text.append(sanitized_text)
937 offset = len(self)
938 text_length = len(sanitized_text)
939 if style is not None:
940 self._spans.append(Span(offset, offset + text_length, style))
941 self._length += text_length
942 elif isinstance(text, Text):
943 _Span = Span
944 if style is not None:
945 raise ValueError(
946 "style must not be set when appending Text instance"
947 )
948 text_length = self._length
949 if text.style is not None:
950 self._spans.append(
951 _Span(text_length, text_length + len(text), text.style)
952 )
953 self._text.append(text.plain)
954 self._spans.extend(
955 _Span(start + text_length, end + text_length, style)
956 for start, end, style in text._spans
957 )
958 self._length += len(text)
959 return self
961 def append_text(self, text: "Text") -> "Text":
962 """Append another Text instance. This method is more performant that Text.append, but
963 only works for Text.
965 Returns:
966 Text: Returns self for chaining.
967 """
968 _Span = Span
969 text_length = self._length
970 if text.style is not None:
971 self._spans.append(_Span(text_length, text_length + len(text), text.style))
972 self._text.append(text.plain)
973 self._spans.extend(
974 _Span(start + text_length, end + text_length, style)
975 for start, end, style in text._spans
976 )
977 self._length += len(text)
978 return self
980 def append_tokens(
981 self, tokens: Iterable[Tuple[str, Optional[StyleType]]]
982 ) -> "Text":
983 """Append iterable of str and style. Style may be a Style instance or a str style definition.
985 Args:
986 pairs (Iterable[Tuple[str, Optional[StyleType]]]): An iterable of tuples containing str content and style.
988 Returns:
989 Text: Returns self for chaining.
990 """
991 append_text = self._text.append
992 append_span = self._spans.append
993 _Span = Span
994 offset = len(self)
995 for content, style in tokens:
996 append_text(content)
997 if style is not None:
998 append_span(_Span(offset, offset + len(content), style))
999 offset += len(content)
1000 self._length = offset
1001 return self
1003 def copy_styles(self, text: "Text") -> None:
1004 """Copy styles from another Text instance.
1006 Args:
1007 text (Text): A Text instance to copy styles from, must be the same length.
1008 """
1009 self._spans.extend(text._spans)
1011 def split(
1012 self,
1013 separator: str = "\n",
1014 *,
1015 include_separator: bool = False,
1016 allow_blank: bool = False,
1017 ) -> Lines:
1018 """Split rich text in to lines, preserving styles.
1020 Args:
1021 separator (str, optional): String to split on. Defaults to "\\\\n".
1022 include_separator (bool, optional): Include the separator in the lines. Defaults to False.
1023 allow_blank (bool, optional): Return a blank line if the text ends with a separator. Defaults to False.
1025 Returns:
1026 List[RichText]: A list of rich text, one per line of the original.
1027 """
1028 assert separator, "separator must not be empty"
1030 text = self.plain
1031 if separator not in text:
1032 return Lines([self.copy()])
1034 if include_separator:
1035 lines = self.divide(
1036 match.end() for match in re.finditer(re.escape(separator), text)
1037 )
1038 else:
1040 def flatten_spans() -> Iterable[int]:
1041 for match in re.finditer(re.escape(separator), text):
1042 start, end = match.span()
1043 yield start
1044 yield end
1046 lines = Lines(
1047 line for line in self.divide(flatten_spans()) if line.plain != separator
1048 )
1050 if not allow_blank and text.endswith(separator):
1051 lines.pop()
1053 return lines
1055 def divide(self, offsets: Iterable[int]) -> Lines:
1056 """Divide text in to a number of lines at given offsets.
1058 Args:
1059 offsets (Iterable[int]): Offsets used to divide text.
1061 Returns:
1062 Lines: New RichText instances between offsets.
1063 """
1064 _offsets = list(offsets)
1066 if not _offsets:
1067 return Lines([self.copy()])
1069 text = self.plain
1070 text_length = len(text)
1071 divide_offsets = [0, *_offsets, text_length]
1072 line_ranges = list(zip(divide_offsets, divide_offsets[1:]))
1074 style = self.style
1075 justify = self.justify
1076 overflow = self.overflow
1077 _Text = Text
1078 new_lines = Lines(
1079 _Text(
1080 text[start:end],
1081 style=style,
1082 justify=justify,
1083 overflow=overflow,
1084 )
1085 for start, end in line_ranges
1086 )
1087 if not self._spans:
1088 return new_lines
1090 _line_appends = [line._spans.append for line in new_lines._lines]
1091 line_count = len(line_ranges)
1092 _Span = Span
1094 for span_start, span_end, style in self._spans:
1096 lower_bound = 0
1097 upper_bound = line_count
1098 start_line_no = (lower_bound + upper_bound) // 2
1100 while True:
1101 line_start, line_end = line_ranges[start_line_no]
1102 if span_start < line_start:
1103 upper_bound = start_line_no - 1
1104 elif span_start > line_end:
1105 lower_bound = start_line_no + 1
1106 else:
1107 break
1108 start_line_no = (lower_bound + upper_bound) // 2
1110 if span_end < line_end:
1111 end_line_no = start_line_no
1112 else:
1113 end_line_no = lower_bound = start_line_no
1114 upper_bound = line_count
1116 while True:
1117 line_start, line_end = line_ranges[end_line_no]
1118 if span_end < line_start:
1119 upper_bound = end_line_no - 1
1120 elif span_end > line_end:
1121 lower_bound = end_line_no + 1
1122 else:
1123 break
1124 end_line_no = (lower_bound + upper_bound) // 2
1126 for line_no in range(start_line_no, end_line_no + 1):
1127 line_start, line_end = line_ranges[line_no]
1128 new_start = max(0, span_start - line_start)
1129 new_end = min(span_end - line_start, line_end - line_start)
1130 if new_end > new_start:
1131 _line_appends[line_no](_Span(new_start, new_end, style))
1133 return new_lines
1135 def right_crop(self, amount: int = 1) -> None:
1136 """Remove a number of characters from the end of the text."""
1137 max_offset = len(self.plain) - amount
1138 _Span = Span
1139 self._spans[:] = [
1140 (
1141 span
1142 if span.end < max_offset
1143 else _Span(span.start, min(max_offset, span.end), span.style)
1144 )
1145 for span in self._spans
1146 if span.start < max_offset
1147 ]
1148 self._text = [self.plain[:-amount]]
1149 self._length -= amount
1151 def wrap(
1152 self,
1153 console: "Console",
1154 width: int,
1155 *,
1156 justify: Optional["JustifyMethod"] = None,
1157 overflow: Optional["OverflowMethod"] = None,
1158 tab_size: int = 8,
1159 no_wrap: Optional[bool] = None,
1160 ) -> Lines:
1161 """Word wrap the text.
1163 Args:
1164 console (Console): Console instance.
1165 width (int): Number of characters per line.
1166 emoji (bool, optional): Also render emoji code. Defaults to True.
1167 justify (str, optional): Justify method: "default", "left", "center", "full", "right". Defaults to "default".
1168 overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None.
1169 tab_size (int, optional): Default tab size. Defaults to 8.
1170 no_wrap (bool, optional): Disable wrapping, Defaults to False.
1172 Returns:
1173 Lines: Number of lines.
1174 """
1175 wrap_justify = justify or self.justify or DEFAULT_JUSTIFY
1176 wrap_overflow = overflow or self.overflow or DEFAULT_OVERFLOW
1178 no_wrap = pick_bool(no_wrap, self.no_wrap, False) or overflow == "ignore"
1180 lines = Lines()
1181 for line in self.split(allow_blank=True):
1182 if "\t" in line:
1183 line.expand_tabs(tab_size)
1184 if no_wrap:
1185 new_lines = Lines([line])
1186 else:
1187 offsets = divide_line(str(line), width, fold=wrap_overflow == "fold")
1188 new_lines = line.divide(offsets)
1189 for line in new_lines:
1190 line.rstrip_end(width)
1191 if wrap_justify:
1192 new_lines.justify(
1193 console, width, justify=wrap_justify, overflow=wrap_overflow
1194 )
1195 for line in new_lines:
1196 line.truncate(width, overflow=wrap_overflow)
1197 lines.extend(new_lines)
1198 return lines
1200 def fit(self, width: int) -> Lines:
1201 """Fit the text in to given width by chopping in to lines.
1203 Args:
1204 width (int): Maximum characters in a line.
1206 Returns:
1207 Lines: List of lines.
1208 """
1209 lines: Lines = Lines()
1210 append = lines.append
1211 for line in self.split():
1212 line.set_length(width)
1213 append(line)
1214 return lines
1216 def detect_indentation(self) -> int:
1217 """Auto-detect indentation of code.
1219 Returns:
1220 int: Number of spaces used to indent code.
1221 """
1223 _indentations = {
1224 len(match.group(1))
1225 for match in re.finditer(r"^( *)(.*)$", self.plain, flags=re.MULTILINE)
1226 }
1228 try:
1229 indentation = (
1230 reduce(gcd, [indent for indent in _indentations if not indent % 2]) or 1
1231 )
1232 except TypeError:
1233 indentation = 1
1235 return indentation
1237 def with_indent_guides(
1238 self,
1239 indent_size: Optional[int] = None,
1240 *,
1241 character: str = "│",
1242 style: StyleType = "dim green",
1243 ) -> "Text":
1244 """Adds indent guide lines to text.
1246 Args:
1247 indent_size (Optional[int]): Size of indentation, or None to auto detect. Defaults to None.
1248 character (str, optional): Character to use for indentation. Defaults to "│".
1249 style (Union[Style, str], optional): Style of indent guides.
1251 Returns:
1252 Text: New text with indentation guides.
1253 """
1255 _indent_size = self.detect_indentation() if indent_size is None else indent_size
1257 text = self.copy()
1258 text.expand_tabs()
1259 indent_line = f"{character}{' ' * (_indent_size - 1)}"
1261 re_indent = re.compile(r"^( *)(.*)$")
1262 new_lines: List[Text] = []
1263 add_line = new_lines.append
1264 blank_lines = 0
1265 for line in text.split(allow_blank=True):
1266 match = re_indent.match(line.plain)
1267 if not match or not match.group(2):
1268 blank_lines += 1
1269 continue
1270 indent = match.group(1)
1271 full_indents, remaining_space = divmod(len(indent), _indent_size)
1272 new_indent = f"{indent_line * full_indents}{' ' * remaining_space}"
1273 line.plain = new_indent + line.plain[len(new_indent) :]
1274 line.stylize(style, 0, len(new_indent))
1275 if blank_lines:
1276 new_lines.extend([Text(new_indent, style=style)] * blank_lines)
1277 blank_lines = 0
1278 add_line(line)
1279 if blank_lines:
1280 new_lines.extend([Text("", style=style)] * blank_lines)
1282 new_text = text.blank_copy("\n").join(new_lines)
1283 return new_text
1286if __name__ == "__main__": # pragma: no cover
1287 from rich.console import Console
1289 text = Text(
1290 """\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"""
1291 )
1292 text.highlight_words(["Lorem"], "bold")
1293 text.highlight_words(["ipsum"], "italic")
1295 console = Console()
1297 console.rule("justify='left'")
1298 console.print(text, style="red")
1299 console.print()
1301 console.rule("justify='center'")
1302 console.print(text, style="green", justify="center")
1303 console.print()
1305 console.rule("justify='right'")
1306 console.print(text, style="blue", justify="right")
1307 console.print()
1309 console.rule("justify='full'")
1310 console.print(text, style="magenta", justify="full")
1311 console.print()