Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/rich/text.py: 67%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

612 statements  

1import re 

2from functools import partial, reduce 

3from math import gcd 

4from operator import itemgetter 

5from typing import ( 

6 TYPE_CHECKING, 

7 Any, 

8 Callable, 

9 Dict, 

10 Iterable, 

11 List, 

12 NamedTuple, 

13 Optional, 

14 Pattern, 

15 Tuple, 

16 Union, 

17) 

18 

19from ._loop import loop_last 

20from ._pick import pick_bool 

21from ._wrap import divide_line 

22from .align import AlignMethod 

23from .cells import cell_len, set_cell_size 

24from .containers import Lines 

25from .control import strip_control_codes 

26from .emoji import EmojiVariant 

27from .jupyter import JupyterMixin 

28from .measure import Measurement 

29from .segment import Segment 

30from .style import Style, StyleType 

31 

32if TYPE_CHECKING: # pragma: no cover 

33 from .console import Console, ConsoleOptions, JustifyMethod, OverflowMethod 

34 

35DEFAULT_JUSTIFY: "JustifyMethod" = "default" 

36DEFAULT_OVERFLOW: "OverflowMethod" = "fold" 

37 

38 

39_re_whitespace = re.compile(r"\s+$") 

40 

41TextType = Union[str, "Text"] 

42"""A plain string or a :class:`Text` instance.""" 

43 

44GetStyleCallable = Callable[[str], Optional[StyleType]] 

45 

46 

47class Span(NamedTuple): 

48 """A marked up region in some text.""" 

49 

50 start: int 

51 """Span start index.""" 

52 end: int 

53 """Span end index.""" 

54 style: Union[str, Style] 

55 """Style associated with the span.""" 

56 

57 def __repr__(self) -> str: 

58 return f"Span({self.start}, {self.end}, {self.style!r})" 

59 

60 def __bool__(self) -> bool: 

61 return self.end > self.start 

62 

63 def split(self, offset: int) -> Tuple["Span", Optional["Span"]]: 

64 """Split a span in to 2 from a given offset.""" 

65 

66 if offset < self.start: 

67 return self, None 

68 if offset >= self.end: 

69 return self, None 

70 

71 start, end, style = self 

72 span1 = Span(start, min(end, offset), style) 

73 span2 = Span(span1.end, end, style) 

74 return span1, span2 

75 

76 def move(self, offset: int) -> "Span": 

77 """Move start and end by a given offset. 

78 

79 Args: 

80 offset (int): Number of characters to add to start and end. 

81 

82 Returns: 

83 TextSpan: A new TextSpan with adjusted position. 

84 """ 

85 start, end, style = self 

86 return Span(start + offset, end + offset, style) 

87 

88 def right_crop(self, offset: int) -> "Span": 

89 """Crop the span at the given offset. 

90 

91 Args: 

92 offset (int): A value between start and end. 

93 

94 Returns: 

95 Span: A new (possibly smaller) span. 

96 """ 

97 start, end, style = self 

98 if offset >= end: 

99 return self 

100 return Span(start, min(offset, end), style) 

101 

102 def extend(self, cells: int) -> "Span": 

103 """Extend the span by the given number of cells. 

104 

105 Args: 

106 cells (int): Additional space to add to end of span. 

107 

108 Returns: 

109 Span: A span. 

110 """ 

111 if cells: 

112 start, end, style = self 

113 return Span(start, end + cells, style) 

114 else: 

115 return self 

116 

117 

118class Text(JupyterMixin): 

119 """Text with color / style. 

120 

121 Args: 

122 text (str, optional): Default unstyled text. Defaults to "". 

123 style (Union[str, Style], optional): Base style for text. Defaults to "". 

124 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None. 

125 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None. 

126 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None. 

127 end (str, optional): Character to end text with. Defaults to "\\\\n". 

128 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None. 

129 spans (List[Span], optional). A list of predefined style spans. Defaults to None. 

130 """ 

131 

132 __slots__ = [ 

133 "_text", 

134 "style", 

135 "justify", 

136 "overflow", 

137 "no_wrap", 

138 "end", 

139 "tab_size", 

140 "_spans", 

141 "_length", 

142 ] 

143 

144 def __init__( 

145 self, 

146 text: str = "", 

147 style: Union[str, Style] = "", 

148 *, 

149 justify: Optional["JustifyMethod"] = None, 

150 overflow: Optional["OverflowMethod"] = None, 

151 no_wrap: Optional[bool] = None, 

152 end: str = "\n", 

153 tab_size: Optional[int] = None, 

154 spans: Optional[List[Span]] = None, 

155 ) -> None: 

156 sanitized_text = strip_control_codes(text) 

157 self._text = [sanitized_text] 

158 self.style = style 

159 self.justify: Optional["JustifyMethod"] = justify 

160 self.overflow: Optional["OverflowMethod"] = overflow 

161 self.no_wrap = no_wrap 

162 self.end = end 

163 self.tab_size = tab_size 

164 self._spans: List[Span] = spans or [] 

165 self._length: int = len(sanitized_text) 

166 

167 def __len__(self) -> int: 

168 return self._length 

169 

170 def __bool__(self) -> bool: 

171 return bool(self._length) 

172 

173 def __str__(self) -> str: 

174 return self.plain 

175 

176 def __repr__(self) -> str: 

177 return f"<text {self.plain!r} {self._spans!r} {self.style!r}>" 

178 

179 def __add__(self, other: Any) -> "Text": 

180 if isinstance(other, (str, Text)): 

181 result = self.copy() 

182 result.append(other) 

183 return result 

184 return NotImplemented 

185 

186 def __eq__(self, other: object) -> bool: 

187 if not isinstance(other, Text): 

188 return NotImplemented 

