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

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

600 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 Tuple, 

15 Union, 

16) 

17 

18from ._loop import loop_last 

19from ._pick import pick_bool 

20from ._wrap import divide_line 

21from .align import AlignMethod 

22from .cells import cell_len, set_cell_size 

23from .containers import Lines 

24from .control import strip_control_codes 

25from .emoji import EmojiVariant 

26from .jupyter import JupyterMixin 

27from .measure import Measurement 

28from .segment import Segment 

29from .style import Style, StyleType 

30 

31if TYPE_CHECKING: # pragma: no cover 

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

33 

34DEFAULT_JUSTIFY: "JustifyMethod" = "default" 

35DEFAULT_OVERFLOW: "OverflowMethod" = "fold" 

36 

37 

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

39 

40TextType = Union[str, "Text"] 

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

42 

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

44 

45 

46class Span(NamedTuple): 

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

48 

49 start: int 

50 """Span start index.""" 

51 end: int 

52 """Span end index.""" 

53 style: Union[str, Style] 

54 """Style associated with the span.""" 

55 

56 def __repr__(self) -> str: 

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

58 

59 def __bool__(self) -> bool: 

60 return self.end > self.start 

61 

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

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

64 

65 if offset < self.start: 

66 return self, None 

67 if offset >= self.end: 

68 return self, None 

69 

70 start, end, style = self 

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

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

73 return span1, span2 

74 

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

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

77 

78 Args: 

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

80 

81 Returns: 

82 TextSpan: A new TextSpan with adjusted position. 

83 """ 

84 start, end, style = self 

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

86 

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

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

89 

90 Args: 

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

92 

93 Returns: 

94 Span: A new (possibly smaller) span. 

95 """ 

96 start, end, style = self 

97 if offset >= end: 

98 return self 

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

100 

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

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

103 

104 Args: 

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

106 

107 Returns: 

108 Span: A span. 

109 """ 

110 if cells: 

111 start, end, style = self 

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

113 else: 

114 return self 

115 

116 

117class Text(JupyterMixin): 

118 """Text with color / style. 

119 

120 Args: 

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

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

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

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

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

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

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

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

129 """ 

130 

131 __slots__ = [ 

132 "_text", 

133 "style", 

134 "justify", 

135 "overflow", 

136 "no_wrap", 

137 "end", 

138 "tab_size", 

139 "_spans", 

140 "_length", 

141 ] 

142 

143 def __init__( 

144 self, 

145 text: str = "", 

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

147 *, 

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

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

150 no_wrap: Optional[bool] = None, 

151 end: str = "\n", 

152 tab_size: Optional[int] = None, 

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

154 ) -> None: 

155 sanitized_text = strip_control_codes(text) 

156 self._text = [sanitized_text] 

157 self.style = style 

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

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

160 self.no_wrap = no_wrap 

161 self.end = end 

162 self.tab_size = tab_size 

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

164 self._length: int = len(sanitized_text) 

165 

166 def __len__(self) -> int: 

167 return self._length 

168 

169 def __bool__(self) -> bool: 

170 return bool(self._length) 

171 

172 def __str__(self) -> str: 

173 return self.plain 

174 

175 def __repr__(self) -> str: 

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

177 

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

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

180 result = self.copy() 

181 result.append(other) 

182 return result 

183 return NotImplemented 

184 

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

186 if not isinstance(other, Text): 

187 return NotImplemented 

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

189 

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

191 if isinstance(other, str): 

192 return other in self.plain 

193 elif isinstance(other, Text): 

194 return other.plain in self.plain 

195 return False 

196 

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

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

199 _Span = Span 

200 text = Text( 

201 self.plain[offset], 

202 spans=[ 

203 _Span(0, 1, style) 

204 for start, end, style in self._spans 

205 if end > offset >= start 

206 ], 

207 end="", 

208 ) 

209 return text 

210 

211 if isinstance(slice, int): 

212 return get_text_at(slice) 

213 else: 

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

215 if step == 1: 

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

217 return lines[1] 

218 else: 

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

220 # For now, its not required 

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

222 

223 @property 

224 def cell_len(self) -> int: 

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

226 return cell_len(self.plain) 

227 

228 @property 

229 def markup(self) -> str: 

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

231 

232 Returns: 

233 str: A string potentially creating markup tags. 

234 """ 

235 from .markup import escape 

236 

237 output: List[str] = [] 

238 

239 plain = self.plain 

240 markup_spans = [ 

241 (0, False, self.style), 

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

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

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

245 ] 

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

