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

609 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 

695 overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW 

696 

697 lines = self.wrap( 

698 console, 

699 options.max_width, 

700 justify=justify, 

701 overflow=overflow, 

702 tab_size=tab_size or 8, 

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

704 ) 

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

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

707 

708 def __rich_measure__( 

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

710 ) -> Measurement: 

711 text = self.plain 

712 lines = text.splitlines() 

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

714 words = text.split() 

715 min_text_width = ( 

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

717 ) 

718 return Measurement(min_text_width, max_text_width) 

719 

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

721 """Render the text as Segments. 

722 

723 Args: 

724 console (Console): Console instance. 

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

726 

727 Returns: 

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

729 """ 

730 _Segment = Segment 

731 text = self.plain 

732 if not self._spans: 

733 yield Segment(text) 

734 if end: 

735 yield _Segment(end) 

736 return 

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

738 

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

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

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

742 

743 spans = [ 

744 (0, False, 0), 

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

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

747 (len(text), True, 0), 

748 ] 

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

750 

751 stack: List[int] = [] 

752 stack_append = stack.append 

753 stack_pop = stack.remove 

754 

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

756 style_cache_get = style_cache.get 

757 combine = Style.combine 

758 

759 def get_current_style() -> Style: 

760 """Construct current style from stack.""" 

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

762 cached_style = style_cache_get(styles) 

763 if cached_style is not None: 

764 return cached_style 

765 current_style = combine(styles) 

766 style_cache[styles] = current_style 

767 return current_style 

768 

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

770 if leaving: 

771 stack_pop(style_id) 

772 else: 

773 stack_append(style_id) 

774 if next_offset > offset: 

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

776 if end: 

777 yield _Segment(end) 

778 

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

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

781 

782 Args: 

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

784 

785 Returns: 

786 Text: A new text instance containing join text. 

787 """ 

788 

789 new_text = self.blank_copy() 

790 

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

792 if self.plain: 

793 for last, line in loop_last(lines): 

794 yield line 

795 if not last: 

796 yield self 

797 else: 

798 yield from lines 

799 

800 extend_text = new_text._text.extend 

801 append_span = new_text._spans.append 

802 extend_spans = new_text._spans.extend 

803 offset = 0 

804 _Span = Span 

805 

806 for text in iter_text(): 

807 extend_text(text._text) 

808 if text.style: 

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

810 extend_spans( 

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

812 for start, end, style in text._spans 

813 ) 

814 offset += len(text) 

815 new_text._length = offset 

816 return new_text 

817 

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

819 """Converts tabs to spaces. 

820 

821 Args: 

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

823 

824 """ 

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

826 return 

827 if tab_size is None: 

828 tab_size = self.tab_size 

829 if tab_size is None: 

830 tab_size = 8 

831 

832 new_text: List[Text] = [] 

833 append = new_text.append 

834 

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

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

837 append(line) 

838 else: 

839 cell_position = 0 

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

841 for part in parts: 

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

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

844 cell_position += part.cell_len 

845 tab_remainder = cell_position % tab_size 

846 if tab_remainder: 

847 spaces = tab_size - tab_remainder 

848 part.extend_style(spaces) 

849 cell_position += spaces 

850 else: 

851 cell_position += part.cell_len 

852 append(part) 

853 

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

855 

856 self._text = [result.plain] 

857 self._length = len(self.plain) 

858 self._spans[:] = result._spans 

859 

860 def truncate( 

861 self, 

862 max_width: int, 

863 *, 

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

865 pad: bool = False, 

866 ) -> None: 

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

868 

869 Args: 

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

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

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

873 """ 

874 _overflow = overflow or self.overflow or DEFAULT_OVERFLOW 

875 if _overflow != "ignore": 

876 length = cell_len(self.plain) 

877 if length > max_width: 

878 if _overflow == "ellipsis": 

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

880 else: 

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

882 if pad and length < max_width: 

883 spaces = max_width - length 

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

885 self._length = len(self.plain) 

886 

887 def _trim_spans(self) -> None: 

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

889 max_offset = len(self.plain) 

890 _Span = Span 

891 self._spans[:] = [ 

892 ( 

893 span 

894 if span.end < max_offset 

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

896 ) 

897 for span in self._spans 

898 if span.start < max_offset 

899 ] 

900 

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

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

903 

904 Args: 

905 count (int): Width of padding. 

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

907 """ 

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

909 if count: 

910 pad_characters = character * count 

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

912 _Span = Span 

913 self._spans[:] = [ 

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

915 for start, end, style in self._spans 

916 ] 

917 

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

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

920 

921 Args: 

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

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

924 """ 

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

926 if count: 

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

928 _Span = Span 

