text.py 43 KB

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