189 return self.plain == other.plain and self._spans == other._spans 

190 

191 def __contains__(self, other: object) -> bool: 

192 if isinstance(other, str): 

193 return other in self.plain 

194 elif isinstance(other, Text): 

195 return other.plain in self.plain 

196 return False 

197 

198 def __getitem__(self, slice: Union[int, slice]) -> "Text": 

199 def get_text_at(offset: int) -> "Text": 

200 _Span = Span 

201 text = Text( 

202 self.plain[offset], 

203 spans=[ 

204 _Span(0, 1, style) 

205 for start, end, style in self._spans 

206 if end > offset >= start 

207 ], 

208 end="", 

209 ) 

210 return text 

211 

212 if isinstance(slice, int): 

213 return get_text_at(slice) 

214 else: 

215 start, stop, step = slice.indices(len(self.plain)) 

216 if step == 1: 

217 lines = self.divide([start, stop]) 

218 return lines[1] 

219 else: 

220 # This would be a bit of work to implement efficiently 

221 # For now, its not required 

222 raise TypeError("slices with step!=1 are not supported") 

223 

224 @property 

225 def cell_len(self) -> int: 

226 """Get the number of cells required to render this text.""" 

227 return cell_len(self.plain) 

228 

229 @property 

230 def markup(self) -> str: 

231 """Get console markup to render this Text. 

232 

233 Returns: 

234 str: A string potentially creating markup tags. 

235 """ 

236 from .markup import escape 

237 

238 output: List[str] = [] 

239 

240 plain = self.plain 

241 markup_spans = [ 

242 (0, False, self.style), 

243 *((span.start, False, span.style) for span in self._spans), 

244 *((span.end, True, span.style) for span in self._spans), 

245 (len(plain), True, self.style), 

246 ] 

247 markup_spans.sort(key=itemgetter(0, 1)) 

248 position = 0 

249 append = output.append 

250 for offset, closing, style in markup_spans: 

251 if offset > position: 

252 append(escape(plain[position:offset])) 

253 position = offset 

254 if style: 

255 append(f"[/{style}]" if closing else f"[{style}]") 

256 markup = "".join(output) 

257 return markup 

258 

259 @classmethod 

260 def from_markup( 

261 cls, 

262 text: str, 

263 *, 

264 style: Union[str, Style] = "", 

265 emoji: bool = True, 

266 emoji_variant: Optional[EmojiVariant] = None, 

267 justify: Optional["JustifyMethod"] = None, 

268 overflow: Optional["OverflowMethod"] = None, 

269 end: str = "\n", 

270 ) -> "Text": 

271 """Create Text instance from markup. 

272 

273 Args: 

274 text (str): A string containing console markup. 

275 style (Union[str, Style], optional): Base style for text. Defaults to "". 

276 emoji (bool, optional): Also render emoji code. Defaults to True. 

277 emoji_variant (str, optional): Optional emoji variant, either "text" or "emoji". Defaults to None. 

278 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None. 

279 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None. 

280 end (str, optional): Character to end text with. Defaults to "\\\\n". 

281 

282 Returns: 

283 Text: A Text instance with markup rendered. 

284 """ 

285 from .markup import render 

286 

287 rendered_text = render(text, style, emoji=emoji, emoji_variant=emoji_variant) 

288 rendered_text.justify = justify 

289 rendered_text.overflow = overflow 

290 rendered_text.end = end 

291 return rendered_text 

292 

293 @classmethod 

294 def from_ansi( 

295 cls, 

296 text: str, 

297 *, 

298 style: Union[str, Style] = "", 

299 justify: Optional["JustifyMethod"] = None, 

300 overflow: Optional["OverflowMethod"] = None, 

301 no_wrap: Optional[bool] = None, 

302 end: str = "\n", 

303 tab_size: Optional[int] = 8, 

304 ) -> "Text": 

305 """Create a Text object from a string containing ANSI escape codes. 

306 

307 Args: 

308 text (str): A string containing escape codes. 

309 style (Union[str, Style], optional): Base style for text. Defaults to "". 

310 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None. 

311 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None. 

312 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None. 

313 end (str, optional): Character to end text with. Defaults to "\\\\n". 

314 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None. 

315 """ 

316 from .ansi import AnsiDecoder 

317 

318 joiner = Text( 

319 "\n", 

320 justify=justify, 

321 overflow=overflow, 

322 no_wrap=no_wrap, 

323 end=end, 

324 tab_size=tab_size, 

325 style=style, 

326 ) 

327 decoder = AnsiDecoder() 

328 result = joiner.join(line for line in decoder.decode(text)) 

329 return result 

330 

331 @classmethod 

332 def styled( 

333 cls, 

334 text: str, 

335 style: StyleType = "", 

336 *, 

337 justify: Optional["JustifyMethod"] = None, 

338 overflow: Optional["OverflowMethod"] = None, 

339 ) -> "Text": 

340 """Construct a Text instance with a pre-applied styled. A style applied in this way won't be used 

341 to pad the text when it is justified. 

342 

343 Args: 

344 text (str): A string containing console markup. 

345 style (Union[str, Style]): Style to apply to the text. Defaults to "". 

346 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None. 

347 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None. 

348 

349 Returns: 

350 Text: A text instance with a style applied to the entire string. 

351 """ 

352 styled_text = cls(text, justify=justify, overflow=overflow) 

353 styled_text.stylize(style) 

354 return styled_text 

355 

356 @classmethod 

357 def assemble( 

358 cls, 

359 *parts: Union[str, "Text", Tuple[str, StyleType]], 

360 style: Union[str, Style] = "", 

361 justify: Optional["JustifyMethod"] = None, 

362 overflow: Optional["OverflowMethod"] = None, 

363 no_wrap: Optional[bool] = None, 

364 end: str = "\n", 

365 tab_size: int = 8, 

366 meta: Optional[Dict[str, Any]] = None, 

367 ) -> "Text": 