929 self._spans[:] = [ 

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

931 for start, end, style in self._spans 

932 ] 

933 

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

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

936 

937 Args: 

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

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

940 """ 

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

942 if count: 

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

944 

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

946 """Align text to a given width. 

947 

948 Args: 

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

950 width (int): Desired width. 

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

952 """ 

953 self.truncate(width) 

954 excess_space = width - cell_len(self.plain) 

955 if excess_space: 

956 if align == "left": 

957 self.pad_right(excess_space, character) 

958 elif align == "center": 

959 left = excess_space // 2 

960 self.pad_left(left, character) 

961 self.pad_right(excess_space - left, character) 

962 else: 

963 self.pad_left(excess_space, character) 

964 

965 def append( 

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

967 ) -> "Text": 

968 """Add text with an optional style. 

969 

970 Args: 

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

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

973 

974 Returns: 

975 Text: Returns self for chaining. 

976 """ 

977 

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

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

980 

981 if len(text): 

982 if isinstance(text, str): 

983 sanitized_text = strip_control_codes(text) 

984 self._text.append(sanitized_text) 

985 offset = len(self) 

986 text_length = len(sanitized_text) 

987 if style: 

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

989 self._length += text_length 

990 elif isinstance(text, Text): 

991 _Span = Span 

992 if style is not None: 

993 raise ValueError( 

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

995 ) 

996 text_length = self._length 

997 if text.style: 

998 self._spans.append( 

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

1000 ) 

1001 self._text.append(text.plain) 

1002 self._spans.extend( 

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

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

1005 ) 

1006 self._length += len(text) 

1007 return self 

1008 

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

1010 """Append another Text instance. This method is more performant that Text.append, but 

1011 only works for Text. 

1012 

1013 Args: 

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

1015 

1016 Returns: 

1017 Text: Returns self for chaining. 

1018 """ 

1019 _Span = Span 

1020 text_length = self._length 

1021 if text.style: 

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

1023 self._text.append(text.plain) 

1024 self._spans.extend( 

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

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

1027 ) 

1028 self._length += len(text) 

1029 return self 

1030 

1031 def append_tokens( 

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

1033 ) -> "Text": 

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

1035 

1036 Args: 

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

1038 

1039 Returns: 

1040 Text: Returns self for chaining. 

1041 """ 

1042 append_text = self._text.append 

1043 append_span = self._spans.append 

1044 _Span = Span 

1045 offset = len(self) 

1046 for content, style in tokens: 

1047 content = strip_control_codes(content) 

1048 append_text(content) 

1049 if style: 

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

1051 offset += len(content) 

1052 self._length = offset 

1053 return self 

1054 

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

1056 """Copy styles from another Text instance. 

1057 

1058 Args: 

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

1060 """ 

1061 self._spans.extend(text._spans) 

1062 

1063 def split( 

1064 self, 

1065 separator: str = "\n", 

1066 *, 

1067 include_separator: bool = False, 

1068 allow_blank: bool = False, 

1069 ) -> Lines: 

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

1071 

1072 Args: 

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

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

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

1076 

1077 Returns: 

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

1079 """ 

1080 assert separator, "separator must not be empty" 

1081 

1082 text = self.plain 

1083 if separator not in text: 

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

1085 

1086 if include_separator: 

1087 lines = self.divide( 

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

1089 ) 

1090 else: 

1091 

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

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

1094 start, end = match.span() 

1095 yield start 

1096 yield end 

1097 

1098 lines = Lines( 

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

1100 ) 

1101 

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

1103 lines.pop() 

1104 

1105 return lines 

1106 

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

1108 """Divide text in to a number of lines at given offsets. 

1109 

1110 Args: 

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

1112 

1113 Returns: 

1114 Lines: New RichText instances between offsets. 