247 position = 0 

248 append = output.append 

249 for offset, closing, style in markup_spans: 

250 if offset > position: 

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

252 position = offset 

253 if style: 

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

255 markup = "".join(output) 

256 return markup 

257 

258 @classmethod 

259 def from_markup( 

260 cls, 

261 text: str, 

262 *, 

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

264 emoji: bool = True, 

265 emoji_variant: Optional[EmojiVariant] = None, 

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

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

268 end: str = "\n", 

269 ) -> "Text": 

270 """Create Text instance from markup. 

271 

272 Args: 

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

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

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

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

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

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

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

280 

281 Returns: 

282 Text: A Text instance with markup rendered. 

283 """ 

284 from .markup import render 

285 

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

287 rendered_text.justify = justify 

288 rendered_text.overflow = overflow 

289 rendered_text.end = end 

290 return rendered_text 

291 

292 @classmethod 

293 def from_ansi( 

294 cls, 

295 text: str, 

296 *, 

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

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

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

300 no_wrap: Optional[bool] = None, 

301 end: str = "\n", 

302 tab_size: Optional[int] = 8, 

303 ) -> "Text": 

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

305 

306 Args: 

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

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

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

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

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

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

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

314 """ 

315 from .ansi import AnsiDecoder 

316 

317 joiner = Text( 

318 "\n", 

319 justify=justify, 

320 overflow=overflow, 

321 no_wrap=no_wrap, 

322 end=end, 

323 tab_size=tab_size, 

324 style=style, 

325 ) 

326 decoder = AnsiDecoder() 

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

328 return result 

329 

330 @classmethod 

331 def styled( 

332 cls, 

333 text: str, 

334 style: StyleType = "", 

335 *, 

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

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

338 ) -> "Text": 

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

340 to pad the text when it is justified. 

341 

342 Args: 

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

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

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

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

347 

348 Returns: 

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

350 """ 

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

352 styled_text.stylize(style) 

353 return styled_text 

354 

355 @classmethod 

356 def assemble( 

357 cls, 

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

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

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

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

362 no_wrap: Optional[bool] = None, 

363 end: str = "\n", 

364 tab_size: int = 8, 

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

366 ) -> "Text": 

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

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

369 

370 Args: 

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

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

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

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

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

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

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

378 

379 Returns: 

380 Text: A new text instance. 

381 """ 

382 text = cls( 

383 style=style, 

384 justify=justify, 

385 overflow=overflow, 

386 no_wrap=no_wrap, 

387 end=end, 

388 tab_size=tab_size, 

389 ) 

390 append = text.append 

391 _Text = Text 

392 for part in parts: 

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

394 append(part) 

395 else: 

396 append(*part) 

397 if meta: 

398 text.apply_meta(meta) 

399 return text 

400 

401 @property 

402 def plain(self) -> str: 

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

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

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

406 return self._text[0] 

407 

408 @plain.setter 

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

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

411 if new_text != self.plain: 

412 sanitized_text = strip_control_codes(new_text) 

413 self._text[:] = [sanitized_text] 

414 old_length = self._length 

415 self._length = len(sanitized_text) 

416 if old_length > self._length: 

417 self._trim_spans() 

418 

419 @property 

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

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

422 return self._spans 

423 

424 @spans.setter 

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

426 """Set spans.""" 

427 self._spans = spans[:] 

428 

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

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

431 copy_self = Text( 

432 plain, 

433 style=self.style, 

434 justify=self.justify, 

435 overflow=self.overflow, 

436 no_wrap=self.no_wrap, 

437 end=self.end, 

438 tab_size=self.tab_size, 

439 ) 

440 return copy_self 

441 

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

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

444 copy_self = Text( 

445 self.plain, 

446 style=self.style, 

447 justify=self.justify, 

448 overflow=self.overflow, 

449 no_wrap=self.no_wrap, 

450 end=self.end, 

451 tab_size=self.tab_size, 

452 ) 

453 copy_self._spans[:] = self._spans 

454 return copy_self 

455 

456 def stylize( 

457 self, 

458 style: Union[str, Style], 

459 start: int = 0, 

460 end: Optional[int] = None, 

461 ) -> None: 

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

463 

464 Args: 

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

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

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

468 """ 

469 if style: 

470 length = len(self) 

471 if start < 0: 

472 start = length + start 

473 if end is None: 

474 end = length 

475 if end < 0: 

476 end = length + end 

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

478 # Span not in text or not valid 