368 """Construct a text instance by combining a sequence of strings with optional styles. 

369 The positional arguments should be either strings, or a tuple of string + style. 

370 

371 Args: 

372 style (Union[str, Style], optional): Base style for text. Defaults to "". 

373 justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None. 

374 overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None. 

375 no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None. 

376 end (str, optional): Character to end text with. Defaults to "\\\\n". 

377 tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None. 

378 meta (Dict[str, Any], optional). Meta data to apply to text, or None for no meta data. Default to None 

379 

380 Returns: 

381 Text: A new text instance. 

382 """ 

383 text = cls( 

384 style=style, 

385 justify=justify, 

386 overflow=overflow, 

387 no_wrap=no_wrap, 

388 end=end, 

389 tab_size=tab_size, 

390 ) 

391 append = text.append 

392 _Text = Text 

393 for part in parts: 

394 if isinstance(part, (_Text, str)): 

395 append(part) 

396 else: 

397 append(*part) 

398 if meta: 

399 text.apply_meta(meta) 

400 return text 

401 

402 @property 

403 def plain(self) -> str: 

404 """Get the text as a single string.""" 

405 if len(self._text) != 1: 

406 self._text[:] = ["".join(self._text)] 

407 return self._text[0] 

408 

409 @plain.setter 

410 def plain(self, new_text: str) -> None: 

411 """Set the text to a new value.""" 

412 if new_text != self.plain: 

413 sanitized_text = strip_control_codes(new_text) 

414 self._text[:] = [sanitized_text] 

415 old_length = self._length 

416 self._length = len(sanitized_text) 

417 if old_length > self._length: 

418 self._trim_spans() 

419 

420 @property 

421 def spans(self) -> List[Span]: 

422 """Get a reference to the internal list of spans.""" 

423 return self._spans 

424 

425 @spans.setter 

426 def spans(self, spans: List[Span]) -> None: 

427 """Set spans.""" 

428 self._spans = spans[:] 

429 

430 def blank_copy(self, plain: str = "") -> "Text": 

431 """Return a new Text instance with copied metadata (but not the string or spans).""" 

432 copy_self = Text( 

433 plain, 

434 style=self.style, 

435 justify=self.justify, 

436 overflow=self.overflow, 

437 no_wrap=self.no_wrap, 

438 end=self.end, 

439 tab_size=self.tab_size, 

440 ) 

441 return copy_self 

442 

443 def copy(self) -> "Text": 

444 """Return a copy of this instance.""" 

445 copy_self = Text( 

446 self.plain, 

447 style=self.style, 

448 justify=self.justify, 

449 overflow=self.overflow, 

450 no_wrap=self.no_wrap, 

451 end=self.end, 

452 tab_size=self.tab_size, 

453 ) 

454 copy_self._spans[:] = self._spans 

455 return copy_self 

456 

457 def stylize( 

458 self, 

459 style: Union[str, Style], 

460 start: int = 0, 

461 end: Optional[int] = None, 

462 ) -> None: 

463 """Apply a style to the text, or a portion of the text. 

464 

465 Args: 

466 style (Union[str, Style]): Style instance or style definition to apply. 

467 start (int): Start offset (negative indexing is supported). Defaults to 0. 

468 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None. 

469 """ 

470 if style: 

471 length = len(self) 

472 if start < 0: 

473 start = length + start 

474 if end is None: 

475 end = length 

476 if end < 0: 

477 end = length + end 

478 if start >= length or end <= start: 

479 # Span not in text or not valid 

480 return 

481 self._spans.append(Span(start, min(length, end), style)) 

482 

483 def stylize_before( 

484 self, 

485 style: Union[str, Style], 

486 start: int = 0, 

487 end: Optional[int] = None, 

488 ) -> None: 

489 """Apply a style to the text, or a portion of the text. Styles will be applied before other styles already present. 

490 

491 Args: 

492 style (Union[str, Style]): Style instance or style definition to apply. 

493 start (int): Start offset (negative indexing is supported). Defaults to 0. 

494 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None. 

495 """ 

496 if style: 

497 length = len(self) 

498 if start < 0: 

499 start = length + start 

500 if end is None: 

501 end = length 

502 if end < 0: 

503 end = length + end 

504 if start >= length or end <= start: 

505 # Span not in text or not valid 

506 return 

507 self._spans.insert(0, Span(start, min(length, end), style)) 

508 

509 def apply_meta( 

510 self, meta: Dict[str, Any], start: int = 0, end: Optional[int] = None 

511 ) -> None: 

512 """Apply metadata to the text, or a portion of the text. 

513 

514 Args: 

515 meta (Dict[str, Any]): A dict of meta information. 

516 start (int): Start offset (negative indexing is supported). Defaults to 0. 

517 end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None. 

518 

519 """ 

520 style = Style.from_meta(meta) 

521 self.stylize(style, start=start, end=end) 

522 

523 def on(self, meta: Optional[Dict[str, Any]] = None, **handlers: Any) -> "Text": 

524 """Apply event handlers (used by Textual project). 

525 

526 Example: 

527 >>> from rich.text import Text 

528 >>> text = Text("hello world") 

529 >>> text.on(click="view.toggle('world')") 

530 

531 Args: 

532 meta (Dict[str, Any]): Mapping of meta information. 

533 **handlers: Keyword args are prefixed with "@" to defined handlers. 

534 

535 Returns: 

536 Text: Self is returned to method may be chained. 

537 """ 

538 meta = {} if meta is None else meta 

539 meta.update({f"@{key}": value for key, value in handlers.items()}) 

