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