479 return 

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

481 

482 def stylize_before( 

483 self, 

484 style: Union[str, Style], 

485 start: int = 0, 

486 end: Optional[int] = None, 

487 ) -> None: 

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

489 

490 Args: 

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

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

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

494 """ 

495 if style: 

496 length = len(self) 

497 if start < 0: 

498 start = length + start 

499 if end is None: 

500 end = length 

501 if end < 0: 

502 end = length + end 

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

504 # Span not in text or not valid 

505 return 

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

507 

508 def apply_meta( 

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

510 ) -> None: 

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

512 

513 Args: 

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

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

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

517 

518 """ 

519 style = Style.from_meta(meta) 

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

521 

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

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

524 

525 Example: 

526 >>> from rich.text import Text 

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

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

529 

530 Args: 

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

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

533 

534 Returns: 

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

536 """ 

537 meta = {} if meta is None else meta 

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

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

540 return self 

541 

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

543 """Remove a suffix if it exists. 

544 

545 Args: 

546 suffix (str): Suffix to remove. 

547 """ 

548 if self.plain.endswith(suffix): 

549 self.right_crop(len(suffix)) 

550 

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

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

553 

554 Args: 

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

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

557 

558 Returns: 

559 Style: A Style instance. 

560 """ 

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

562 if offset < 0: 

563 offset = len(self) + offset 

564 get_style = console.get_style 

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

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

567 if end > offset >= start: 

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

569 return style 

570 

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

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

573 

574 Args: 

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

576 """ 

577 if spaces <= 0: 

578 return 

579 spans = self.spans 

580 new_spaces = " " * spaces 

581 if spans: 

582 end_offset = len(self) 

583 self._spans[:] = [ 

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

585 for span in spans 

586 ] 

587 self._text.append(new_spaces) 

588 self._length += spaces 

589 else: 

590 self.plain += new_spaces 

591 

592 def highlight_regex( 

593 self, 

594 re_highlight: str, 

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

596 *, 

597 style_prefix: str = "", 

598 ) -> int: 

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

600 translated to styles. 

601 

602 Args: 

603 re_highlight (str): A regular expression. 

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

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

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

607 

608 Returns: 

609 int: Number of regex matches 

610 """ 

611 count = 0 

612 append_span = self._spans.append 

613 _Span = Span 

614 plain = self.plain 

615 for match in re.finditer(re_highlight, plain): 

616 get_span = match.span 

617 if style: 

618 start, end = get_span() 

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

620 if match_style is not None and end > start: 

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

622 

623 count += 1 

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

625 start, end = get_span(name) 

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

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

628 return count 

629 

630 def highlight_words( 

631 self, 

632 words: Iterable[str], 

633 style: Union[str, Style], 

634 *, 

635 case_sensitive: bool = True, 

636 ) -> int: 

637 """Highlight words with a style. 

638 

639 Args: 

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

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

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

643 

644 Returns: 

645 int: Number of words highlighted. 

646 """ 

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

648 add_span = self._spans.append 

649 count = 0 

650 _Span = Span 

651 for match in re.finditer( 

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

653 ): 

654 start, end = match.span(0) 

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

656 count += 1 

657 return count 

658 

659 def rstrip(self) -> None: 

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

661 self.plain = self.plain.rstrip() 

662 

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

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

665 

666 Args: 

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

668 """ 

669 text_length = len(self) 

670 if text_length > size: 

671 excess = text_length - size 

672 whitespace_match = _re_whitespace.search(self.plain) 

673 if whitespace_match is not None: 

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

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

676 

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

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

679 length = len(self) 

680 if length != new_length: 

681 if length < new_length: 

682 self.pad_right(new_length - length) 

683 else: 

684 self.right_crop(length - new_length) 

685 

686 def __rich_console__( 

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

688 ) -> Iterable[Segment]: 

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

690 justify = self.justify or options.justify or DEFAULT_JUSTIFY 

691 

692 overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW 

693 

694 lines = self.wrap( 

695 console, 

696 options.max_width, 

697 justify=justify, 

698 overflow=overflow, 

699 tab_size=tab_size or 8, 

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

701 ) 

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

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

704 

705 def __rich_measure__( 

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

707 ) -> Measurement: 

708 text = self.plain 

709 lines = text.splitlines() 

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

711 words = text.split() 

712 min_text_width = ( 

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

714 ) 

715 return Measurement(min_text_width, max_text_width) 

716 

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

718 """Render the text as Segments. 

719 

720 Args: 

