Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/rich/text.py: 67%
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 Pattern,
15 Tuple,
16 Union,
17)
19from ._loop import loop_last
20from ._pick import pick_bool
21from ._wrap import divide_line
22from .align import AlignMethod
23from .cells import cell_len, set_cell_size
24from .containers import Lines
25from .control import strip_control_codes
26from .emoji import EmojiVariant
27from .jupyter import JupyterMixin
28from .measure import Measurement
29from .segment import Segment
30from .style import Style, StyleType
32if TYPE_CHECKING: # pragma: no cover
33 from .console import Console, ConsoleOptions, JustifyMethod, OverflowMethod
35DEFAULT_JUSTIFY: "JustifyMethod" = "default"
36DEFAULT_OVERFLOW: "OverflowMethod" = "fold"
39_re_whitespace = re.compile(r"\s+$")
41TextType = Union[str, "Text"]
42"""A plain string or a :class:`Text` instance."""
44GetStyleCallable = Callable[[str], Optional[StyleType]]
47class Span(NamedTuple):
48 """A marked up region in some text."""
50 start: int
51 """Span start index."""
52 end: int
53 """Span end index."""
54 style: Union[str, Style]
55 """Style associated with the span."""
57 def __repr__(self) -> str:
58 return f"Span({self.start}, {self.end}, {self.style!r})"
60 def __bool__(self) -> bool:
61 return self.end > self.start
63 def split(self, offset: int) -> Tuple["Span", Optional["Span"]]:
64 """Split a span in to 2 from a given offset."""
66 if offset < self.start:
67 return self, None
68 if offset >= self.end:
69 return self, None
71 start, end, style = self
72 span1 = Span(start, min(end, offset), style)
73 span2 = Span(span1.end, end, style)
74 return span1, span2
76 def move(self, offset: int) -> "Span":
77 """Move start and end by a given offset.
79 Args:
80 offset (int): Number of characters to add to start and end.
82 Returns:
83 TextSpan: A new TextSpan with adjusted position.
84 """
85 start, end, style = self
86 return Span(start + offset, end + offset, style)
88 def right_crop(self, offset: int) -> "Span":
89 """Crop the span at the given offset.
91 Args:
92 offset (int): A value between start and end.
94 Returns:
95 Span: A new (possibly smaller) span.
96 """
97 start, end, style = self
98 if offset >= end:
99 return self
100 return Span(start, min(offset, end), style)
102 def extend(self, cells: int) -> "Span":
103 """Extend the span by the given number of cells.
105 Args:
106 cells (int): Additional space to add to end of span.
108 Returns:
109 Span: A span.
110 """
111 if cells:
112 start, end, style = self
113 return Span(start, end + cells, style)
114 else:
115 return self
118class Text(JupyterMixin):
119 """Text with color / style.
121 Args:
122 text (str, optional): Default unstyled text. Defaults to "".
123 style (Union[str, Style], optional): Base style for text. Defaults to "".
124 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
125 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
126 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.
127 end (str, optional): Character to end text with. Defaults to "\\\\n".
128 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None.
129 spans (List[Span], optional). A list of predefined style spans. Defaults to None.
130 """
132 __slots__ = [
133 "_text",
134 "style",
135 "justify",
136 "overflow",
137 "no_wrap",
138 "end",
139 "tab_size",
140 "_spans",
141 "_length",
142 ]
144 def __init__(
145 self,
146 text: str = "",
147 style: Union[str, Style] = "",
148 *,
149 justify: Optional["JustifyMethod"] = None,
150 overflow: Optional["OverflowMethod"] = None,
151 no_wrap: Optional[bool] = None,
152 end: str = "\n",
153 tab_size: Optional[int] = None,
154 spans: Optional[List[Span]] = None,
155 ) -> None:
156 sanitized_text = strip_control_codes(text)
157 self._text = [sanitized_text]
158 self.style = style
159 self.justify: Optional["JustifyMethod"] = justify
160 self.overflow: Optional["OverflowMethod"] = overflow
161 self.no_wrap = no_wrap
162 self.end = end
163 self.tab_size = tab_size
164 self._spans: List[Span] = spans or []
165 self._length: int = len(sanitized_text)
167 def __len__(self) -> int:
168 return self._length
170 def __bool__(self) -> bool:
171 return bool(self._length)
173 def __str__(self) -> str:
174 return self.plain
176 def __repr__(self) -> str:
177 return f"<text {self.plain!r} {self._spans!r} {self.style!r}>"
179 def __add__(self, other: Any) -> "Text":
180 if isinstance(other, (str, Text)):
181 result = self.copy()
182 result.append(other)
183 return result
184 return NotImplemented
186 def __eq__(self, other: object) -> bool:
187 if not isinstance(other, Text):
188 return NotImplemented
189 return self.plain == other.plain and self._spans == other._spans
191 def __contains__(self, other: object) -> bool:
192 if isinstance(other, str):
193 return other in self.plain
194 elif isinstance(other, Text):
195 return other.plain in self.plain
196 return False
198 def __getitem__(self, slice: Union[int, slice]) -> "Text":
199 def get_text_at(offset: int) -> "Text":
200 _Span = Span
201 text = Text(
202 self.plain[offset],
203 spans=[
204 _Span(0, 1, style)
205 for start, end, style in self._spans
206 if end > offset >= start
207 ],
208 end="",
209 )
210 return text
212 if isinstance(slice, int):
213 return get_text_at(slice)
214 else:
215 start, stop, step = slice.indices(len(self.plain))
216 if step == 1:
217 lines = self.divide([start, stop])
218 return lines[1]
219 else:
220 # This would be a bit of work to implement efficiently
221 # For now, its not required
222 raise TypeError("slices with step!=1 are not supported")
224 @property
225 def cell_len(self) -> int:
226 """Get the number of cells required to render this text."""
227 return cell_len(self.plain)
229 @property
230 def markup(self) -> str:
231 """Get console markup to render this Text.
233 Returns:
234 str: A string potentially creating markup tags.
235 """
236 from .markup import escape
238 output: List[str] = []
240 plain = self.plain
241 markup_spans = [
242 (0, False, self.style),
243 *((span.start, False, span.style) for span in self._spans),
244 *((span.end, True, span.style) for span in self._spans),
245 (len(plain), True, self.style),
246 ]
247 markup_spans.sort(key=itemgetter(0, 1))
248 position = 0
249 append = output.append
250 for offset, closing, style in markup_spans:
251 if offset > position:
252 append(escape(plain[position:offset]))
253 position = offset
254 if style:
255 append(f"[/{style}]" if closing else f"[{style}]")
256 markup = "".join(output)
257 return markup
259 @classmethod
260 def from_markup(
261 cls,
262 text: str,
263 *,
264 style: Union[str, Style] = "",
265 emoji: bool = True,
266 emoji_variant: Optional[EmojiVariant] = None,
267 justify: Optional["JustifyMethod"] = None,
268 overflow: Optional["OverflowMethod"] = None,
269 end: str = "\n",
270 ) -> "Text":
271 """Create Text instance from markup.
273 Args:
274 text (str): A string containing console markup.
275 style (Union[str, Style], optional): Base style for text. Defaults to "".
276 emoji (bool, optional): Also render emoji code. Defaults to True.
277 emoji_variant (str, optional): Optional emoji variant, either "text" or "emoji". Defaults to None.
278 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
279 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
280 end (str, optional): Character to end text with. Defaults to "\\\\n".
282 Returns:
283 Text: A Text instance with markup rendered.
284 """
285 from .markup import render
287 rendered_text = render(text, style, emoji=emoji, emoji_variant=emoji_variant)
288 rendered_text.justify = justify
289 rendered_text.overflow = overflow
290 rendered_text.end = end
291 return rendered_text
293 @classmethod
294 def from_ansi(
295 cls,
296 text: str,
297 *,
298 style: Union[str, Style] = "",
299 justify: Optional["JustifyMethod"] = None,
300 overflow: Optional["OverflowMethod"] = None,
301 no_wrap: Optional[bool] = None,
302 end: str = "\n",
303 tab_size: Optional[int] = 8,
304 ) -> "Text":
305 """Create a Text object from a string containing ANSI escape codes.
307 Args:
308 text (str): A string containing escape codes.
309 style (Union[str, Style], optional): Base style for text. Defaults to "".
310 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
311 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
312 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.
313 end (str, optional): Character to end text with. Defaults to "\\\\n".
314 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None.
315 """
316 from .ansi import AnsiDecoder
318 joiner = Text(
319 "\n",
320 justify=justify,
321 overflow=overflow,
322 no_wrap=no_wrap,
323 end=end,
324 tab_size=tab_size,
325 style=style,
326 )
327 decoder = AnsiDecoder()
328 result = joiner.join(line for line in decoder.decode(text))
329 return result
331 @classmethod
332 def styled(
333 cls,
334 text: str,
335 style: StyleType = "",
336 *,
337 justify: Optional["JustifyMethod"] = None,
338 overflow: Optional["OverflowMethod"] = None,
339 ) -> "Text":
340 """Construct a Text instance with a pre-applied styled. A style applied in this way won't be used
341 to pad the text when it is justified.
343 Args:
344 text (str): A string containing console markup.
345 style (Union[str, Style]): Style to apply to the text. Defaults to "".
346 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
347 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
349 Returns:
350 Text: A text instance with a style applied to the entire string.
351 """
352 styled_text = cls(text, justify=justify, overflow=overflow)
353 styled_text.stylize(style)
354 return styled_text
356 @classmethod
357 def assemble(
358 cls,
359 *parts: Union[str, "Text", Tuple[str, StyleType]],
360 style: Union[str, Style] = "",
361 justify: Optional["JustifyMethod"] = None,
362 overflow: Optional["OverflowMethod"] = None,
363 no_wrap: Optional[bool] = None,
364 end: str = "\n",
365 tab_size: int = 8,
366 meta: Optional[Dict[str, Any]] = None,
367 ) -> "Text":
368 """Construct a text instance by combining a sequence of strings with optional styles.
369 The positional arguments should be either strings, or a tuple of string + style.
371 Args:
372 style (Union[str, Style], optional): Base style for text. Defaults to "".
373 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None.
374 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None.
375 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.
376 end (str, optional): Character to end text with. Defaults to "\\\\n".
377 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None.
378 meta (Dict[str, Any], optional). Meta data to apply to text, or None for no meta data. Default to None
380 Returns:
381 Text: A new text instance.
382 """
383 text = cls(
384 style=style,
385 justify=justify,
386 overflow=overflow,
387 no_wrap=no_wrap,
388 end=end,
389 tab_size=tab_size,
390 )
391 append = text.append
392 _Text = Text
393 for part in parts:
394 if isinstance(part, (_Text, str)):
395 append(part)
396 else:
397 append(*part)
398 if meta:
399 text.apply_meta(meta)
400 return text
402 @property
403 def plain(self) -> str:
404 """Get the text as a single string."""
405 if len(self._text) != 1:
406 self._text[:] = ["".join(self._text)]
407 return self._text[0]
409 @plain.setter
410 def plain(self, new_text: str) -> None:
411 """Set the text to a new value."""
412 if new_text != self.plain:
413 sanitized_text = strip_control_codes(new_text)
414 self._text[:] = [sanitized_text]
415 old_length = self._length
416 self._length = len(sanitized_text)
417 if old_length > self._length:
418 self._trim_spans()
420 @property
421 def spans(self) -> List[Span]:
422 """Get a reference to the internal list of spans."""
423 return self._spans
425 @spans.setter
426 def spans(self, spans: List[Span]) -> None:
427 """Set spans."""
428 self._spans = spans[:]
430 def blank_copy(self, plain: str = "") -> "Text":
431 """Return a new Text instance with copied metadata (but not the string or spans)."""
432 copy_self = Text(
433 plain,
434 style=self.style,
435 justify=self.justify,
436 overflow=self.overflow,
437 no_wrap=self.no_wrap,
438 end=self.end,
439 tab_size=self.tab_size,
440 )
441 return copy_self
443 def copy(self) -> "Text":
444 """Return a copy of this instance."""
445 copy_self = Text(
446 self.plain,
447 style=self.style,
448 justify=self.justify,
449 overflow=self.overflow,
450 no_wrap=self.no_wrap,
451 end=self.end,
452 tab_size=self.tab_size,
453 )
454 copy_self._spans[:] = self._spans
455 return copy_self
457 def stylize(
458 self,
459 style: Union[str, Style],
460 start: int = 0,
461 end: Optional[int] = None,
462 ) -> None:
463 """Apply a style to the text, or a portion of the text.
465 Args:
466 style (Union[str, Style]): Style instance or style definition to apply.
467 start (int): Start offset (negative indexing is supported). Defaults to 0.
468 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.
469 """
470 if style:
471 length = len(self)
472 if start < 0:
473 start = length + start
474 if end is None:
475 end = length
476 if end < 0:
477 end = length + end
478 if start >= length or end <= start:
479 # Span not in text or not valid
480 return
481 self._spans.append(Span(start, min(length, end), style))
483 def stylize_before(
484 self,
485 style: Union[str, Style],
486 start: int = 0,
487 end: Optional[int] = None,
488 ) -> None:
489 """Apply a style to the text, or a portion of the text. Styles will be applied before other styles already present.
491 Args:
492 style (Union[str, Style]): Style instance or style definition to apply.
493 start (int): Start offset (negative indexing is supported). Defaults to 0.
494 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.
495 """
496 if style:
497 length = len(self)
498 if start < 0:
499 start = length + start
500 if end is None:
501 end = length
502 if end < 0:
503 end = length + end
504 if start >= length or end <= start:
505 # Span not in text or not valid
506 return
507 self._spans.insert(0, Span(start, min(length, end), style))
509 def apply_meta(
510 self, meta: Dict[str, Any], start: int = 0, end: Optional[int] = None
511 ) -> None:
512 """Apply metadata to the text, or a portion of the text.
514 Args:
515 meta (Dict[str, Any]): A dict of meta information.
516 start (int): Start offset (negative indexing is supported). Defaults to 0.
517 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None.
519 """
520 style = Style.from_meta(meta)
521 self.stylize(style, start=start, end=end)
523 def on(self, meta: Optional[Dict[str, Any]] = None, **handlers: Any) -> "Text":
524 """Apply event handlers (used by Textual project).
526 Example:
527 >>> from rich.text import Text
528 >>> text = Text("hello world")
529 >>> text.on(click="view.toggle('world')")
531 Args:
532 meta (Dict[str, Any]): Mapping of meta information.
533 **handlers: Keyword args are prefixed with "@" to defined handlers.
535 Returns:
536 Text: Self is returned to method may be chained.
537 """
538 meta = {} if meta is None else meta
539 meta.update({f"@{key}": value for key, value in handlers.items()})
540 self.stylize(Style.from_meta(meta))
541 return self
543 def remove_suffix(self, suffix: str) -> None:
544 """Remove a suffix if it exists.
546 Args:
547 suffix (str): Suffix to remove.
548 """
549 if self.plain.endswith(suffix):
550 self.right_crop(len(suffix))
552 def get_style_at_offset(self, console: "Console", offset: int) -> Style:
553 """Get the style of a character at give offset.
555 Args:
556 console (~Console): Console where text will be rendered.
557 offset (int): Offset in to text (negative indexing supported)
559 Returns:
560 Style: A Style instance.
561 """
562 # TODO: This is a little inefficient, it is only used by full justify
563 if offset < 0:
564 offset = len(self) + offset
565 get_style = console.get_style
566 style = get_style(self.style).copy()
567 for start, end, span_style in self._spans:
568 if end > offset >= start:
569 style += get_style(span_style, default="")
570 return style
572 def extend_style(self, spaces: int) -> None:
573 """Extend the Text given number of spaces where the spaces have the same style as the last character.
575 Args:
576 spaces (int): Number of spaces to add to the Text.
577 """
578 if spaces <= 0:
579 return
580 spans = self.spans
581 new_spaces = " " * spaces
582 if spans:
583 end_offset = len(self)
584 self._spans[:] = [
585 span.extend(spaces) if span.end >= end_offset else span
586 for span in spans
587 ]
588 self._text.append(new_spaces)
589 self._length += spaces
590 else:
591 self.plain += new_spaces
593 def highlight_regex(
594 self,
595 re_highlight: Union[Pattern[str], str],
596 style: Optional[Union[GetStyleCallable, StyleType]] = None,
597 *,
598 style_prefix: str = "",
599 ) -> int:
600 """Highlight text with a regular expression, where group names are
601 translated to styles.
603 Args:
604 re_highlight (Union[re.Pattern, str]): A regular expression object or string.
605 style (Union[GetStyleCallable, StyleType]): Optional style to apply to whole match, or a callable
606 which accepts the matched text and returns a style. Defaults to None.
607 style_prefix (str, optional): Optional prefix to add to style group names.
609 Returns:
610 int: Number of regex matches
611 """
612 count = 0
613 append_span = self._spans.append
614 _Span = Span
615 plain = self.plain
616 if isinstance(re_highlight, str):
617 re_highlight = re.compile(re_highlight)
618 for match in re_highlight.finditer(plain):
619 get_span = match.span
620 if style:
621 start, end = get_span()
622 match_style = style(plain[start:end]) if callable(style) else style
623 if match_style is not None and end > start:
624 append_span(_Span(start, end, match_style))
626 count += 1
627 for name in match.groupdict().keys():
628 start, end = get_span(name)
629 if start != -1 and end > start:
630 append_span(_Span(start, end, f"{style_prefix}{name}"))
631 return count
633 def highlight_words(
634 self,
635 words: Iterable[str],
636 style: Union[str, Style],
637 *,
638 case_sensitive: bool = True,
639 ) -> int:
640 """Highlight words with a style.
642 Args:
643 words (Iterable[str]): Words to highlight.
644 style (Union[str, Style]): Style to apply.
645 case_sensitive (bool, optional): Enable case sensitive matching. Defaults to True.
647 Returns:
648 int: Number of words highlighted.
649 """
650 re_words = "|".join(re.escape(word) for word in words)
651 add_span = self._spans.append
652 count = 0
653 _Span = Span
654 for match in re.finditer(
655 re_words, self.plain, flags=0 if case_sensitive else re.IGNORECASE
656 ):
657 start, end = match.span(0)
658 add_span(_Span(start, end, style))
659 count += 1
660 return count
662 def rstrip(self) -> None:
663 """Strip whitespace from end of text."""
664 self.plain = self.plain.rstrip()
666 def rstrip_end(self, size: int) -> None:
667 """Remove whitespace beyond a certain width at the end of the text.
669 Args:
670 size (int): The desired size of the text.
671 """
672 text_length = len(self)
673 if text_length > size:
674 excess = text_length - size
675 whitespace_match = _re_whitespace.search(self.plain)
676 if whitespace_match is not None:
677 whitespace_count = len(whitespace_match.group(0))
678 self.right_crop(min(whitespace_count, excess))
680 def set_length(self, new_length: int) -> None:
681 """Set new length of the text, clipping or padding is required."""
682 length = len(self)
683 if length != new_length:
684 if length < new_length:
685 self.pad_right(new_length - length)
686 else:
687 self.right_crop(length - new_length)
689 def __rich_console__(
690 self, console: "Console", options: "ConsoleOptions"
691 ) -> Iterable[Segment]:
692 tab_size: int = console.tab_size if self.tab_size is None else self.tab_size
693 justify = self.justify or options.justify or DEFAULT_JUSTIFY
695 overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW
697 lines = self.wrap(
698 console,
699 options.max_width,
700 justify=justify,
701 overflow=overflow,
702 tab_size=tab_size or 8,
703 no_wrap=pick_bool(self.no_wrap, options.no_wrap, False),
704 )
705 all_lines = Text("\n").join(lines)
706 yield from all_lines.render(console, end=self.end)
708 def __rich_measure__(
709 self, console: "Console", options: "ConsoleOptions"
710 ) -> Measurement:
711 text = self.plain
712 lines = text.splitlines()
713 max_text_width = max(cell_len(line) for line in lines) if lines else 0
714 words = text.split()
715 min_text_width = (
716 max(cell_len(word) for word in words) if words else max_text_width
717 )
718 return Measurement(min_text_width, max_text_width)
720 def render(self, console: "Console", end: str = "") -> Iterable["Segment"]:
721 """Render the text as Segments.
723 Args:
724 console (Console): Console instance.
725 end (Optional[str], optional): Optional end character.
727 Returns:
728 Iterable[Segment]: Result of render that may be written to the console.
729 """
730 _Segment = Segment
731 text = self.plain
732 if not self._spans:
733 yield Segment(text)
734 if end:
735 yield _Segment(end)
736 return
737 get_style = partial(console.get_style, default=Style.null())
739 enumerated_spans = list(enumerate(self._spans, 1))
740 style_map = {index: get_style(span.style) for index, span in enumerated_spans}
741 style_map[0] = get_style(self.style)
743 spans = [
744 (0, False, 0),
745 *((span.start, False, index) for index, span in enumerated_spans),
746 *((span.end, True, index) for index, span in enumerated_spans),
747 (len(text), True, 0),
748 ]
749 spans.sort(key=itemgetter(0, 1))
751 stack: List[int] = []
752 stack_append = stack.append
753 stack_pop = stack.remove
755 style_cache: Dict[Tuple[Style, ...], Style] = {}
756 style_cache_get = style_cache.get
757 combine = Style.combine
759 def get_current_style() -> Style:
760 """Construct current style from stack."""
761 styles = tuple(style_map[_style_id] for _style_id in sorted(stack))
762 cached_style = style_cache_get(styles)
763 if cached_style is not None:
764 return cached_style
765 current_style = combine(styles)
766 style_cache[styles] = current_style
767 return current_style
769 for (offset, leaving, style_id), (next_offset, _, _) in zip(spans, spans[1:]):
770 if leaving:
771 stack_pop(style_id)
772 else:
773 stack_append(style_id)
774 if next_offset > offset:
775 yield _Segment(text[offset:next_offset], get_current_style())
776 if end:
777 yield _Segment(end)
779 def join(self, lines: Iterable["Text"]) -> "Text":
780 """Join text together with this instance as the separator.
782 Args:
783 lines (Iterable[Text]): An iterable of Text instances to join.
785 Returns:
786 Text: A new text instance containing join text.
787 """
789 new_text = self.blank_copy()
791 def iter_text() -> Iterable["Text"]:
792 if self.plain:
793 for last, line in loop_last(lines):
794 yield line
795 if not last:
796 yield self
797 else:
798 yield from lines
800 extend_text = new_text._text.extend
801 append_span = new_text._spans.append
802 extend_spans = new_text._spans.extend
803 offset = 0
804 _Span = Span
806 for text in iter_text():
807 extend_text(text._text)
808 if text.style:
809 append_span(_Span(offset, offset + len(text), text.style))
810 extend_spans(
811 _Span(offset + start, offset + end, style)
812 for start, end, style in text._spans
813 )
814 offset += len(text)
815 new_text._length = offset
816 return new_text
818 def expand_tabs(self, tab_size: Optional[int] = None) -> None:
819 """Converts tabs to spaces.
821 Args:
822 tab_size (int, optional): Size of tabs. Defaults to 8.
824 """
825 if "\t" not in self.plain:
826 return
827 if tab_size is None:
828 tab_size = self.tab_size
829 if tab_size is None:
830 tab_size = 8
832 new_text: List[Text] = []
833 append = new_text.append
835 for line in self.split("\n", include_separator=True):
836 if "\t" not in line.plain:
837 append(line)
838 else:
839 cell_position = 0
840 parts = line.split("\t", include_separator=True)
841 for part in parts:
842 if part.plain.endswith("\t"):
843 part._text[-1] = part._text[-1][:-1] + " "
844 cell_position += part.cell_len
845 tab_remainder = cell_position % tab_size
846 if tab_remainder:
847 spaces = tab_size - tab_remainder
848 part.extend_style(spaces)
849 cell_position += spaces
850 else:
851 cell_position += part.cell_len
852 append(part)
854 result = Text("").join(new_text)
856 self._text = [result.plain]
857 self._length = len(self.plain)
858 self._spans[:] = result._spans
860 def truncate(
861 self,
862 max_width: int,
863 *,
864 overflow: Optional["OverflowMethod"] = None,
865 pad: bool = False,
866 ) -> None:
867 """Truncate text if it is longer that a given width.
869 Args:
870 max_width (int): Maximum number of characters in text.
871 overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None, to use self.overflow.
872 pad (bool, optional): Pad with spaces if the length is less than max_width. Defaults to False.
873 """
874 _overflow = overflow or self.overflow or DEFAULT_OVERFLOW
875 if _overflow != "ignore":
876 length = cell_len(self.plain)
877 if length > max_width:
878 if _overflow == "ellipsis":
879 self.plain = set_cell_size(self.plain, max_width - 1) + "…"
880 else:
881 self.plain = set_cell_size(self.plain, max_width)
882 if pad and length < max_width:
883 spaces = max_width - length
884 self._text = [f"{self.plain}{' ' * spaces}"]
885 self._length = len(self.plain)
887 def _trim_spans(self) -> None:
888 """Remove or modify any spans that are over the end of the text."""
889 max_offset = len(self.plain)
890 _Span = Span
891 self._spans[:] = [
892 (
893 span
894 if span.end < max_offset
895 else _Span(span.start, min(max_offset, span.end), span.style)
896 )
897 for span in self._spans
898 if span.start < max_offset
899 ]
901 def pad(self, count: int, character: str = " ") -> None:
902 """Pad left and right with a given number of characters.
904 Args:
905 count (int): Width of padding.
906 character (str): The character to pad with. Must be a string of length 1.
907 """
908 assert len(character) == 1, "Character must be a string of length 1"
909 if count:
910 pad_characters = character * count
911 self.plain = f"{pad_characters}{self.plain}{pad_characters}"
912 _Span = Span
913 self._spans[:] = [
914 _Span(start + count, end + count, style)
915 for start, end, style in self._spans
916 ]
918 def pad_left(self, count: int, character: str = " ") -> None:
919 """Pad the left with a given character.
921 Args:
922 count (int): Number of characters to pad.
923 character (str, optional): Character to pad with. Defaults to " ".
924 """
925 assert len(character) == 1, "Character must be a string of length 1"
926 if count:
927 self.plain = f"{character * count}{self.plain}"
928 _Span = Span
929 self._spans[:] = [
930 _Span(start + count, end + count, style)
931 for start, end, style in self._spans
932 ]
934 def pad_right(self, count: int, character: str = " ") -> None:
935 """Pad the right with a given character.
937 Args:
938 count (int): Number of characters to pad.
939 character (str, optional): Character to pad with. Defaults to " ".
940 """
941 assert len(character) == 1, "Character must be a string of length 1"
942 if count:
943 self.plain = f"{self.plain}{character * count}"
945 def align(self, align: AlignMethod, width: int, character: str = " ") -> None:
946 """Align text to a given width.
948 Args:
949 align (AlignMethod): One of "left", "center", or "right".
950 width (int): Desired width.
951 character (str, optional): Character to pad with. Defaults to " ".
952 """
953 self.truncate(width)
954 excess_space = width - cell_len(self.plain)
955 if excess_space:
956 if align == "left":
957 self.pad_right(excess_space, character)
958 elif align == "center":
959 left = excess_space // 2
960 self.pad_left(left, character)
961 self.pad_right(excess_space - left, character)
962 else:
963 self.pad_left(excess_space, character)
965 def append(
966 self, text: Union["Text", str], style: Optional[Union[str, "Style"]] = None
967 ) -> "Text":
968 """Add text with an optional style.
970 Args:
971 text (Union[Text, str]): A str or Text to append.
972 style (str, optional): A style name. Defaults to None.
974 Returns:
975 Text: Returns self for chaining.
976 """
978 if not isinstance(text, (str, Text)):
979 raise TypeError("Only str or Text can be appended to Text")
981 if len(text):
982 if isinstance(text, str):
983 sanitized_text = strip_control_codes(text)
984 self._text.append(sanitized_text)
985 offset = len(self)
986 text_length = len(sanitized_text)
987 if style:
988 self._spans.append(Span(offset, offset + text_length, style))
989 self._length += text_length
990 elif isinstance(text, Text):
991 _Span = Span
992 if style is not None:
993 raise ValueError(
994 "style must not be set when appending Text instance"
995 )
996 text_length = self._length
997 if text.style:
998 self._spans.append(
999 _Span(text_length, text_length + len(text), text.style)
1000 )
1001 self._text.append(text.plain)
1002 self._spans.extend(
1003 _Span(start + text_length, end + text_length, style)
1004 for start, end, style in text._spans.copy()
1005 )
1006 self._length += len(text)
1007 return self
1009 def append_text(self, text: "Text") -> "Text":
1010 """Append another Text instance. This method is more performant that Text.append, but
1011 only works for Text.
1013 Args:
1014 text (Text): The Text instance to append to this instance.
1016 Returns:
1017 Text: Returns self for chaining.
1018 """
1019 _Span = Span
1020 text_length = self._length
1021 if text.style:
1022 self._spans.append(_Span(text_length, text_length + len(text), text.style))
1023 self._text.append(text.plain)
1024 self._spans.extend(
1025 _Span(start + text_length, end + text_length, style)
1026 for start, end, style in text._spans.copy()
1027 )
1028 self._length += len(text)
1029 return self
1031 def append_tokens(
1032 self, tokens: Iterable[Tuple[str, Optional[StyleType]]]
1033 ) -> "Text":
1034 """Append iterable of str and style. Style may be a Style instance or a str style definition.
1036 Args:
1037 tokens (Iterable[Tuple[str, Optional[StyleType]]]): An iterable of tuples containing str content and style.
1039 Returns:
1040 Text: Returns self for chaining.
1041 """
1042 append_text = self._text.append
1043 append_span = self._spans.append
1044 _Span = Span
1045 offset = len(self)
1046 for content, style in tokens:
1047 content = strip_control_codes(content)
1048 append_text(content)
1049 if style:
1050 append_span(_Span(offset, offset + len(content), style))
1051 offset += len(content)
1052 self._length = offset
1053 return self
1055 def copy_styles(self, text: "Text") -> None:
1056 """Copy styles from another Text instance.
1058 Args:
1059 text (Text): A Text instance to copy styles from, must be the same length.
1060 """
1061 self._spans.extend(text._spans)
1063 def split(
1064 self,
1065 separator: str = "\n",
1066 *,
1067 include_separator: bool = False,
1068 allow_blank: bool = False,
1069 ) -> Lines:
1070 """Split rich text in to lines, preserving styles.
1072 Args:
1073 separator (str, optional): String to split on. Defaults to "\\\\n".
1074 include_separator (bool, optional): Include the separator in the lines. Defaults to False.
1075 allow_blank (bool, optional): Return a blank line if the text ends with a separator. Defaults to False.
1077 Returns:
1078 List[RichText]: A list of rich text, one per line of the original.
1079 """
1080 assert separator, "separator must not be empty"
1082 text = self.plain
1083 if separator not in text:
1084 return Lines([self.copy()])
1086 if include_separator:
1087 lines = self.divide(
1088 match.end() for match in re.finditer(re.escape(separator), text)
1089 )
1090 else:
1092 def flatten_spans() -> Iterable[int]:
1093 for match in re.finditer(re.escape(separator), text):
1094 start, end = match.span()
1095 yield start
1096 yield end
1098 lines = Lines(
1099 line for line in self.divide(flatten_spans()) if line.plain != separator
1100 )
1102 if not allow_blank and text.endswith(separator):
1103 lines.pop()
1105 return lines
1107 def divide(self, offsets: Iterable[int]) -> Lines:
1108 """Divide text in to a number of lines at given offsets.
1110 Args:
1111 offsets (Iterable[int]): Offsets used to divide text.
1113 Returns:
1114 Lines: New RichText instances between offsets.
1115 """
1116 _offsets = list(offsets)
1118 if not _offsets:
1119 return Lines([self.copy()])
1121 text = self.plain
1122 text_length = len(text)
1123 divide_offsets = [0, *_offsets, text_length]
1124 line_ranges = list(zip(divide_offsets, divide_offsets[1:]))
1126 style = self.style
1127 justify = self.justify
1128 overflow = self.overflow
1129 _Text = Text
1130 new_lines = Lines(
1131 _Text(
1132 text[start:end],
1133 style=style,
1134 justify=justify,
1135 overflow=overflow,
1136 )
1137 for start, end in line_ranges
1138 )
1139 if not self._spans:
1140 return new_lines
1142 _line_appends = [line._spans.append for line in new_lines._lines]
1143 line_count = len(line_ranges)
1144 _Span = Span
1146 for span_start, span_end, style in self._spans:
1147 lower_bound = 0
1148 upper_bound = line_count
1149 start_line_no = (lower_bound + upper_bound) // 2
1151 while True:
1152 line_start, line_end = line_ranges[start_line_no]
1153 if span_start < line_start:
1154 upper_bound = start_line_no - 1
1155 elif span_start > line_end:
1156 lower_bound = start_line_no + 1
1157 else:
1158 break
1159 start_line_no = (lower_bound + upper_bound) // 2
1161 if span_end < line_end:
1162 end_line_no = start_line_no
1163 else:
1164 end_line_no = lower_bound = start_line_no
1165 upper_bound = line_count
1167 while True:
1168 line_start, line_end = line_ranges[end_line_no]
1169 if span_end < line_start:
1170 upper_bound = end_line_no - 1
1171 elif span_end > line_end:
1172 lower_bound = end_line_no + 1
1173 else:
1174 break
1175 end_line_no = (lower_bound + upper_bound) // 2
1177 for line_no in range(start_line_no, end_line_no + 1):
1178 line_start, line_end = line_ranges[line_no]
1179 new_start = max(0, span_start - line_start)
1180 new_end = min(span_end - line_start, line_end - line_start)
1181 if new_end > new_start:
1182 _line_appends[line_no](_Span(new_start, new_end, style))
1184 return new_lines
1186 def right_crop(self, amount: int = 1) -> None:
1187 """Remove a number of characters from the end of the text."""
1188 max_offset = len(self.plain) - amount
1189 _Span = Span
1190 self._spans[:] = [
1191 (
1192 span
1193 if span.end < max_offset
1194 else _Span(span.start, min(max_offset, span.end), span.style)
1195 )
1196 for span in self._spans
1197 if span.start < max_offset
1198 ]
1199 self._text = [self.plain[:-amount]]
1200 self._length -= amount
1202 def wrap(
1203 self,
1204 console: "Console",
1205 width: int,
1206 *,
1207 justify: Optional["JustifyMethod"] = None,
1208 overflow: Optional["OverflowMethod"] = None,
1209 tab_size: int = 8,
1210 no_wrap: Optional[bool] = None,
1211 ) -> Lines:
1212 """Word wrap the text.
1214 Args:
1215 console (Console): Console instance.
1216 width (int): Number of cells available per line.
1217 justify (str, optional): Justify method: "default", "left", "center", "full", "right". Defaults to "default".
1218 overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None.
1219 tab_size (int, optional): Default tab size. Defaults to 8.
1220 no_wrap (bool, optional): Disable wrapping, Defaults to False.
1222 Returns:
1223 Lines: Number of lines.
1224 """
1225 wrap_justify = justify or self.justify or DEFAULT_JUSTIFY
1226 wrap_overflow = overflow or self.overflow or DEFAULT_OVERFLOW
1228 no_wrap = pick_bool(no_wrap, self.no_wrap, False) or overflow == "ignore"
1230 lines = Lines()
1231 for line in self.split(allow_blank=True):
1232 if "\t" in line:
1233 line.expand_tabs(tab_size)
1234 if no_wrap:
1235 new_lines = Lines([line])
1236 else:
1237 offsets = divide_line(str(line), width, fold=wrap_overflow == "fold")
1238 new_lines = line.divide(offsets)
1239 for line in new_lines:
1240 line.rstrip_end(width)
1241 if wrap_justify:
1242 new_lines.justify(
1243 console, width, justify=wrap_justify, overflow=wrap_overflow
1244 )
1245 for line in new_lines:
1246 line.truncate(width, overflow=wrap_overflow)
1247 lines.extend(new_lines)
1248 return lines
1250 def fit(self, width: int) -> Lines:
1251 """Fit the text in to given width by chopping in to lines.
1253 Args:
1254 width (int): Maximum characters in a line.
1256 Returns:
1257 Lines: Lines container.
1258 """
1259 lines: Lines = Lines()
1260 append = lines.append
1261 for line in self.split():
1262 line.set_length(width)
1263 append(line)
1264 return lines
1266 def detect_indentation(self) -> int:
1267 """Auto-detect indentation of code.
1269 Returns:
1270 int: Number of spaces used to indent code.
1271 """
1273 _indentations = {
1274 len(match.group(1))
1275 for match in re.finditer(r"^( *)(.*)$", self.plain, flags=re.MULTILINE)
1276 }
1278 try:
1279 indentation = (
1280 reduce(gcd, [indent for indent in _indentations if not indent % 2]) or 1
1281 )
1282 except TypeError:
1283 indentation = 1
1285 return indentation
1287 def with_indent_guides(
1288 self,
1289 indent_size: Optional[int] = None,
1290 *,
1291 character: str = "│",
1292 style: StyleType = "dim green",
1293 ) -> "Text":
1294 """Adds indent guide lines to text.
1296 Args:
1297 indent_size (Optional[int]): Size of indentation, or None to auto detect. Defaults to None.
1298 character (str, optional): Character to use for indentation. Defaults to "│".
1299 style (Union[Style, str], optional): Style of indent guides.
1301 Returns:
1302 Text: New text with indentation guides.
1303 """
1305 _indent_size = self.detect_indentation() if indent_size is None else indent_size
1307 text = self.copy()
1308 text.expand_tabs()
1309 indent_line = f"{character}{' ' * (_indent_size - 1)}"
1311 re_indent = re.compile(r"^( *)(.*)$")
1312 new_lines: List[Text] = []
1313 add_line = new_lines.append
1314 blank_lines = 0
1315 for line in text.split(allow_blank=True):
1316 match = re_indent.match(line.plain)
1317 if not match or not match.group(2):
1318 blank_lines += 1
1319 continue
1320 indent = match.group(1)
1321 full_indents, remaining_space = divmod(len(indent), _indent_size)
1322 new_indent = f"{indent_line * full_indents}{' ' * remaining_space}"
1323 line.plain = new_indent + line.plain[len(new_indent) :]
1324 line.stylize(style, 0, len(new_indent))
1325 if blank_lines:
1326 new_lines.extend([Text(new_indent, style=style)] * blank_lines)
1327 blank_lines = 0
1328 add_line(line)
1329 if blank_lines:
1330 new_lines.extend([Text("", style=style)] * blank_lines)
1332 new_text = text.blank_copy("\n").join(new_lines)
1333 return new_text
1336if __name__ == "__main__": # pragma: no cover
1337 from rich.console import Console
1339 text = Text(
1340 """\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"""
1341 )
1342 text.highlight_words(["Lorem"], "bold")
1343 text.highlight_words(["ipsum"], "italic")
1345 console = Console()
1347 console.rule("justify='left'")
1348 console.print(text, style="red")
1349 console.print()
1351 console.rule("justify='center'")
1352 console.print(text, style="green", justify="center")
1353 console.print()
1355 console.rule("justify='right'")
1356 console.print(text, style="blue", justify="right")
1357 console.print()
1359 console.rule("justify='full'")
1360 console.print(text, style="magenta", justify="full")
1361 console.print()