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
694 overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW
696 lines = self.wrap(
697 console,
698 options.max_width,
699 justify=justify,
700 overflow=overflow,
701 tab_size=tab_size or 8,
702 no_wrap=pick_bool(self.no_wrap, options.no_wrap, False),
703 )
704 all_lines = Text("\n").join(lines)
705 yield from all_lines.render(console, end=self.end)
707 def __rich_measure__(
708 self, console: "Console", options: "ConsoleOptions"
709 ) -> Measurement:
710 text = self.plain
711 lines = text.splitlines()
712 max_text_width = max(cell_len(line) for line in lines) if lines else 0
713 words = text.split()
714 min_text_width = (
715 max(cell_len(word) for word in words) if words else max_text_width
716 )
717 return Measurement(min_text_width, max_text_width)
719 def render(self, console: "Console", end: str = "") -> Iterable["Segment"]:
720 """Render the text as Segments.
722 Args:
723 console (Console): Console instance.
724 end (Optional[str], optional): Optional end character.
726 Returns:
727 Iterable[Segment]: Result of render that may be written to the console.
728 """
729 _Segment = Segment
730 text = self.plain
731 if not self._spans:
732 yield Segment(text)
733 if end:
734 yield _Segment(end)
735 return
736 get_style = partial(console.get_style, default=Style.null())
738 enumerated_spans = list(enumerate(self._spans, 1))
739 style_map = {index: get_style(span.style) for index, span in enumerated_spans}
740 style_map[0] = get_style(self.style)
742 spans = [
743 (0, False, 0),
744 *((span.start, False, index) for index, span in enumerated_spans),
745 *((span.end, True, index) for index, span in enumerated_spans),
746 (len(text), True, 0),
747 ]
748 spans.sort(key=itemgetter(0, 1))
750 stack: List[int] = []
751 stack_append = stack.append
752 stack_pop = stack.remove
754 style_cache: Dict[Tuple[Style, ...], Style] = {}
755 style_cache_get = style_cache.get
756 combine = Style.combine
758 def get_current_style() -> Style:
759 """Construct current style from stack."""
760 styles = tuple(style_map[_style_id] for _style_id in sorted(stack))
761 cached_style = style_cache_get(styles)
762 if cached_style is not None:
763 return cached_style
764 current_style = combine(styles)
765 style_cache[styles] = current_style
766 return current_style
768 for (offset, leaving, style_id), (next_offset, _, _) in zip(spans, spans[1:]):
769 if leaving:
770 stack_pop(style_id)
771 else:
772 stack_append(style_id)
773 if next_offset > offset:
774 yield _Segment(text[offset:next_offset], get_current_style())
775 if end:
776 yield _Segment(end)
778 def join(self, lines: Iterable["Text"]) -> "Text":
779 """Join text together with this instance as the separator.
781 Args:
782 lines (Iterable[Text]): An iterable of Text instances to join.
784 Returns:
785 Text: A new text instance containing join text.
786 """
788 new_text = self.blank_copy()
790 def iter_text() -> Iterable["Text"]:
791 if self.plain:
792 for last, line in loop_last(lines):
793 yield line
794 if not last:
795 yield self
796 else:
797 yield from lines
799 extend_text = new_text._text.extend
800 append_span = new_text._spans.append
801 extend_spans = new_text._spans.extend
802 offset = 0
803 _Span = Span
805 for text in iter_text():
806 extend_text(text._text)
807 if text.style:
808 append_span(_Span(offset, offset + len(text), text.style))
809 extend_spans(
810 _Span(offset + start, offset + end, style)
811 for start, end, style in text._spans
812 )
813 offset += len(text)
814 new_text._length = offset
815 return new_text
817 def expand_tabs(self, tab_size: Optional[int] = None) -> None:
818 """Converts tabs to spaces.
820 Args:
821 tab_size (int, optional): Size of tabs. Defaults to 8.
823 """
824 if "\t" not in self.plain:
825 return
826 if tab_size is None:
827 tab_size = self.tab_size
828 if tab_size is None:
829 tab_size = 8
831 new_text: List[Text] = []
832 append = new_text.append
834 for line in self.split("\n", include_separator=True):
835 if "\t" not in line.plain:
836 append(line)
837 else:
838 cell_position = 0
839 parts = line.split("\t", include_separator=True)
840 for part in parts:
841 if part.plain.endswith("\t"):
842 part._text[-1] = part._text[-1][:-1] + " "
843 cell_position += part.cell_len
844 tab_remainder = cell_position % tab_size
845 if tab_remainder:
846 spaces = tab_size - tab_remainder
847 part.extend_style(spaces)
848 cell_position += spaces
849 else:
850 cell_position += part.cell_len
851 append(part)
853 result = Text("").join(new_text)
855 self._text = [result.plain]
856 self._length = len(self.plain)
857 self._spans[:] = result._spans
859 def truncate(
860 self,
861 max_width: int,
862 *,
863 overflow: Optional["OverflowMethod"] = None,
864 pad: bool = False,
865 ) -> None:
866 """Truncate text if it is longer that a given width.
868 Args:
869 max_width (int): Maximum number of characters in text.
870 overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None, to use self.overflow.
871 pad (bool, optional): Pad with spaces if the length is less than max_width. Defaults to False.
872 """
873 _overflow = overflow or self.overflow or DEFAULT_OVERFLOW
874 if _overflow != "ignore":
875 length = cell_len(self.plain)
876 if length > max_width:
877 if _overflow == "ellipsis":
878 self.plain = set_cell_size(self.plain, max_width - 1) + "…"
879 else:
880 self.plain = set_cell_size(self.plain, max_width)
881 if pad and length < max_width:
882 spaces = max_width - length
883 self._text = [f"{self.plain}{' ' * spaces}"]
884 self._length = len(self.plain)
886 def _trim_spans(self) -> None:
887 """Remove or modify any spans that are over the end of the text."""
888 max_offset = len(self.plain)
889 _Span = Span
890 self._spans[:] = [
891 (
892 span
893 if span.end < max_offset
894 else _Span(span.start, min(max_offset, span.end), span.style)
895 )
896 for span in self._spans
897 if span.start < max_offset
898 ]
900 def pad(self, count: int, character: str = " ") -> None:
901 """Pad left and right with a given number of characters.
903 Args:
904 count (int): Width of padding.
905 character (str): The character to pad with. Must be a string of length 1.
906 """
907 assert len(character) == 1, "Character must be a string of length 1"
908 if count:
909 pad_characters = character * count
910 self.plain = f"{pad_characters}{self.plain}{pad_characters}"
911 _Span = Span
912 self._spans[:] = [
913 _Span(start + count, end + count, style)
914 for start, end, style in self._spans
915 ]
917 def pad_left(self, count: int, character: str = " ") -> None:
918 """Pad the left with a given character.
920 Args:
921 count (int): Number of characters to pad.
922 character (str, optional): Character to pad with. Defaults to " ".
923 """
924 assert len(character) == 1, "Character must be a string of length 1"
925 if count:
926 self.plain = f"{character * count}{self.plain}"
927 _Span = Span
928 self._spans[:] = [
929 _Span(start + count, end + count, style)
930 for start, end, style in self._spans
931 ]
933 def pad_right(self, count: int, character: str = " ") -> None:
934 """Pad the right with a given character.
936 Args:
937 count (int): Number of characters to pad.
938 character (str, optional): Character to pad with. Defaults to " ".
939 """
940 assert len(character) == 1, "Character must be a string of length 1"
941 if count:
942 self.plain = f"{self.plain}{character * count}"
944 def align(self, align: AlignMethod, width: int, character: str = " ") -> None:
945 """Align text to a given width.
947 Args:
948 align (AlignMethod): One of "left", "center", or "right".
949 width (int): Desired width.
950 character (str, optional): Character to pad with. Defaults to " ".
951 """
952 self.truncate(width)
953 excess_space = width - cell_len(self.plain)
954 if excess_space:
955 if align == "left":
956 self.pad_right(excess_space, character)
957 elif align == "center":
958 left = excess_space // 2
959 self.pad_left(left, character)
960 self.pad_right(excess_space - left, character)
961 else:
962 self.pad_left(excess_space, character)
964 def append(
965 self, text: Union["Text", str], style: Optional[Union[str, "Style"]] = None
966 ) -> "Text":
967 """Add text with an optional style.
969 Args:
970 text (Union[Text, str]): A str or Text to append.
971 style (str, optional): A style name. Defaults to None.
973 Returns:
974 Text: Returns self for chaining.
975 """
977 if not isinstance(text, (str, Text)):
978 raise TypeError("Only str or Text can be appended to Text")
980 if len(text):
981 if isinstance(text, str):
982 sanitized_text = strip_control_codes(text)
983 self._text.append(sanitized_text)
984 offset = len(self)
985 text_length = len(sanitized_text)
986 if style:
987 self._spans.append(Span(offset, offset + text_length, style))
988 self._length += text_length
989 elif isinstance(text, Text):
990 _Span = Span
991 if style is not None:
992 raise ValueError(
993 "style must not be set when appending Text instance"
994 )
995 text_length = self._length
996 if text.style:
997 self._spans.append(
998 _Span(text_length, text_length + len(text), text.style)
999 )
1000 self._text.append(text.plain)
1001 self._spans.extend(
1002 _Span(start + text_length, end + text_length, style)
1003 for start, end, style in text._spans.copy()
1004 )
1005 self._length += len(text)
1006 return self
1008 def append_text(self, text: "Text") -> "Text":
1009 """Append another Text instance. This method is more performant than Text.append, but
1010 only works for Text.
1012 Args:
1013 text (Text): The Text instance to append to this instance.
1015 Returns:
1016 Text: Returns self for chaining.
1017 """
1018 _Span = Span
1019 text_length = self._length
1020 if text.style:
1021 self._spans.append(_Span(text_length, text_length + len(text), text.style))
1022 self._text.append(text.plain)
1023 self._spans.extend(
1024 _Span(start + text_length, end + text_length, style)
1025 for start, end, style in text._spans.copy()
1026 )
1027 self._length += len(text)
1028 return self
1030 def append_tokens(
1031 self, tokens: Iterable[Tuple[str, Optional[StyleType]]]
1032 ) -> "Text":
1033 """Append iterable of str and style. Style may be a Style instance or a str style definition.
1035 Args:
1036 tokens (Iterable[Tuple[str, Optional[StyleType]]]): An iterable of tuples containing str content and style.
1038 Returns:
1039 Text: Returns self for chaining.
1040 """
1041 append_text = self._text.append
1042 append_span = self._spans.append
1043 _Span = Span
1044 offset = len(self)
1045 for content, style in tokens:
1046 content = strip_control_codes(content)
1047 append_text(content)
1048 if style:
1049 append_span(_Span(offset, offset + len(content), style))
1050 offset += len(content)
1051 self._length = offset
1052 return self
1054 def copy_styles(self, text: "Text") -> None:
1055 """Copy styles from another Text instance.
1057 Args:
1058 text (Text): A Text instance to copy styles from, must be the same length.
1059 """
1060 self._spans.extend(text._spans)
1062 def split(
1063 self,
1064 separator: str = "\n",
1065 *,
1066 include_separator: bool = False,
1067 allow_blank: bool = False,
1068 ) -> Lines:
1069 """Split rich text in to lines, preserving styles.
1071 Args:
1072 separator (str, optional): String to split on. Defaults to "\\\\n".
1073 include_separator (bool, optional): Include the separator in the lines. Defaults to False.
1074 allow_blank (bool, optional): Return a blank line if the text ends with a separator. Defaults to False.
1076 Returns:
1077 List[RichText]: A list of rich text, one per line of the original.
1078 """
1079 assert separator, "separator must not be empty"
1081 text = self.plain
1082 if separator not in text:
1083 return Lines([self.copy()])
1085 if include_separator:
1086 lines = self.divide(
1087 match.end() for match in re.finditer(re.escape(separator), text)
1088 )
1089 else:
1091 def flatten_spans() -> Iterable[int]:
1092 for match in re.finditer(re.escape(separator), text):
1093 start, end = match.span()
1094 yield start
1095 yield end
1097 lines = Lines(
1098 line for line in self.divide(flatten_spans()) if line.plain != separator
1099 )
1101 if not allow_blank and text.endswith(separator):
1102 lines.pop()
1104 return lines
1106 def divide(self, offsets: Iterable[int]) -> Lines:
1107 """Divide text into a number of lines at given offsets.
1109 Args:
1110 offsets (Iterable[int]): Offsets used to divide text.
1112 Returns:
1113 Lines: New RichText instances between offsets.
1114 """
1115 _offsets = list(offsets)
1117 if not _offsets:
1118 return Lines([self.copy()])
1120 text = self.plain
1121 text_length = len(text)
1122 divide_offsets = [0, *_offsets, text_length]
1123 line_ranges = list(zip(divide_offsets, divide_offsets[1:]))
1125 style = self.style
1126 justify = self.justify
1127 overflow = self.overflow
1128 _Text = Text
1129 new_lines = Lines(
1130 _Text(
1131 text[start:end],
1132 style=style,
1133 justify=justify,
1134 overflow=overflow,
1135 )
1136 for start, end in line_ranges
1137 )
1138 if not self._spans:
1139 return new_lines
1141 _line_appends = [line._spans.append for line in new_lines._lines]
1142 line_count = len(line_ranges)
1143 _Span = Span
1145 for span_start, span_end, style in self._spans:
1146 lower_bound = 0
1147 upper_bound = line_count
1148 start_line_no = (lower_bound + upper_bound) // 2
1150 while True:
1151 line_start, line_end = line_ranges[start_line_no]
1152 if span_start < line_start:
1153 upper_bound = start_line_no - 1
1154 elif span_start > line_end:
1155 lower_bound = start_line_no + 1
1156 else:
1157 break
1158 start_line_no = (lower_bound + upper_bound) // 2
1160 if span_end < line_end:
1161 end_line_no = start_line_no
1162 else:
1163 end_line_no = lower_bound = start_line_no
1164 upper_bound = line_count
1166 while True:
1167 line_start, line_end = line_ranges[end_line_no]
1168 if span_end < line_start:
1169 upper_bound = end_line_no - 1
1170 elif span_end > line_end:
1171 lower_bound = end_line_no + 1
1172 else:
1173 break
1174 end_line_no = (lower_bound + upper_bound) // 2
1176 for line_no in range(start_line_no, end_line_no + 1):
1177 line_start, line_end = line_ranges[line_no]
1178 new_start = max(0, span_start - line_start)
1179 new_end = min(span_end - line_start, line_end - line_start)
1180 if new_end > new_start:
1181 _line_appends[line_no](_Span(new_start, new_end, style))
1183 return new_lines
1185 def right_crop(self, amount: int = 1) -> None:
1186 """Remove a number of characters from the end of the text."""
1187 max_offset = len(self.plain) - amount
1188 _Span = Span
1189 self._spans[:] = [
1190 (
1191 span
1192 if span.end < max_offset
1193 else _Span(span.start, min(max_offset, span.end), span.style)
1194 )
1195 for span in self._spans
1196 if span.start < max_offset
1197 ]
1198 self._text = [self.plain[:-amount]]
1199 self._length -= amount
1201 def wrap(
1202 self,
1203 console: "Console",
1204 width: int,
1205 *,
1206 justify: Optional["JustifyMethod"] = None,
1207 overflow: Optional["OverflowMethod"] = None,
1208 tab_size: int = 8,
1209 no_wrap: Optional[bool] = None,
1210 ) -> Lines:
1211 """Word wrap the text.
1213 Args:
1214 console (Console): Console instance.
1215 width (int): Number of cells available per line.
1216 justify (str, optional): Justify method: "default", "left", "center", "full", "right". Defaults to "default".
1217 overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None.
1218 tab_size (int, optional): Default tab size. Defaults to 8.
1219 no_wrap (bool, optional): Disable wrapping, Defaults to False.
1221 Returns:
1222 Lines: Number of lines.
1223 """
1224 wrap_justify = justify or self.justify or DEFAULT_JUSTIFY
1225 wrap_overflow = overflow or self.overflow or DEFAULT_OVERFLOW
1227 no_wrap = pick_bool(no_wrap, self.no_wrap, False) or overflow == "ignore"
1229 lines = Lines()
1230 for line in self.split(allow_blank=True):
1231 if "\t" in line:
1232 line.expand_tabs(tab_size)
1233 if no_wrap:
1234 if overflow == "ignore":
1235 lines.append(line)
1236 continue
1237 new_lines = Lines([line])
1238 else:
1239 offsets = divide_line(str(line), width, fold=wrap_overflow == "fold")
1240 new_lines = line.divide(offsets)
1241 for line in new_lines:
1242 line.rstrip_end(width)
1243 if wrap_justify:
1244 new_lines.justify(
1245 console, width, justify=wrap_justify, overflow=wrap_overflow
1246 )
1247 for line in new_lines:
1248 line.truncate(width, overflow=wrap_overflow)
1249 lines.extend(new_lines)
1250 return lines
1252 def fit(self, width: int) -> Lines:
1253 """Fit the text in to given width by chopping in to lines.
1255 Args:
1256 width (int): Maximum characters in a line.
1258 Returns:
1259 Lines: Lines container.
1260 """
1261 lines: Lines = Lines()
1262 append = lines.append
1263 for line in self.split():
1264 line.set_length(width)
1265 append(line)
1266 return lines
1268 def detect_indentation(self) -> int:
1269 """Auto-detect indentation of code.
1271 Returns:
1272 int: Number of spaces used to indent code.
1273 """
1275 _indentations = {
1276 len(match.group(1))
1277 for match in re.finditer(r"^( *)(.*)$", self.plain, flags=re.MULTILINE)
1278 }
1280 try:
1281 indentation = (
1282 reduce(gcd, [indent for indent in _indentations if not indent % 2]) or 1
1283 )
1284 except TypeError:
1285 indentation = 1
1287 return indentation
1289 def with_indent_guides(
1290 self,
1291 indent_size: Optional[int] = None,
1292 *,
1293 character: str = "│",
1294 style: StyleType = "dim green",
1295 ) -> "Text":
1296 """Adds indent guide lines to text.
1298 Args:
1299 indent_size (Optional[int]): Size of indentation, or None to auto detect. Defaults to None.
1300 character (str, optional): Character to use for indentation. Defaults to "│".
1301 style (Union[Style, str], optional): Style of indent guides.
1303 Returns:
1304 Text: New text with indentation guides.
1305 """
1307 _indent_size = self.detect_indentation() if indent_size is None else indent_size
1309 text = self.copy()
1310 text.expand_tabs()
1311 indent_line = f"{character}{' ' * (_indent_size - 1)}"
1313 re_indent = re.compile(r"^( *)(.*)$")
1314 new_lines: List[Text] = []
1315 add_line = new_lines.append
1316 blank_lines = 0
1317 for line in text.split(allow_blank=True):
1318 match = re_indent.match(line.plain)
1319 if not match or not match.group(2):
1320 blank_lines += 1
1321 continue
1322 indent = match.group(1)
1323 full_indents, remaining_space = divmod(len(indent), _indent_size)
1324 new_indent = f"{indent_line * full_indents}{' ' * remaining_space}"
1325 line.plain = new_indent + line.plain[len(new_indent) :]
1326 line.stylize(style, 0, len(new_indent))
1327 if blank_lines:
1328 new_lines.extend([Text(new_indent, style=style)] * blank_lines)
1329 blank_lines = 0
1330 add_line(line)
1331 if blank_lines:
1332 new_lines.extend([Text("", style=style)] * blank_lines)
1334 new_text = text.blank_copy("\n").join(new_lines)
1335 return new_text
1338if __name__ == "__main__": # pragma: no cover
1339 from rich.console import Console
1341 text = Text(
1342 """\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"""
1343 )
1344 text.highlight_words(["Lorem"], "bold")
1345 text.highlight_words(["ipsum"], "italic")
1347 console = Console()
1349 console.rule("justify='left'")
1350 console.print(text, style="red")
1351 console.print()
1353 console.rule("justify='center'")
1354 console.print(text, style="green", justify="center")
1355 console.print()
1357 console.rule("justify='right'")
1358 console.print(text, style="blue", justify="right")
1359 console.print()
1361 console.rule("justify='full'")
1362 console.print(text, style="magenta", justify="full")
1363 console.print()