1115 """ 

1116 _offsets = list(offsets) 

1117 

1118 if not _offsets: 

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

1120 

1121 text = self.plain 

1122 text_length = len(text) 

1123 divide_offsets = [0, *_offsets, text_length] 

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

1125 

1126 style = self.style 

1127 justify = self.justify 

1128 overflow = self.overflow 

1129 _Text = Text 

1130 new_lines = Lines( 

1131 _Text( 

1132 text[start:end], 

1133 style=style, 

1134 justify=justify, 

1135 overflow=overflow, 

1136 ) 

1137 for start, end in line_ranges 

1138 ) 

1139 if not self._spans: 

1140 return new_lines 

1141 

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

1143 line_count = len(line_ranges) 

1144 _Span = Span 

1145 

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

1147 lower_bound = 0 

1148 upper_bound = line_count 

1149 start_line_no = (lower_bound + upper_bound) // 2 

1150 

1151 while True: 

1152 line_start, line_end = line_ranges[start_line_no] 

1153 if span_start < line_start: 

1154 upper_bound = start_line_no - 1 

1155 elif span_start > line_end: 

1156 lower_bound = start_line_no + 1 

1157 else: 

1158 break 

1159 start_line_no = (lower_bound + upper_bound) // 2 

1160 

1161 if span_end < line_end: 

1162 end_line_no = start_line_no 

1163 else: 

1164 end_line_no = lower_bound = start_line_no 

1165 upper_bound = line_count 

1166 

1167 while True: 

1168 line_start, line_end = line_ranges[end_line_no] 

1169 if span_end < line_start: 

1170 upper_bound = end_line_no - 1 

1171 elif span_end > line_end: 

1172 lower_bound = end_line_no + 1 

1173 else: 

1174 break 

1175 end_line_no = (lower_bound + upper_bound) // 2 

1176 

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

1178 line_start, line_end = line_ranges[line_no] 

1179 new_start = max(0, span_start - line_start) 

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

1181 if new_end > new_start: 

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

1183 

1184 return new_lines 

1185 

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

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

1188 max_offset = len(self.plain) - amount 

1189 _Span = Span 

1190 self._spans[:] = [ 

1191 ( 

1192 span 

1193 if span.end < max_offset 

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

1195 ) 

1196 for span in self._spans 

1197 if span.start < max_offset 

1198 ] 

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

1200 self._length -= amount 

1201 

1202 def wrap( 

1203 self, 

1204 console: "Console", 

1205 width: int, 

1206 *, 

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

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

1209 tab_size: int = 8, 

1210 no_wrap: Optional[bool] = None, 

1211 ) -> Lines: 

1212 """Word wrap the text. 

1213 

1214 Args: 

1215 console (Console): Console instance. 

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

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

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

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

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

1221 

1222 Returns: 

1223 Lines: Number of lines. 

1224 """ 

1225 wrap_justify = justify or self.justify or DEFAULT_JUSTIFY 

1226 wrap_overflow = overflow or self.overflow or DEFAULT_OVERFLOW 

1227 

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

1229 

1230 lines = Lines() 

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

1232 if "\t" in line: 

1233 line.expand_tabs(tab_size) 

1234 if no_wrap: 

1235 new_lines = Lines([line]) 

1236 else: 

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

1238 new_lines = line.divide(offsets) 

1239 for line in new_lines: 

1240 line.rstrip_end(width) 

1241 if wrap_justify: 

1242 new_lines.justify( 

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

1244 ) 

1245 for line in new_lines: 

1246 line.truncate(width, overflow=wrap_overflow) 

1247 lines.extend(new_lines) 

1248 return lines 

1249 

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

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

1252 

1253 Args: 

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

1255 

1256 Returns: 

1257 Lines: Lines container. 

1258 """ 

1259 lines: Lines = Lines() 

1260 append = lines.append 

1261 for line in self.split(): 

1262 line.set_length(width) 

1263 append(line) 

1264 return lines 

1265 

1266 def detect_indentation(self) -> int: 

1267 """Auto-detect indentation of code. 

1268 

1269 Returns: 

1270 int: Number of spaces used to indent code. 

1271 """ 

1272 

1273 _indentations = { 

1274 len(match.group(1)) 

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

1276 } 

1277 

1278 try: 

1279 indentation = ( 

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

1281 ) 

1282 except TypeError: 

1283 indentation = 1 

1284 

1285 return indentation 

1286 

1287 def with_indent_guides( 

1288 self, 

1289 indent_size: Optional[int] = None, 

1290 *, 

1291 character: str = "│", 

1292 style: StyleType = "dim green", 

1293 ) -> "Text": 

1294 """Adds indent guide lines to text. 

1295 

1296 Args: 

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

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

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

1300 

1301 Returns: 

1302 Text: New text with indentation guides. 

1303 """ 

1304 

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

1306 

1307 text = self.copy() 

1308 text.expand_tabs() 

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

1310 

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

1312 new_lines: List[Text] = [] 

1313 add_line = new_lines.append 

1314 blank_lines = 0 

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

1316 match = re_indent.match(line.plain) 

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

1318 blank_lines += 1 

1319 continue 

1320 indent = match.group(1) 

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

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

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

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

1325 if blank_lines: 

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

1327 blank_lines = 0 

1328 add_line(line) 

1329 if blank_lines: 

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

1331 

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

1333 return new_text 

1334 

1335 

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

1337 from rich.console import Console 

1338 

1339 text = Text( 

1340 """\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""" 

1341 ) 

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

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

1344 

1345 console = Console() 

1346 

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

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

1349 console.print() 

1350 

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

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

1353 console.print() 

1354 

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

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

1357 console.print() 

1358 

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

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

1361 console.print()