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

601 statements  

« prev     ^ index     » next       coverage.py v7.3.1, created at 2023-09-18 06:13 +0000

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 [Text][rich.text.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 emoji (bool, optional): Also render emoji code. Defaults to True. 

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

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

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

278 

279 Returns: 

280 Text: A Text instance with markup rendered. 

281 """ 

282 from .markup import render 

283 

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

285 rendered_text.justify = justify 

286 rendered_text.overflow = overflow 

287 rendered_text.end = end 

288 return rendered_text 

289 

290 @classmethod 

291 def from_ansi( 

292 cls, 

293 text: str, 

294 *, 

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

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

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

298 no_wrap: Optional[bool] = None, 

299 end: str = "\n", 

300 tab_size: Optional[int] = 8, 

301 ) -> "Text": 

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

303 

304 Args: 

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

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

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

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

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

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

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

312 """ 

313 from .ansi import AnsiDecoder 

314 

315 joiner = Text( 

316 "\n", 

317 justify=justify, 

318 overflow=overflow, 

319 no_wrap=no_wrap, 

320 end=end, 

321 tab_size=tab_size, 

322 style=style, 

323 ) 

324 decoder = AnsiDecoder() 

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

326 return result 

327 

328 @classmethod 

329 def styled( 

330 cls, 

331 text: str, 

332 style: StyleType = "", 

333 *, 

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

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

336 ) -> "Text": 

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

338 to pad the text when it is justified. 

339 

340 Args: 

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

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

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

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

345 

346 Returns: 

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

348 """ 

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

350 styled_text.stylize(style) 

351 return styled_text 

352 

353 @classmethod 

354 def assemble( 

355 cls, 

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

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

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

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

360 no_wrap: Optional[bool] = None, 

361 end: str = "\n", 

362 tab_size: int = 8, 

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

364 ) -> "Text": 

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

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

367 

368 Args: 

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

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

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

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

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

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

375 

376 Returns: 

377 Text: A new text instance. 

378 """ 

379 text = cls( 

380 style=style, 

381 justify=justify, 

382 overflow=overflow, 

383 no_wrap=no_wrap, 

384 end=end, 

385 tab_size=tab_size, 

386 ) 

387 append = text.append 

388 _Text = Text 

389 for part in parts: 

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

391 append(part) 

392 else: 

393 append(*part) 

394 if meta: 

395 text.apply_meta(meta) 

396 return text 

397 

398 @property 

399 def plain(self) -> str: 

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

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

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

403 return self._text[0] 

404 

405 @plain.setter 

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

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

408 if new_text != self.plain: 

409 sanitized_text = strip_control_codes(new_text) 

410 self._text[:] = [sanitized_text] 

411 old_length = self._length 

412 self._length = len(sanitized_text) 

413 if old_length > self._length: 

414 self._trim_spans() 

415 

416 @property 

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

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

419 return self._spans 

420 

421 @spans.setter 

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

423 """Set spans.""" 

424 self._spans = spans[:] 

425 

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

427 """Return a new Text instance with copied meta data (but not the string or spans).""" 

428 copy_self = Text( 

429 plain, 

430 style=self.style, 

431 justify=self.justify, 

432 overflow=self.overflow, 

433 no_wrap=self.no_wrap, 

434 end=self.end, 

435 tab_size=self.tab_size, 

436 ) 

437 return copy_self 

438 

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

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

441 copy_self = Text( 

442 self.plain, 

443 style=self.style, 

444 justify=self.justify, 

445 overflow=self.overflow, 

446 no_wrap=self.no_wrap, 

447 end=self.end, 

448 tab_size=self.tab_size, 

449 ) 

450 copy_self._spans[:] = self._spans 

451 return copy_self 

452 

453 def stylize( 

454 self, 

455 style: Union[str, Style], 

456 start: int = 0, 

457 end: Optional[int] = None, 

458 ) -> None: 

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

460 

461 Args: 

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

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

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

465 """ 

466 if style: 

467 length = len(self) 

468 if start < 0: 

469 start = length + start 

470 if end is None: 

471 end = length 

472 if end < 0: 

473 end = length + end 

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

475 # Span not in text or not valid 

476 return 

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

478 

479 def stylize_before( 

480 self, 

481 style: Union[str, Style], 

482 start: int = 0, 

483 end: Optional[int] = None, 

484 ) -> None: 

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

486 

487 Args: 

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

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

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

491 """ 

492 if style: 

493 length = len(self) 

494 if start < 0: 

495 start = length + start 

496 if end is None: 

497 end = length 

498 if end < 0: 

499 end = length + end 

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

501 # Span not in text or not valid 

502 return 

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

504 

505 def apply_meta( 

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

507 ) -> None: 

508 """Apply meta data to the text, or a portion of the text. 

509 

510 Args: 

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

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

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

514 

515 """ 

516 style = Style.from_meta(meta) 

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

518 

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

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

521 

522 Example: 

523 >>> from rich.text import Text 

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

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

526 

527 Args: 

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

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

530 

531 Returns: 

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

533 """ 

534 meta = {} if meta is None else meta 

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

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

537 return self 

538 

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

540 """Remove a suffix if it exists. 

541 

542 Args: 

543 suffix (str): Suffix to remove. 

544 """ 

545 if self.plain.endswith(suffix): 

546 self.right_crop(len(suffix)) 

547 

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

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

550 

551 Args: 

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

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

554 

555 Returns: 

556 Style: A Style instance. 

557 """ 

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

559 if offset < 0: 

560 offset = len(self) + offset 

561 get_style = console.get_style 

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

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

564 if end > offset >= start: 

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

566 return style 

567 

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

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

570 

571 Args: 

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

573 """ 

574 if spaces <= 0: 

575 return 

576 spans = self.spans 

577 new_spaces = " " * spaces 

578 if spans: 

579 end_offset = len(self) 

580 self._spans[:] = [ 

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

582 for span in spans 

583 ] 

584 self._text.append(new_spaces) 

585 self._length += spaces 

586 else: 

587 self.plain += new_spaces 

588 

589 def highlight_regex( 

590 self, 

591 re_highlight: str, 

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

593 *, 

594 style_prefix: str = "", 

595 ) -> int: 

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

597 translated to styles. 

598 

599 Args: 

600 re_highlight (str): A regular expression. 

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

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

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

604 

605 Returns: 

606 int: Number of regex matches 

607 """ 

608 count = 0 

609 append_span = self._spans.append 

610 _Span = Span 

611 plain = self.plain 

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

613 get_span = match.span 

614 if style: 

615 start, end = get_span() 

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

617 if match_style is not None and end > start: 

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

619 

620 count += 1 

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

622 start, end = get_span(name) 

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

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

625 return count 

626 

627 def highlight_words( 

628 self, 

629 words: Iterable[str], 

630 style: Union[str, Style], 

631 *, 

632 case_sensitive: bool = True, 

633 ) -> int: 

634 """Highlight words with a style. 

635 

636 Args: 

637 words (Iterable[str]): Worlds to highlight. 

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

639 case_sensitive (bool, optional): Enable case sensitive matchings. Defaults to True. 

640 

641 Returns: 

642 int: Number of words highlighted. 

643 """ 

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

645 add_span = self._spans.append 

646 count = 0 

647 _Span = Span 

648 for match in re.finditer( 

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

650 ): 

651 start, end = match.span(0) 

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

653 count += 1 

654 return count 

655 

656 def rstrip(self) -> None: 

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

658 self.plain = self.plain.rstrip() 

659 

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

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

662 

663 Args: 

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

665 """ 

666 text_length = len(self) 

667 if text_length > size: 

668 excess = text_length - size 

669 whitespace_match = _re_whitespace.search(self.plain) 

670 if whitespace_match is not None: 

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

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

673 

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

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

676 length = len(self) 

677 if length != new_length: 

678 if length < new_length: 

679 self.pad_right(new_length - length) 

680 else: 

681 self.right_crop(length - new_length) 

682 

683 def __rich_console__( 

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

685 ) -> Iterable[Segment]: 

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

687 justify = self.justify or options.justify or DEFAULT_JUSTIFY 

688 

689 overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW 

690 

691 lines = self.wrap( 

692 console, 

693 options.max_width, 

694 justify=justify, 

695 overflow=overflow, 

696 tab_size=tab_size or 8, 

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

698 ) 

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

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

701 

702 def __rich_measure__( 

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

704 ) -> Measurement: 

705 text = self.plain 

706 lines = text.splitlines() 

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

708 words = text.split() 

709 min_text_width = ( 

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

711 ) 

712 return Measurement(min_text_width, max_text_width) 

713 

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

715 """Render the text as Segments. 

716 

717 Args: 

718 console (Console): Console instance. 

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

720 

721 Returns: 

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

723 """ 

724 _Segment = Segment 

725 text = self.plain 

726 if not self._spans: 

727 yield Segment(text) 

728 if end: 

729 yield _Segment(end) 

730 return 

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

732 

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

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

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

736 

737 spans = [ 

738 (0, False, 0), 

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

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

741 (len(text), True, 0), 

742 ] 

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

744 

745 stack: List[int] = [] 

746 stack_append = stack.append 

747 stack_pop = stack.remove 

748 

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

750 style_cache_get = style_cache.get 

751 combine = Style.combine 

752 

753 def get_current_style() -> Style: 

754 """Construct current style from stack.""" 

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

756 cached_style = style_cache_get(styles) 

757 if cached_style is not None: 

758 return cached_style 

759 current_style = combine(styles) 

760 style_cache[styles] = current_style 

761 return current_style 

762 

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

764 if leaving: 

765 stack_pop(style_id) 

766 else: 

767 stack_append(style_id) 

768 if next_offset > offset: 

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

770 if end: 

771 yield _Segment(end) 

772 

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

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

775 

776 Args: 

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

778 

779 Returns: 

780 Text: A new text instance containing join text. 

781 """ 

782 

783 new_text = self.blank_copy() 

784 

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

786 if self.plain: 

787 for last, line in loop_last(lines): 

788 yield line 

789 if not last: 

790 yield self 

791 else: 

792 yield from lines 

793 

794 extend_text = new_text._text.extend 

795 append_span = new_text._spans.append 

796 extend_spans = new_text._spans.extend 

797 offset = 0 

798 _Span = Span 

799 

800 for text in iter_text(): 

801 extend_text(text._text) 

802 if text.style: 

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

804 extend_spans( 

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

806 for start, end, style in text._spans 

807 ) 

808 offset += len(text) 

809 new_text._length = offset 

810 return new_text 

811 

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

813 """Converts tabs to spaces. 

814 

815 Args: 

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

817 

818 """ 

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

820 return 

821 if tab_size is None: 

822 tab_size = self.tab_size 

823 if tab_size is None: 

824 tab_size = 8 

825 

826 result = self.blank_copy() 

827 

828 new_text: List[Text] = [] 

829 append = new_text.append 

830 

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

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

833 append(line) 

834 else: 

835 cell_position = 0 

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

837 for part in parts: 

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

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

840 cell_position += part.cell_len 

841 tab_remainder = cell_position % tab_size 

842 if tab_remainder: 

843 spaces = tab_size - tab_remainder 

844 part.extend_style(spaces) 

845 cell_position += spaces 

846 else: 

847 cell_position += part.cell_len 

848 append(part) 

849 

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

851 

852 self._text = [result.plain] 

853 self._length = len(self.plain) 

854 self._spans[:] = result._spans 

855 

856 def truncate( 

857 self, 

858 max_width: int, 

859 *, 

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

861 pad: bool = False, 

862 ) -> None: 

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

864 

865 Args: 

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

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

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

869 """ 

870 _overflow = overflow or self.overflow or DEFAULT_OVERFLOW 

871 if _overflow != "ignore": 

872 length = cell_len(self.plain) 

873 if length > max_width: 

874 if _overflow == "ellipsis": 

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

876 else: 

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

878 if pad and length < max_width: 

879 spaces = max_width - length 

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

881 self._length = len(self.plain) 

882 

883 def _trim_spans(self) -> None: 

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

885 max_offset = len(self.plain) 

886 _Span = Span 

887 self._spans[:] = [ 

888 ( 

889 span 

890 if span.end < max_offset 

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

892 ) 

893 for span in self._spans 

894 if span.start < max_offset 

895 ] 

896 

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

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

899 

900 Args: 

901 count (int): Width of padding. 

902 """ 

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

904 if count: 

905 pad_characters = character * count 

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

907 _Span = Span 

908 self._spans[:] = [ 

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

910 for start, end, style in self._spans 

911 ] 

912 

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

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

915 

916 Args: 

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

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

919 """ 

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

921 if count: 

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

923 _Span = Span 

924 self._spans[:] = [ 

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

926 for start, end, style in self._spans 

927 ] 

928 

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

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

931 

932 Args: 

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

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

935 """ 

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

937 if count: 

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

939 

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

941 """Align text to a given width. 

942 

943 Args: 

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

945 width (int): Desired width. 

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

947 """ 

948 self.truncate(width) 

949 excess_space = width - cell_len(self.plain) 

950 if excess_space: 

951 if align == "left": 

952 self.pad_right(excess_space, character) 

953 elif align == "center": 

954 left = excess_space // 2 

955 self.pad_left(left, character) 

956 self.pad_right(excess_space - left, character) 

957 else: 

958 self.pad_left(excess_space, character) 

959 

960 def append( 

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

962 ) -> "Text": 

963 """Add text with an optional style. 

964 

965 Args: 

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

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

968 

969 Returns: 

970 Text: Returns self for chaining. 

971 """ 

972 

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

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

975 

976 if len(text): 

977 if isinstance(text, str): 

978 sanitized_text = strip_control_codes(text) 

979 self._text.append(sanitized_text) 

980 offset = len(self) 

981 text_length = len(sanitized_text) 

982 if style: 

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

984 self._length += text_length 

985 elif isinstance(text, Text): 

986 _Span = Span 

987 if style is not None: 

988 raise ValueError( 

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

990 ) 

991 text_length = self._length 

992 if text.style: 

993 self._spans.append( 

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

995 ) 

996 self._text.append(text.plain) 

997 self._spans.extend( 

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

999 for start, end, style in text._spans 

1000 ) 

1001 self._length += len(text) 

1002 return self 

1003 

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

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

1006 only works for Text. 

1007 

1008 Returns: 

1009 Text: Returns self for chaining. 

1010 """ 

1011 _Span = Span 

1012 text_length = self._length 

1013 if text.style: 

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

1015 self._text.append(text.plain) 

1016 self._spans.extend( 

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

1018 for start, end, style in text._spans 

1019 ) 

1020 self._length += len(text) 

1021 return self 

1022 

1023 def append_tokens( 

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

1025 ) -> "Text": 

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

1027 

1028 Args: 

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

1030 

1031 Returns: 

1032 Text: Returns self for chaining. 

1033 """ 

1034 append_text = self._text.append 

1035 append_span = self._spans.append 

1036 _Span = Span 

1037 offset = len(self) 

1038 for content, style in tokens: 

1039 append_text(content) 

1040 if style: 

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

1042 offset += len(content) 

1043 self._length = offset 

1044 return self 

1045 

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

1047 """Copy styles from another Text instance. 

1048 

1049 Args: 

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

1051 """ 

1052 self._spans.extend(text._spans) 

1053 

1054 def split( 

1055 self, 

1056 separator: str = "\n", 

1057 *, 

1058 include_separator: bool = False, 

1059 allow_blank: bool = False, 

1060 ) -> Lines: 

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

1062 

1063 Args: 

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

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

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

1067 

1068 Returns: 

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

1070 """ 

1071 assert separator, "separator must not be empty" 

1072 

1073 text = self.plain 

1074 if separator not in text: 

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

1076 

1077 if include_separator: 

1078 lines = self.divide( 

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

1080 ) 

1081 else: 

1082 

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

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

1085 start, end = match.span() 

1086 yield start 

1087 yield end 

1088 

1089 lines = Lines( 

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

1091 ) 

1092 

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

1094 lines.pop() 

1095 

1096 return lines 

1097 

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

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

1100 

1101 Args: 

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

1103 

1104 Returns: 

1105 Lines: New RichText instances between offsets. 

1106 """ 

1107 _offsets = list(offsets) 

1108 

1109 if not _offsets: 

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

1111 

1112 text = self.plain 

1113 text_length = len(text) 

1114 divide_offsets = [0, *_offsets, text_length] 

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

1116 

1117 style = self.style 

1118 justify = self.justify 

1119 overflow = self.overflow 

1120 _Text = Text 

1121 new_lines = Lines( 

1122 _Text( 

1123 text[start:end], 

1124 style=style, 

1125 justify=justify, 

1126 overflow=overflow, 

1127 ) 

1128 for start, end in line_ranges 

1129 ) 

1130 if not self._spans: 

1131 return new_lines 

1132 

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

1134 line_count = len(line_ranges) 

1135 _Span = Span 

1136 

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

1138 lower_bound = 0 

1139 upper_bound = line_count 

1140 start_line_no = (lower_bound + upper_bound) // 2 

1141 

1142 while True: 

1143 line_start, line_end = line_ranges[start_line_no] 

1144 if span_start < line_start: 

1145 upper_bound = start_line_no - 1 

1146 elif span_start > line_end: 

1147 lower_bound = start_line_no + 1 

1148 else: 

1149 break 

1150 start_line_no = (lower_bound + upper_bound) // 2 

1151 

1152 if span_end < line_end: 

1153 end_line_no = start_line_no 

1154 else: 

1155 end_line_no = lower_bound = start_line_no 

1156 upper_bound = line_count 

1157 

1158 while True: 

1159 line_start, line_end = line_ranges[end_line_no] 

1160 if span_end < line_start: 

1161 upper_bound = end_line_no - 1 

1162 elif span_end > line_end: 

1163 lower_bound = end_line_no + 1 

1164 else: 

1165 break 

1166 end_line_no = (lower_bound + upper_bound) // 2 

1167 

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

1169 line_start, line_end = line_ranges[line_no] 

1170 new_start = max(0, span_start - line_start) 

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

1172 if new_end > new_start: 

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

1174 

1175 return new_lines 

1176 

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

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

1179 max_offset = len(self.plain) - amount 

1180 _Span = Span 

1181 self._spans[:] = [ 

1182 ( 

1183 span 

1184 if span.end < max_offset 

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

1186 ) 

1187 for span in self._spans 

1188 if span.start < max_offset 

1189 ] 

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

1191 self._length -= amount 

1192 

1193 def wrap( 

1194 self, 

1195 console: "Console", 

1196 width: int, 

1197 *, 

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

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

1200 tab_size: int = 8, 

1201 no_wrap: Optional[bool] = None, 

1202 ) -> Lines: 

1203 """Word wrap the text. 

1204 

1205 Args: 

1206 console (Console): Console instance. 

1207 width (int): Number of characters per line. 

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

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

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

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

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

1213 

1214 Returns: 

1215 Lines: Number of lines. 

1216 """ 

1217 wrap_justify = justify or self.justify or DEFAULT_JUSTIFY 

1218 wrap_overflow = overflow or self.overflow or DEFAULT_OVERFLOW 

1219 

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

1221 

1222 lines = Lines() 

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

1224 if "\t" in line: 

1225 line.expand_tabs(tab_size) 

1226 if no_wrap: 

1227 new_lines = Lines([line]) 

1228 else: 

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

1230 new_lines = line.divide(offsets) 

1231 for line in new_lines: 

1232 line.rstrip_end(width) 

1233 if wrap_justify: 

1234 new_lines.justify( 

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

1236 ) 

1237 for line in new_lines: 

1238 line.truncate(width, overflow=wrap_overflow) 

1239 lines.extend(new_lines) 

1240 return lines 

1241 

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

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

1244 

1245 Args: 

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

1247 

1248 Returns: 

1249 Lines: Lines container. 

1250 """ 

1251 lines: Lines = Lines() 

1252 append = lines.append 

1253 for line in self.split(): 

1254 line.set_length(width) 

1255 append(line) 

1256 return lines 

1257 

1258 def detect_indentation(self) -> int: 

1259 """Auto-detect indentation of code. 

1260 

1261 Returns: 

1262 int: Number of spaces used to indent code. 

1263 """ 

1264 

1265 _indentations = { 

1266 len(match.group(1)) 

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

1268 } 

1269 

1270 try: 

1271 indentation = ( 

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

1273 ) 

1274 except TypeError: 

1275 indentation = 1 

1276 

1277 return indentation 

1278 

1279 def with_indent_guides( 

1280 self, 

1281 indent_size: Optional[int] = None, 

1282 *, 

1283 character: str = "│", 

1284 style: StyleType = "dim green", 

1285 ) -> "Text": 

1286 """Adds indent guide lines to text. 

1287 

1288 Args: 

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

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

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

1292 

1293 Returns: 

1294 Text: New text with indentation guides. 

1295 """ 

1296 

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

1298 

1299 text = self.copy() 

1300 text.expand_tabs() 

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

1302 

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

1304 new_lines: List[Text] = [] 

1305 add_line = new_lines.append 

1306 blank_lines = 0 

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

1308 match = re_indent.match(line.plain) 

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

1310 blank_lines += 1 

1311 continue 

1312 indent = match.group(1) 

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

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

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

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

1317 if blank_lines: 

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

1319 blank_lines = 0 

1320 add_line(line) 

1321 if blank_lines: 

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

1323 

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

1325 return new_text 

1326 

1327 

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

1329 from rich.console import Console 

1330 

1331 text = Text( 

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

1333 ) 

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

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

1336 

1337 console = Console() 

1338 

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

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

1341 console.print() 

1342 

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

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

1345 console.print() 

1346 

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

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

1349 console.print() 

1350 

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

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

1353 console.print()