540 self.stylize(Style.from_meta(meta)) 

541 return self 

542 

543 def remove_suffix(self, suffix: str) -> None: 

544 """Remove a suffix if it exists. 

545 

546 Args: 

547 suffix (str): Suffix to remove. 

548 """ 

549 if self.plain.endswith(suffix): 

550 self.right_crop(len(suffix)) 

551 

552 def get_style_at_offset(self, console: "Console", offset: int) -> Style: 

553 """Get the style of a character at give offset. 

554 

555 Args: 

556 console (~Console): Console where text will be rendered. 

557 offset (int): Offset in to text (negative indexing supported) 

558 

559 Returns: 

560 Style: A Style instance. 

561 """ 

562 # TODO: This is a little inefficient, it is only used by full justify 

563 if offset < 0: 

564 offset = len(self) + offset 

565 get_style = console.get_style 

566 style = get_style(self.style).copy() 

567 for start, end, span_style in self._spans: 

568 if end > offset >= start: 

569 style += get_style(span_style, default="") 

570 return style 

571 

572 def extend_style(self, spaces: int) -> None: 

573 """Extend the Text given number of spaces where the spaces have the same style as the last character. 

574 

575 Args: 

576 spaces (int): Number of spaces to add to the Text. 

577 """ 

578 if spaces <= 0: 

579 return 

580 spans = self.spans 

581 new_spaces = " " * spaces 

582 if spans: 

583 end_offset = len(self) 

584 self._spans[:] = [ 

585 span.extend(spaces) if span.end >= end_offset else span 

586 for span in spans 

587 ] 

588 self._text.append(new_spaces) 

589 self._length += spaces 

590 else: 

591 self.plain += new_spaces 

592 

593 def highlight_regex( 

594 self, 

595 re_highlight: Union[Pattern[str], str], 

596 style: Optional[Union[GetStyleCallable, StyleType]] = None, 

597 *, 

598 style_prefix: str = "", 

599 ) -> int: 

600 """Highlight text with a regular expression, where group names are 

601 translated to styles. 

602 

603 Args: 

604 re_highlight (Union[re.Pattern, str]): A regular expression object or string. 

605 style (Union[GetStyleCallable, StyleType]): Optional style to apply to whole match, or a callable 

606 which accepts the matched text and returns a style. Defaults to None. 

607 style_prefix (str, optional): Optional prefix to add to style group names. 

608 

609 Returns: 

610 int: Number of regex matches 

611 """ 

612 count = 0 

613 append_span = self._spans.append 

614 _Span = Span 

615 plain = self.plain 

616 if isinstance(re_highlight, str): 

617 re_highlight = re.compile(re_highlight) 

618 for match in re_highlight.finditer(plain): 

619 get_span = match.span 

620 if style: 

621 start, end = get_span() 

622 match_style = style(plain[start:end]) if callable(style) else style 

623 if match_style is not None and end > start: 

624 append_span(_Span(start, end, match_style)) 

625 

626 count += 1 

627 for name in match.groupdict().keys(): 

628 start, end = get_span(name) 

629 if start != -1 and end > start: 

630 append_span(_Span(start, end, f"{style_prefix}{name}")) 

631 return count 

632 

633 def highlight_words( 

634 self, 

635 words: Iterable[str], 

636 style: Union[str, Style], 

637 *, 

638 case_sensitive: bool = True, 

639 ) -> int: 

640 """Highlight words with a style. 

641 

642 Args: 

643 words (Iterable[str]): Words to highlight. 

644 style (Union[str, Style]): Style to apply. 

645 case_sensitive (bool, optional): Enable case sensitive matching. Defaults to True. 

646 

647 Returns: 

648 int: Number of words highlighted. 

649 """ 

650 re_words = "|".join(re.escape(word) for word in words) 

651 add_span = self._spans.append 

652 count = 0 

653 _Span = Span 

654 for match in re.finditer( 

655 re_words, self.plain, flags=0 if case_sensitive else re.IGNORECASE 

656 ): 

657 start, end = match.span(0) 

658 add_span(_Span(start, end, style)) 

659 count += 1 

660 return count 

661 

662 def rstrip(self) -> None: 

663 """Strip whitespace from end of text.""" 

664 self.plain = self.plain.rstrip() 

665 

666 def rstrip_end(self, size: int) -> None: 

667 """Remove whitespace beyond a certain width at the end of the text. 

668 

669 Args: 

670 size (int): The desired size of the text. 

671 """ 

672 text_length = len(self) 

673 if text_length > size: 

674 excess = text_length - size 

675 whitespace_match = _re_whitespace.search(self.plain) 

676 if whitespace_match is not None: 

677 whitespace_count = len(whitespace_match.group(0)) 

678 self.right_crop(min(whitespace_count, excess)) 

679 

680 def set_length(self, new_length: int) -> None: 

681 """Set new length of the text, clipping or padding is required.""" 

682 length = len(self) 

683 if length != new_length: 

684 if length < new_length: 

685 self.pad_right(new_length - length) 

686 else: 

687 self.right_crop(length - new_length) 

688 

689 def __rich_console__( 

690 self, console: "Console", options: "ConsoleOptions" 

691 ) -> Iterable[Segment]: 

692 tab_size: int = console.tab_size if self.tab_size is None else self.tab_size 

693 justify = self.justify or options.justify or DEFAULT_JUSTIFY 

694 overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW 

695 

696 lines = self.wrap( 

697 console, 

698 options.max_width, 

699 justify=justify, 

700 overflow=overflow, 

701 tab_size=tab_size or 8, 

702 no_wrap=pick_bool(self.no_wrap, options.no_wrap, False), 

703 ) 

704 all_lines = Text("\n").join(lines) 