721 console (Console): Console instance. 

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

723 

724 Returns: 

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

726 """ 

727 _Segment = Segment 

728 text = self.plain 

729 if not self._spans: 

730 yield Segment(text) 

731 if end: 

732 yield _Segment(end) 

733 return 

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

735 

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

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

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

739 

740 spans = [ 

741 (0, False, 0), 

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

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

744 (len(text), True, 0), 

745 ] 

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

747 

748 stack: List[int] = [] 

749 stack_append = stack.append 

750 stack_pop = stack.remove 

751 

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

753 style_cache_get = style_cache.get 

754 combine = Style.combine 

755 

756 def get_current_style() -> Style: 

757 """Construct current style from stack.""" 

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

759 cached_style = style_cache_get(styles) 

760 if cached_style is not None: 

761 return cached_style 

762 current_style = combine(styles) 

763 style_cache[styles] = current_style 

764 return current_style 

765 

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

767 if leaving: 

768 stack_pop(style_id) 

769 else: 

770 stack_append(style_id) 

771 if next_offset > offset: 

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

773 if end: 

774 yield _Segment(end) 

775 

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

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

778 

779 Args: 

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

781 

782 Returns: 

783 Text: A new text instance containing join text. 

784 """ 

785 

786 new_text = self.blank_copy() 

787 

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

789 if self.plain: 

790 for last, line in loop_last(lines): 

791 yield line 

792 if not last: 

793 yield self 

794 else: 

795 yield from lines 

796 

797 extend_text = new_text._text.extend 

798 append_span = new_text._spans.append 

799 extend_spans = new_text._spans.extend 

800 offset = 0 

801 _Span = Span 

802 

803 for text in iter_text(): 

804 extend_text(text._text) 

805 if text.style: 

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

807 extend_spans( 

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

809 for start, end, style in text._spans 

810 ) 

811 offset += len(text) 

812 new_text._length = offset 

813 return new_text 

814 

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

816 """Converts tabs to spaces. 

817 

818 Args: 

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

820 

821 """ 

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

823 return 

824 if tab_size is None: 

825 tab_size = self.tab_size 

826 if tab_size is None: 

827 tab_size = 8 

828 

829 new_text: List[Text] = [] 

830 append = new_text.append 

831 

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

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

834 append(line) 

835 else: 

836 cell_position = 0 

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

838 for part in parts: 

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

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

841 cell_position += part.cell_len 

842 tab_remainder = cell_position % tab_size 

843 if tab_remainder: 

844 spaces = tab_size - tab_remainder 

845 part.extend_style(spaces) 

846 cell_position += spaces 

847 else: 

848 cell_position += part.cell_len 

849 append(part) 

850 

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

852 

853 self._text = [result.plain] 

854 self._length = len(self.plain) 

855 self._spans[:] = result._spans 

856 

857 def truncate( 

858 self, 

859 max_width: int, 

860 *, 

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

862 pad: bool = False, 

863 ) -> None: 

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

865 

866 Args: 

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

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

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

870 """ 

871 _overflow = overflow or self.overflow or DEFAULT_OVERFLOW 

872 if _overflow != "ignore": 

873 length = cell_len(self.plain) 

874 if length > max_width: 

875 if _overflow == "ellipsis": 

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

877 else: 

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

879 if pad and length < max_width: 

880 spaces = max_width - length 

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

882 self._length = len(self.plain) 

883 

884 def _trim_spans(self) -> None: 

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

886 max_offset = len(self.plain) 

887 _Span = Span 

888 self._spans[:] = [ 

889 ( 

890 span 

891 if span.end < max_offset 

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

893 ) 

894 for span in self._spans 

895 if span.start < max_offset 

896 ] 

897 

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

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

900 

901 Args: 

902 count (int): Width of padding. 

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

904 """ 

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

906 if count: 

907 pad_characters = character * count 

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

909 _Span = Span 

910 self._spans[:] = [ 

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

912 for start, end, style in self._spans 

913 ] 

914 

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

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

917 

918 Args: 

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

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

921 """ 

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

923 if count: 

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

925 _Span = Span 

926 self._spans[:] = [ 

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

928 for start, end, style in self._spans 

929 ] 

930 

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

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

933 

934 Args: 

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

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

937 """ 

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

939 if count: 

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

941 

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

943 """Align text to a given width. 

944 

945 Args: 

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

947 width (int): Desired width. 

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

949 """ 

950 self.truncate(width) 

951 excess_space = width - cell_len(self.plain) 

