text.py 46 KB

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