705 yield from all_lines.render(console, end=self.end) 

706 

707 def __rich_measure__( 

708 self, console: "Console", options: "ConsoleOptions" 

709 ) -> Measurement: 

710 text = self.plain 

711 lines = text.splitlines() 

712 max_text_width = max(cell_len(line) for line in lines) if lines else 0 

713 words = text.split() 

714 min_text_width = ( 

715 max(cell_len(word) for word in words) if words else max_text_width 

716 ) 

717 return Measurement(min_text_width, max_text_width) 

718 

719 def render(self, console: "Console", end: str = "") -> Iterable["Segment"]: 

720 """Render the text as Segments. 

721 

722 Args: 

723 console (Console): Console instance. 

724 end (Optional[str], optional): Optional end character. 

725 

726 Returns: 

727 Iterable[Segment]: Result of render that may be written to the console. 

728 """ 

729 _Segment = Segment 

730 text = self.plain 

731 if not self._spans: 

732 yield Segment(text) 

733 if end: 

734 yield _Segment(end) 

735 return 

736 get_style = partial(console.get_style, default=Style.null()) 

737 

738 enumerated_spans = list(enumerate(self._spans, 1)) 

739 style_map = {index: get_style(span.style) for index, span in enumerated_spans} 

740 style_map[0] = get_style(self.style) 

741 

742 spans = [ 

743 (0, False, 0), 

744 *((span.start, False, index) for index, span in enumerated_spans), 

745 *((span.end, True, index) for index, span in enumerated_spans), 

746 (len(text), True, 0), 

747 ] 

748 spans.sort(key=itemgetter(0, 1)) 

749 

750 stack: List[int] = [] 

751 stack_append = stack.append 

752 stack_pop = stack.remove 

753 

754 style_cache: Dict[Tuple[Style, ...], Style] = {} 

755 style_cache_get = style_cache.get 

756 combine = Style.combine 

757 

758 def get_current_style() -> Style: 

759 """Construct current style from stack.""" 

760 styles = tuple(style_map[_style_id] for _style_id in sorted(stack)) 

761 cached_style = style_cache_get(styles) 

762 if cached_style is not None: 

763 return cached_style 

764 current_style = combine(styles) 

765 style_cache[styles] = current_style 

766 return current_style 

767 

768 for (offset, leaving, style_id), (next_offset, _, _) in zip(spans, spans[1:]): 

769 if leaving: 

770 stack_pop(style_id) 

771 else: 

772 stack_append(style_id) 

773 if next_offset > offset: 

774 yield _Segment(text[offset:next_offset], get_current_style()) 

775 if end: 

776 yield _Segment(end) 

777 

778 def join(self, lines: Iterable["Text"]) -> "Text": 

779 """Join text together with this instance as the separator. 

780 

781 Args: 

782 lines (Iterable[Text]): An iterable of Text instances to join. 

783 

784 Returns: 

785 Text: A new text instance containing join text. 

786 """ 

787 

788 new_text = self.blank_copy() 

789 

790 def iter_text() -> Iterable["Text"]: 

791 if self.plain: 

792 for last, line in loop_last(lines): 

793 yield line 

794 if not last: 

795 yield self 

796 else: 

797 yield from lines 

798 

799 extend_text = new_text._text.extend 

800 append_span = new_text._spans.append 

801 extend_spans = new_text._spans.extend 

802 offset = 0 

803 _Span = Span 

804 

805 for text in iter_text(): 

806 extend_text(text._text) 

807 if text.style: 

808 append_span(_Span(offset, offset + len(text), text.style)) 

809 extend_spans( 

810 _Span(offset + start, offset + end, style) 

811 for start, end, style in text._spans 

812 ) 

813 offset += len(text) 

814 new_text._length = offset 

815 return new_text 

816 

817 def expand_tabs(self, tab_size: Optional[int] = None) -> None: 

818 """Converts tabs to spaces. 

819 

820 Args: 

821 tab_size (int, optional): Size of tabs. Defaults to 8. 

822 

823 """ 

824 if "\t" not in self.plain: 

825 return 

826 if tab_size is None: 

827 tab_size = self.tab_size 

828 if tab_size is None: 

829 tab_size = 8 

830 

831 new_text: List[Text] = [] 

832 append = new_text.append 

833 

834 for line in self.split("\n", include_separator=True): 

835 if "\t" not in line.plain: 

836 append(line) 

837 else: 

838 cell_position = 0 

839 parts = line.split("\t", include_separator=True) 

840 for part in parts: 

841 if part.plain.endswith("\t"): 

842 part._text[-1] = part._text[-1][:-1] + " " 

843 cell_position += part.cell_len 

844 tab_remainder = cell_position % tab_size 

845 if tab_remainder: 

846 spaces = tab_size - tab_remainder 

847 part.extend_style(spaces) 

848 cell_position += spaces 

849 else: 

850 cell_position += part.cell_len 

851 append(part) 

852 

853 result = Text("").join(new_text) 

854 

855 self._text = [result.plain] 

856 self._length = len(self.plain) 

857 self._spans[:] = result._spans 

858 

859 def truncate( 

860 self, 

861 max_width: int, 

862 *, 

863 overflow: Optional["OverflowMethod"] = None, 

864 pad: bool = False, 

865 ) -> None: 

866 """Truncate text if it is longer that a given width. 

867 

868 Args: 

869 max_width (int): Maximum number of characters in text. 

870 overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None, to use self.overflow. 

871 pad (bool, optional): Pad with spaces if the length is less than max_width. Defaults to False. 

872 """ 

873 _overflow = overflow or self.overflow or DEFAULT_OVERFLOW 

874 if _overflow != "ignore": 

875 length = cell_len(self.plain) 

876 if length > max_width: 