952 if excess_space: 

953 if align == "left": 

954 self.pad_right(excess_space, character) 

955 elif align == "center": 

956 left = excess_space // 2 

957 self.pad_left(left, character) 

958 self.pad_right(excess_space - left, character) 

959 else: 

960 self.pad_left(excess_space, character) 

961 

962 def append( 

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

964 ) -> "Text": 

965 """Add text with an optional style. 

966 

967 Args: 

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

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

970 

971 Returns: 

972 Text: Returns self for chaining. 

973 """ 

974 

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

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

977 

978 if len(text): 

979 if isinstance(text, str): 

980 sanitized_text = strip_control_codes(text) 

981 self._text.append(sanitized_text) 

982 offset = len(self) 

983 text_length = len(sanitized_text) 

984 if style: 

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

986 self._length += text_length 

987 elif isinstance(text, Text): 

988 _Span = Span 

989 if style is not None: 

990 raise ValueError( 

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

992 ) 

993 text_length = self._length 

994 if text.style: 

995 self._spans.append( 

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

997 ) 

998 self._text.append(text.plain) 

999 self._spans.extend( 

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

1001 for start, end, style in text._spans 

1002 ) 

1003 self._length += len(text) 

1004 return self 

1005 

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

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

1008 only works for Text. 

1009 

1010 Args: 

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

1012 

1013 Returns: 

1014 Text: Returns self for chaining. 

1015 """ 

1016 _Span = Span 

1017 text_length = self._length 

1018 if text.style: 

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

1020 self._text.append(text.plain) 

1021 self._spans.extend( 

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

1023 for start, end, style in text._spans 

1024 ) 

1025 self._length += len(text) 

1026 return self 

1027 

1028 def append_tokens( 

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

1030 ) -> "Text": 

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

1032 

1033 Args: 

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

1035 

1036 Returns: 

1037 Text: Returns self for chaining. 

1038 """ 

1039 append_text = self._text.append 

1040 append_span = self._spans.append 

1041 _Span = Span 

1042 offset = len(self) 

1043 for content, style in tokens: 

1044 append_text(content) 

1045 if style: 

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

1047 offset += len(content) 

1048 self._length = offset 

1049 return self 

1050 

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

1052 """Copy styles from another Text instance. 

1053 

1054 Args: 

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

1056 """ 

1057 self._spans.extend(text._spans) 

1058 

1059 def split( 

1060 self, 

1061 separator: str = "\n", 

1062 *, 

1063 include_separator: bool = False, 

1064 allow_blank: bool = False, 

1065 ) -> Lines: 

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

1067 

1068 Args: 

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

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

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

1072 

1073 Returns: 

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

1075 """ 

1076 assert separator, "separator must not be empty" 

1077 

1078 text = self.plain 

1079 if separator not in text: 

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

1081 

1082 if include_separator: 

1083 lines = self.divide( 

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

1085 ) 

1086 else: 

1087 

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

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

1090 start, end = match.span() 

1091 yield start 

1092 yield end 

1093 

1094 lines = Lines( 

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

1096 ) 

1097 

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

1099 lines.pop() 

1100 

1101 return lines 

1102 

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

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

1105 

1106 Args: 

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

1108 

1109 Returns: 

1110 Lines: New RichText instances between offsets. 

1111 """ 

1112 _offsets = list(offsets) 

1113 

1114 if not _offsets: 

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

1116 

1117 text = self.plain 

1118 text_length = len(text) 

1119 divide_offsets = [0, *_offsets, text_length] 

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

1121 

1122 style = self.style 

1123 justify = self.justify 

1124 overflow = self.overflow 

1125 _Text = Text 

1126 new_lines = Lines( 

1127 _Text( 

1128 text[start:end], 

1129 style=style, 

1130 justify=justify, 

1131 overflow=overflow, 

1132 ) 

1133 for start, end in line_ranges 

1134 ) 

1135 if not self._spans: 

1136 return new_lines 

1137 

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

1139 line_count = len(line_ranges) 

1140 _Span = Span 

1141 

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

1143 lower_bound = 0 

1144 upper_bound = line_count 

1145 start_line_no = (lower_bound + upper_bound) // 2 

1146 

1147 while True: 

1148 line_start, line_end = line_ranges[start_line_no] 

1149 if span_start < line_start: 

1150 upper_bound = start_line_no - 1 

1151 elif span_start > line_end: 

1152 lower_bound = start_line_no + 1 

1153 else: 

1154 break 