877 if _overflow == "ellipsis": 

878 self.plain = set_cell_size(self.plain, max_width - 1) + "…" 

879 else: 

880 self.plain = set_cell_size(self.plain, max_width) 

881 if pad and length < max_width: 

882 spaces = max_width - length 

883 self._text = [f"{self.plain}{' ' * spaces}"] 

884 self._length = len(self.plain) 

885 

886 def _trim_spans(self) -> None: 

887 """Remove or modify any spans that are over the end of the text.""" 

888 max_offset = len(self.plain) 

889 _Span = Span 

890 self._spans[:] = [ 

891 ( 

892 span 

893 if span.end < max_offset 

894 else _Span(span.start, min(max_offset, span.end), span.style) 

895 ) 

896 for span in self._spans 

897 if span.start < max_offset 

898 ] 

899 

900 def pad(self, count: int, character: str = " ") -> None: 

901 """Pad left and right with a given number of characters. 

902 

903 Args: 

904 count (int): Width of padding. 

905 character (str): The character to pad with. Must be a string of length 1. 

906 """ 

907 assert len(character) == 1, "Character must be a string of length 1" 

908 if count: 

909 pad_characters = character * count 

910 self.plain = f"{pad_characters}{self.plain}{pad_characters}" 

911 _Span = Span 

912 self._spans[:] = [ 

913 _Span(start + count, end + count, style) 

914 for start, end, style in self._spans 

915 ] 

916 

917 def pad_left(self, count: int, character: str = " ") -> None: 

918 """Pad the left with a given character. 

919 

920 Args: 

921 count (int): Number of characters to pad. 

922 character (str, optional): Character to pad with. Defaults to " ". 

923 """ 

924 assert len(character) == 1, "Character must be a string of length 1" 

925 if count: 

926 self.plain = f"{character * count}{self.plain}" 

927 _Span = Span 

928 self._spans[:] = [ 

929 _Span(start + count, end + count, style) 

930 for start, end, style in self._spans 

931 ] 

932 

933 def pad_right(self, count: int, character: str = " ") -> None: 

934 """Pad the right with a given character. 

935 

936 Args: 

937 count (int): Number of characters to pad. 

938 character (str, optional): Character to pad with. Defaults to " ". 

939 """ 

940 assert len(character) == 1, "Character must be a string of length 1" 

941 if count: 

942 self.plain = f"{self.plain}{character * count}" 

943 

944 def align(self, align: AlignMethod, width: int, character: str = " ") -> None: 

945 """Align text to a given width. 

946 

947 Args: 

948 align (AlignMethod): One of "left", "center", or "right". 

949 width (int): Desired width. 

950 character (str, optional): Character to pad with. Defaults to " ". 

951 """ 

952 self.truncate(width) 

953 excess_space = width - cell_len(self.plain) 

954 if excess_space: 

955 if align == "left": 

956 self.pad_right(excess_space, character) 

957 elif align == "center": 

958 left = excess_space // 2 

959 self.pad_left(left, character) 

960 self.pad_right(excess_space - left, character) 

961 else: 

962 self.pad_left(excess_space, character) 

963 

964 def append( 

965 self, text: Union["Text", str], style: Optional[Union[str, "Style"]] = None 

966 ) -> "Text": 

967 """Add text with an optional style. 

968 

969 Args: 

970 text (Union[Text, str]): A str or Text to append. 

971 style (str, optional): A style name. Defaults to None. 

972 

973 Returns: 

974 Text: Returns self for chaining. 

975 """ 

976 

977 if not isinstance(text, (str, Text)): 

978 raise TypeError("Only str or Text can be appended to Text") 

979 

980 if len(text): 

981 if isinstance(text, str): 

982 sanitized_text = strip_control_codes(text) 

983 self._text.append(sanitized_text) 

984 offset = len(self) 

985 text_length = len(sanitized_text) 

986 if style: 

987 self._spans.append(Span(offset, offset + text_length, style)) 

988 self._length += text_length 

989 elif isinstance(text, Text): 

990 _Span = Span 

991 if style is not None: 

992 raise ValueError( 

993 "style must not be set when appending Text instance" 

994 ) 

995 text_length = self._length 

996 if text.style: 

997 self._spans.append( 

998 _Span(text_length, text_length + len(text), text.style) 

999 ) 

1000 self._text.append(text.plain) 

1001 self._spans.extend( 

1002 _Span(start + text_length, end + text_length, style) 

1003 for start, end, style in text._spans.copy() 

1004 ) 

1005 self._length += len(text) 

1006 return self 

1007 

1008 def append_text(self, text: "Text") -> "Text": 

1009 """Append another Text instance. This method is more performant than Text.append, but 

1010 only works for Text. 

1011 

1012 Args: 

1013 text (Text): The Text instance to append to this instance. 

1014 

1015 Returns: 

1016 Text: Returns self for chaining. 

1017 """ 

1018 _Span = Span 

1019 text_length = self._length 

1020 if text.style: 

1021 self._spans.append(_Span(text_length, text_length + len(text), text.style)) 

1022 self._text.append(text.plain) 

1023 self._spans.extend( 

1024 _Span(start + text_length, end + text_length, style) 

1025 for start, end, style in text._spans.copy() 

1026 ) 

1027 self._length += len(text) 

1028 return self 

1029 

1030 def append_tokens( 

1031 self, tokens: Iterable[Tuple[str, Optional[StyleType]]] 

1032 ) -> "Text": 

1033 """Append iterable of str and style. Style may be a Style instance or a str style definition. 

1034 

1035 Args: 

1036 tokens (Iterable[Tuple[str, Optional[StyleType]]]): An iterable of tuples containing str content and style. 

1037 

1038 Returns: 

1039 Text: Returns self for chaining. 

1040 """ 

1041 append_text = self._text.append 

1042 append_span = self._spans.append 

1043 _Span = Span 

1044 offset = len(self) 

1045 for content, style in tokens: 

1046 content = strip_control_codes(content) 

1047 append_text(content) 

1048 if style: 

1049 append_span(_Span(offset, offset + len(content), style)) 

1050 offset += len(content) 

1051 self._length = offset 

1052 return self 

1053 

1054 def copy_styles(self, text: "Text") -> None: 

1055 """Copy styles from another Text instance. 

1056 

1057 Args: 

1058 text (Text): A Text instance to copy styles from, must be the same length. 

1059 """ 

1060 self._spans.extend(text._spans) 

1061 

1062 def split( 

1063 self, 

1064 separator: str = "\n", 

1065 *, 

1066 include_separator: bool = False, 

1067 allow_blank: bool = False, 

1068 ) -> Lines: 

1069 """Split rich text in to lines, preserving styles. 

1070 

1071 Args: 

1072 separator (str, optional): String to split on. Defaults to "\\\\n". 

1073 include_separator (bool, optional): Include the separator in the lines. Defaults to False. 

1074 allow_blank (bool, optional): Return a blank line if the text ends with a separator. Defaults to False. 

1075 

1076 Returns: 

1077 List[RichText]: A list of rich text, one per line of the original. 

1078 """ 

1079 assert separator, "separator must not be empty" 

1080 

1081 text = self.plain 

1082 if separator not in text: 

1083 return Lines([self.copy()]) 

1084 

1085 if include_separator: 

1086 lines = self.divide( 

1087 match.end() for match in re.finditer(re.escape(separator), text) 

1088 ) 

1089 else: 

1090 

1091 def flatten_spans() -> Iterable[int]: 

1092 for match in re.finditer(re.escape(separator), text): 

1093 start, end = match.span() 

1094 yield start 

1095 yield end 

1096 

1097 lines = Lines( 

1098 line for line in self.divide(flatten_spans()) if line.plain != separator 

1099 ) 

1100 

1101 if not allow_blank and text.endswith(separator): 

1102 lines.pop() 

1103 

1104 return lines 

1105 

1106 def divide(self, offsets: Iterable[int]) -> Lines: 

1107 """Divide text into a number of lines at given offsets. 

1108 

1109 Args: 

1110 offsets (Iterable[int]): Offsets used to divide text. 

1111 

1112 Returns: 

1113 Lines: New RichText instances between offsets. 

1114 """ 

1115 _offsets = list(offsets) 

1116 

1117 if not _offsets: 

1118 return Lines([self.copy()]) 

1119 

1120 text = self.plain 

1121 text_length = len(text) 

1122 divide_offsets = [0, *_offsets, text_length] 

1123 line_ranges = list(zip(divide_offsets, divide_offsets[1:])) 

1124 

1125 style = self.style 

1126 justify = self.justify 

1127 overflow = self.overflow 

1128 _Text = Text 

1129 new_lines = Lines( 

1130 _Text( 

1131 text[start:end], 

1132 style=style, 

1133 justify=justify, 

1134 overflow=overflow, 

1135 ) 

1136 for start, end in line_ranges 

1137 ) 

1138 if not self._spans: 

1139 return new_lines 

1140 

1141 _line_appends = [line._spans.append for line in new_lines._lines] 

1142 line_count = len(line_ranges) 

1143 _Span = Span 

1144 

1145 for span_start, span_end, style in self._spans: 

1146 lower_bound = 0 

1147 upper_bound = line_count 

1148 start_line_no = (lower_bound + upper_bound) // 2 

1149 

1150 while True: 

1151 line_start, line_end = line_ranges[start_line_no] 

1152 if span_start < line_start: 

1153 upper_bound = start_line_no - 1 

1154 elif span_start > line_end: 

1155 lower_bound = start_line_no + 1 

1156 else: 

1157 break 

1158 start_line_no = (lower_bound + upper_bound) // 2 

1159 

1160 if span_end < line_end: 

1161 end_line_no = start_line_no 

1162 else: 

1163 end_line_no = lower_bound = start_line_no 

1164 upper_bound = line_count 

1165 

1166 while True: 

1167 line_start, line_end = line_ranges[end_line_no] 

1168 if span_end < line_start: 

1169 upper_bound = end_line_no - 1 

1170 elif span_end > line_end: 

1171 lower_bound = end_line_no + 1 

1172 else: 

1173 break 

1174 end_line_no = (lower_bound + upper_bound) // 2 

1175 

1176 for line_no in range(start_line_no, end_line_no + 1): 

1177 line_start, line_end = line_ranges[line_no] 

1178 new_start = max(0, span_start - line_start) 

1179 new_end = min(span_end - line_start, line_end - line_start) 

1180 if new_end > new_start: 

1181 _line_appends[line_no](_Span(new_start, new_end, style)) 

1182 

1183 return new_lines 

1184 

1185 def right_crop(self, amount: int = 1) -> None: 

1186 """Remove a number of characters from the end of the text.""" 

1187 max_offset = len(self.plain) - amount 

1188 _Span = Span 

1189 self._spans[:] = [ 

1190 ( 

1191 span 

1192 if span.end < max_offset 

1193 else _Span(span.start, min(max_offset, span.end), span.style) 

1194 ) 

1195 for span in self._spans 

1196 if span.start < max_offset 

1197 ] 

1198 self._text = [self.plain[:-amount]] 

1199 self._length -= amount 

1200 

1201 def wrap( 

1202 self, 

1203 console: "Console", 

1204 width: int, 

1205 *, 

1206 justify: Optional["JustifyMethod"] = None, 

1207 overflow: Optional["OverflowMethod"] = None, 

1208 tab_size: int = 8, 

1209 no_wrap: Optional[bool] = None, 

1210 ) -> Lines: 