1155 start_line_no = (lower_bound + upper_bound) // 2 

1156 

1157 if span_end < line_end: 

1158 end_line_no = start_line_no 

1159 else: 

1160 end_line_no = lower_bound = start_line_no 

1161 upper_bound = line_count 

1162 

1163 while True: 

1164 line_start, line_end = line_ranges[end_line_no] 

1165 if span_end < line_start: 

1166 upper_bound = end_line_no - 1 

1167 elif span_end > line_end: 

1168 lower_bound = end_line_no + 1 

1169 else: 

1170 break 

1171 end_line_no = (lower_bound + upper_bound) // 2 

1172 

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

1174 line_start, line_end = line_ranges[line_no] 

1175 new_start = max(0, span_start - line_start) 

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

1177 if new_end > new_start: 

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

1179 

1180 return new_lines 

1181 

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

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

1184 max_offset = len(self.plain) - amount 

1185 _Span = Span 

1186 self._spans[:] = [ 

1187 ( 

1188 span 

1189 if span.end < max_offset 

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

1191 ) 

1192 for span in self._spans 

1193 if span.start < max_offset 

1194 ] 

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

1196 self._length -= amount 

1197 

1198 def wrap( 

1199 self, 

1200 console: "Console", 

1201 width: int, 

1202 *, 

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

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

1205 tab_size: int = 8, 

1206 no_wrap: Optional[bool] = None, 

1207 ) -> Lines: 

1208 """Word wrap the text. 

1209 

1210 Args: 

1211 console (Console): Console instance. 

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

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

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

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

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

1217 

1218 Returns: 

1219 Lines: Number of lines. 

1220 """ 

1221 wrap_justify = justify or self.justify or DEFAULT_JUSTIFY 

1222 wrap_overflow = overflow or self.overflow or DEFAULT_OVERFLOW 

1223 

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

1225 

1226 lines = Lines() 

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

1228 if "\t" in line: 

1229 line.expand_tabs(tab_size) 

1230 if no_wrap: 

1231 new_lines = Lines([line]) 

1232 else: 

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

1234 new_lines = line.divide(offsets) 

1235 for line in new_lines: 

1236 line.rstrip_end(width) 

1237 if wrap_justify: 

1238 new_lines.justify( 

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

1240 ) 

1241 for line in new_lines: 

1242 line.truncate(width, overflow=wrap_overflow) 

1243 lines.extend(new_lines) 

1244 return lines 

1245 

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

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

1248 

1249 Args: 

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

1251 

1252 Returns: 

1253 Lines: Lines container. 

1254 """ 

1255 lines: Lines = Lines() 

1256 append = lines.append 

1257 for line in self.split(): 

1258 line.set_length(width) 

1259 append(line) 

1260 return lines 

1261 

1262 def detect_indentation(self) -> int: 

1263 """Auto-detect indentation of code. 

1264 

1265 Returns: 

1266 int: Number of spaces used to indent code. 

1267 """ 

1268 

1269 _indentations = { 

1270 len(match.group(1)) 

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

1272 } 

1273 

1274 try: 

1275 indentation = ( 

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

1277 ) 

1278 except TypeError: 

1279 indentation = 1 

1280 

1281 return indentation 

1282 

1283 def with_indent_guides( 

1284 self, 

1285 indent_size: Optional[int] = None, 

1286 *, 

1287 character: str = "│", 

1288 style: StyleType = "dim green", 

1289 ) -> "Text": 

1290 """Adds indent guide lines to text. 

1291 

1292 Args: 

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

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

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

1296 

1297 Returns: 

1298 Text: New text with indentation guides. 

1299 """ 

1300 

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

1302 

1303 text = self.copy() 

1304 text.expand_tabs() 

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

1306 

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

1308 new_lines: List[Text] = [] 

1309 add_line = new_lines.append 

1310 blank_lines = 0 

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

1312 match = re_indent.match(line.plain) 

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

1314 blank_lines += 1 

1315 continue 

1316 indent = match.group(1) 

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

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

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

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

1321 if blank_lines: 

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

1323 blank_lines = 0 

1324 add_line(line) 

1325 if blank_lines: 

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

1327 

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

1329 return new_text 

1330 

1331 

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

1333 from rich.console import Console 

1334 

1335 text = Text( 

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

1337 ) 

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

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

1340 

1341 console = Console() 

1342 

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

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

1345 console.print() 

1346 

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

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

1349 console.print() 

1350 

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

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

1353 console.print() 

1354 

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

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

1357 console.print()