1211 """Word wrap the text. 

1212 

1213 Args: 

1214 console (Console): Console instance. 

1215 width (int): Number of cells available per line. 

1216 justify (str, optional): Justify method: "default", "left", "center", "full", "right". Defaults to "default". 

1217 overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None. 

1218 tab_size (int, optional): Default tab size. Defaults to 8. 

1219 no_wrap (bool, optional): Disable wrapping, Defaults to False. 

1220 

1221 Returns: 

1222 Lines: Number of lines. 

1223 """ 

1224 wrap_justify = justify or self.justify or DEFAULT_JUSTIFY 

1225 wrap_overflow = overflow or self.overflow or DEFAULT_OVERFLOW 

1226 

1227 no_wrap = pick_bool(no_wrap, self.no_wrap, False) or overflow == "ignore" 

1228 

1229 lines = Lines() 

1230 for line in self.split(allow_blank=True): 

1231 if "\t" in line: 

1232 line.expand_tabs(tab_size) 

1233 if no_wrap: 

1234 if overflow == "ignore": 

1235 lines.append(line) 

1236 continue 

1237 new_lines = Lines([line]) 

1238 else: 

1239 offsets = divide_line(str(line), width, fold=wrap_overflow == "fold") 

1240 new_lines = line.divide(offsets) 

1241 for line in new_lines: 

1242 line.rstrip_end(width) 

1243 if wrap_justify: 

1244 new_lines.justify( 

1245 console, width, justify=wrap_justify, overflow=wrap_overflow 

1246 ) 

1247 for line in new_lines: 

1248 line.truncate(width, overflow=wrap_overflow) 

1249 lines.extend(new_lines) 

1250 return lines 

1251 

1252 def fit(self, width: int) -> Lines: 

1253 """Fit the text in to given width by chopping in to lines. 

1254 

1255 Args: 

1256 width (int): Maximum characters in a line. 

1257 

1258 Returns: 

1259 Lines: Lines container. 

1260 """ 

1261 lines: Lines = Lines() 

1262 append = lines.append 

1263 for line in self.split(): 

1264 line.set_length(width) 

1265 append(line) 

1266 return lines 

1267 

1268 def detect_indentation(self) -> int: 

1269 """Auto-detect indentation of code. 

1270 

1271 Returns: 

1272 int: Number of spaces used to indent code. 

1273 """ 

1274 

1275 _indentations = { 

1276 len(match.group(1)) 

1277 for match in re.finditer(r"^( *)(.*)$", self.plain, flags=re.MULTILINE) 

1278 } 

1279 

1280 try: 

1281 indentation = ( 

1282 reduce(gcd, [indent for indent in _indentations if not indent % 2]) or 1 

1283 ) 

1284 except TypeError: 

1285 indentation = 1 

1286 

1287 return indentation 

1288 

1289 def with_indent_guides( 

1290 self, 

1291 indent_size: Optional[int] = None, 

1292 *, 

1293 character: str = "│", 

1294 style: StyleType = "dim green", 

1295 ) -> "Text": 

1296 """Adds indent guide lines to text. 

1297 

1298 Args: 

1299 indent_size (Optional[int]): Size of indentation, or None to auto detect. Defaults to None. 

1300 character (str, optional): Character to use for indentation. Defaults to "│". 

1301 style (Union[Style, str], optional): Style of indent guides. 

1302 

1303 Returns: 

1304 Text: New text with indentation guides. 

1305 """ 

1306 

1307 _indent_size = self.detect_indentation() if indent_size is None else indent_size 

1308 

1309 text = self.copy() 

1310 text.expand_tabs() 

1311 indent_line = f"{character}{' ' * (_indent_size - 1)}" 

1312 

1313 re_indent = re.compile(r"^( *)(.*)$") 

1314 new_lines: List[Text] = [] 

1315 add_line = new_lines.append 

1316 blank_lines = 0 

1317 for line in text.split(allow_blank=True): 

1318 match = re_indent.match(line.plain) 

1319 if not match or not match.group(2): 

1320 blank_lines += 1 

1321 continue 

1322 indent = match.group(1) 

1323 full_indents, remaining_space = divmod(len(indent), _indent_size) 

1324 new_indent = f"{indent_line * full_indents}{' ' * remaining_space}" 

1325 line.plain = new_indent + line.plain[len(new_indent) :] 

1326 line.stylize(style, 0, len(new_indent)) 

1327 if blank_lines: 

1328 new_lines.extend([Text(new_indent, style=style)] * blank_lines) 

1329 blank_lines = 0 

1330 add_line(line) 

1331 if blank_lines: 

1332 new_lines.extend([Text("", style=style)] * blank_lines) 

1333 

1334 new_text = text.blank_copy("\n").join(new_lines) 

1335 return new_text 

1336 

1337 

1338if __name__ == "__main__": # pragma: no cover 

1339 from rich.console import Console 

1340 

1341 text = Text( 

1342 """\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n""" 

1343 ) 

1344 text.highlight_words(["Lorem"], "bold") 

1345 text.highlight_words(["ipsum"], "italic") 

1346 

1347 console = Console() 

1348 

1349 console.rule("justify='left'") 

1350 console.print(text, style="red") 

1351 console.print() 

1352 

1353 console.rule("justify='center'") 

1354 console.print(text, style="green", justify="center") 

1355 console.print() 

1356 

1357 console.rule("justify='right'") 

1358 console.print(text, style="blue", justify="right") 

1359 console.print() 

1360 

1361 console.rule("justify='full'") 

1362 console.print(text, style="magenta", justify="full") 

1363 console.print()